Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 43 additions & 7 deletions verifier/jwt_verifier.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package verifier
import (
"errors"
"strings"
"time"

"github.com/fiware/VCVerifier/common"
"github.com/fiware/VCVerifier/logging"
Expand All @@ -23,19 +24,31 @@ 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}

// 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.
Expand Down Expand Up @@ -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 {
Comment thread
Mortega5 marked this conversation as resolved.
return false, err
}

switch cv.validationMode {
case ValidationModeNone:
Expand Down Expand Up @@ -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,
Expand All @@ -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
}
152 changes: 152 additions & 0 deletions verifier/jwt_verifier_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
6 changes: 3 additions & 3 deletions verifier/verifier.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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)
Expand Down
Loading