Skip to content
Open
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
72 changes: 57 additions & 15 deletions vcr/pe/presentation_definition.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,32 @@ func ParsePresentationDefinition(raw []byte) (*PresentationDefinition, error) {
return &result, nil
}

// CredentialSelector picks one credential from a list of candidates that all match a given input descriptor.
// It is called by matchConstraints after collecting all matching VCs for an input descriptor.
//
// Return values:
// - (*vc, nil): a credential was selected successfully.
// - (nil, nil): no credential was selected. The input descriptor is not fulfilled, which may
// be acceptable depending on submission requirements (e.g., pick rules with min: 0).
// - (nil, ErrNoCredentials): no candidates matched the selector's criteria. Treated as a soft
// failure: the input descriptor is not fulfilled, but submission requirements may still accept
// this (e.g., pick rules with min: 0).
// - (nil, ErrMultipleCredentials): multiple candidates matched but the selector requires exactly one.
// This is a hard failure — the match is aborted.
// - (nil, other error): any other error is a hard failure.
//
// Selectors that are lenient (like FirstMatchSelector) may return (nil, nil) to let the caller decide.
type CredentialSelector func(descriptor InputDescriptor, candidates []vc.VerifiableCredential) (*vc.VerifiableCredential, error)

// FirstMatchSelector is the default CredentialSelector that picks the first matching credential.
// This preserves the existing behavior of matchConstraints.
func FirstMatchSelector(_ InputDescriptor, candidates []vc.VerifiableCredential) (*vc.VerifiableCredential, error) {
if len(candidates) == 0 {
return nil, nil
}
return &candidates[0], nil
}

// Candidate is a struct that holds the result of a match between an input descriptor and a VC
// A non-matching VC also leads to a Candidate, but without a VC.
type Candidate struct {
Expand All @@ -74,14 +100,20 @@ type PresentationContext struct {
// 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) ([]vc.VerifiableCredential, []InputDescriptorMappingObject, error) {
return presentationDefinition.MatchWithSelector(vcs, FirstMatchSelector)
}

// MatchWithSelector matches the VCs against the presentation definition using the provided CredentialSelector.
// The selector is called for each input descriptor with all matching VCs, and must pick one (or return nil).
func (presentationDefinition PresentationDefinition) MatchWithSelector(vcs []vc.VerifiableCredential, selector CredentialSelector) ([]vc.VerifiableCredential, []InputDescriptorMappingObject, error) {
var selectedVCs []vc.VerifiableCredential
var descriptorMaps []InputDescriptorMappingObject
var err error
if len(presentationDefinition.SubmissionRequirements) > 0 {
if descriptorMaps, selectedVCs, err = presentationDefinition.matchSubmissionRequirements(vcs); err != nil {
if descriptorMaps, selectedVCs, err = presentationDefinition.matchSubmissionRequirements(vcs, selector); err != nil {
return nil, nil, err
}
} else if descriptorMaps, selectedVCs, err = presentationDefinition.matchBasic(vcs); err != nil {
} else if descriptorMaps, selectedVCs, err = presentationDefinition.matchBasic(vcs, selector); err != nil {
return nil, nil, err
}

Expand Down Expand Up @@ -136,35 +168,45 @@ func (presentationDefinition PresentationDefinition) CredentialsRequired() bool
return len(presentationDefinition.InputDescriptors) > 0
}

func (presentationDefinition PresentationDefinition) matchConstraints(vcs []vc.VerifiableCredential) ([]Candidate, error) {
func (presentationDefinition PresentationDefinition) matchConstraints(vcs []vc.VerifiableCredential, selector CredentialSelector) ([]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,
}
// Collect all matching VCs for this input descriptor
var matchingVCs []vc.VerifiableCredential
for _, credential := range vcs {
isMatch, err := matchCredential(*inputDescriptor, credential)
if err != nil {
return nil, err
}
// InputDescriptor formats must be a subset of the PresentationDefinition formats, so it must satisfy both.
if isMatch && matchFormat(presentationDefinition.Format, credential) && matchFormat(inputDescriptor.Format, credential) {
match.VC = &credential
break
matchingVCs = append(matchingVCs, credential)
}
}
candidates = append(candidates, match)
// Use the selector to pick one credential from the candidates
selected, err := selector(*inputDescriptor, matchingVCs)
if err != nil {
if errors.Is(err, ErrNoCredentials) {
// Treat as "no match" — submission requirements may still accept this
// (e.g., pick rules with min: 0).
selected = nil
} else {
return nil, err
}
}
candidates = append(candidates, Candidate{
InputDescriptor: *inputDescriptor,
VC: selected,
})
}

return candidates, nil
}

func (presentationDefinition PresentationDefinition) matchBasic(vcs []vc.VerifiableCredential) ([]InputDescriptorMappingObject, []vc.VerifiableCredential, error) {
func (presentationDefinition PresentationDefinition) matchBasic(vcs []vc.VerifiableCredential, selector CredentialSelector) ([]InputDescriptorMappingObject, []vc.VerifiableCredential, error) {
// do the constraints check
candidates, err := presentationDefinition.matchConstraints(vcs)
candidates, err := presentationDefinition.matchConstraints(vcs, selector)
if err != nil {
return nil, nil, err
}
Expand Down Expand Up @@ -201,9 +243,9 @@ func (presentationDefinition PresentationDefinition) matchBasic(vcs []vc.Verifia
return descriptors, matchingCredentials, nil
}

func (presentationDefinition PresentationDefinition) matchSubmissionRequirements(vcs []vc.VerifiableCredential) ([]InputDescriptorMappingObject, []vc.VerifiableCredential, error) {
func (presentationDefinition PresentationDefinition) matchSubmissionRequirements(vcs []vc.VerifiableCredential, selector CredentialSelector) ([]InputDescriptorMappingObject, []vc.VerifiableCredential, error) {
// first we use the constraint matching algorithm to get the matching credentials
candidates, err := presentationDefinition.matchConstraints(vcs)
candidates, err := presentationDefinition.matchConstraints(vcs, selector)
if err != nil {
return nil, nil, err
}
Expand Down
34 changes: 34 additions & 0 deletions vcr/pe/presentation_definition_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,40 @@ func TestMatch(t *testing.T) {
})
}

func TestPresentationDefinition_MatchWithSelector_SubmissionRequirements(t *testing.T) {
// test.Empty PD has: pick rule, min: 0, max: 1, group "A"
// Two input descriptors matching $.id == "1" and $.id == "2"
var pd PresentationDefinition
require.NoError(t, json.Unmarshal([]byte(test.Empty), &pd))

t.Run("Match with no matching VCs succeeds (min: 0 allows empty)", func(t *testing.T) {
// Old behavior: no VCs match, FirstMatchSelector returns (nil, nil),
// submission requirement pick rule with min: 0 accepts zero fulfilled descriptors.
vcs, mappings, err := pd.Match([]vc.VerifiableCredential{})

require.NoError(t, err)
assert.Empty(t, vcs)
assert.Empty(t, mappings)
})
t.Run("MatchWithSelector with ErrNoCredentials succeeds (min: 0 allows empty)", func(t *testing.T) {
// A custom selector that returns ErrNoCredentials when no candidates match.
// With min: 0, this should behave identically to Match — the submission
// requirement allows zero fulfilled descriptors.
strictSelector := func(_ InputDescriptor, candidates []vc.VerifiableCredential) (*vc.VerifiableCredential, error) {
if len(candidates) == 0 {
return nil, ErrNoCredentials
}
return &candidates[0], nil
}

vcs, mappings, err := pd.MatchWithSelector([]vc.VerifiableCredential{}, strictSelector)

require.NoError(t, err)
assert.Empty(t, vcs)
assert.Empty(t, mappings)
})
}

func TestPresentationDefinition_CredentialsRequired(t *testing.T) {
t.Run("no input descriptors", func(t *testing.T) {
pd := PresentationDefinition{}
Expand Down
15 changes: 14 additions & 1 deletion vcr/pe/presentation_submission.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ type PresentationSubmissionBuilder struct {
holders []did.DID
presentationDefinition PresentationDefinition
wallets [][]vc.VerifiableCredential
credentialSelector CredentialSelector
}

// PresentationSubmissionBuilder returns a new PresentationSubmissionBuilder.
Expand All @@ -62,6 +63,13 @@ func (presentationDefinition PresentationDefinition) PresentationSubmissionBuild
}
}

// SetCredentialSelector configures a custom CredentialSelector for picking credentials
// when multiple match an input descriptor. If not set, FirstMatchSelector is used.
func (b *PresentationSubmissionBuilder) SetCredentialSelector(selector CredentialSelector) *PresentationSubmissionBuilder {
b.credentialSelector = selector
return b
}

// 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)
Expand Down Expand Up @@ -107,8 +115,13 @@ func (b *PresentationSubmissionBuilder) Build(format string) (PresentationSubmis
var inputDescriptorMappingObjects []InputDescriptorMappingObject
var selectedDID *did.DID

selector := b.credentialSelector
if selector == nil {
selector = FirstMatchSelector
}

for i, walletVCs := range b.wallets {
vcs, mappingObjects, err := b.presentationDefinition.Match(walletVCs)
vcs, mappingObjects, err := b.presentationDefinition.MatchWithSelector(walletVCs, selector)
if err == nil {
selectedVCs = vcs
inputDescriptorMappingObjects = mappingObjects
Expand Down
83 changes: 83 additions & 0 deletions vcr/pe/presentation_submission_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ package pe

import (
"encoding/json"
"errors"
ssi "github.com/nuts-foundation/go-did"
"github.com/nuts-foundation/go-did/did"
"github.com/nuts-foundation/go-did/vc"
Expand Down Expand Up @@ -194,6 +195,88 @@ func TestPresentationSubmissionBuilder_Build(t *testing.T) {
})
}

func TestPresentationSubmissionBuilder_SetCredentialSelector(t *testing.T) {
holder := did.MustParseDID("did:example:1")
id1 := ssi.MustParseURI("1")
id2 := ssi.MustParseURI("2")
// Both VCs have a "field" key — a broad PD will match both
vc1 := credentialToJSONLD(vc.VerifiableCredential{ID: &id1, CredentialSubject: []map[string]any{{"field": "a"}}})
vc2 := credentialToJSONLD(vc.VerifiableCredential{ID: &id2, CredentialSubject: []map[string]any{{"field": "b"}}})

// PD with a single input descriptor that matches any VC with a "field" key
var pd PresentationDefinition
require.NoError(t, json.Unmarshal([]byte(`{
"id": "test",
"input_descriptors": [{
"id": "match_field",
"constraints": {
"fields": [{
"path": ["$.credentialSubject.field"]
}]
}
}]
}`), &pd))

t.Run("custom selector picks second credential", func(t *testing.T) {
lastSelector := func(_ InputDescriptor, candidates []vc.VerifiableCredential) (*vc.VerifiableCredential, error) {
if len(candidates) == 0 {
return nil, nil
}
return &candidates[len(candidates)-1], nil
}

builder := pd.PresentationSubmissionBuilder()
builder.AddWallet(holder, []vc.VerifiableCredential{vc1, vc2})
builder.SetCredentialSelector(lastSelector)

_, signInstruction, err := builder.Build("ldp_vp")

require.NoError(t, err)
require.Len(t, signInstruction.VerifiableCredentials, 1)
assert.Equal(t, &id2, signInstruction.VerifiableCredentials[0].ID)
})
t.Run("without selector uses first match (default)", func(t *testing.T) {
builder := pd.PresentationSubmissionBuilder()
builder.AddWallet(holder, []vc.VerifiableCredential{vc1, vc2})

_, signInstruction, err := builder.Build("ldp_vp")

require.NoError(t, err)
require.Len(t, signInstruction.VerifiableCredentials, 1)
assert.Equal(t, &id1, signInstruction.VerifiableCredentials[0].ID)
})
t.Run("selector receives all matching candidates", func(t *testing.T) {
var receivedCandidates []vc.VerifiableCredential
spySelector := func(_ InputDescriptor, candidates []vc.VerifiableCredential) (*vc.VerifiableCredential, error) {
receivedCandidates = candidates
return &candidates[0], nil
}

builder := pd.PresentationSubmissionBuilder()
builder.AddWallet(holder, []vc.VerifiableCredential{vc1, vc2})
builder.SetCredentialSelector(spySelector)
builder.Build("ldp_vp")

require.Len(t, receivedCandidates, 2)
assert.Equal(t, &id1, receivedCandidates[0].ID)
assert.Equal(t, &id2, receivedCandidates[1].ID)
})
t.Run("selector error propagates", func(t *testing.T) {
selectorErr := errors.New("no matching credential for this input descriptor")
errorSelector := func(_ InputDescriptor, _ []vc.VerifiableCredential) (*vc.VerifiableCredential, error) {
return nil, selectorErr
}

builder := pd.PresentationSubmissionBuilder()
builder.AddWallet(holder, []vc.VerifiableCredential{vc1})
builder.SetCredentialSelector(errorSelector)

_, _, err := builder.Build("ldp_vp")

assert.ErrorIs(t, err, selectorErr)
})
}

func TestPresentationSubmission_Resolve(t *testing.T) {
id1 := ssi.MustParseURI("1")
id2 := ssi.MustParseURI("2")
Expand Down
1 change: 1 addition & 0 deletions vcr/pe/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ package pe
import "errors"

var ErrNoCredentials = errors.New("missing credentials")
var ErrMultipleCredentials = errors.New("multiple matching credentials")

// PresentationDefinitionClaimFormatDesignations (replaces generated one)
type PresentationDefinitionClaimFormatDesignations map[string]map[string][]string
Expand Down