diff --git a/verifier/jwt_verifier.go b/verifier/jwt_verifier.go index 02e51c9..6a5a12f 100644 --- a/verifier/jwt_verifier.go +++ b/verifier/jwt_verifier.go @@ -3,6 +3,7 @@ package verifier import ( "errors" "strings" + "time" "github.com/fiware/VCVerifier/common" "github.com/fiware/VCVerifier/logging" @@ -23,12 +24,15 @@ const ( ) var ( - ErrorNoVerificationKey = errors.New("no_verification_key") - ErrorNotAValidVerficationMethod = errors.New("not_a_valid_verfication_method") - ErrorNoOriginalCredential = errors.New("no_original_credential_for_validation") - ErrorCredentialMissingIssuer = errors.New("credential_missing_issuer") - ErrorCredentialMissingType = errors.New("credential_missing_type") - ErrorCredentialNonBaseType = errors.New("credential_contains_non_base_context_type") + ErrorNoVerificationKey = errors.New("no_verification_key") + ErrorNotAValidVerficationMethod = errors.New("not_a_valid_verfication_method") + ErrorNoOriginalCredential = errors.New("no_original_credential_for_validation") + ErrorCredentialMissingIssuer = errors.New("credential_missing_issuer") + ErrorCredentialMissingType = errors.New("credential_missing_type") + ErrorCredentialNonBaseType = errors.New("credential_contains_non_base_context_type") + ErrorCredentialExpired = errors.New("credential_expired") + ErrorCredentialNotYetValid = errors.New("credential_not_yet_valid") + ErrorCredentialInvalidValidityPeriod = errors.New("credential_invalid_validity_period") ) var SupportedModes = []string{ValidationModeNone, ValidationModeCombined, ValidationModeJsonLd, ValidationModeBaseContext} @@ -36,6 +40,15 @@ var SupportedModes = []string{ValidationModeNone, ValidationModeCombined, Valida // CredentialValidator validates credential content (not signatures — those are checked by JWTProofChecker). type CredentialValidator struct { validationMode string + clock common.Clock +} + +// now returns the current time, falling back to time.Now() when no clock is injected. +func (cv CredentialValidator) now() time.Time { + if cv.clock == nil { + return time.Now() + } + return cv.clock.Now() } // 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 } // ValidateVC validates credential content. Signature verification is handled separately by JWTProofChecker. +// Temporal validity (validFrom/validUntil) is always enforced regardless of mode. func (cv CredentialValidator) ValidateVC(verifiableCredential *common.Credential, verificationContext ValidationContext) (result bool, err error) { + if ok, err := validateCredentialDates(verifiableCredential.Contents(), cv.now()); !ok { + return false, err + } switch cv.validationMode { case ValidationModeNone: @@ -106,7 +123,7 @@ func validateCredentialContent(cred *common.Credential) (bool, error) { return true, nil } -// validateBaseContext checks that the credential uses only W3C base context types. +// validateBaseContext checks that the credential uses only W3C base context types and is temporally valid. var baseContextTypes = map[string]bool{ TypeVerifiableCredential: true, TypeVerifiablePresentation: true, @@ -126,3 +143,22 @@ func validateBaseContext(cred *common.Credential) (bool, error) { } return true, nil } + +// validateCredentialDates checks validFrom and validUntil against now, both bounds inclusive: +// the credential is valid for now in [validFrom, validUntil]. A zero-length validity period +// (validFrom == validUntil) is always rejected. Either field being absent is not an error. +func validateCredentialDates(contents common.CredentialContents, now time.Time) (bool, error) { + if contents.ValidFrom != nil && contents.ValidUntil != nil && contents.ValidFrom.Equal(*contents.ValidUntil) { + logging.Log().Warnf("Credential validation failed: zero-length validity period (validFrom == validUntil: %s)", contents.ValidFrom.Format(time.RFC3339)) + return false, ErrorCredentialInvalidValidityPeriod + } + if contents.ValidFrom != nil && now.Before(*contents.ValidFrom) { + logging.Log().Warnf("Credential validation failed: not yet valid (validFrom: %s, now: %s)", contents.ValidFrom.Format(time.RFC3339), now.Format(time.RFC3339)) + return false, ErrorCredentialNotYetValid + } + if contents.ValidUntil != nil && now.After(*contents.ValidUntil) { + logging.Log().Warnf("Credential validation failed: expired (validUntil: %s, now: %s)", contents.ValidUntil.Format(time.RFC3339), now.Format(time.RFC3339)) + return false, ErrorCredentialExpired + } + return true, nil +} diff --git a/verifier/jwt_verifier_test.go b/verifier/jwt_verifier_test.go index 71bdb17..e2186fc 100644 --- a/verifier/jwt_verifier_test.go +++ b/verifier/jwt_verifier_test.go @@ -2,10 +2,16 @@ package verifier import ( "testing" + "time" common "github.com/fiware/VCVerifier/common" ) +// fixedClock is a test double that always returns the configured instant. +type fixedClock struct{ t time.Time } + +func (fc fixedClock) Now() time.Time { return fc.t } + func TestGetKeyFromMethod(t *testing.T) { type test struct { testName string @@ -224,3 +230,149 @@ func TestSupportedModes(t *testing.T) { } } } + +// --------------------------------------------------------------------------- +// Temporal validity tests +// --------------------------------------------------------------------------- + +// baseTime is a fixed "now" used across all temporal tests so results are deterministic. +var baseTime = time.Date(2024, 6, 15, 12, 0, 0, 0, time.UTC) + +func makeCredential(validFrom, validUntil *time.Time) *common.Credential { + c, _ := common.CreateCredential(common.CredentialContents{ + Issuer: &common.Issuer{ID: "did:web:example.com"}, + Types: []string{"VerifiableCredential"}, + Subject: []common.Subject{{CustomFields: map[string]interface{}{"name": "test"}}}, + ValidFrom: validFrom, + ValidUntil: validUntil, + }, common.CustomFields{}) + return c +} + +func tp(t time.Time) *time.Time { return &t } + +func TestValidateCredentialContent_TemporalValidity(t *testing.T) { + past := baseTime.Add(-24 * time.Hour) + future := baseTime.Add(24 * time.Hour) + + tests := []struct { + name string + validFrom *time.Time + validUntil *time.Time + wantErr error + }{ + { + name: "no_dates_always_valid", + wantErr: nil, + }, + { + name: "valid_from_past_no_expiry", + validFrom: tp(past), + wantErr: nil, + }, + { + name: "valid_until_future_no_issued", + validUntil: tp(future), + wantErr: nil, + }, + { + name: "both_in_valid_window", + validFrom: tp(past), + validUntil: tp(future), + wantErr: nil, + }, + { + name: "expired_credential", + validFrom: tp(past.Add(-48 * time.Hour)), + validUntil: tp(past), + wantErr: ErrorCredentialExpired, + }, + { + name: "not_yet_valid", + validFrom: tp(future), + wantErr: ErrorCredentialNotYetValid, + }, + { + name: "not_yet_valid_with_future_expiry", + validFrom: tp(future), + validUntil: tp(future.Add(24 * time.Hour)), + wantErr: ErrorCredentialNotYetValid, + }, + } + + for _, mode := range []string{ValidationModeCombined, ValidationModeJsonLd, ValidationModeBaseContext} { + for _, tc := range tests { + t.Run(mode+"/"+tc.name, func(t *testing.T) { + cred := makeCredential(tc.validFrom, tc.validUntil) + validator := CredentialValidator{validationMode: mode, clock: fixedClock{t: baseTime}} + _, err := validator.ValidateVC(cred, nil) + if tc.wantErr != nil { + if err == nil { + t.Fatalf("expected error %v, got nil", tc.wantErr) + } + if !isErr(err, tc.wantErr) { + t.Fatalf("expected error %v, got %v", tc.wantErr, err) + } + } else if err != nil { + t.Fatalf("expected no error, got %v", err) + } + }) + } + } +} + +func TestValidateCredentialContent_NoneMode_StillChecksDates(t *testing.T) { + past := baseTime.Add(-1 * time.Hour) + // Even in "none" mode, expired credentials must be rejected. + cred := makeCredential(nil, tp(past)) + validator := CredentialValidator{validationMode: ValidationModeNone, clock: fixedClock{t: baseTime}} + result, err := validator.ValidateVC(cred, nil) + if result || !isErr(err, ErrorCredentialExpired) { + t.Fatalf("none mode should still reject expired credential, got result=%v err=%v", result, err) + } +} + +func TestValidateCredentialContent_ExactBoundary(t *testing.T) { + // validFrom == now is still valid (inclusive). + fromCred := makeCredential(tp(baseTime), nil) + fromValidator := CredentialValidator{validationMode: ValidationModeCombined, clock: fixedClock{t: baseTime}} + if _, err := fromValidator.ValidateVC(fromCred, nil); err != nil { + t.Fatalf("credential starting exactly at now should be valid, got %v", err) + } + + // validUntil == now is still valid (inclusive). + untilCred := makeCredential(nil, tp(baseTime)) + untilValidator := CredentialValidator{validationMode: ValidationModeCombined, clock: fixedClock{t: baseTime}} + if _, err := untilValidator.ValidateVC(untilCred, nil); err != nil { + t.Fatalf("credential expiring exactly at now should be valid, got %v", err) + } +} + +func TestValidateCredentialContent_ZeroLengthValidityPeriod(t *testing.T) { + // validFrom == validUntil is always rejected, regardless of now. + cred := makeCredential(tp(baseTime), tp(baseTime)) + validator := CredentialValidator{validationMode: ValidationModeCombined, clock: fixedClock{t: baseTime}} + _, err := validator.ValidateVC(cred, nil) + if !isErr(err, ErrorCredentialInvalidValidityPeriod) { + t.Fatalf("credential with validFrom == validUntil should be rejected, got %v", err) + } +} + +// isErr reports whether err wraps or equals target. +func isErr(err, target error) bool { + if err == target { + return true + } + type unwrapper interface{ Unwrap() error } + for err != nil { + if err == target { + return true + } + u, ok := err.(unwrapper) + if !ok { + break + } + err = u.Unwrap() + } + return false +} diff --git a/verifier/verifier.go b/verifier/verifier.go index 56c7992..4775848 100644 --- a/verifier/verifier.go +++ b/verifier/verifier.go @@ -325,7 +325,9 @@ func InitVerifier(config *configModel.Configuration, repo database.ServiceReposi sessionCache := cache.New(time.Duration(verifierConfig.SessionExpiry)*time.Second, time.Duration(2*verifierConfig.SessionExpiry)*time.Second) tokenCache := cache.New(time.Duration(verifierConfig.SessionExpiry)*time.Second, time.Duration(2*verifierConfig.SessionExpiry)*time.Second) - credentialsVerifier := CredentialValidator{validationMode: config.Verifier.ValidationMode} + clock := common.RealClock{} + + credentialsVerifier := CredentialValidator{validationMode: config.Verifier.ValidationMode, clock: clock} externalGaiaXValidator := InitGaiaXRegistryValidationService(verifierConfig) @@ -334,8 +336,6 @@ func InitVerifier(config *configModel.Configuration, repo database.ServiceReposi logging.Log().Errorf("Was not able to initiate the credentials config. Err: %v", err) } - clock := common.RealClock{} - var tokenProvider tir.TokenProvider if (&config.M2M).AuthEnabled { tokenProvider, err = tir.InitM2MTokenProvider(config, clock)