From dd225d54090f3e9a81f01acb878996b7f9ad9451 Mon Sep 17 00:00:00 2001 From: Gabe <7622243+decentralgabe@users.noreply.github.com> Date: Thu, 13 Apr 2023 10:06:21 -0700 Subject: [PATCH] Presentation Exchange: support for json schema filtering and relational constraints (#351) * constraint filter * subject is issuer constraint * fix 165 too * fix annoying pathing issues * update tests --- credential/exchange/builder_test.go | 3 +- credential/exchange/model.go | 30 ++-- credential/exchange/request.go | 12 +- credential/exchange/request_test.go | 2 +- credential/exchange/submission.go | 29 +-- credential/exchange/submission_test.go | 79 ++++---- credential/exchange/verification.go | 69 +++++-- credential/exchange/verification_test.go | 168 ++++++++++++++++++ .../manifest/testdata/full-credential.json | 2 +- .../manifest/testdata/full-manifest.json | 20 ++- credential/manifest/validation.go | 17 +- credential/signing/jwt.go | 46 +++-- credential/signing/jwt_test.go | 14 +- credential/util/util.go | 100 +++++++---- credential/util/util_test.go | 33 +++- .../apartment_application.go | 14 +- example/usecase/steel_thread/testdata/cm.json | 4 +- example/usecase/steel_thread/testdata/vc.json | 2 +- 18 files changed, 472 insertions(+), 172 deletions(-) diff --git a/credential/exchange/builder_test.go b/credential/exchange/builder_test.go index eb94b875..08a4d583 100644 --- a/credential/exchange/builder_test.go +++ b/credential/exchange/builder_test.go @@ -9,7 +9,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/TBD54566975/ssi-sdk/crypto" - "github.com/TBD54566975/ssi-sdk/util" ) func TestPresentationDefinitionBuilder(t *testing.T) { @@ -425,5 +424,5 @@ func TestPE(t *testing.T) { assert.NoError(t, err) definition, err := builder.Build() assert.NoError(t, err) - println(util.PrettyJSON(definition)) + assert.NotEmpty(t, definition) } diff --git a/credential/exchange/model.go b/credential/exchange/model.go index f3eaebe7..415611fc 100644 --- a/credential/exchange/model.go +++ b/credential/exchange/model.go @@ -3,6 +3,7 @@ package exchange import ( "reflect" + "github.com/goccy/go-json" "github.com/pkg/errors" "github.com/TBD54566975/ssi-sdk/crypto" @@ -260,21 +261,22 @@ type Constraints struct { LimitDisclosure *Preference `json:"limit_disclosure,omitempty"` // https://identity.foundation/presentation-exchange/#relational-constraint-feature - SubjectIsIssuer *Preference `json:"subject_is_issuer,omitempty"` - IsHolder *RelationalConstraint `json:"is_holder,omitempty" validate:"omitempty,dive"` - SameSubject *RelationalConstraint `json:"same_subject,omitempty"` + SubjectIsIssuer *Preference `json:"subject_is_issuer,omitempty"` + IsHolder []RelationalConstraint `json:"is_holder,omitempty" validate:"omitempty,dive"` + SameSubject []RelationalConstraint `json:"same_subject,omitempty"` // https://identity.foundation/presentation-exchange/#credential-status-constraint-feature Statuses *CredentialStatus `json:"statuses,omitempty"` } type Field struct { - ID string `json:"id,omitempty"` - Name string `json:"name,omitempty"` - Path []string `json:"path,omitempty" validate:"required"` - Purpose string `json:"purpose,omitempty"` - Optional bool `json:"optional,omitempty"` - IntentToRetain bool `json:"intent_to_retain,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Path []string `json:"path,omitempty" validate:"required"` + Purpose string `json:"purpose,omitempty"` + Optional bool `json:"optional,omitempty"` + // https://identity.foundation/presentation-exchange/spec/v2.0.0/#retention-feature + IntentToRetain bool `json:"intent_to_retain,omitempty"` // If a predicate property is present, filter must be too // https://identity.foundation/presentation-exchange/#predicate-feature Predicate *Preference `json:"predicate,omitempty"` @@ -282,7 +284,7 @@ type Field struct { } type RelationalConstraint struct { - FieldID string `json:"field_id" validate:"required"` + FieldID []string `json:"field_id" validate:"required"` Directive *Preference `json:"directive" validate:"required"` } @@ -306,6 +308,14 @@ type Filter struct { OneOf any `json:"oneOf,omitempty"` } +func (f Filter) ToJSON() (string, error) { + jsonBytes, err := json.Marshal(f) + if err != nil { + return "", err + } + return string(jsonBytes), nil +} + // CredentialStatus https://identity.foundation/presentation-exchange/#credential-status-constraint-feature type CredentialStatus struct { Active *struct { diff --git a/credential/exchange/request.go b/credential/exchange/request.go index 5fa45476..c2cecd7d 100644 --- a/credential/exchange/request.go +++ b/credential/exchange/request.go @@ -24,7 +24,7 @@ const ( // Presentation Request Option types - TargetOption PresentationRequestOptionType = "target" + AudienceOption PresentationRequestOptionType = "audience" ) type PresentationRequestOptionType string @@ -46,16 +46,16 @@ func BuildPresentationRequest(signer any, pt PresentationRequestType, def Presen if len(opts) > 1 { return nil, fmt.Errorf("only one option supported") } - var target string + var audience string if len(opts) == 1 { opt := opts[0] - if opt.Type != TargetOption { + if opt.Type != AudienceOption { return nil, fmt.Errorf("unsupported option type: %s", opt.Type) } var ok bool - target, ok = opt.Value.(string) + audience, ok = opt.Value.(string) if !ok { - return nil, fmt.Errorf("target option value must be a string") + return nil, fmt.Errorf("audience option value must be a string") } } @@ -68,7 +68,7 @@ func BuildPresentationRequest(signer any, pt PresentationRequestType, def Presen if !ok { return nil, errors.New("signer is not a JWTSigner") } - return BuildJWTPresentationRequest(jwtSigner, def, target) + return BuildJWTPresentationRequest(jwtSigner, def, audience) default: return nil, fmt.Errorf("presentation request type <%s> is not implemented", pt) } diff --git a/credential/exchange/request_test.go b/credential/exchange/request_test.go index 3bcf07b1..8be6e2d2 100644 --- a/credential/exchange/request_test.go +++ b/credential/exchange/request_test.go @@ -72,7 +72,7 @@ func TestBuildPresentationRequest(t *testing.T) { testDef := getDummyPresentationDefinition() _, err = BuildPresentationRequest(*signer, "bad", testDef, PresentationRequestOption{ - Type: TargetOption, + Type: AudienceOption, Value: "did:test:abcd", }) assert.Error(t, err) diff --git a/credential/exchange/submission.go b/credential/exchange/submission.go index 9849cc95..5f7b4860 100644 --- a/credential/exchange/submission.go +++ b/credential/exchange/submission.go @@ -12,6 +12,7 @@ import ( "github.com/TBD54566975/ssi-sdk/util" "github.com/goccy/go-json" "github.com/google/uuid" + "github.com/lestrrat-go/jwx/v2/jwt" "github.com/oliveagle/jsonpath" "github.com/pkg/errors" ) @@ -41,22 +42,22 @@ type PresentationClaim struct { LDPFormat *LinkedDataFormat // If we have a token, we assume we have a JWT format value - TokenJSON *string - JWTFormat *JWTFormat + TokenBytes []byte + JWTFormat *JWTFormat // The algorithm or Linked Data proof type by which the claim was signed must be present SignatureAlgorithmOrProofType string } func (pc *PresentationClaim) IsEmpty() bool { - if pc == nil || (pc.Credential == nil && pc.Presentation == nil && pc.TokenJSON == nil) { + if pc == nil || (pc.Credential == nil && pc.Presentation == nil && len(pc.TokenBytes) == 0) { return true } return reflect.DeepEqual(pc, &PresentationClaim{}) } -// GetClaimValue returns the value of the claim, since PresentationClaim is a union type. An error is returned if -// no value is present in any of the possible embedded types. +// GetClaimValue returns the value of the claim as JSON. Since PresentationClaim is a union type. An error +// is returned if no value is present in any of the possible embedded types. func (pc *PresentationClaim) GetClaimValue() (any, error) { if pc.Credential != nil { return *pc.Credential, nil @@ -64,8 +65,13 @@ func (pc *PresentationClaim) GetClaimValue() (any, error) { if pc.Presentation != nil { return *pc.Presentation, nil } - if pc.TokenJSON != nil { - return *pc.TokenJSON, nil + if pc.TokenBytes != nil { + switch pc.JWTFormat.String() { + case JWT.String(), JWTVC.String(), JWTVP.String(): + return jwt.Parse(pc.TokenBytes, jwt.WithValidate(false), jwt.WithVerify(false)) + default: + return nil, fmt.Errorf("unsupported JWT format: %s", pc.JWTFormat) + } } return nil, errors.New("claim is empty") } @@ -86,7 +92,7 @@ func (pc *PresentationClaim) GetClaimFormat() (string, error) { } return string(*pc.LDPFormat), nil } - if pc.TokenJSON != nil { + if pc.TokenBytes != nil { if pc.JWTFormat == nil { return "", errors.New("JWT claim has no JWT format set") } @@ -472,8 +478,8 @@ func canProcessDefinition(def PresentationDefinition) error { } if len(id.Constraints.Fields) > 0 { for _, field := range id.Constraints.Fields { - if field.Predicate != nil || field.Filter != nil { - return errors.New("predicate and filter features not supported") + if field.Predicate != nil { + return errors.New("predicate feature not supported") } } } @@ -496,11 +502,12 @@ func canProcessDefinition(def PresentationDefinition) error { } // hasRelationalConstraint checks a constraint property for relational constraint field values +// except for subject is issuer, which is supported func hasRelationalConstraint(constraints *Constraints) bool { if constraints == nil { return false } - return constraints.IsHolder != nil || constraints.SameSubject != nil || constraints.SubjectIsIssuer != nil + return constraints.IsHolder != nil || constraints.SameSubject != nil } func IsSupportedEmbedTarget(et EmbedTarget) bool { diff --git a/credential/exchange/submission_test.go b/credential/exchange/submission_test.go index 081d7f84..f6ef3840 100644 --- a/credential/exchange/submission_test.go +++ b/credential/exchange/submission_test.go @@ -1,7 +1,6 @@ package exchange import ( - "strings" "testing" "github.com/goccy/go-json" @@ -233,9 +232,13 @@ func TestBuildPresentationSubmissionVP(t *testing.T) { Constraints: &Constraints{ Fields: []Field{ { - Path: []string{"$.vc.credentialSubject.color"}, - ID: "color-input-descriptor", - Purpose: "need to check the color", + Path: []string{"$.vc.credentialSubject.name"}, + ID: "name-input-descriptor", + Purpose: "need to check the name contains Jim", + Filter: &Filter{ + Type: "string", + Pattern: "Jim*", + }, }, }, }, @@ -252,7 +255,7 @@ func TestBuildPresentationSubmissionVP(t *testing.T) { } testVCJWT := getTestJWTVerifiableCredential() presentationClaimJWT := PresentationClaim{ - TokenJSON: &testVCJWT, + TokenBytes: testVCJWT, JWTFormat: JWTVC.Ptr(), SignatureAlgorithmOrProofType: string(crypto.EdDSA), } @@ -293,7 +296,7 @@ func TestBuildPresentationSubmissionVP(t *testing.T) { assert.NotEmpty(tt, asVCJWT) assert.Equal(tt, "did:example:456", asVCJWT["sub"]) - assert.Equal(tt, "yellow", asVCJWT["vc"].(map[string]any)["credentialSubject"].(map[string]any)["color"]) + assert.Equal(tt, "JimBobertson", asVCJWT["vc"].(map[string]any)["credentialSubject"].(map[string]any)["name"]) }) } @@ -487,7 +490,7 @@ func TestCanProcessDefinition(tt *testing.T) { } err := canProcessDefinition(def) assert.Error(tt, err) - assert.Contains(tt, err.Error(), "predicate and filter features not supported") + assert.Contains(tt, err.Error(), "predicate feature not supported") }) tt.Run("With Relational Constraint", func(tt *testing.T) { @@ -497,9 +500,11 @@ func TestCanProcessDefinition(tt *testing.T) { { ID: "id-with-relational-constraint", Constraints: &Constraints{ - IsHolder: &RelationalConstraint{ - FieldID: "field-id", - Directive: Allowed.Ptr(), + IsHolder: []RelationalConstraint{ + { + FieldID: []string{"field-id"}, + Directive: Allowed.Ptr(), + }, }, }, }, @@ -688,7 +693,7 @@ func TestNormalizePresentationClaims(t *testing.T) { assert.NotEmpty(tt, jwtVC) presentationClaim := PresentationClaim{ - TokenJSON: &jwtVC, + TokenBytes: jwtVC, JWTFormat: JWTVC.Ptr(), SignatureAlgorithmOrProofType: string(crypto.EdDSA), } @@ -741,30 +746,34 @@ func TestNormalizePresentationClaims(t *testing.T) { }) } -func getTestJWTVerifiableCredential() string { - literalToken := `{ - "exp": 1925061804, - "iss": "did:example:123", - "nbf": 1609529004, - "sub": "did:example:456", - "vc": { - "@context": [ - "https://www.w3.org/2018/credentials/v1", - "https://w3id.org/security/suites/jws-2020/v1" - ], - "credentialSubject": { - "id": "did:example:456", - "color": "yellow" - }, - "expirationDate": "2031-01-01T19:23:24Z", - "issuanceDate": "2021-01-01T19:23:24Z", - "issuer": "did:example:123", - "type": ["VerifiableCredential"] - } - }` - noNewLines := strings.ReplaceAll(literalToken, "\n", "") - noTabs := strings.ReplaceAll(noNewLines, "\t", "") - return strings.ReplaceAll(noTabs, " ", "") +func getTestJWTVerifiableCredential() []byte { + // { + // "alg": "EdDSA", + // "typ": "JWT" + // } + // { + // "iat": 1609529004, + // "iss": "did:example:123", + // "jti": "http://example.edu/credentials/1872", + // "nbf": 1609529004, + // "nonce": "24976372-adc4-4808-90c2-d86ea805e11b", + // "sub": "did:example:456", + // "vc": { + // "@context": [ + // "https://www.w3.org/2018/credentials/v1", + // "https://w3id.org/security/suites/jws-2020/v1" + // ], + // "type": [ + // "VerifiableCredential" + // ], + // "issuer": "", + // "issuanceDate": "", + // "credentialSubject": { + // "name": "JimBobertson" + // } + // } + // } + return []byte("eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE2MDk1MjkwMDQsImlzcyI6ImRpZDpleGFtcGxlOjEyMyIsImp0aSI6Imh0dHA6Ly9leGFtcGxlLmVkdS9jcmVkZW50aWFscy8xODcyIiwibmJmIjoxNjA5NTI5MDA0LCJub25jZSI6IjI0OTc2MzcyLWFkYzQtNDgwOC05MGMyLWQ4NmVhODA1ZTExYiIsInN1YiI6ImRpZDpleGFtcGxlOjQ1NiIsInZjIjp7IkBjb250ZXh0IjpbImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL3YxIiwiaHR0cHM6Ly93M2lkLm9yZy9zZWN1cml0eS9zdWl0ZXMvandzLTIwMjAvdjEiXSwidHlwZSI6WyJWZXJpZmlhYmxlQ3JlZGVudGlhbCJdLCJpc3N1ZXIiOiIiLCJpc3N1YW5jZURhdGUiOiIiLCJjcmVkZW50aWFsU3ViamVjdCI6eyJuYW1lIjoiSmltQm9iZXJ0c29uIn19fQ.STf2oFVPTwEyEhCpU_u9Qy52VzAwHlWtxq2NrlXhzvh0aJIbr5astEagEY2PRZ_S6Og-7Q4sYTT7sq6HJSjLBA") } func getGenericTestClaim() map[string]any { diff --git a/credential/exchange/verification.go b/credential/exchange/verification.go index 572d134b..067eee6e 100644 --- a/credential/exchange/verification.go +++ b/credential/exchange/verification.go @@ -5,6 +5,7 @@ import ( "strings" credutil "github.com/TBD54566975/ssi-sdk/credential/util" + "github.com/TBD54566975/ssi-sdk/schema" "github.com/TBD54566975/ssi-sdk/credential" "github.com/TBD54566975/ssi-sdk/credential/signing" @@ -73,7 +74,9 @@ func VerifyPresentationSubmissionVP(def PresentationDefinition, vp credential.Ve } // validate each input descriptor is fulfilled + inputDescriptorLookup := make(map[string]InputDescriptor) for _, inputDescriptor := range def.InputDescriptors { + inputDescriptorLookup[inputDescriptor.ID] = inputDescriptor submissionDescriptor, ok := submissionDescriptorLookup[inputDescriptor.ID] if !ok { return fmt.Errorf("unfulfilled input descriptor<%s>; submission not valid", inputDescriptor.ID) @@ -86,8 +89,7 @@ func VerifyPresentationSubmissionVP(def PresentationDefinition, vp credential.Ve strings.Join(inputDescriptor.Format.FormatValues(), ", ")) } - // TODO(gabe) support nested paths in presentation submissions - // https://github.com/TBD54566975/ssi-sdk/issues/73 + // TODO(gabe) support nested paths in presentation submissions https://github.com/TBD54566975/ssi-sdk/issues/73 if submissionDescriptor.PathNested != nil { return fmt.Errorf("submission with nested paths not supported: %s", submissionDescriptor.ID) } @@ -98,25 +100,64 @@ func VerifyPresentationSubmissionVP(def PresentationDefinition, vp credential.Ve return errors.Wrapf(err, "could not resolve claim from submission descriptor<%s> with path: %s", submissionDescriptor.ID, submissionDescriptor.Path) } - submittedClaim, err := credutil.ClaimAsJSON(claim) + + // TODO(gabe) add in signature verification of claims here https://github.com/TBD54566975/ssi-sdk/issues/71 + cred, err := credutil.ToCredential(claim) if err != nil { - return errors.Wrapf(err, "getting claim as go-json: <%s>", claim) + return errors.Wrapf(err, "getting claim as json: <%s>", claim) } // verify the submitted claim complies with the input descriptor // if there are no constraints, we are done checking for validity - if inputDescriptor.Constraints == nil { + constraints := inputDescriptor.Constraints + if constraints == nil { continue } // TODO(gabe) consider enforcing limited disclosure if present // for each field we need to verify at least one path matches - for _, field := range inputDescriptor.Constraints.Fields { - if err = findMatchingPath(submittedClaim, field.Path); err != nil { - return errors.Wrapf(err, "input descriptor<%s> not fulfilled for field: %s", inputDescriptor.ID, field.ID) + credJSON, err := credutil.ToCredentialJSONMap(claim) + if err != nil { + return errors.Wrapf(err, "getting credential as json: %v", cred) + } + for _, field := range constraints.Fields { + // get data from path + pathedDataJSON, err := getJSONDataFromPath(credJSON, field.Path) + if err != nil && !field.Optional { + return errors.Wrapf(err, "input descriptor<%s> not fulfilled for non-optional field: %s", inputDescriptor.ID, field.ID) + } + + // apply json schema filter if present + if field.Filter != nil { + filterJSON, err := field.Filter.ToJSON() + if err != nil && !field.Optional { + return errors.Wrapf(err, "turning filter into JSON schema") + } + if err = schema.IsJSONValidAgainstSchema(pathedDataJSON, filterJSON); err != nil && !field.Optional { + return errors.Wrapf(err, "unable to apply filter<%s> to data from path: %s", filterJSON, field.Path) + } + } + } + + // check relational constraints if present + subjectIsIssuerConstraint := constraints.SubjectIsIssuer + if subjectIsIssuerConstraint != nil && *subjectIsIssuerConstraint == Required { + issuer, ok := cred.Issuer.(string) + if !ok { + return fmt.Errorf("unable to get issuer from cred: %s", cred.Issuer) + } + subject, ok := cred.CredentialSubject[credential.VerifiableCredentialIDProperty] + if !ok { + return fmt.Errorf("unable to get subject from cred: %s", cred.CredentialSubject) + } + if issuer != subject { + return fmt.Errorf("subject<%s> is not the same as issuer<%s>", subject, issuer) } } + + // TODO(gabe) is_holder and same_subject cannot yet be implemented https://github.com/TBD54566975/ssi-sdk/issues/64 + // TODO(gabe) check credential status https://github.com/TBD54566975/ssi-sdk/issues/65 } return nil } @@ -133,11 +174,15 @@ func toPresentationSubmission(maybePresentationSubmission any) (*PresentationSub return &submission, nil } -func findMatchingPath(claim any, paths []string) error { +func getJSONDataFromPath(claim any, paths []string) (string, error) { for _, path := range paths { - if _, err := jsonpath.JsonPathLookup(claim, path); err == nil { - return nil + if pathedData, err := jsonpath.JsonPathLookup(claim, path); err == nil { + pathedDataBytes, err := json.Marshal(pathedData) + if err != nil { + return "", errors.Wrapf(err, "marshalling pathed data<%s> to bytes", pathedData) + } + return string(pathedDataBytes), nil } } - return errors.New("matching path for claim could not be found") + return "", errors.New("matching path for claim could not be found") } diff --git a/credential/exchange/verification_test.go b/credential/exchange/verification_test.go index c5bb1a1c..95189415 100644 --- a/credential/exchange/verification_test.go +++ b/credential/exchange/verification_test.go @@ -250,6 +250,174 @@ func TestVerifyPresentationSubmissionVP(t *testing.T) { assert.Contains(tt, err.Error(), "matching path for claim could not be found") }) + t.Run("Input Descriptor with invalid and valid optional filter (test issuer)", func(tt *testing.T) { + def := PresentationDefinition{ + ID: "test-id", + InputDescriptors: []InputDescriptor{ + { + ID: "id-1", + Constraints: &Constraints{ + Fields: []Field{ + { + ID: "issuer-input-descriptor", + Path: []string{"$.issuer"}, + Filter: &Filter{ + Type: "string", + Pattern: "not-test-issuer", + }, + Optional: true, + }, + }, + }, + }, + }, + } + assert.NoError(tt, def.IsValid()) + + presentation := credential.VerifiablePresentation{ + Context: []string{"https://www.w3.org/2018/credentials/v1", + "https://identity.foundation/presentation-exchange/submission/v1"}, + ID: "55da1f5c-e2b3-443a-b687-0434712c5469", + Type: []string{"VerifiablePresentation", "PresentationSubmission"}, + PresentationSubmission: PresentationSubmission{ + ID: "45da2588-3637-45b0-84f1-17e97945ac09", + DefinitionID: "test-id", + DescriptorMap: []SubmissionDescriptor{ + { + Format: "ldp_vc", + ID: "id-1", + Path: "$.verifiableCredential[0]", + }, + }, + }, + VerifiableCredential: []any{ + getTestVerifiableCredential(), + }, + } + + err := VerifyPresentationSubmissionVP(def, presentation) + assert.NoError(tt, err) + + // set optional flag to false and re-verify + def.InputDescriptors[0].Constraints.Fields[0].Optional = false + err = VerifyPresentationSubmissionVP(def, presentation) + assert.Error(tt, err) + assert.Contains(tt, err.Error(), "unable to apply filter") + }) + + t.Run("Input Descriptor with subject == issuer constraint", func(tt *testing.T) { + def := PresentationDefinition{ + ID: "test-id", + InputDescriptors: []InputDescriptor{ + { + ID: "id-1", + Constraints: &Constraints{ + SubjectIsIssuer: Required.Ptr(), + Fields: []Field{ + { + ID: "issuer-input-descriptor", + Path: []string{"$.issuer"}, + Filter: &Filter{ + Type: "string", + Pattern: "test-issuer", + }, + }, + }, + }, + }, + }, + } + assert.NoError(tt, def.IsValid()) + + presentation := credential.VerifiablePresentation{ + Context: []string{"https://www.w3.org/2018/credentials/v1", + "https://identity.foundation/presentation-exchange/submission/v1"}, + ID: "55da1f5c-e2b3-443a-b687-0434712c5469", + Type: []string{"VerifiablePresentation", "PresentationSubmission"}, + PresentationSubmission: PresentationSubmission{ + ID: "45da2588-3637-45b0-84f1-17e97945ac09", + DefinitionID: "test-id", + DescriptorMap: []SubmissionDescriptor{ + { + Format: "ldp_vc", + ID: "id-1", + Path: "$.verifiableCredential[0]", + }, + }, + }, + VerifiableCredential: []any{ + getTestVerifiableCredential(), + }, + } + + err := VerifyPresentationSubmissionVP(def, presentation) + assert.Error(tt, err) + assert.Contains(tt, err.Error(), "subject is not the same as issuer") + + // modify the VC to have the same issuer and subject + testVC := getTestVerifiableCredential() + testVC.CredentialSubject[credential.VerifiableCredentialIDProperty] = "test-issuer" + presentation.VerifiableCredential = []any{testVC} + err = VerifyPresentationSubmissionVP(def, presentation) + assert.NoError(tt, err) + }) + + t.Run("Input Descriptor with valid filter (credential properties)", func(tt *testing.T) { + def := PresentationDefinition{ + ID: "test-id", + InputDescriptors: []InputDescriptor{ + { + ID: "id-1", + Constraints: &Constraints{ + Fields: []Field{ + { + ID: "issuer-input-descriptor", + Path: []string{"$.issuer"}, + Filter: &Filter{ + Type: "string", + Pattern: "test-issuer", + }, + }, + { + ID: "company-input-descriptor", + Path: []string{"$.credentialSubject.company"}, + Filter: &Filter{ + Type: "string", + Pattern: "Block", + }, + }, + }, + }, + }, + }, + } + assert.NoError(tt, def.IsValid()) + + presentation := credential.VerifiablePresentation{ + Context: []string{"https://www.w3.org/2018/credentials/v1", + "https://identity.foundation/presentation-exchange/submission/v1"}, + ID: "55da1f5c-e2b3-443a-b687-0434712c5469", + Type: []string{"VerifiablePresentation", "PresentationSubmission"}, + PresentationSubmission: PresentationSubmission{ + ID: "45da2588-3637-45b0-84f1-17e97945ac09", + DefinitionID: "test-id", + DescriptorMap: []SubmissionDescriptor{ + { + Format: "ldp_vc", + ID: "id-1", + Path: "$.verifiableCredential[0]", + }, + }, + }, + VerifiableCredential: []any{ + getTestVerifiableCredential(), + }, + } + + err := VerifyPresentationSubmissionVP(def, presentation) + assert.NoError(tt, err) + }) + t.Run("Verification with JWT credential", func(t *testing.T) { def := PresentationDefinition{ ID: "test-id", diff --git a/credential/manifest/testdata/full-credential.json b/credential/manifest/testdata/full-credential.json index 923b2867..e3e53a4a 100644 --- a/credential/manifest/testdata/full-credential.json +++ b/credential/manifest/testdata/full-credential.json @@ -13,6 +13,6 @@ "familyName": "simpson", "birthDate": "2009-01-03", "postalAddress": "p sherman 42 wallaby way, sydney", - "taxID": "123" + "taxId": "123" } } \ No newline at end of file diff --git a/credential/manifest/testdata/full-manifest.json b/credential/manifest/testdata/full-manifest.json index 1069bf05..8bfe5908 100644 --- a/credential/manifest/testdata/full-manifest.json +++ b/credential/manifest/testdata/full-manifest.json @@ -31,7 +31,8 @@ { "id": "givenName", "path": [ - "$.credentialSubject.givenName" + "$.credentialSubject.givenName", + "$.vc.credentialSubject.givenName" ], "filter": { "type": "string", @@ -41,7 +42,8 @@ { "id": "additionalName", "path": [ - "$.credentialSubject.additionalName" + "$.credentialSubject.additionalName", + "$.vc.credentialSubject.additionalName" ], "filter": { "type": "string", @@ -51,7 +53,8 @@ { "id": "familyName", "path": [ - "$.credentialSubject.familyName" + "$.credentialSubject.familyName", + "$.vc.credentialSubject.familyName" ], "filter": { "type": "string", @@ -61,7 +64,8 @@ { "id": "birthDate", "path": [ - "$.credentialSubject.birthDate" + "$.credentialSubject.birthDate", + "$.vc.credentialSubject.birthDate" ], "filter": { "type": "string", @@ -71,16 +75,18 @@ { "id": "postalAddress", "path": [ - "$.credentialSubject.postalAddress" + "$.credentialSubject.postalAddress", + "$.vc.credentialSubject.postalAddress" ], "filter": { "type": "string" } }, { - "id": "taxID", + "id": "taxId", "path": [ - "$.credentialSubject.taxID" + "$.credentialSubject.taxId", + "$.vc.credentialSubject.taxId" ], "filter": { "type": "string" diff --git a/credential/manifest/validation.go b/credential/manifest/validation.go index 00bceaf0..45356138 100644 --- a/credential/manifest/validation.go +++ b/credential/manifest/validation.go @@ -177,7 +177,7 @@ func IsValidCredentialApplicationForManifest(cm CredentialManifest, applicationA } // convert submitted claim vc to map[string]any - cred, credErr := credutil.CredentialsFromInterface(submittedClaim) + cred, credErr := credutil.ToCredential(submittedClaim) if credErr != nil { unfulfilledInputDescriptors[inputDescriptor.ID] = "failed to extract credential from json" continue @@ -196,18 +196,13 @@ func IsValidCredentialApplicationForManifest(cm CredentialManifest, applicationA // TODO(gabe) consider enforcing limited disclosure if present // for each field we need to verify at least one path matches - credMap := make(map[string]any) - claimBytes, jsonErr := json.Marshal(cred) - if jsonErr != nil { - err = errresp.NewErrorResponseWithErrorAndMsg(errresp.CriticalError, err, "failed to marshal vc") - return unfulfilledInputDescriptors, err - } - if err = json.Unmarshal(claimBytes, &credMap); err != nil { - err = errresp.NewErrorResponseWithErrorAndMsg(errresp.CriticalError, err, "problem in unmarshalling credential") - return unfulfilledInputDescriptors, err + credJSON, err := credutil.ToCredentialJSONMap(submittedClaim) + if err != nil { + unfulfilledInputDescriptors[inputDescriptor.ID] = "failed to extract credential from json" + continue } for _, field := range inputDescriptor.Constraints.Fields { - if err = findMatchingPath(credMap, field.Path); err != nil { + if err = findMatchingPath(credJSON, field.Path); err != nil { errMsg := fmt.Sprintf("input descriptor not fulfilled for field: %s", field.ID) unfulfilledInputDescriptors[inputDescriptor.ID] = errMsg continue diff --git a/credential/signing/jwt.go b/credential/signing/jwt.go index 4b2eb9a6..d0bb35ed 100644 --- a/credential/signing/jwt.go +++ b/credential/signing/jwt.go @@ -105,52 +105,64 @@ func ParseVerifiableCredentialFromJWT(token string) (jws.Headers, jwt.Token, *cr if err != nil { return nil, nil, nil, errors.Wrap(err, "parsing credential token") } - vcClaim, ok := parsed.Get(VCJWTProperty) + + // get headers + headers, err := getJWTHeaders([]byte(token)) + if err != nil { + return nil, nil, nil, errors.Wrap(err, "could not get JWT headers") + } + + // parse remaining JWT properties and set in the credential + cred, err := ParseVerifiableCredentialFromToken(parsed) + if err != nil { + return nil, nil, nil, errors.Wrap(err, "parsing credential from token") + } + + return headers, parsed, cred, nil +} + +// ParseVerifiableCredentialFromToken takes a JWT object and parses it into a VerifiableCredential +func ParseVerifiableCredentialFromToken(token jwt.Token) (*credential.VerifiableCredential, error) { + // parse remaining JWT properties and set in the credential + vcClaim, ok := token.Get(VCJWTProperty) if !ok { - return nil, nil, nil, fmt.Errorf("did not find %s property in token", VCJWTProperty) + return nil, fmt.Errorf("did not find %s property in token", VCJWTProperty) } vcBytes, err := json.Marshal(vcClaim) if err != nil { - return nil, nil, nil, errors.Wrap(err, "marshalling credential claim") + return nil, errors.Wrap(err, "marshalling credential claim") } var cred credential.VerifiableCredential if err = json.Unmarshal(vcBytes, &cred); err != nil { - return nil, nil, nil, errors.Wrap(err, "reconstructing Verifiable Credential") + return nil, errors.Wrap(err, "reconstructing Verifiable Credential") } - // get headers - headers, err := getJWTHeaders([]byte(token)) - if err != nil { - return nil, nil, nil, errors.Wrap(err, "could not get JWT headers") - } - - // parse remaining JWT properties and set in the credential - jti, hasJTI := parsed.Get(jwt.JwtIDKey) + jti, hasJTI := token.Get(jwt.JwtIDKey) jtiStr, ok := jti.(string) if hasJTI && ok && jtiStr != "" { cred.ID = jtiStr } - iat, hasIAT := parsed.Get(jwt.IssuedAtKey) + iat, hasIAT := token.Get(jwt.IssuedAtKey) iatTime, ok := iat.(time.Time) if hasIAT && ok { cred.IssuanceDate = iatTime.Format(time.RFC3339) } - exp, hasExp := parsed.Get(jwt.ExpirationKey) + exp, hasExp := token.Get(jwt.ExpirationKey) expTime, ok := exp.(time.Time) if hasExp && ok { cred.ExpirationDate = expTime.Format(time.RFC3339) } // Note: we only handle string issuer values, not objects for JWTs - iss, hasIss := parsed.Get(jwt.IssuerKey) + iss, hasIss := token.Get(jwt.IssuerKey) issStr, ok := iss.(string) if hasIss && ok && issStr != "" { cred.Issuer = issStr } - sub, hasSub := parsed.Get(jwt.SubjectKey) + sub, hasSub := token.Get(jwt.SubjectKey) subStr, ok := sub.(string) if hasSub && ok && subStr != "" { if cred.CredentialSubject == nil { @@ -159,7 +171,7 @@ func ParseVerifiableCredentialFromJWT(token string) (jws.Headers, jwt.Token, *cr cred.CredentialSubject[credential.VerifiableCredentialIDProperty] = subStr } - return headers, parsed, &cred, nil + return &cred, nil } // JWTVVPParameters represents additional parameters needed when constructing a JWT VP as opposed to a VP diff --git a/credential/signing/jwt_test.go b/credential/signing/jwt_test.go index 2d454f0f..b466b40a 100644 --- a/credential/signing/jwt_test.go +++ b/credential/signing/jwt_test.go @@ -13,11 +13,15 @@ import ( func TestVerifiableCredentialJWT(t *testing.T) { testCredential := credential.VerifiableCredential{ - Context: []any{"https://www.w3.org/2018/credentials/v1", "https://w3id.org/security/suites/jws-2020/v1"}, - Type: []string{"VerifiableCredential"}, - Issuer: "did:example:123", - IssuanceDate: "2021-01-01T19:23:24Z", - CredentialSubject: map[string]any{}, + ID: "http://example.edu/credentials/1872", + Context: []any{"https://www.w3.org/2018/credentials/v1", "https://w3id.org/security/suites/jws-2020/v1"}, + Type: []string{"VerifiableCredential"}, + Issuer: "did:example:123", + IssuanceDate: "2021-01-01T19:23:24Z", + CredentialSubject: map[string]any{ + "id": "did:example:456", + "name": "JimBobertson", + }, } t.Run("Known JWK Signer", func(t *testing.T) { diff --git a/credential/util/util.go b/credential/util/util.go index 9f6facaa..671f368f 100644 --- a/credential/util/util.go +++ b/credential/util/util.go @@ -7,59 +7,93 @@ import ( "github.com/TBD54566975/ssi-sdk/credential" "github.com/TBD54566975/ssi-sdk/credential/signing" "github.com/goccy/go-json" + "github.com/lestrrat-go/jwx/v2/jwt" "github.com/pkg/errors" ) -// CredentialsFromInterface turn a generic cred into a known shape without maintaining the proof/signature wrapper -func CredentialsFromInterface(genericCred any) (*credential.VerifiableCredential, error) { +// ToCredential turn a generic cred into its known object model +func ToCredential(genericCred any) (*credential.VerifiableCredential, error) { switch genericCred.(type) { + case *credential.VerifiableCredential: + return genericCred.(*credential.VerifiableCredential), nil + case credential.VerifiableCredential: + verifiableCredential := genericCred.(credential.VerifiableCredential) + return &verifiableCredential, nil case string: // JWT - _, _, cred, err := signing.ParseVerifiableCredentialFromJWT(genericCred.(string)) + _, _, parsedCred, err := signing.ParseVerifiableCredentialFromJWT(genericCred.(string)) if err != nil { - return nil, errors.Wrap(err, "could not parse credential from JWT") + return nil, errors.Wrap(err, "parsing credential from JWT") } - return cred, nil + return parsedCred, nil case map[string]any: - // JSON - var cred credential.VerifiableCredential - credMapBytes, err := json.Marshal(genericCred.(map[string]any)) - if err != nil { - return nil, errors.Wrap(err, "could not marshal credential map") + // VC or JWTVC JSON + credJSON := genericCred.(map[string]any) + credMapBytes, marshalErr := json.Marshal(credJSON) + if marshalErr != nil { + return nil, errors.Wrap(marshalErr, "marshalling credential map") } - if err = json.Unmarshal(credMapBytes, &cred); err != nil { - return nil, errors.Wrap(err, "could not unmarshal credential map") + + // first try as a VC object + var cred credential.VerifiableCredential + if err := json.Unmarshal(credMapBytes, &cred); err != nil || cred.IsEmpty() { + // if that fails, try as a JWT + _, vcFromJWT, err := VCJWTJSONToVC(credMapBytes) + if err != nil { + return nil, errors.Wrap(err, "parsing generic credential as either VC or JWT") + } + return vcFromJWT, nil } return &cred, nil - case credential.VerifiableCredential: - // VerifiableCredential - cred := genericCred.(credential.VerifiableCredential) - return &cred, nil - default: - return nil, fmt.Errorf("invalid credential type: %s", reflect.TypeOf(genericCred).Kind().String()) } + return nil, fmt.Errorf("invalid credential type: %s", reflect.TypeOf(genericCred).Kind().String()) } -// ClaimAsJSON converts a claim with an unknown any into the go-json representation of that credential. -// claim can only be of type {string, map[string]interface, VerifiableCredential}. -func ClaimAsJSON(claim any) (map[string]any, error) { - switch c := claim.(type) { +// ToCredentialJSONMap turn a generic cred into a JSON object +func ToCredentialJSONMap(genericCred any) (map[string]any, error) { + switch genericCred.(type) { case map[string]any: - return c, nil - default: + return genericCred.(map[string]any), nil + case string: + // JWT + _, token, _, parseErr := signing.ParseVerifiableCredentialFromJWT(genericCred.(string)) + if parseErr != nil { + return nil, errors.Wrap(parseErr, "parsing credential from JWT") + } + // marshal it into a JSON map + tokenJSONBytes, marshalErr := json.Marshal(token) + if marshalErr != nil { + return nil, errors.Wrap(marshalErr, "marshaling credential JWT") + } + var credJSON map[string]any + if err := json.Unmarshal(tokenJSONBytes, &credJSON); err != nil { + return nil, errors.Wrap(err, "unmarshalling credential JWT") + } + return credJSON, nil + case credential.VerifiableCredential, *credential.VerifiableCredential: + credJSONBytes, marshalErr := json.Marshal(genericCred) + if marshalErr != nil { + return nil, errors.Wrap(marshalErr, "marshalling credential object") + } + var credJSON map[string]any + if err := json.Unmarshal(credJSONBytes, &credJSON); err != nil { + return nil, errors.Wrap(err, "unmarshalling credential object") + } + return credJSON, nil } + return nil, fmt.Errorf("invalid credential type: %s", reflect.TypeOf(genericCred).Kind().String()) +} - vc, err := CredentialsFromInterface(claim) +// VCJWTJSONToVC converts a JSON representation of a VC JWT into a VerifiableCredential +func VCJWTJSONToVC(vcJWTJSON []byte) (jwt.Token, *credential.VerifiableCredential, error) { + // next, try to turn it into a JWT to check if it's a VC JWT + token, err := jwt.Parse(vcJWTJSON, jwt.WithValidate(false), jwt.WithVerify(false)) if err != nil { - return nil, errors.Wrap(err, "credential from interface") + return nil, nil, errors.Wrap(err, "coercing generic cred to JWT") } - vcData, err := json.Marshal(vc) + cred, err := signing.ParseVerifiableCredentialFromToken(token) if err != nil { - return nil, errors.Wrap(err, "marshalling credential") - } - var submittedClaim map[string]any - if err := json.Unmarshal(vcData, &submittedClaim); err != nil { - return nil, errors.Wrap(err, "unmarshalling credential") + return nil, nil, errors.Wrap(err, "parsing credential from token") } - return submittedClaim, nil + return token, cred, nil } diff --git a/credential/util/util_test.go b/credential/util/util_test.go index ff813da6..f30ffa2b 100644 --- a/credential/util/util_test.go +++ b/credential/util/util_test.go @@ -12,18 +12,27 @@ import ( func TestCredentialsFromInterface(t *testing.T) { t.Run("Bad Cred", func(tt *testing.T) { - parsedCred, err := CredentialsFromInterface("bad") + parsedCred, err := ToCredential("bad") assert.Error(tt, err) assert.Empty(tt, parsedCred) + + genericCred, err := ToCredentialJSONMap("bad") + assert.Error(tt, err) + assert.Empty(tt, genericCred) }) t.Run("Unsigned Cred", func(tt *testing.T) { testCred := getTestCredential() - parsedCred, err := CredentialsFromInterface(testCred) + parsedCred, err := ToCredential(testCred) assert.NoError(tt, err) assert.NotEmpty(tt, parsedCred) - assert.True(tt, parsedCred.Issuer == testCred.Issuer) + assert.Equal(tt, testCred.Issuer, parsedCred.Issuer) + + genericCred, err := ToCredentialJSONMap(testCred) + assert.NoError(tt, err) + assert.NotEmpty(tt, genericCred) + assert.Equal(tt, testCred.Issuer, genericCred["issuer"]) }) t.Run("Data Integrity Cred", func(tt *testing.T) { @@ -51,10 +60,15 @@ func TestCredentialsFromInterface(t *testing.T) { err = suite.Sign(signer, &testCred) assert.NoError(t, err) - parsedCred, err := CredentialsFromInterface(testCred) + parsedCred, err := ToCredential(testCred) assert.NoError(tt, err) assert.NotEmpty(tt, parsedCred) - assert.True(tt, parsedCred.Issuer == testCred.Issuer) + assert.Equal(tt, testCred.Issuer, parsedCred.Issuer) + + genericCred, err := ToCredentialJSONMap(testCred) + assert.NoError(tt, err) + assert.NotEmpty(tt, genericCred) + assert.Equal(tt, parsedCred.Issuer, genericCred["issuer"]) }) t.Run("JWT Cred", func(tt *testing.T) { @@ -73,10 +87,15 @@ func TestCredentialsFromInterface(t *testing.T) { assert.NoError(tt, err) assert.NotEmpty(tt, signed) - parsedCred, err := CredentialsFromInterface(string(signed)) + parsedCred, err := ToCredential(string(signed)) assert.NoError(tt, err) assert.NotEmpty(tt, parsedCred) - assert.True(tt, parsedCred.Issuer == testCred.Issuer) + assert.Equal(tt, parsedCred.Issuer, testCred.Issuer) + + genericCred, err := ToCredentialJSONMap(string(signed)) + assert.NoError(tt, err) + assert.NotEmpty(tt, genericCred) + assert.Equal(tt, parsedCred.Issuer, genericCred["iss"]) }) } diff --git a/example/usecase/apartment_application/apartment_application.go b/example/usecase/apartment_application/apartment_application.go index aad67ff8..f4114ae0 100644 --- a/example/usecase/apartment_application/apartment_application.go +++ b/example/usecase/apartment_application/apartment_application.go @@ -21,7 +21,6 @@ import ( "github.com/TBD54566975/ssi-sdk/did" "github.com/TBD54566975/ssi-sdk/example" "github.com/TBD54566975/ssi-sdk/util" - "github.com/goccy/go-json" ) func main() { @@ -105,7 +104,7 @@ func main() { }, Constraints: &exchange.Constraints{Fields: []exchange.Field{ { - Path: []string{"$.credentialSubject.birthdate"}, + Path: []string{"$.vc.credentialSubject.birthdate"}, ID: "birthdate", }, }}, @@ -118,7 +117,7 @@ func main() { example.HandleExampleError(presentationDefinition.IsValid(), "Presentation definition is not valid") presentationRequestBytes, err := exchange.BuildPresentationRequest(*aptSigner, exchange.JWTRequest, *presentationDefinition, exchange.PresentationRequestOption{ - Type: exchange.TargetOption, + Type: exchange.AudienceOption, Value: holderDIDKey.String(), }) example.HandleExampleError(err, "Failed to make presentation request") @@ -136,15 +135,8 @@ func main() { example.HandleExampleError(err, "Failed to verify presentation request") example.HandleExampleError(verifiedPresentationDefinition.IsValid(), "Verified presentation definition is not valid") - // TODO: (neal) (issue https://github.com/TBD54566975/ssi-sdk/issues/165) - // Have the presentation claim's token format support signedVCBytes for the BuildPresentationSubmission function - _, _, vsJSON, err := signing.ParseVerifiableCredentialFromJWT(string(signedVCBytes)) - example.HandleExampleError(err, "Failed to parse VC") - vcJSONBytes, err := json.Marshal(vsJSON) - example.HandleExampleError(err, "Failed to marshal vc jwt") - presentationClaim := exchange.PresentationClaim{ - TokenJSON: util.StringPtr(string(vcJSONBytes)), + TokenBytes: signedVCBytes, JWTFormat: exchange.JWTVC.Ptr(), SignatureAlgorithmOrProofType: string(crypto.EdDSA), } diff --git a/example/usecase/steel_thread/testdata/cm.json b/example/usecase/steel_thread/testdata/cm.json index 158e55ef..a524db46 100644 --- a/example/usecase/steel_thread/testdata/cm.json +++ b/example/usecase/steel_thread/testdata/cm.json @@ -88,9 +88,9 @@ } }, { - "id": "taxID", + "id": "taxId", "path": [ - "$.vc.credentialSubject.taxID" + "$.vc.credentialSubject.taxId" ], "filter": { "type": "string" diff --git a/example/usecase/steel_thread/testdata/vc.json b/example/usecase/steel_thread/testdata/vc.json index ad62a121..fe508819 100644 --- a/example/usecase/steel_thread/testdata/vc.json +++ b/example/usecase/steel_thread/testdata/vc.json @@ -25,7 +25,7 @@ "postalCode": "78724", "streetAddress": "7405 Janktopia Ave." }, - "taxID": "123-45-6789" + "taxId": "123-45-6789" }, "iss": "did:janky:alice", "sub": "did:janky:alice"