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
49 changes: 28 additions & 21 deletions common/vc_parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
15 changes: 4 additions & 11 deletions openapi/api_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
40 changes: 40 additions & 0 deletions openapi/api_api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
Expand Down Expand Up @@ -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 {"<query_id>": ["<sd-jwt-vc>~<disclosures>~<kb-jwt>"]}.
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)
Expand Down
1 change: 1 addition & 0 deletions verifier/jwt_verifier.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 31 additions & 1 deletion verifier/presentation_parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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{})
}

Expand Down
131 changes: 131 additions & 0 deletions verifier/presentation_parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"encoding/base64"
"encoding/json"
"testing"
"time"

"github.com/fiware/VCVerifier/common"
configModel "github.com/fiware/VCVerifier/config"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
Loading