From caa491331b3327283863ff4fa9c9075d502791da Mon Sep 17 00:00:00 2001 From: Wout Slakhorst Date: Fri, 3 Nov 2023 13:15:13 +0100 Subject: [PATCH] generate presentation submission with path_nested (#2563) --- auth/api/iam/openid4vp.go | 29 ++++-- vcr/pe/presentation_definition.go | 121 +++++++++++---------- vcr/pe/presentation_definition_test.go | 52 +++++---- vcr/pe/presentation_submission.go | 139 +++++++++++++++++++++++++ vcr/pe/presentation_submission_test.go | 88 ++++++++++++++++ vcr/pe/submission.go | 39 ------- vcr/pe/submission_requirement.go | 49 +++++---- vcr/pe/submission_requirement_test.go | 8 +- vcr/pe/submission_test.go | 37 ------- vcr/pe/types.go | 8 +- 10 files changed, 371 insertions(+), 199 deletions(-) create mode 100644 vcr/pe/presentation_submission.go create mode 100644 vcr/pe/presentation_submission_test.go delete mode 100644 vcr/pe/submission.go delete mode 100644 vcr/pe/submission_test.go diff --git a/auth/api/iam/openid4vp.go b/auth/api/iam/openid4vp.go index b282b4f5f8..5cfe9f6915 100644 --- a/auth/api/iam/openid4vp.go +++ b/auth/api/iam/openid4vp.go @@ -136,16 +136,18 @@ func (r *Wrapper) handlePresentationRequest(params map[string]string, session *S } } - // TODO: https://github.com/nuts-foundation/nuts-node/issues/2359 - // TODO: What if multiple credentials of the same type match? - _, matchingCredentials, err := presentationDefinition.Match(credentials) + submissionBuilder := presentationDefinition.PresentationSubmissionBuilder() + submissionBuilder.AddWallet(session.OwnDID, ownCredentials) + _, signInstructions, err := submissionBuilder.Build("ldp_vp") if err != nil { return nil, fmt.Errorf("unable to match presentation definition: %w", err) } var credentialIDs []string - for _, matchingCredential := range matchingCredentials { - templateParams.Credentials = append(templateParams.Credentials, makeCredentialInfo(matchingCredential)) - credentialIDs = append(credentialIDs, matchingCredential.ID.String()) + for _, signInstruction := range signInstructions { + for _, matchingCredential := range signInstruction.VerifiableCredentials { + templateParams.Credentials = append(templateParams.Credentials, makeCredentialInfo(matchingCredential)) + credentialIDs = append(credentialIDs, matchingCredential.ID.String()) + } } session.ServerState["openid4vp_credentials"] = credentialIDs @@ -204,16 +206,21 @@ func (r *Wrapper) handlePresentationRequestAccept(c echo.Context) error { if presentationDefinition == nil { return fmt.Errorf("unsupported scope for presentation exchange: %s", session.Scope) } - // TODO: Options + // TODO: Options (including format) resultParams := map[string]string{} - presentationSubmission, credentials, err := presentationDefinition.Match(credentials) + submissionBuilder := presentationDefinition.PresentationSubmissionBuilder() + submissionBuilder.AddWallet(session.OwnDID, credentials) + submission, signInstructions, err := submissionBuilder.Build("ldp_vp") if err != nil { - // Matched earlier, shouldn't happen return err } - presentationSubmissionJSON, _ := json.Marshal(presentationSubmission) + presentationSubmissionJSON, _ := json.Marshal(submission) resultParams[presentationSubmissionParam] = string(presentationSubmissionJSON) - verifiablePresentation, err := r.vcr.Wallet().BuildPresentation(c.Request().Context(), credentials, holder.PresentationOptions{}, &session.OwnDID, false) + if len(signInstructions) != 1 { + // todo support multiple wallets (org + user) + return errors.New("expected to create exactly one presentation") + } + verifiablePresentation, err := r.vcr.Wallet().BuildPresentation(c.Request().Context(), signInstructions[0].VerifiableCredentials, holder.PresentationOptions{}, &signInstructions[0].Holder, false) if err != nil { return err } diff --git a/vcr/pe/presentation_definition.go b/vcr/pe/presentation_definition.go index 8e966d0dea..4b1261641c 100644 --- a/vcr/pe/presentation_definition.go +++ b/vcr/pe/presentation_definition.go @@ -22,11 +22,11 @@ import ( "encoding/json" "errors" "fmt" + "strings" + "github.com/PaesslerAG/jsonpath" "github.com/dlclark/regexp2" - "github.com/google/uuid" "github.com/nuts-foundation/go-did/vc" - "strings" ) // ErrUnsupportedFilter is returned when a filter uses unsupported features. @@ -39,6 +39,12 @@ type Candidate struct { VC *vc.VerifiableCredential } +// PresentationContext is a helper struct to keep track of the index of the VP in the nested paths of a PresentationSubmission. +type PresentationContext struct { + Index int + PresentationSubmission *PresentationSubmission +} + // Match matches the VCs against the presentation definition. // It implements ยง5 of the Presentation Exchange specification (v2.x.x pre-Draft, 2023-07-29) (https://identity.foundation/presentation-exchange/#presentation-definition) // It supports the following: @@ -47,21 +53,31 @@ type Candidate struct { // - number, boolean, array and string JSON schema types // - Submission Requirements Feature // It doesn't do the credential search, this should be done before calling this function. -// The PresentationDefinition.Format should be altered/set if an envelope defines the supported format before calling. -// The resulting PresentationSubmission has paths that are relative to the matching VCs. -// The PresentationSubmission needs to be altered so the paths use "path_nested"s that are relative to the created VP. +// The given PresentationContext is used to set the correct vp index in the nested paths and to alter the given PresentationSubmission. +// It assumes this method is used for OpenID4VP since other envelopes require different nesting. // ErrUnsupportedFilter is returned when a filter uses unsupported features. // Other errors can be returned for faulty JSON paths or regex patterns. -func (presentationDefinition PresentationDefinition) Match(vcs []vc.VerifiableCredential) (PresentationSubmission, []vc.VerifiableCredential, error) { +func (presentationDefinition PresentationDefinition) Match(vcs []vc.VerifiableCredential) ([]vc.VerifiableCredential, []InputDescriptorMappingObject, error) { + var selectedVCs []vc.VerifiableCredential + var descriptorMaps []InputDescriptorMappingObject + var err error if len(presentationDefinition.SubmissionRequirements) > 0 { - return presentationDefinition.matchSubmissionRequirements(vcs) + if descriptorMaps, selectedVCs, err = presentationDefinition.matchSubmissionRequirements(vcs); err != nil { + return nil, nil, err + } + } else if descriptorMaps, selectedVCs, err = presentationDefinition.matchBasic(vcs); err != nil { + return nil, nil, err } - return presentationDefinition.matchBasic(vcs) + + return selectedVCs, descriptorMaps, nil } func (presentationDefinition PresentationDefinition) matchConstraints(vcs []vc.VerifiableCredential) ([]Candidate, error) { var candidates []Candidate + for _, inputDescriptor := range presentationDefinition.InputDescriptors { + // we create an empty Candidate. If a VC matches, it'll be attached to the Candidate. + // if no VC matches, the Candidate will have an nil VC which is detected later on for SubmissionRequirement rules. match := Candidate{ InputDescriptor: *inputDescriptor, } @@ -77,66 +93,64 @@ func (presentationDefinition PresentationDefinition) matchConstraints(vcs []vc.V } candidates = append(candidates, match) } + return candidates, nil } -func (presentationDefinition PresentationDefinition) matchBasic(vcs []vc.VerifiableCredential) (PresentationSubmission, []vc.VerifiableCredential, error) { - // for each VC in vcs: - // for each descriptor in presentation_definition.descriptors: - // for each constraint in descriptor.constraints: - // for each field in constraint.fields: - // a vc must match the field - presentationSubmission := PresentationSubmission{ - Id: uuid.New().String(), - DefinitionId: presentationDefinition.Id, - } - matches, err := presentationDefinition.matchConstraints(vcs) +func (presentationDefinition PresentationDefinition) matchBasic(vcs []vc.VerifiableCredential) ([]InputDescriptorMappingObject, []vc.VerifiableCredential, error) { + // do the constraints check + candidates, err := presentationDefinition.matchConstraints(vcs) if err != nil { - return PresentationSubmission{}, nil, err + return nil, nil, err } + matchingCredentials := make([]vc.VerifiableCredential, len(candidates)) + var descriptors []InputDescriptorMappingObject var index int - matchingCredentials := make([]vc.VerifiableCredential, len(matches)) - for _, match := range matches { - if match.VC == nil { - return PresentationSubmission{}, []vc.VerifiableCredential{}, nil + for i, candidate := range candidates { + // a constraint is not matched, return early + // we do not raise an error here since SubmissionRequirements might specify a "pick" rule + if candidate.VC == nil { + return nil, []vc.VerifiableCredential{}, nil } + // create the InputDescriptorMappingObject with the relative path mapping := InputDescriptorMappingObject{ - Id: match.InputDescriptor.Id, - Format: match.VC.Format(), + Id: candidate.InputDescriptor.Id, + Format: candidate.VC.Format(), Path: fmt.Sprintf("$.verifiableCredential[%d]", index), } - presentationSubmission.DescriptorMap = append(presentationSubmission.DescriptorMap, mapping) - matchingCredentials[index] = *match.VC + descriptors = append(descriptors, mapping) + matchingCredentials[i] = *candidate.VC index++ } - return presentationSubmission, matchingCredentials, nil + return descriptors, matchingCredentials, nil } -func (presentationDefinition PresentationDefinition) matchSubmissionRequirements(vcs []vc.VerifiableCredential) (PresentationSubmission, []vc.VerifiableCredential, error) { +func (presentationDefinition PresentationDefinition) matchSubmissionRequirements(vcs []vc.VerifiableCredential) ([]InputDescriptorMappingObject, []vc.VerifiableCredential, error) { // first we use the constraint matching algorithm to get the matching credentials candidates, err := presentationDefinition.matchConstraints(vcs) if err != nil { - return PresentationSubmission{}, nil, err + return nil, nil, err } // then we check the group constraints // for each 'group' in input_descriptor there must be a matching 'from' field in a submission requirement - availableGroups := make(map[string]GroupCandidates) + // This is the "all groups must be present" check + availableGroups := make(map[string]groupCandidates) for _, submissionRequirement := range presentationDefinition.SubmissionRequirements { - for _, group := range submissionRequirement.Groups() { - availableGroups[group] = GroupCandidates{ + for _, group := range submissionRequirement.groups() { + availableGroups[group] = groupCandidates{ Name: group, } } } for _, group := range presentationDefinition.groups() { if _, ok := availableGroups[group.Name]; !ok { - return PresentationSubmission{}, nil, fmt.Errorf("group %s is required but not available", group.Name) + return nil, nil, fmt.Errorf("group %s is required but not available", group.Name) } } - // now we know there are no missing groups, we can start matching the submission requirements + // now we know there are no missing groups, we can start matching the SubmissionRequirements // now we add each specific match to the correct group(s) for _, match := range candidates { for _, group := range match.InputDescriptor.Group { @@ -146,29 +160,27 @@ func (presentationDefinition PresentationDefinition) matchSubmissionRequirements } } - presentationSubmission := PresentationSubmission{ - Id: uuid.New().String(), - DefinitionId: presentationDefinition.Id, - } - var selectedVCs []vc.VerifiableCredential - - // for each submission requirement: + // for each SubmissionRequirement: // we select the credentials that match the requirement // then we apply the rules and save the resulting credentials + var selectedVCs []vc.VerifiableCredential for _, submissionRequirement := range presentationDefinition.SubmissionRequirements { submissionRequirementVCs, err := submissionRequirement.match(availableGroups) if err != nil { - return PresentationSubmission{}, nil, err + return nil, nil, err } selectedVCs = append(selectedVCs, submissionRequirementVCs...) } uniqueVCs := deduplicate(selectedVCs) - // now we have the selected VCs, we can create the presentation submission + // now we have the selected VCs, we can create the PresentationSubmission var index int + var descriptors []InputDescriptorMappingObject outer: for _, uniqueVC := range uniqueVCs { + // we loop over the candidate VCs and find the one that matches the unique VC + // for each match we create a InputDescriptorMappingObject which links the VC to the InputDescriptor from the PresentationDefinition for _, candidate := range candidates { if candidate.VC != nil && vcEqual(uniqueVC, *candidate.VC) { mapping := InputDescriptorMappingObject{ @@ -176,25 +188,24 @@ outer: Format: candidate.VC.Format(), Path: fmt.Sprintf("$.verifiableCredential[%d]", index), } - presentationSubmission.DescriptorMap = append(presentationSubmission.DescriptorMap, mapping) + descriptors = append(descriptors, mapping) index++ continue outer } } } - return presentationSubmission, uniqueVCs, nil + return descriptors, uniqueVCs, nil } -// groups returns all the Matches with input descriptors and matching VCs. -// If no VC matches the input descriptor, the match is still returned. -func (presentationDefinition PresentationDefinition) groups() []GroupCandidates { - groups := make(map[string]GroupCandidates) +// groups returns all the groupCandidates with input descriptors and matching VCs. +func (presentationDefinition PresentationDefinition) groups() []groupCandidates { + groups := make(map[string]groupCandidates) for _, inputDescriptor := range presentationDefinition.InputDescriptors { for _, group := range inputDescriptor.Group { existing, ok := groups[group] if !ok { - existing = GroupCandidates{ + existing = groupCandidates{ Name: group, } } @@ -202,7 +213,7 @@ func (presentationDefinition PresentationDefinition) groups() []GroupCandidates groups[group] = existing } } - var result []GroupCandidates + var result []groupCandidates for _, group := range groups { result = append(result, group) } @@ -260,7 +271,7 @@ func matchDescriptor(descriptor InputDescriptor, credential vc.VerifiableCredent return &InputDescriptorMappingObject{ Id: descriptor.Id, - Format: "ldp_vc", // todo: hardcoded for now, must be derived from the VC, but we don't support other VC types yet + Format: credential.Format(), }, nil } @@ -351,7 +362,7 @@ func getValueAtPath(path string, vcAsInterface interface{}) (interface{}, error) // Supported schema types: string, number, boolean, array, enum. // Supported schema properties: const, enum, pattern. These only work for strings. // Supported go value types: string, float64, int, bool and array. -// 'null' values are also not supported. +// 'null' values are not supported. // It returns an error on unsupported features or when the regex pattern fails. func matchFilter(filter Filter, value interface{}) (bool, error) { // first we check if it's an enum, so we can recursively call matchFilter for each value diff --git a/vcr/pe/presentation_definition_test.go b/vcr/pe/presentation_definition_test.go index b88808ca79..b935bf2c15 100644 --- a/vcr/pe/presentation_definition_test.go +++ b/vcr/pe/presentation_definition_test.go @@ -109,20 +109,19 @@ func TestMatch(t *testing.T) { _ = json.Unmarshal(vcJSON, &verifiableCredential) t.Run("Happy flow", func(t *testing.T) { - presentationSubmission, vcs, err := presentationDefinition.Match([]vc.VerifiableCredential{verifiableCredential}) + vcs, mappingObjects, err := presentationDefinition.Match([]vc.VerifiableCredential{verifiableCredential}) require.NoError(t, err) assert.Len(t, vcs, 1) - require.Len(t, presentationSubmission.DescriptorMap, 1) - assert.Equal(t, "$.verifiableCredential[0]", presentationSubmission.DescriptorMap[0].Path) + require.Len(t, mappingObjects, 1) + assert.Equal(t, "$.verifiableCredential[0]", mappingObjects[0].Path) }) t.Run("Only second VC matches", func(t *testing.T) { - presentationSubmission, vcs, err := presentationDefinition.Match([]vc.VerifiableCredential{{Type: []ssi.URI{ssi.MustParseURI("VerifiableCredential")}}, verifiableCredential}) + vcs, mappingObjects, err := presentationDefinition.Match([]vc.VerifiableCredential{{Type: []ssi.URI{ssi.MustParseURI("VerifiableCredential")}}, verifiableCredential}) require.NoError(t, err) assert.Len(t, vcs, 1) - require.Len(t, presentationSubmission.DescriptorMap, 1) - assert.Equal(t, "$.verifiableCredential[0]", presentationSubmission.DescriptorMap[0].Path) + assert.Len(t, mappingObjects, 1) }) }) t.Run("Submission requirement feature", func(t *testing.T) { @@ -131,13 +130,11 @@ func TestMatch(t *testing.T) { presentationDefinition := PresentationDefinition{} _ = json.Unmarshal([]byte(test.PickOne), &presentationDefinition) - submission, vcs, err := presentationDefinition.Match([]vc.VerifiableCredential{vc1, vc2}) + vcs, mappingObjects, err := presentationDefinition.Match([]vc.VerifiableCredential{vc1, vc2}) require.NoError(t, err) assert.Len(t, vcs, 1) - require.NotNil(t, submission) - require.Len(t, submission.DescriptorMap, 1) - assert.Equal(t, "$.verifiableCredential[0]", submission.DescriptorMap[0].Path) + assert.Len(t, mappingObjects, 1) }) t.Run("error", func(t *testing.T) { presentationDefinition := PresentationDefinition{} @@ -154,11 +151,11 @@ func TestMatch(t *testing.T) { presentationDefinition := PresentationDefinition{} _ = json.Unmarshal([]byte(test.PickMinMax), &presentationDefinition) - submission, vcs, err := presentationDefinition.Match([]vc.VerifiableCredential{vc1, vc2}) + vcs, mappingObjects, err := presentationDefinition.Match([]vc.VerifiableCredential{vc1, vc2}) require.NoError(t, err) assert.Len(t, vcs, 2) - assert.NotNil(t, submission) + assert.Len(t, mappingObjects, 2) }) t.Run("error", func(t *testing.T) { presentationDefinition := PresentationDefinition{} @@ -174,31 +171,31 @@ func TestMatch(t *testing.T) { presentationDefinition := PresentationDefinition{} _ = json.Unmarshal([]byte(test.PickOnePerGroup), &presentationDefinition) - submission, vcs, err := presentationDefinition.Match([]vc.VerifiableCredential{vc1, vc2}) + vcs, mappingObjects, err := presentationDefinition.Match([]vc.VerifiableCredential{vc1, vc2}) require.NoError(t, err) assert.Len(t, vcs, 2) - assert.NotNil(t, submission) + assert.Len(t, mappingObjects, 2) }) t.Run("Pick all", func(t *testing.T) { presentationDefinition := PresentationDefinition{} _ = json.Unmarshal([]byte(test.All), &presentationDefinition) - submission, vcs, err := presentationDefinition.Match([]vc.VerifiableCredential{vc1, vc2}) + vcs, mappingObjects, err := presentationDefinition.Match([]vc.VerifiableCredential{vc1, vc2}) require.NoError(t, err) assert.Len(t, vcs, 2) - assert.NotNil(t, submission) + assert.Len(t, mappingObjects, 2) }) t.Run("Deduplicate", func(t *testing.T) { presentationDefinition := PresentationDefinition{} _ = json.Unmarshal([]byte(test.DeduplicationRequired), &presentationDefinition) - submission, vcs, err := presentationDefinition.Match([]vc.VerifiableCredential{vc1}) + vcs, mappingObjects, err := presentationDefinition.Match([]vc.VerifiableCredential{vc1}) require.NoError(t, err) assert.Len(t, vcs, 1) - assert.NotNil(t, submission) + assert.Len(t, mappingObjects, 1) }) t.Run("Pick 1 from nested", func(t *testing.T) { presentationDefinition := PresentationDefinition{} @@ -206,26 +203,25 @@ func TestMatch(t *testing.T) { t.Run("all from group A or all from group B", func(t *testing.T) { t.Run("all A", func(t *testing.T) { - submission, vcs, err := presentationDefinition.Match([]vc.VerifiableCredential{vc1, vc2}) + vcs, mappingObjects, err := presentationDefinition.Match([]vc.VerifiableCredential{vc1, vc2}) require.NoError(t, err) assert.Len(t, vcs, 2) - assert.NotNil(t, submission) + assert.Len(t, mappingObjects, 2) }) t.Run("all B", func(t *testing.T) { - submission, vcs, err := presentationDefinition.Match([]vc.VerifiableCredential{vc1, vc3, vc4}) + vcs, mappingObjects, err := presentationDefinition.Match([]vc.VerifiableCredential{vc1, vc3, vc4}) require.NoError(t, err) assert.Len(t, vcs, 2) + assert.Len(t, mappingObjects, 2) assert.Equal(t, "3", vcs[0].ID.String()) - assert.NotNil(t, submission) }) t.Run("no match", func(t *testing.T) { - submission, vcs, err := presentationDefinition.Match([]vc.VerifiableCredential{vc1, vc3}) + vcs, _, err := presentationDefinition.Match([]vc.VerifiableCredential{vc1, vc3}) require.Error(t, err) assert.Len(t, vcs, 0) - assert.NotNil(t, submission) }) }) }) @@ -234,11 +230,11 @@ func TestMatch(t *testing.T) { presentationDefinition := PresentationDefinition{} _ = json.Unmarshal([]byte(test.AllFromNested), &presentationDefinition) - submission, vcs, err := presentationDefinition.Match([]vc.VerifiableCredential{vc1, vc2}) + vcs, mappingObjects, err := presentationDefinition.Match([]vc.VerifiableCredential{vc1, vc2}) require.NoError(t, err) assert.Len(t, vcs, 2) - assert.NotNil(t, submission) + assert.Len(t, mappingObjects, 2) }) t.Run("error", func(t *testing.T) { presentationDefinition := PresentationDefinition{} @@ -255,11 +251,11 @@ func TestMatch(t *testing.T) { presentationDefinition := PresentationDefinition{} _ = json.Unmarshal([]byte(test.PickMinMaxFromNested), &presentationDefinition) - submission, vcs, err := presentationDefinition.Match([]vc.VerifiableCredential{vc1, vc2, vc3}) + vcs, mappingObjects, err := presentationDefinition.Match([]vc.VerifiableCredential{vc1, vc2, vc3}) require.NoError(t, err) assert.Len(t, vcs, 2) - assert.NotNil(t, submission) + assert.Len(t, mappingObjects, 2) }) t.Run("error", func(t *testing.T) { presentationDefinition := PresentationDefinition{} diff --git a/vcr/pe/presentation_submission.go b/vcr/pe/presentation_submission.go new file mode 100644 index 0000000000..5771e4443f --- /dev/null +++ b/vcr/pe/presentation_submission.go @@ -0,0 +1,139 @@ +/* + * Copyright (C) 2023 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package pe + +import ( + "encoding/json" + "fmt" + "github.com/google/uuid" + "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/go-did/vc" + v2 "github.com/nuts-foundation/nuts-node/vcr/pe/schema/v2" +) + +// ParsePresentationSubmission validates the given JSON and parses it into a PresentationSubmission. +// It returns an error if the JSON is invalid or doesn't match the JSON schema for a PresentationSubmission. +func ParsePresentationSubmission(raw []byte) (*PresentationSubmission, error) { + enveloped := `{"presentation_submission":` + string(raw) + `}` + if err := v2.Validate([]byte(enveloped), v2.PresentationSubmission); err != nil { + return nil, err + } + var result PresentationSubmission + err := json.Unmarshal(raw, &result) + if err != nil { + return nil, err + } + return &result, nil +} + +// PresentationSubmissionBuilder is a builder for PresentationSubmissions. +// Multiple presentation definitions can be added to the builder. +type PresentationSubmissionBuilder struct { + holders []did.DID + presentationDefinition PresentationDefinition + wallets [][]vc.VerifiableCredential +} + +// PresentationSubmissionBuilder returns a new PresentationSubmissionBuilder. +// A PresentationSubmissionBuilder can be used to create a PresentationSubmission with multiple wallets as input. +func (presentationDefinition PresentationDefinition) PresentationSubmissionBuilder() PresentationSubmissionBuilder { + return PresentationSubmissionBuilder{ + presentationDefinition: presentationDefinition, + } +} + +// AddWallet adds credentials from a wallet that may be used to create the PresentationSubmission. +func (b *PresentationSubmissionBuilder) AddWallet(holder did.DID, vcs []vc.VerifiableCredential) *PresentationSubmissionBuilder { + b.holders = append(b.holders, holder) + b.wallets = append(b.wallets, vcs) + return b +} + +// SignInstruction is a list of Holder/VCs combinations that can be used to create a VerifiablePresentation. +// When using multiple wallets, the outcome of a PresentationSubmission might require multiple VPs. +type SignInstruction struct { + // Holder contains the DID of the holder that should sign the VP. + Holder did.DID + // VerifiableCredentials contains the VCs that should be included in the VP. + VerifiableCredentials []vc.VerifiableCredential + inputDescriptorMappingObjects []InputDescriptorMappingObject +} + +// Build creates a PresentationSubmission from the added wallets. +// The VP format is determined by the given format. +func (b *PresentationSubmissionBuilder) Build(format string) (PresentationSubmission, []SignInstruction, error) { + presentationSubmission := PresentationSubmission{ + Id: uuid.New().String(), + DefinitionId: b.presentationDefinition.Id, + } + signInstructions := make([]SignInstruction, len(b.wallets)) + + // first we need to select the VCs from all wallets that match the presentation definition + allVCs := make([]vc.VerifiableCredential, 0) + for _, vcs := range b.wallets { + allVCs = append(allVCs, vcs...) + } + + selectedVCs, inputDescriptorMappingObjects, err := b.presentationDefinition.Match(allVCs) + if err != nil { + return presentationSubmission, nil, err + } + + // next we need to map the selected VCs to the correct wallet + // loop over all selected VCs and find the wallet that contains the VC + for j := range selectedVCs { + for i, walletVCs := range b.wallets { + var index int + for _, walletVC := range walletVCs { + // do a JSON equality check + if vcEqual(selectedVCs[j], walletVC) { + signInstructions[i].Holder = b.holders[i] + signInstructions[i].VerifiableCredentials = append(signInstructions[i].VerifiableCredentials, selectedVCs[j]) + // remap the path to the correct wallet index + inputDescriptorMappingObjectForVC := inputDescriptorMappingObjects[j] + inputDescriptorMappingObjectForVC.Path = fmt.Sprintf("$.verifiableCredential[%d]", index) + signInstructions[i].inputDescriptorMappingObjects = append(signInstructions[i].inputDescriptorMappingObjects, inputDescriptorMappingObjectForVC) + index++ + } + } + } + } + + index := 0 + // last we create the descriptor map for the presentation submission + // If there's only one sign instruction the Path will be $. If there are multiple sign instructions the Path will be $[0], $[1], etc. + for _, signInstruction := range signInstructions { + if len(signInstruction.VerifiableCredentials) > 0 { + // wrap each InputDescriptorMappingObject for the outer VP + nestedDescriptorMap := InputDescriptorMappingObject{ + Id: "", // todo what to add here? + Format: format, + Path: "$.", + PathNested: signInstruction.inputDescriptorMappingObjects, + } + if len(signInstructions) > 1 { + nestedDescriptorMap.Path = fmt.Sprintf("$[%d]", index) + } + presentationSubmission.DescriptorMap = append(presentationSubmission.DescriptorMap, nestedDescriptorMap) + index++ + } + } + + return presentationSubmission, signInstructions, nil +} diff --git a/vcr/pe/presentation_submission_test.go b/vcr/pe/presentation_submission_test.go new file mode 100644 index 0000000000..6c9f31c5e2 --- /dev/null +++ b/vcr/pe/presentation_submission_test.go @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2023 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package pe + +import ( + "encoding/json" + ssi "github.com/nuts-foundation/go-did" + "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/go-did/vc" + "github.com/nuts-foundation/nuts-node/vcr/pe/test" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "testing" +) + +func TestParsePresentationSubmission(t *testing.T) { + t.Run("ok", func(t *testing.T) { + submission, err := ParsePresentationSubmission([]byte(`{"id": "1", "definition_id":"1", "descriptor_map": []}`)) + require.NoError(t, err) + assert.Equal(t, "1", submission.Id) + }) + t.Run("missing id", func(t *testing.T) { + _, err := ParsePresentationSubmission([]byte(`{"definition_id":"1", "descriptor_map": []}`)) + assert.ErrorContains(t, err, `missing properties: "id"`) + }) +} + +func TestPresentationSubmissionBuilder_Build(t *testing.T) { + holder1 := did.MustParseDID("did:example:1") + holder2 := did.MustParseDID("did:example:2") + id1 := ssi.MustParseURI("1") + id2 := ssi.MustParseURI("2") + vc1 := vc.VerifiableCredential{ID: &id1} + vc2 := vc.VerifiableCredential{ID: &id2} + + t.Run("ok - single wallet", func(t *testing.T) { + presentationDefinition := PresentationDefinition{} + _ = json.Unmarshal([]byte(test.All), &presentationDefinition) + builder := presentationDefinition.PresentationSubmissionBuilder() + builder.AddWallet(holder1, []vc.VerifiableCredential{vc1, vc2}) + + submission, signInstructions, err := builder.Build("ldp_vp") + + require.NoError(t, err) + require.NotNil(t, signInstructions) + assert.Len(t, signInstructions, 1) + assert.Len(t, submission.DescriptorMap, 1) + assert.Equal(t, "$.", submission.DescriptorMap[0].Path) + require.Len(t, submission.DescriptorMap[0].PathNested, 2) + assert.Equal(t, "$.verifiableCredential[0]", submission.DescriptorMap[0].PathNested[0].Path) + }) + t.Run("ok - two wallets", func(t *testing.T) { + presentationDefinition := PresentationDefinition{} + _ = json.Unmarshal([]byte(test.All), &presentationDefinition) + builder := presentationDefinition.PresentationSubmissionBuilder() + builder.AddWallet(holder1, []vc.VerifiableCredential{vc1}) + builder.AddWallet(holder2, []vc.VerifiableCredential{vc2}) + + submission, signInstructions, err := builder.Build("ldp_vp") + + require.NoError(t, err) + require.NotNil(t, signInstructions) + assert.Len(t, signInstructions, 2) + assert.Len(t, submission.DescriptorMap, 2) + assert.Equal(t, "$[0]", submission.DescriptorMap[0].Path) + require.Len(t, submission.DescriptorMap[0].PathNested, 1) + assert.Equal(t, "$.verifiableCredential[0]", submission.DescriptorMap[0].PathNested[0].Path) + assert.Equal(t, "$[1]", submission.DescriptorMap[1].Path) + require.Len(t, submission.DescriptorMap[1].PathNested, 1) + assert.Equal(t, "$.verifiableCredential[0]", submission.DescriptorMap[1].PathNested[0].Path) + }) +} diff --git a/vcr/pe/submission.go b/vcr/pe/submission.go deleted file mode 100644 index 9cd668d734..0000000000 --- a/vcr/pe/submission.go +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright (C) 2023 Nuts community - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - */ - -package pe - -import ( - "encoding/json" - v2 "github.com/nuts-foundation/nuts-node/vcr/pe/schema/v2" -) - -// ParsePresentationSubmission validates the given JSON and parses it into a PresentationSubmission. -// It returns an error if the JSON is invalid or doesn't match the JSON schema for a PresentationSubmission. -func ParsePresentationSubmission(raw []byte) (*PresentationSubmission, error) { - enveloped := `{"presentation_submission":` + string(raw) + `}` - if err := v2.Validate([]byte(enveloped), v2.PresentationSubmission); err != nil { - return nil, err - } - var result PresentationSubmission - err := json.Unmarshal(raw, &result) - if err != nil { - return nil, err - } - return &result, nil -} diff --git a/vcr/pe/submission_requirement.go b/vcr/pe/submission_requirement.go index 0dfa5649a8..8707cd2979 100644 --- a/vcr/pe/submission_requirement.go +++ b/vcr/pe/submission_requirement.go @@ -24,27 +24,27 @@ import ( "slices" ) -// GroupCandidates is a struct that holds all InputDescriptor/VC candidates for a group -type GroupCandidates struct { +// groupCandidates is a struct that holds all InputDescriptor/VC candidates for a group +type groupCandidates struct { Name string Candidates []Candidate } -// Groups returns all the group names from the 'from' field. It traverses the 'from_nested' field recursively. -func (submissionRequirement SubmissionRequirement) Groups() []string { - result := []string{} +// groups returns all the group names from the 'from' field. It traverses the 'from_nested' field recursively. +func (submissionRequirement SubmissionRequirement) groups() []string { + var result []string if submissionRequirement.From != "" { result = append(result, submissionRequirement.From) } for _, nested := range submissionRequirement.FromNested { - result = append(result, nested.Groups()...) + result = append(result, nested.groups()...) } //deduplicate by using sort and compact slices.Sort(result) return slices.Compact(result) } -func (submissionRequirement SubmissionRequirement) match(availableGroups map[string]GroupCandidates) ([]vc.VerifiableCredential, error) { +func (submissionRequirement SubmissionRequirement) match(availableGroups map[string]groupCandidates) ([]vc.VerifiableCredential, error) { if submissionRequirement.From != "" && len(submissionRequirement.FromNested) > 0 { return nil, fmt.Errorf("submission requirement (%s) contains both 'from' and 'from_nested'", submissionRequirement.Name) } @@ -62,7 +62,7 @@ func (submissionRequirement SubmissionRequirement) match(availableGroups map[str return submissionRequirement.from(availableGroups) } -func (submissionRequirement SubmissionRequirement) from(availableGroups map[string]GroupCandidates) ([]vc.VerifiableCredential, error) { +func (submissionRequirement SubmissionRequirement) from(availableGroups map[string]groupCandidates) ([]vc.VerifiableCredential, error) { selectedVCs := make([]selectableVC, 0) group := availableGroups[submissionRequirement.From] for _, match := range group.Candidates { @@ -76,7 +76,7 @@ func (submissionRequirement SubmissionRequirement) from(availableGroups map[stri return apply(selectedVCs, submissionRequirement) } -func (submissionRequirement SubmissionRequirement) fromNested(availableGroups map[string]GroupCandidates) ([]vc.VerifiableCredential, error) { +func (submissionRequirement SubmissionRequirement) fromNested(availableGroups map[string]groupCandidates) ([]vc.VerifiableCredential, error) { selectedVCs := make([]selectableVCList, len(submissionRequirement.FromNested)) for i, nested := range submissionRequirement.FromNested { vcs, err := nested.match(availableGroups) @@ -90,7 +90,7 @@ func (submissionRequirement SubmissionRequirement) fromNested(availableGroups ma // selectable is a helper interface to determine if an entry can be selected for a SubmissionRequirement. // If it's non-empty then it can be used for counting. -// This interface is used as arrays, empty places in these lists have a meaning. +// This interface is used as slices, empty places in these slices have a meaning. type selectable interface { empty() bool flatten() []vc.VerifiableCredential @@ -122,19 +122,22 @@ func (v selectableVCList) flatten() []vc.VerifiableCredential { func apply[S ~[]E, E selectable](list S, submissionRequirement SubmissionRequirement) ([]vc.VerifiableCredential, error) { var returnVCs []vc.VerifiableCredential - // check for non-countable members - var size int + // count the non-nil/non-empty members + // an empty member means that the constraints did not match for that group member + var selectableCount int for _, member := range list { if !member.empty() { - size++ + selectableCount++ } } - // check all + // check "all" rule if submissionRequirement.Rule == "all" { - if size != len(list) { + // no empty members allowed + if selectableCount != len(list) { return nil, fmt.Errorf("submission requirement (%s) does not have all credentials from the group", submissionRequirement.Name) } for _, member := range list { + // shouldn't happen, but prevents a panic if !member.empty() { returnVCs = append(returnVCs, member.flatten()...) } @@ -142,10 +145,11 @@ func apply[S ~[]E, E selectable](list S, submissionRequirement SubmissionRequire return returnVCs, nil } - // check count + // check "count" rule if submissionRequirement.Count != nil { - if size < *submissionRequirement.Count { - return nil, fmt.Errorf("submission requirement (%s) has less credentials (%d) than required (%d)", submissionRequirement.Name, size, *submissionRequirement.Count) + // not enough matching constraints + if selectableCount < *submissionRequirement.Count { + return nil, fmt.Errorf("submission requirement (%s) has less credentials (%d) than required (%d)", submissionRequirement.Name, selectableCount, *submissionRequirement.Count) } i := 0 for _, member := range list { @@ -154,14 +158,16 @@ func apply[S ~[]E, E selectable](list S, submissionRequirement SubmissionRequire i++ } if i == *submissionRequirement.Count { + // we have enough to fulfill the count requirement, stop break } } return returnVCs, nil } - // check min and max - if submissionRequirement.Min != nil && size < *submissionRequirement.Min { - return nil, fmt.Errorf("submission requirement (%s) has less matches (%d) than minimal required (%d)", submissionRequirement.Name, size, *submissionRequirement.Min) + // check min and max rules + // only check if min requirement is met, max just determines the upper bound for the return + if submissionRequirement.Min != nil && selectableCount < *submissionRequirement.Min { + return nil, fmt.Errorf("submission requirement (%s) has less matches (%d) than minimal required (%d)", submissionRequirement.Name, selectableCount, *submissionRequirement.Min) } // take max if both min and max are set index := 0 @@ -171,6 +177,7 @@ func apply[S ~[]E, E selectable](list S, submissionRequirement SubmissionRequire index++ } if index == *submissionRequirement.Max { + // we have enough to fulfill the max requirement, stop break } } diff --git a/vcr/pe/submission_requirement_test.go b/vcr/pe/submission_requirement_test.go index 5b7308304d..c645747b8a 100644 --- a/vcr/pe/submission_requirement_test.go +++ b/vcr/pe/submission_requirement_test.go @@ -31,7 +31,7 @@ func Test_match(t *testing.T) { From: "A", FromNested: []*SubmissionRequirement{{Name: "test"}}, } - availableGroups := map[string]GroupCandidates{} + availableGroups := map[string]groupCandidates{} _, err := submissionRequirement.match(availableGroups) require.Error(t, err) assert.EqualError(t, err, "submission requirement (test) contains both 'from' and 'from_nested'") @@ -44,7 +44,7 @@ func TestSubmissionRequirement_Groups(t *testing.T) { From: "A", } - groups := requirement.Groups() + groups := requirement.groups() assert.Equal(t, []string{"A"}, groups) }) @@ -56,7 +56,7 @@ func TestSubmissionRequirement_Groups(t *testing.T) { }, } - groups := requirement.Groups() + groups := requirement.groups() assert.Equal(t, []string{"A", "B"}, groups) }) @@ -69,7 +69,7 @@ func TestSubmissionRequirement_Groups(t *testing.T) { }, } - groups := requirement.Groups() + groups := requirement.groups() assert.Equal(t, []string{"A", "B"}, groups) }) diff --git a/vcr/pe/submission_test.go b/vcr/pe/submission_test.go deleted file mode 100644 index 1cd9aefb21..0000000000 --- a/vcr/pe/submission_test.go +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright (C) 2023 Nuts community - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - */ - -package pe - -import ( - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "testing" -) - -func TestParsePresentationSubmission(t *testing.T) { - t.Run("ok", func(t *testing.T) { - submission, err := ParsePresentationSubmission([]byte(`{"id": "1", "definition_id":"1", "descriptor_map": []}`)) - require.NoError(t, err) - assert.Equal(t, "1", submission.Id) - }) - t.Run("missing id", func(t *testing.T) { - _, err := ParsePresentationSubmission([]byte(`{"definition_id":"1", "descriptor_map": []}`)) - assert.ErrorContains(t, err, `missing properties: "id"`) - }) -} diff --git a/vcr/pe/types.go b/vcr/pe/types.go index 31fb145708..aa57bdd4e4 100644 --- a/vcr/pe/types.go +++ b/vcr/pe/types.go @@ -34,10 +34,10 @@ type PresentationSubmission struct { // InputDescriptorMappingObject type InputDescriptorMappingObject struct { - Id string `json:"id"` - Path string `json:"path"` - Format string `json:"format"` - inputDescriptor InputDescriptor `json:"-"` + Format string `json:"format"` + Id string `json:"id"` + Path string `json:"path"` + PathNested []InputDescriptorMappingObject `json:"path_nested,omitempty"` } // Constraints