diff --git a/common/vc_parser.go b/common/vc_parser.go index 00d95ec..c17662f 100644 --- a/common/vc_parser.go +++ b/common/vc_parser.go @@ -5,6 +5,33 @@ import ( "time" ) +// ParseCredentialDates extracts validFrom/validUntil (VC Data Model 2.0) from a raw VC +// JSON object, falling back to their VC Data Model 1.0/1.1 equivalents issuanceDate/ +// expirationDate when the 2.0 properties are absent. +func ParseCredentialDates(raw JSONObject) (validFrom, validUntil *time.Time) { + if vf, ok := raw[VCKeyValidFrom].(string); ok { + if t, err := time.Parse(time.RFC3339, vf); err == nil { + validFrom = &t + } + } else if vf, ok := raw[VCKeyIssuanceDate].(string); ok { + if t, err := time.Parse(time.RFC3339, vf); err == nil { + validFrom = &t + } + } + + if vu, ok := raw[VCKeyValidUntil].(string); ok { + if t, err := time.Parse(time.RFC3339, vu); err == nil { + validUntil = &t + } + } else if vu, ok := raw[VCKeyExpirationDate].(string); ok { + if t, err := time.Parse(time.RFC3339, vu); err == nil { + validUntil = &t + } + } + + return validFrom, validUntil +} + // ParseCredentialJSON parses a Verifiable Credential from its JSON representation. func ParseCredentialJSON(data []byte) (*Credential, error) { var raw JSONObject @@ -36,27 +63,7 @@ func ParseCredentialJSON(data []byte) (*Credential, error) { } } - // validFrom (VC v2) or issuanceDate (VC v1) - if vf, ok := raw[VCKeyValidFrom].(string); ok { - if t, err := time.Parse(time.RFC3339, vf); err == nil { - contents.ValidFrom = &t - } - } else if vf, ok := raw[VCKeyIssuanceDate].(string); ok { - if t, err := time.Parse(time.RFC3339, vf); err == nil { - contents.ValidFrom = &t - } - } - - // validUntil (VC v2) or expirationDate (VC v1) - if vu, ok := raw[VCKeyValidUntil].(string); ok { - if t, err := time.Parse(time.RFC3339, vu); err == nil { - contents.ValidUntil = &t - } - } else if vu, ok := raw[VCKeyExpirationDate].(string); ok { - if t, err := time.Parse(time.RFC3339, vu); err == nil { - contents.ValidUntil = &t - } - } + contents.ValidFrom, contents.ValidUntil = ParseCredentialDates(raw) if cs, ok := raw[VCKeyCredentialSubject]; ok { contents.Subject = parseSubjects(cs) diff --git a/openapi/api_api.go b/openapi/api_api.go index a2d9b66..94e5fb1 100644 --- a/openapi/api_api.go +++ b/openapi/api_api.go @@ -720,27 +720,20 @@ func getPresentationFromQuery(c *gin.Context, vpToken string) (parsedPresentatio // checks if the presented token contains a single sd-jwt credential. Will be repackage to a presentation for further validation func isSdJWT(c *gin.Context, vpToken string) (isSdJwt bool, presentation *common.Presentation, err error) { - claims, err := getSdJwtParser().Parse(vpToken) + sdJwtParser := getSdJwtParser() + claims, err := sdJwtParser.Parse(vpToken) if err != nil { logging.Log().Debugf("Was not a sdjwt. Err: %v", err) return false, presentation, err } issuer, i_ok := claims[common.JWTClaimIss] - vct, vct_ok := claims[common.JWTClaimVct] + _, vct_ok := claims[common.JWTClaimVct] if !i_ok || !vct_ok { // Not an SD-JWT VC (missing iss or vct) — let other parsers handle it logging.Log().Debugf("Token does not contain issuer(%v) or vct(%v), not an SD-JWT VC.", i_ok, vct_ok) return false, presentation, nil } - customFields := common.CustomFields{} - for k, v := range claims { - if k != common.JWTClaimIss && k != common.JWTClaimVct { - customFields[k] = v - } - } - subject := common.Subject{CustomFields: customFields} - contents := common.CredentialContents{Issuer: &common.Issuer{ID: issuer.(string)}, Types: []string{vct.(string)}, Subject: []common.Subject{subject}} - credential, err := common.CreateCredential(contents, common.CustomFields{}) + credential, err := sdJwtParser.ClaimsToCredential(claims) if err != nil { logging.Log().Infof("Was not able to create credential from sdJwt. E: %v", err) c.AbortWithStatusJSON(http.StatusBadRequest, ErrorMessageInvalidSdJwt) diff --git a/openapi/api_api_test.go b/openapi/api_api_test.go index bf317ed..6fc6a1f 100644 --- a/openapi/api_api_test.go +++ b/openapi/api_api_test.go @@ -5,6 +5,7 @@ import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" + "encoding/base64" "encoding/binary" "encoding/json" "errors" @@ -545,6 +546,45 @@ func getNoVCVPToken() string { return "ewogICJAY29udGV4dCI6IFsKICAgICJodHRwczovL3d3dy53My5vcmcvMjAxOC9jcmVkZW50aWFscy92MSIKICBdLAogICJ0eXBlIjogWwogICAgIlZlcmlmaWFibGVQcmVzZW50YXRpb24iCiAgXSwKICAiaWQiOiAiZWJjNmYxYzIiLAogICJob2xkZXIiOiB7CiAgICAiaWQiOiAiZGlkOmtleTp6Nk1rczltOWlmTHd5M0pXcUg0YzU3RWJCUVZTMlNwUkNqZmE3OXdIYjV2V002dmgiCiAgfSwKICAicHJvb2YiOiB7CiAgICAidHlwZSI6ICJKc29uV2ViU2lnbmF0dXJlMjAyMCIsCiAgICAiY3JlYXRvciI6ICJkaWQ6a2V5Ono2TWtzOW05aWZMd3kzSldxSDRjNTdFYkJRVlMyU3BSQ2pmYTc5d0hiNXZXTTZ2aCIsCiAgICAiY3JlYXRlZCI6ICIyMDIzLTAxLTA2VDA3OjUxOjM2WiIsCiAgICAidmVyaWZpY2F0aW9uTWV0aG9kIjogImRpZDprZXk6ejZNa3M5bTlpZkx3eTNKV3FINGM1N0ViQlFWUzJTcFJDamZhNzl3SGI1dldNNnZoI3o2TWtzOW05aWZMd3kzSldxSDRjNTdFYkJRVlMyU3BSQ2pmYTc5d0hiNXZXTTZ2aCIsCiAgICAiandzIjogImV5SmlOalFpT21aaGJITmxMQ0pqY21sMElqcGJJbUkyTkNKZExDSmhiR2NpT2lKRlpFUlRRU0o5Li42eFNxb1pqYTBOd2pGMGFmOVprbnF4M0NiaDlHRU51bkJmOUM4dUwydWxHZnd1czNVRk1fWm5oUGpXdEhQbC03MkU5cDNCVDVmMnB0Wm9Za3RNS3BEQSIKICB9Cn0" } +func TestIsSdJWT_MapsValidityDates(t *testing.T) { + // Single SD-JWT VC token (not wrapped in a vp claim) — the shape produced when a + // wallet responds with {"": ["~~"]}. + header := map[string]interface{}{"alg": "ES256", "typ": "vc+sd-jwt"} + payload := map[string]interface{}{ + "iss": "did:web:issuer.example.com", + "vct": "VerifiableCredential", + "iat": 1700000000, + "exp": 1800000000, + } + headerBytes, _ := json.Marshal(header) + payloadBytes, _ := json.Marshal(payload) + token := base64.RawURLEncoding.EncodeToString(headerBytes) + "." + + base64.RawURLEncoding.EncodeToString(payloadBytes) + ".fakesig~" + + sdJwtParser = &verifier.ConfigurableSdJwtParser{} + recorder := httptest.NewRecorder() + testContext, _ := gin.CreateTestContext(recorder) + + isSdJwt, presentation, err := isSdJWT(testContext, token) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if !isSdJwt { + t.Fatal("Expected token to be recognized as SD-JWT") + } + creds := presentation.Credentials() + if len(creds) != 1 { + t.Fatalf("Expected 1 credential, got %d", len(creds)) + } + contents := creds[0].Contents() + if contents.ValidFrom == nil || contents.ValidFrom.Unix() != 1700000000 { + t.Errorf("Expected ValidFrom from iat, got %v", contents.ValidFrom) + } + if contents.ValidUntil == nil || contents.ValidUntil.Unix() != 1800000000 { + t.Errorf("Expected ValidUntil from exp, got %v", contents.ValidUntil) + } +} + func newTestProofChecker() *verifier.JWTProofChecker { registry := did.NewRegistry(did.WithVDR(did.NewWebVDR()), did.WithVDR(did.NewKeyVDR()), did.WithVDR(did.NewJWKVDR())) return verifier.NewJWTProofChecker(registry, nil) diff --git a/verifier/jwt_verifier.go b/verifier/jwt_verifier.go index 6a5a12f..18dc645 100644 --- a/verifier/jwt_verifier.go +++ b/verifier/jwt_verifier.go @@ -148,6 +148,7 @@ func validateBaseContext(cred *common.Credential) (bool, error) { // 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) { + logging.Log().Debugf("Validating credential dates: validFrom=%v, validUntil=%v, now=%v", contents.ValidFrom, contents.ValidUntil, now) 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 diff --git a/verifier/presentation_parser.go b/verifier/presentation_parser.go index b8b985c..660786f 100644 --- a/verifier/presentation_parser.go +++ b/verifier/presentation_parser.go @@ -44,6 +44,7 @@ type PresentationParser interface { type SdJwtParser interface { Parse(tokenString string) (map[string]interface{}, error) ParseWithSdJwt(tokenBytes []byte) (presentation *common.Presentation, err error) + ClaimsToCredential(claims map[string]interface{}) (credential *common.Credential, err error) } type ConfigurablePresentationParser struct { @@ -304,6 +305,19 @@ func jwtClaimsToCredential(claims map[string]interface{}) (*common.Credential, e t := time.Unix(int64(exp), 0) contents.ValidUntil = &t } + // Fall back to issuanceDate/expirationDate embedded in the vc claim (JWT-VC 1.0 style, + // used before nbf/exp became the standard mapping for validity dates). + if vcClaim != nil { + if contents.ValidFrom == nil || contents.ValidUntil == nil { + legacyFrom, legacyUntil := common.ParseCredentialDates(vcClaim) + if contents.ValidFrom == nil { + contents.ValidFrom = legacyFrom + } + if contents.ValidUntil == nil { + contents.ValidUntil = legacyUntil + } + } + } // Preserve cnf (confirmation) claim for cryptographic holder binding (RFC 7800). if cnf, ok := claims[common.JWTClaimCnf]; ok { @@ -430,6 +444,8 @@ func parseJSONLDCredential(vcMap map[string]interface{}) (*common.Credential, er } } + contents.ValidFrom, contents.ValidUntil = common.ParseCredentialDates(vcMap) + if subject, ok := vcMap[common.VCKeyCredentialSubject].(map[string]interface{}); ok { s := common.Subject{CustomFields: common.CustomFields{}} if id, ok := subject[common.JSONLDKeyID].(string); ok { @@ -475,14 +491,28 @@ func (sjp *ConfigurableSdJwtParser) ClaimsToCredential(claims map[string]interfa logging.Log().Warnf("Token does not contain issuer(%v) or vct(%v).", i_ok, vct_ok) return credential, ErrorInvalidSdJwt } + dateClaims := map[string]bool{common.JWTClaimNbf: true, common.JWTClaimIat: true, common.JWTClaimExp: true} customFields := common.CustomFields{} for k, v := range claims { - if k != common.JWTClaimIss && k != common.JWTClaimVct { + if k != common.JWTClaimIss && k != common.JWTClaimVct && !dateClaims[k] { customFields[k] = v } } subject := common.Subject{CustomFields: customFields} contents := common.CredentialContents{Issuer: &common.Issuer{ID: issuer.(string)}, Types: []string{vct.(string)}, Subject: []common.Subject{subject}} + + if nbf, ok := claims[common.JWTClaimNbf].(float64); ok { + t := time.Unix(int64(nbf), 0) + contents.ValidFrom = &t + } else if iat, ok := claims[common.JWTClaimIat].(float64); ok { + t := time.Unix(int64(iat), 0) + contents.ValidFrom = &t + } + if exp, ok := claims[common.JWTClaimExp].(float64); ok { + t := time.Unix(int64(exp), 0) + contents.ValidUntil = &t + } + return common.CreateCredential(contents, common.CustomFields{}) } diff --git a/verifier/presentation_parser_test.go b/verifier/presentation_parser_test.go index e80ff15..19de4d8 100644 --- a/verifier/presentation_parser_test.go +++ b/verifier/presentation_parser_test.go @@ -7,6 +7,7 @@ import ( "encoding/base64" "encoding/json" "testing" + "time" "github.com/fiware/VCVerifier/common" configModel "github.com/fiware/VCVerifier/config" @@ -178,6 +179,55 @@ func TestClaimsToCredential_MissingVct(t *testing.T) { } } +func TestClaimsToCredential_MapsValidityDates(t *testing.T) { + parser := &ConfigurableSdJwtParser{} + claims := map[string]interface{}{ + "iss": "did:web:issuer.example.com", + "vct": "VerifiableCredential", + "name": "Alice", + "iat": 1700000000.0, + "exp": 1800000000.0, + } + + cred, err := parser.ClaimsToCredential(claims) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + contents := cred.Contents() + if contents.ValidFrom == nil || contents.ValidFrom.Unix() != 1700000000 { + t.Errorf("Expected ValidFrom from iat, got %v", contents.ValidFrom) + } + if contents.ValidUntil == nil || contents.ValidUntil.Unix() != 1800000000 { + t.Errorf("Expected ValidUntil from exp, got %v", contents.ValidUntil) + } + // iat/exp should not leak into the subject's custom fields + if _, ok := contents.Subject[0].CustomFields["iat"]; ok { + t.Error("iat should not be in custom fields") + } + if _, ok := contents.Subject[0].CustomFields["exp"]; ok { + t.Error("exp should not be in custom fields") + } +} + +func TestClaimsToCredential_NbfTakesPrecedenceOverIat(t *testing.T) { + parser := &ConfigurableSdJwtParser{} + claims := map[string]interface{}{ + "iss": "did:web:issuer.example.com", + "vct": "VerifiableCredential", + "nbf": 1650000000.0, + "iat": 1700000000.0, + } + + cred, err := parser.ClaimsToCredential(claims) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + contents := cred.Contents() + if contents.ValidFrom == nil || contents.ValidFrom.Unix() != 1650000000 { + t.Errorf("Expected ValidFrom from nbf, got %v", contents.ValidFrom) + } +} + // --- Tests for ParseWithSdJwt --- // helper to build a fake JWT token with a given payload @@ -331,6 +381,58 @@ func TestParseWithSdJwt_RejectsUnverifiableVCSignature(t *testing.T) { } } +// --- Tests for JSON-LD VC parsing --- + +func TestParseJSONLDCredential_MapsV1IssuanceExpirationDates(t *testing.T) { + vcMap := map[string]interface{}{ + "@context": []interface{}{"https://www.w3.org/2018/credentials/v1"}, + "type": []interface{}{"VerifiableCredential"}, + "issuer": "did:web:issuer.example.com", + "issuanceDate": "2023-11-14T22:13:20Z", + "expirationDate": "2023-11-16T01:20:00Z", + "credentialSubject": map[string]interface{}{ + "id": "did:web:subject.example.com", + }, + } + + cred, err := parseJSONLDCredential(vcMap) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + contents := cred.Contents() + if contents.ValidFrom == nil || contents.ValidFrom.Format(time.RFC3339) != "2023-11-14T22:13:20Z" { + t.Errorf("Expected ValidFrom from issuanceDate, got %v", contents.ValidFrom) + } + if contents.ValidUntil == nil || contents.ValidUntil.Format(time.RFC3339) != "2023-11-16T01:20:00Z" { + t.Errorf("Expected ValidUntil from expirationDate, got %v", contents.ValidUntil) + } +} + +func TestParseJSONLDCredential_MapsV2ValidFromValidUntil(t *testing.T) { + vcMap := map[string]interface{}{ + "@context": []interface{}{"https://www.w3.org/ns/credentials/v2"}, + "type": []interface{}{"VerifiableCredential"}, + "issuer": "did:web:issuer.example.com", + "validFrom": "2023-11-14T22:13:20Z", + "validUntil": "2023-11-16T01:20:00Z", + "credentialSubject": map[string]interface{}{ + "id": "did:web:subject.example.com", + }, + } + + cred, err := parseJSONLDCredential(vcMap) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + contents := cred.Contents() + if contents.ValidFrom == nil || contents.ValidFrom.Format(time.RFC3339) != "2023-11-14T22:13:20Z" { + t.Errorf("Expected ValidFrom from validFrom, got %v", contents.ValidFrom) + } + if contents.ValidUntil == nil || contents.ValidUntil.Format(time.RFC3339) != "2023-11-16T01:20:00Z" { + t.Errorf("Expected ValidUntil from validUntil, got %v", contents.ValidUntil) + } +} + // --- Tests for JSON-LD VP parsing --- func TestParseJSONLDPresentation(t *testing.T) { @@ -412,6 +514,35 @@ func TestJwtClaimsToCredential(t *testing.T) { } } +func TestJwtClaimsToCredential_FallsBackToLegacyVcDates(t *testing.T) { + // No top-level nbf/iat/exp — a JWT-VC 1.0 style credential carrying + // issuanceDate/expirationDate inside the vc claim instead. + claims := map[string]interface{}{ + "iss": "did:web:issuer.example.com", + "vc": map[string]interface{}{ + "@context": []interface{}{"https://www.w3.org/2018/credentials/v1"}, + "type": []interface{}{"VerifiableCredential"}, + "issuanceDate": "2023-11-14T22:13:20Z", + "expirationDate": "2023-11-16T01:20:00Z", + "credentialSubject": map[string]interface{}{ + "id": "did:web:subject.example.com", + }, + }, + } + + cred, err := jwtClaimsToCredential(claims) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + contents := cred.Contents() + if contents.ValidFrom == nil || contents.ValidFrom.Format(time.RFC3339) != "2023-11-14T22:13:20Z" { + t.Errorf("Expected ValidFrom from legacy issuanceDate, got %v", contents.ValidFrom) + } + if contents.ValidUntil == nil || contents.ValidUntil.Format(time.RFC3339) != "2023-11-16T01:20:00Z" { + t.Errorf("Expected ValidUntil from legacy expirationDate, got %v", contents.ValidUntil) + } +} + // --- Tests for verifyCnfBinding --- func TestVerifyCnfBinding_MatchingKey(t *testing.T) {