Skip to content

Commit 659536c

Browse files
authored
Merge pull request #111 from FIWARE/fix/exp-vc-validation
fix(verifier): enforce credential temporal validity on every validation mode
2 parents ef9e7c2 + 31975a5 commit 659536c

3 files changed

Lines changed: 198 additions & 10 deletions

File tree

verifier/jwt_verifier.go

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package verifier
33
import (
44
"errors"
55
"strings"
6+
"time"
67

78
"github.com/fiware/VCVerifier/common"
89
"github.com/fiware/VCVerifier/logging"
@@ -23,19 +24,31 @@ const (
2324
)
2425

2526
var (
26-
ErrorNoVerificationKey = errors.New("no_verification_key")
27-
ErrorNotAValidVerficationMethod = errors.New("not_a_valid_verfication_method")
28-
ErrorNoOriginalCredential = errors.New("no_original_credential_for_validation")
29-
ErrorCredentialMissingIssuer = errors.New("credential_missing_issuer")
30-
ErrorCredentialMissingType = errors.New("credential_missing_type")
31-
ErrorCredentialNonBaseType = errors.New("credential_contains_non_base_context_type")
27+
ErrorNoVerificationKey = errors.New("no_verification_key")
28+
ErrorNotAValidVerficationMethod = errors.New("not_a_valid_verfication_method")
29+
ErrorNoOriginalCredential = errors.New("no_original_credential_for_validation")
30+
ErrorCredentialMissingIssuer = errors.New("credential_missing_issuer")
31+
ErrorCredentialMissingType = errors.New("credential_missing_type")
32+
ErrorCredentialNonBaseType = errors.New("credential_contains_non_base_context_type")
33+
ErrorCredentialExpired = errors.New("credential_expired")
34+
ErrorCredentialNotYetValid = errors.New("credential_not_yet_valid")
35+
ErrorCredentialInvalidValidityPeriod = errors.New("credential_invalid_validity_period")
3236
)
3337

3438
var SupportedModes = []string{ValidationModeNone, ValidationModeCombined, ValidationModeJsonLd, ValidationModeBaseContext}
3539

3640
// CredentialValidator validates credential content (not signatures — those are checked by JWTProofChecker).
3741
type CredentialValidator struct {
3842
validationMode string
43+
clock common.Clock
44+
}
45+
46+
// now returns the current time, falling back to time.Now() when no clock is injected.
47+
func (cv CredentialValidator) now() time.Time {
48+
if cv.clock == nil {
49+
return time.Now()
50+
}
51+
return cv.clock.Now()
3952
}
4053

4154
// the jwt-vc standard defines multiple options for the kid-header, while the standard implementation only allows for absolute paths.
@@ -77,7 +90,11 @@ func getKeyFromMethod(verificationMethod string) (keyId, absolutePath, fullAbsol
7790
}
7891

7992
// ValidateVC validates credential content. Signature verification is handled separately by JWTProofChecker.
93+
// Temporal validity (validFrom/validUntil) is always enforced regardless of mode.
8094
func (cv CredentialValidator) ValidateVC(verifiableCredential *common.Credential, verificationContext ValidationContext) (result bool, err error) {
95+
if ok, err := validateCredentialDates(verifiableCredential.Contents(), cv.now()); !ok {
96+
return false, err
97+
}
8198

8299
switch cv.validationMode {
83100
case ValidationModeNone:
@@ -106,7 +123,7 @@ func validateCredentialContent(cred *common.Credential) (bool, error) {
106123
return true, nil
107124
}
108125

109-
// validateBaseContext checks that the credential uses only W3C base context types.
126+
// validateBaseContext checks that the credential uses only W3C base context types and is temporally valid.
110127
var baseContextTypes = map[string]bool{
111128
TypeVerifiableCredential: true,
112129
TypeVerifiablePresentation: true,
@@ -126,3 +143,22 @@ func validateBaseContext(cred *common.Credential) (bool, error) {
126143
}
127144
return true, nil
128145
}
146+
147+
// validateCredentialDates checks validFrom and validUntil against now, both bounds inclusive:
148+
// the credential is valid for now in [validFrom, validUntil]. A zero-length validity period
149+
// (validFrom == validUntil) is always rejected. Either field being absent is not an error.
150+
func validateCredentialDates(contents common.CredentialContents, now time.Time) (bool, error) {
151+
if contents.ValidFrom != nil && contents.ValidUntil != nil && contents.ValidFrom.Equal(*contents.ValidUntil) {
152+
logging.Log().Warnf("Credential validation failed: zero-length validity period (validFrom == validUntil: %s)", contents.ValidFrom.Format(time.RFC3339))
153+
return false, ErrorCredentialInvalidValidityPeriod
154+
}
155+
if contents.ValidFrom != nil && now.Before(*contents.ValidFrom) {
156+
logging.Log().Warnf("Credential validation failed: not yet valid (validFrom: %s, now: %s)", contents.ValidFrom.Format(time.RFC3339), now.Format(time.RFC3339))
157+
return false, ErrorCredentialNotYetValid
158+
}
159+
if contents.ValidUntil != nil && now.After(*contents.ValidUntil) {
160+
logging.Log().Warnf("Credential validation failed: expired (validUntil: %s, now: %s)", contents.ValidUntil.Format(time.RFC3339), now.Format(time.RFC3339))
161+
return false, ErrorCredentialExpired
162+
}
163+
return true, nil
164+
}

verifier/jwt_verifier_test.go

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,16 @@ package verifier
22

33
import (
44
"testing"
5+
"time"
56

67
common "github.com/fiware/VCVerifier/common"
78
)
89

10+
// fixedClock is a test double that always returns the configured instant.
11+
type fixedClock struct{ t time.Time }
12+
13+
func (fc fixedClock) Now() time.Time { return fc.t }
14+
915
func TestGetKeyFromMethod(t *testing.T) {
1016
type test struct {
1117
testName string
@@ -224,3 +230,149 @@ func TestSupportedModes(t *testing.T) {
224230
}
225231
}
226232
}
233+
234+
// ---------------------------------------------------------------------------
235+
// Temporal validity tests
236+
// ---------------------------------------------------------------------------
237+
238+
// baseTime is a fixed "now" used across all temporal tests so results are deterministic.
239+
var baseTime = time.Date(2024, 6, 15, 12, 0, 0, 0, time.UTC)
240+
241+
func makeCredential(validFrom, validUntil *time.Time) *common.Credential {
242+
c, _ := common.CreateCredential(common.CredentialContents{
243+
Issuer: &common.Issuer{ID: "did:web:example.com"},
244+
Types: []string{"VerifiableCredential"},
245+
Subject: []common.Subject{{CustomFields: map[string]interface{}{"name": "test"}}},
246+
ValidFrom: validFrom,
247+
ValidUntil: validUntil,
248+
}, common.CustomFields{})
249+
return c
250+
}
251+
252+
func tp(t time.Time) *time.Time { return &t }
253+
254+
func TestValidateCredentialContent_TemporalValidity(t *testing.T) {
255+
past := baseTime.Add(-24 * time.Hour)
256+
future := baseTime.Add(24 * time.Hour)
257+
258+
tests := []struct {
259+
name string
260+
validFrom *time.Time
261+
validUntil *time.Time
262+
wantErr error
263+
}{
264+
{
265+
name: "no_dates_always_valid",
266+
wantErr: nil,
267+
},
268+
{
269+
name: "valid_from_past_no_expiry",
270+
validFrom: tp(past),
271+
wantErr: nil,
272+
},
273+
{
274+
name: "valid_until_future_no_issued",
275+
validUntil: tp(future),
276+
wantErr: nil,
277+
},
278+
{
279+
name: "both_in_valid_window",
280+
validFrom: tp(past),
281+
validUntil: tp(future),
282+
wantErr: nil,
283+
},
284+
{
285+
name: "expired_credential",
286+
validFrom: tp(past.Add(-48 * time.Hour)),
287+
validUntil: tp(past),
288+
wantErr: ErrorCredentialExpired,
289+
},
290+
{
291+
name: "not_yet_valid",
292+
validFrom: tp(future),
293+
wantErr: ErrorCredentialNotYetValid,
294+
},
295+
{
296+
name: "not_yet_valid_with_future_expiry",
297+
validFrom: tp(future),
298+
validUntil: tp(future.Add(24 * time.Hour)),
299+
wantErr: ErrorCredentialNotYetValid,
300+
},
301+
}
302+
303+
for _, mode := range []string{ValidationModeCombined, ValidationModeJsonLd, ValidationModeBaseContext} {
304+
for _, tc := range tests {
305+
t.Run(mode+"/"+tc.name, func(t *testing.T) {
306+
cred := makeCredential(tc.validFrom, tc.validUntil)
307+
validator := CredentialValidator{validationMode: mode, clock: fixedClock{t: baseTime}}
308+
_, err := validator.ValidateVC(cred, nil)
309+
if tc.wantErr != nil {
310+
if err == nil {
311+
t.Fatalf("expected error %v, got nil", tc.wantErr)
312+
}
313+
if !isErr(err, tc.wantErr) {
314+
t.Fatalf("expected error %v, got %v", tc.wantErr, err)
315+
}
316+
} else if err != nil {
317+
t.Fatalf("expected no error, got %v", err)
318+
}
319+
})
320+
}
321+
}
322+
}
323+
324+
func TestValidateCredentialContent_NoneMode_StillChecksDates(t *testing.T) {
325+
past := baseTime.Add(-1 * time.Hour)
326+
// Even in "none" mode, expired credentials must be rejected.
327+
cred := makeCredential(nil, tp(past))
328+
validator := CredentialValidator{validationMode: ValidationModeNone, clock: fixedClock{t: baseTime}}
329+
result, err := validator.ValidateVC(cred, nil)
330+
if result || !isErr(err, ErrorCredentialExpired) {
331+
t.Fatalf("none mode should still reject expired credential, got result=%v err=%v", result, err)
332+
}
333+
}
334+
335+
func TestValidateCredentialContent_ExactBoundary(t *testing.T) {
336+
// validFrom == now is still valid (inclusive).
337+
fromCred := makeCredential(tp(baseTime), nil)
338+
fromValidator := CredentialValidator{validationMode: ValidationModeCombined, clock: fixedClock{t: baseTime}}
339+
if _, err := fromValidator.ValidateVC(fromCred, nil); err != nil {
340+
t.Fatalf("credential starting exactly at now should be valid, got %v", err)
341+
}
342+
343+
// validUntil == now is still valid (inclusive).
344+
untilCred := makeCredential(nil, tp(baseTime))
345+
untilValidator := CredentialValidator{validationMode: ValidationModeCombined, clock: fixedClock{t: baseTime}}
346+
if _, err := untilValidator.ValidateVC(untilCred, nil); err != nil {
347+
t.Fatalf("credential expiring exactly at now should be valid, got %v", err)
348+
}
349+
}
350+
351+
func TestValidateCredentialContent_ZeroLengthValidityPeriod(t *testing.T) {
352+
// validFrom == validUntil is always rejected, regardless of now.
353+
cred := makeCredential(tp(baseTime), tp(baseTime))
354+
validator := CredentialValidator{validationMode: ValidationModeCombined, clock: fixedClock{t: baseTime}}
355+
_, err := validator.ValidateVC(cred, nil)
356+
if !isErr(err, ErrorCredentialInvalidValidityPeriod) {
357+
t.Fatalf("credential with validFrom == validUntil should be rejected, got %v", err)
358+
}
359+
}
360+
361+
// isErr reports whether err wraps or equals target.
362+
func isErr(err, target error) bool {
363+
if err == target {
364+
return true
365+
}
366+
type unwrapper interface{ Unwrap() error }
367+
for err != nil {
368+
if err == target {
369+
return true
370+
}
371+
u, ok := err.(unwrapper)
372+
if !ok {
373+
break
374+
}
375+
err = u.Unwrap()
376+
}
377+
return false
378+
}

verifier/verifier.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -325,7 +325,9 @@ func InitVerifier(config *configModel.Configuration, repo database.ServiceReposi
325325
sessionCache := cache.New(time.Duration(verifierConfig.SessionExpiry)*time.Second, time.Duration(2*verifierConfig.SessionExpiry)*time.Second)
326326
tokenCache := cache.New(time.Duration(verifierConfig.SessionExpiry)*time.Second, time.Duration(2*verifierConfig.SessionExpiry)*time.Second)
327327

328-
credentialsVerifier := CredentialValidator{validationMode: config.Verifier.ValidationMode}
328+
clock := common.RealClock{}
329+
330+
credentialsVerifier := CredentialValidator{validationMode: config.Verifier.ValidationMode, clock: clock}
329331

330332
externalGaiaXValidator := InitGaiaXRegistryValidationService(verifierConfig)
331333

@@ -334,8 +336,6 @@ func InitVerifier(config *configModel.Configuration, repo database.ServiceReposi
334336
logging.Log().Errorf("Was not able to initiate the credentials config. Err: %v", err)
335337
}
336338

337-
clock := common.RealClock{}
338-
339339
var tokenProvider tir.TokenProvider
340340
if (&config.M2M).AuthEnabled {
341341
tokenProvider, err = tir.InitM2MTokenProvider(config, clock)

0 commit comments

Comments
 (0)