diff --git a/vcr/pe/doc.go b/vcr/pe/doc.go new file mode 100644 index 0000000000..f4bbf5457b --- /dev/null +++ b/vcr/pe/doc.go @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2026 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 implements Presentation Exchange (PEX): matching the Verifiable Credentials in a +// wallet against a Presentation Definition, and building the Presentation Submission that +// presents the chosen ones. Select is the single matching engine; Match and the submission +// builder are layered on top of it. +// +// # Vocabulary +// +// The documentation in this package uses these terms consistently: +// +// - Presentation definition (PD): the verifier's description of the credentials it wants: a +// set of input descriptors, plus optional submission requirements. In this system the PD is +// the data contract: only fields the PD declares are mapped to token introspection, so a +// field the PD does not declare plays no role in credential selection either. A filter that +// admits several values, such as an issuer pattern, is a deliberate equivalence declaration +// by the PD author. +// - Input descriptor: one slot of the PD, filled by at most one credential. Its constraint +// fields (JSONPaths with optional filters) and format designations decide which credentials +// fit the slot. +// - Field id / binding name: the optional id on a constraint field. A field id names the +// resolved value in two directions: outward, as the key under which the value appears in +// token introspection and is addressed by the credential_selection parameter; and inward, +// as a binding name, meaning that wherever the same id appears in one selection, the +// resolved values must agree. +// - Binding: an id-to-value pair the selection has committed to. Bindings accumulate while +// the engine fills descriptors, and constrain every later choice. A credential's resolved +// id-to-value pairs for a descriptor are its binding tuple. +// - Initial bindings: bindings supplied before the search starts (WithInitialBindings): the +// caller's credential_selection, or values captured from an earlier presentation in a +// two-VP flow. A descriptor is caller-bound when one of its field ids appears in the +// initial bindings. A caller-bound descriptor must resolve the bound field to the bound +// value (an unresolved optional field does not qualify) and must resolve to exactly one +// interchangeable set of credentials. +// - Eligible credential: a credential that satisfies one input descriptor on its own, +// constraints and formats, before any cross-descriptor consistency is considered. +// - Interchangeable credentials: eligible credentials with identical binding tuples. By the +// data-contract principle nothing the PD declares tells them apart, and no +// credential_selection key could, so the engine treats them as a single choice and uses the +// first in candidate order. Interchangeable credentials whose credentialSubjects +// nevertheless differ are flagged in the MatchReport (DivergingAlternatives). +// - Assignment: a choice of credential, or none, per input descriptor. The engine searches +// for a complete assignment with consistent bindings. The assignment it settles on, also on +// failure, is the decisive assignment: it is what Result reports and what the MatchReport +// explains dismissals against. +// - Optional descriptor: a descriptor the submission requirements allow to go unfilled, for +// example a member of a pick group. Optionality governs whether zero matches is acceptable; +// it never relaxes the multiplicity or consistency rules (zero-vs-some, never one-vs-many). +// - Selection strategy: what to do when more than one materially different complete +// assignment exists. FirstMatch, the default, takes the first found; Strict reports +// ambiguity as ErrMultipleCredentials, naming the descriptors to disambiguate with +// credential_selection keys. +// - Selection trace: the opt-in diagnostic (WithSelectionTrace) that explains, per descriptor +// and candidate, why each credential was or wasn't used. See MatchReport. +// +// These concepts originate as the numbered policies of the design in +// https://github.com/nuts-foundation/nuts-node/issues/4253. +package pe diff --git a/vcr/pe/example_select_test.go b/vcr/pe/example_select_test.go new file mode 100644 index 0000000000..a7c778ba94 --- /dev/null +++ b/vcr/pe/example_select_test.go @@ -0,0 +1,201 @@ +/* + * Copyright (C) 2026 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_test + +import ( + "encoding/json" + "fmt" + + "github.com/nuts-foundation/go-did/vc" + "github.com/nuts-foundation/nuts-node/vcr/pe" +) + +func mustParsePD(jsonStr string) pe.PresentationDefinition { + var pd pe.PresentationDefinition + if err := json.Unmarshal([]byte(jsonStr), &pd); err != nil { + panic(err) + } + return pd +} + +func mustParseVC(jsonStr string) vc.VerifiableCredential { + var cred vc.VerifiableCredential + if err := json.Unmarshal([]byte(jsonStr), &cred); err != nil { + panic(err) + } + return cred +} + +// The engine picks a credential per input descriptor. A field id (patient_id) both names the +// value for token introspection and acts as a binding name. +func ExampleSelect() { + pd := mustParsePD(`{ + "id": "example-pd", + "input_descriptors": [{ + "id": "patient_credential", + "constraints": {"fields": [{"id": "patient_id", "path": ["$.credentialSubject.patientId"]}]} + }] + }`) + wallet := []vc.VerifiableCredential{ + mustParseVC(`{"id": "vc-1", "credentialSubject": {"patientId": "123"}}`), + } + + result, err := pe.Select(pd, wallet) + if err != nil { + panic(err) + } + + fmt.Println(result.Candidates[0].VC.ID.String()) + fmt.Println(result.Bindings["patient_id"]) + // Output: + // vc-1 + // 123 +} + +// Initial bindings (the credential_selection parameter) pin a field id to a value, selecting +// the credential that carries it. +func ExampleWithInitialBindings() { + pd := mustParsePD(`{ + "id": "example-pd", + "input_descriptors": [{ + "id": "patient_credential", + "constraints": {"fields": [{"id": "patient_id", "path": ["$.credentialSubject.patientId"]}]} + }] + }`) + wallet := []vc.VerifiableCredential{ + mustParseVC(`{"id": "vc-1", "credentialSubject": {"patientId": "123"}}`), + mustParseVC(`{"id": "vc-2", "credentialSubject": {"patientId": "456"}}`), + } + + result, err := pe.Select(pd, wallet, pe.WithInitialBindings(map[string]string{"patient_id": "456"})) + if err != nil { + panic(err) + } + + fmt.Println(result.Candidates[0].VC.ID.String()) + // Output: vc-2 +} + +// Under Strict, a wallet that can satisfy the PD in more than one materially different way is +// an error naming the descriptors to disambiguate, instead of a silent first pick. +func ExampleWithStrategy() { + pd := mustParsePD(`{ + "id": "example-pd", + "input_descriptors": [ + {"id": "A", "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "ACredential"}}, + {"id": "foo", "path": ["$.credentialSubject.foo"]} + ]}}, + {"id": "B", "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "BCredential"}}, + {"id": "foo", "path": ["$.credentialSubject.foo"]} + ]}} + ] + }`) + wallet := []vc.VerifiableCredential{ + mustParseVC(`{"id": "a1", "type": ["VerifiableCredential", "ACredential"], "credentialSubject": {"foo": "X"}}`), + mustParseVC(`{"id": "a2", "type": ["VerifiableCredential", "ACredential"], "credentialSubject": {"foo": "Y"}}`), + mustParseVC(`{"id": "b1", "type": ["VerifiableCredential", "BCredential"], "credentialSubject": {"foo": "X"}}`), + mustParseVC(`{"id": "b2", "type": ["VerifiableCredential", "BCredential"], "credentialSubject": {"foo": "Y"}}`), + } + + _, err := pe.Select(pd, wallet, pe.WithStrategy(pe.Strict)) + + fmt.Println(err) + // Output: ambiguous input descriptors [A B]: multiple matching credentials +} + +// The selection trace explains why each candidate was or wasn't used: here the first credential +// fails the PD's filter, and the report names the reason. +func ExampleWithSelectionTrace() { + pd := mustParsePD(`{ + "id": "example-pd", + "input_descriptors": [{ + "id": "patient_credential", + "constraints": {"fields": [{"id": "patient_id", "path": ["$.credentialSubject.patientId"], "filter": {"type": "string", "const": "999"}}]} + }] + }`) + wallet := []vc.VerifiableCredential{ + mustParseVC(`{"id": "vc-wrong", "credentialSubject": {"patientId": "123"}}`), + mustParseVC(`{"id": "vc-right", "credentialSubject": {"patientId": "999"}}`), + } + + result, err := pe.Select(pd, wallet, pe.WithSelectionTrace()) + if err != nil { + panic(err) + } + + report := result.Report + fmt.Println(report.Outcome) + descriptor := report.Descriptors[0] + fmt.Println(descriptor.SelectedID) + dismissed := descriptor.Considered[0] + fmt.Println(dismissed.CredentialID, dismissed.Dismissal.Reason) + // Output: + // matched + // vc-right + // vc-wrong constraint_filter +} + +// Two-VP composition: build the first presentation, filter its captured bindings down to the +// field ids of the second PD, and seed the second selection with the survivors, so both +// presentations agree on the shared identity. +func ExampleSelect_twoVpComposition() { + orgPD := mustParsePD(`{ + "id": "org-pd", + "input_descriptors": [ + {"id": "org", "constraints": {"fields": [{"id": "org_did", "path": ["$.credentialSubject.orgDid"]}]}} + ] + }`) + spPD := mustParsePD(`{ + "id": "sp-pd", + "input_descriptors": [ + {"id": "delegation", "constraints": {"fields": [{"id": "org_did", "path": ["$.credentialSubject.issuedTo"]}]}} + ] + }`) + orgWallet := []vc.VerifiableCredential{ + mustParseVC(`{"id": "org-1", "credentialSubject": {"orgDid": "did:web:org"}}`), + } + spWallet := []vc.VerifiableCredential{ + mustParseVC(`{"id": "delegation-other", "credentialSubject": {"issuedTo": "did:web:other"}}`), + mustParseVC(`{"id": "delegation-org", "credentialSubject": {"issuedTo": "did:web:org"}}`), + } + + orgResult, err := pe.Select(orgPD, orgWallet) + if err != nil { + panic(err) + } + + // caller-side filter: keep only the captured bindings that are field ids on the SP PD + spFieldIDs := map[string]bool{"org_did": true} + seed := make(map[string]string) + for key, value := range orgResult.Bindings { + if spFieldIDs[key] { + seed[key] = value + } + } + + spResult, err := pe.Select(spPD, spWallet, pe.WithInitialBindings(seed)) + if err != nil { + panic(err) + } + + fmt.Println(spResult.Candidates[0].VC.ID.String()) + // Output: delegation-org +} diff --git a/vcr/pe/match_report.go b/vcr/pe/match_report.go new file mode 100644 index 0000000000..6a333e415f --- /dev/null +++ b/vcr/pe/match_report.go @@ -0,0 +1,319 @@ +/* + * Copyright (C) 2026 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 ( + "bytes" + "encoding/json" + "errors" + "fmt" + + "github.com/nuts-foundation/go-did/vc" +) + +// Outcome classifies the overall result of a Select call in a MatchReport. +type Outcome string + +const ( + // OutcomeMatched means a complete, binding-consistent assignment was found. + OutcomeMatched Outcome = "matched" + // OutcomeNoCredentials means some required descriptor could not be filled consistently. + OutcomeNoCredentials Outcome = "no_credentials" + // OutcomeMultipleCredentials means the selection was ambiguous. + OutcomeMultipleCredentials Outcome = "multiple_credentials" +) + +// DismissalReason classifies why a candidate credential was not used for a descriptor. +type DismissalReason string + +const ( + // ReasonNoValue means no path of a required field produced a value. + ReasonNoValue DismissalReason = "constraint_no_value" + // ReasonFilter means a field resolved a value that its filter rejected. + ReasonFilter DismissalReason = "constraint_filter" + // ReasonFormat means the credential format did not satisfy the PD or descriptor format gate. + ReasonFormat DismissalReason = "format_mismatch" + // ReasonBindingConflict means the credential was eligible but disagrees with the decisive + // assignment on a shared field id. + ReasonBindingConflict DismissalReason = "binding_conflict" + // ReasonNotSelected means the credential was eligible and consistent, but another candidate + // was chosen. + ReasonNotSelected DismissalReason = "not_selected" +) + +// MatchReport explains, per input descriptor, why each candidate credential was or wasn't +// selected. It answers the developer question "why doesn't my wallet satisfy this presentation +// definition" without trimming the policy file by hand. +// +// A report is produced only when Select is called with WithSelectionTrace; without it, +// Result.Report is nil and the run pays no reporting cost. The report is assembled after the +// selection by re-evaluating each candidate, so enabling it does not change the outcome. +// +// How to read it: +// +// - Outcome classifies the run as a whole. OutcomeMatched can coexist with unfilled +// descriptors (optional ones that were skipped or dropped by a pick rule). +// - Descriptors follows PD order. Each entry names the selected credential (SelectedID), +// whether the descriptor went unfilled (Skipped) and whether that was allowed (Optional). +// - Considered holds one entry per candidate credential, in candidate order. The selected +// credential has a nil Dismissal; every other candidate carries the reason it was not used, +// with the offending field id, JSONPath, and expected/found values where known. +// - AmbiguousDescriptors names the descriptors to disambiguate with credential_selection keys +// when Outcome is OutcomeMultipleCredentials. The search stops at the first rival assignment +// it finds, so the list covers that rival only: a wallet can hold further ambiguities that +// surface on a later run, after the named ones are pinned. +// - DivergingAlternatives (per descriptor) points at wallet hygiene: interchangeable +// credentials whose subjects differ in fields the PD does not declare. Selection is +// unaffected by contract, but the wallet holds potentially contradicting claims. +// +// Binding conflicts are explained against the decisive assignment, with the pick at the +// candidate's own descriptor excluded, so that a candidate that merely lost to an earlier pick +// reads as ReasonNotSelected, not as a conflict. The report does not narrate every combination +// the backtracking search visited. On a failed run the decisive assignment is best-effort: each +// descriptor's first candidate consistent with the initial bindings, chosen independently, so +// SelectedID and the Expected values in conflict explanations are relative to that assignment +// and can vary with candidate order; they show one concrete way the selection fails, not an +// authoritative commitment. +// +// The report is a diagnostic surface for developer tooling (a dev endpoint or UI is a planned +// follow-up); selection outcomes should be branched on Result and the returned error, not on +// report contents. +type MatchReport struct { + // Descriptors reports on every input descriptor, in PD order. + Descriptors []DescriptorReport + // Outcome classifies the overall result. + Outcome Outcome + // AmbiguousDescriptors names the descriptors to disambiguate (OutcomeMultipleCredentials + // only). Populated from the first rival assignment found; see the type documentation. + AmbiguousDescriptors []string +} + +// DescriptorReport explains the selection for one input descriptor. +type DescriptorReport struct { + DescriptorID string + // Optional is whether the search was allowed to leave the descriptor unfilled. + Optional bool + // Considered reports every candidate credential evaluated against this descriptor. + Considered []CandidateReport + // SelectedID is the id of the chosen credential, empty when the descriptor went unfilled. + SelectedID string + // Skipped is whether the descriptor ended up without a credential. + Skipped bool + // DivergingAlternatives is whether the chosen credential was picked among interchangeable + // alternatives (agreeing on every declared field id) whose credentialSubjects nevertheless + // differ. The PD does not declare the differing fields, so they played no role in selection; + // this flag gives the operator visibility into a wallet holding such diverging credentials. + DivergingAlternatives bool +} + +// CandidateReport explains one credential's evaluation against one descriptor. +type CandidateReport struct { + CredentialID string + // Eligible is whether the credential passed the descriptor's constraints and format gates. + Eligible bool + // Dismissal explains why the credential was not used; nil for the selected credential. + Dismissal *Dismissal +} + +// Dismissal is the reason a candidate was not used, with the offending field where known. +type Dismissal struct { + Reason DismissalReason + // FieldID is the field id involved (constraint and binding reasons, when known). + FieldID string + // Path is the JSONPath that was evaluated (constraint reasons). + Path string + // Expected is the filter constant or type, or the bound value (binding conflicts). + Expected string + // Found is the value the credential resolved; empty when none. + Found string + // Message is a rendered human-readable line. + Message string +} + +// buildReport assembles the MatchReport after a Select run. It re-evaluates step 1 per descriptor +// and candidate to recover the dismissal reasons, so a non-traced run pays nothing. Binding +// conflicts are explained against the decisive assignment (the chosen one, or the best-effort +// diagnostic assignment on failure), not against every backtracking visit. +func buildReport(pd PresentationDefinition, candidates []vc.VerifiableCredential, required []bool, + assignment []*candidateGroup, ambiguous []string, result Result, err error, initialBindings map[string]string) *MatchReport { + report := &MatchReport{ + Outcome: OutcomeMatched, + AmbiguousDescriptors: ambiguous, + } + switch { + case err == nil: + case errors.Is(err, ErrMultipleCredentials): + report.Outcome = OutcomeMultipleCredentials + default: + report.Outcome = OutcomeNoCredentials + } + + for i, descriptor := range pd.InputDescriptors { + // The decisive bindings for this descriptor's candidates: the caller's plus everything + // the OTHER descriptors' choices resolved. The own descriptor's pick is excluded, so a + // candidate that merely lost to an earlier candidate is not misreported as a binding + // conflict with the winner. + bindings := copyBindings(initialBindings) + for j, group := range assignment { + if j == i || group == nil { + continue + } + for id, value := range group.idValues { + if _, bound := bindings[id]; !bound { + bindings[id] = value + } + } + } + descriptorReport := DescriptorReport{ + DescriptorID: descriptor.Id, + Optional: i < len(required) && !required[i], + } + selected := result.Candidates[i].VC + if selected != nil && selected.ID != nil { + descriptorReport.SelectedID = selected.ID.String() + } + descriptorReport.Skipped = selected == nil + strictIDs := strictBoundIDs(*descriptor, initialBindings) + + var selectedIDValues map[string]string + selectedKey := "" + if selected != nil { + _, selectedIDValues, _ = evaluateCandidate(pd, *descriptor, *selected) + selectedKey = tupleKey(selectedIDValues) + } + selectedSeen := false + for _, candidate := range candidates { + candidateReport := CandidateReport{} + if candidate.ID != nil { + candidateReport.CredentialID = candidate.ID.String() + } + if selected != nil && !selectedSeen && vcEqual(candidate, *selected) { + // the chosen credential carries no dismissal; its evaluation is already done + selectedSeen = true + candidateReport.Eligible = true + descriptorReport.Considered = append(descriptorReport.Considered, candidateReport) + continue + } + eligible, idValues, _ := evaluateCandidate(pd, *descriptor, candidate) + candidateReport.Eligible = eligible + if !eligible { + candidateReport.Dismissal = explainIneligible(pd, *descriptor, candidate) + } else { + candidateReport.Dismissal = explainNotChosen(idValues, bindings, strictIDs) + // An interchangeable alternative (same binding tuple as the chosen credential) + // whose subject nevertheless differs is worth the operator's attention. + if selected != nil && tupleKey(idValues) == selectedKey && !subjectsEqual(candidate, *selected) { + descriptorReport.DivergingAlternatives = true + } + } + descriptorReport.Considered = append(descriptorReport.Considered, candidateReport) + } + report.Descriptors = append(report.Descriptors, descriptorReport) + } + return report +} + +// subjectsEqual reports whether two credentials carry structurally equal credentialSubjects. +// Metadata (proof, VC id, dates) is deliberately not compared. +func subjectsEqual(a, b vc.VerifiableCredential) bool { + aJSON, errA := json.Marshal(a.CredentialSubject) + bJSON, errB := json.Marshal(b.CredentialSubject) + return errA == nil && errB == nil && bytes.Equal(aJSON, bJSON) +} + +// explainIneligible pinpoints why a credential failed step 1: the first failing constraint field +// (filter rejection or missing value), or the format gate. +func explainIneligible(pd PresentationDefinition, descriptor InputDescriptor, credential vc.VerifiableCredential) *Dismissal { + if descriptor.Constraints != nil { + credentialJSON, err := credentialAsMap(credential) + if err != nil { + return &Dismissal{Reason: ReasonNoValue, Message: err.Error()} + } + for _, field := range descriptor.Constraints.Fields { + if match, _, err := matchField(field, credentialJSON); err == nil && match { + continue + } + return explainFieldMismatch(field, credentialJSON) + } + } + dismissal := &Dismissal{Reason: ReasonFormat} + dismissal.Message = fmt.Sprintf("credential format does not satisfy the format requirements of the presentation definition or input descriptor '%s'", descriptor.Id) + return dismissal +} + +// explainFieldMismatch distinguishes "a value was found but the filter rejected it" from "no path +// produced a value". +func explainFieldMismatch(field Field, credentialJSON map[string]interface{}) *Dismissal { + dismissal := &Dismissal{} + if field.Id != nil { + dismissal.FieldID = *field.Id + } + for _, path := range field.Path { + value, err := getValueAtPath(path, credentialJSON) + if err != nil || value == nil { + continue + } + dismissal.Reason = ReasonFilter + dismissal.Path = path + dismissal.Found = fmt.Sprintf("%v", value) + if field.Filter != nil { + if field.Filter.Const != nil { + dismissal.Expected = *field.Filter.Const + } else { + dismissal.Expected = field.Filter.Type + } + } + dismissal.Message = fmt.Sprintf("value at %s does not satisfy the filter: expected %s, found %s", path, dismissal.Expected, dismissal.Found) + return dismissal + } + dismissal.Reason = ReasonNoValue + if len(field.Path) > 0 { + dismissal.Path = field.Path[0] + } + dismissal.Message = fmt.Sprintf("no value found at %v", field.Path) + return dismissal +} + +// explainNotChosen explains an eligible credential that was not selected: either it conflicts with +// the decisive bindings on a shared id (or fails to resolve a strictly bound one), or it simply +// lost to an earlier candidate. +func explainNotChosen(idValues map[string]string, bindings map[string]string, strictIDs []string) *Dismissal { + for id, value := range idValues { + if bound, ok := bindings[id]; ok && bound != value { + return &Dismissal{ + Reason: ReasonBindingConflict, + FieldID: id, + Expected: bound, + Found: value, + Message: fmt.Sprintf("field id %s resolves to %s, but the selection binds it to %s", id, value, bound), + } + } + } + for _, id := range strictIDs { + if _, ok := idValues[id]; !ok { + return &Dismissal{ + Reason: ReasonBindingConflict, + FieldID: id, + Expected: bindings[id], + Message: fmt.Sprintf("field id %s is bound to %s but does not resolve on this credential", id, bindings[id]), + } + } + } + return &Dismissal{Reason: ReasonNotSelected, Message: "not selected: another candidate fills this descriptor"} +} diff --git a/vcr/pe/presentation_definition.go b/vcr/pe/presentation_definition.go index f204463f13..26cdcb50b8 100644 --- a/vcr/pe/presentation_definition.go +++ b/vcr/pe/presentation_definition.go @@ -398,21 +398,7 @@ func matchCredential(descriptor InputDescriptor, credential vc.VerifiableCredent // LimitDisclosure is not supported for now. // If the constraint matches, it returns true and a map containing constraint field IDs and matched values. func matchConstraint(constraint *Constraints, credential vc.VerifiableCredential) (bool, map[string]interface{}, error) { - // jsonpath works on interfaces, so convert the VC to an interface - var credentialAsMap map[string]interface{} - var err error - switch credential.Format() { - case vc.JWTCredentialProofFormat: - // JWT-VCs marshal to a JSON string, so marshal an alias to make sure we get a JSON object with the VC properties, - // instead of a JWT string. - type Alias vc.VerifiableCredential - credentialAsMap, err = remarshalToMap(Alias(credential)) - case vc.JSONLDCredentialProofFormat: - fallthrough - case "": // holder credential - credentialAsMap, err = remarshalToMap(credential) - } - + credentialAsMap, err := credentialAsMap(credential) if err != nil { return false, nil, err } @@ -435,6 +421,24 @@ func matchConstraint(constraint *Constraints, credential vc.VerifiableCredential return true, values, nil } +// credentialAsMap converts a VC to a plain JSON object, the form the JSONPath evaluation works on. +func credentialAsMap(credential vc.VerifiableCredential) (map[string]interface{}, error) { + switch credential.Format() { + case vc.JWTCredentialProofFormat: + // JWT-VCs marshal to a JSON string, so marshal an alias to make sure we get a JSON object + // with the VC properties, instead of a JWT string. + type Alias vc.VerifiableCredential + return remarshalToMap(Alias(credential)) + case vc.JSONLDCredentialProofFormat, "": // "" is a holder credential + return remarshalToMap(credential) + default: + // fail closed: a format this engine does not know resolves no fields and is never + // eligible, rather than being remarshaled by guesswork (a token-backed format would + // marshal to a JSON string and turn into an error that aborts the whole match). + return nil, nil + } +} + // matchField matches the field against the VC. // If the field matches, it returns true and the matched value. The matched value can be nil if the field is optional. // All fields need to match unless optional is set to true and no values are found for all the paths. diff --git a/vcr/pe/select.go b/vcr/pe/select.go new file mode 100644 index 0000000000..1a7b8a270d --- /dev/null +++ b/vcr/pe/select.go @@ -0,0 +1,764 @@ +/* + * Copyright (C) 2026 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 ( + "errors" + "fmt" + "maps" + "sort" + "strconv" + "strings" + + "github.com/nuts-foundation/go-did/vc" +) + +// SelectionStrategy controls what Select does when more than one complete, binding-consistent +// assignment exists. It gates only the ambiguity policy; binding consistency itself is always on. +type SelectionStrategy int + +const ( + // FirstMatch takes the first consistent assignment (the lenient, backward-compatible default). + FirstMatch SelectionStrategy = iota + // Strict returns ErrMultipleCredentials when a rival assignment exists: one that fills a + // common descriptor with a different binding tuple. Interchangeable credentials are never + // rivals, since no credential_selection key could separate them. + Strict +) + +// selectOptions holds the knobs configured by the Option functions passed to Select. +type selectOptions struct { + // initialBindings seeds the id->value bindings (typically from a credential_selection parameter). + initialBindings map[string]string + // strategy selects the ambiguity policy; the zero value is FirstMatch. + strategy SelectionStrategy + // trace enables the MatchReport diagnostics. + trace bool +} + +// Option configures a single Select call. Options exist so that callers can opt in to +// behaviour (initial bindings, strict ambiguity detection, tracing) without widening the +// Select signature for every new knob. +type Option func(*selectOptions) + +// WithInitialBindings seeds the search with id->value bindings, constraining which candidates +// can fill the descriptors carrying those field ids. Keys that are not field ids on the PD have +// no effect. +func WithInitialBindings(b map[string]string) Option { + return func(o *selectOptions) { + o.initialBindings = b + } +} + +// WithStrategy selects the ambiguity policy; the default is FirstMatch. +func WithStrategy(s SelectionStrategy) Option { + return func(o *selectOptions) { + o.strategy = s + } +} + +// WithSelectionTrace populates Result.Report with a MatchReport explaining, per descriptor, why +// each candidate was or wasn't selected. Off by default; a non-traced run pays nothing for it. +func WithSelectionTrace() Option { + return func(o *selectOptions) { + o.trace = true + } +} + +// Result is the outcome of a Select call. +type Result struct { + // Candidates pairs every input descriptor (in PD order) with the VC chosen for it, on every + // path including errors (best-effort on failure, for diagnostics). + // A nil VC means the descriptor was left unfilled (optional and skipped, dropped by a rule, + // or unfillable). + Candidates []Candidate + // Bindings holds the id-to-value pairs resolved by the surviving credentials of the + // decisive assignment, for chaining into a next Select (the two-VP composition) or for + // reporting. Nil when Select returns an error. + Bindings map[string]string + // Report explains the selection per descriptor; non-nil only under WithSelectionTrace. + Report *MatchReport +} + +// Select resolves a presentation definition against a set of candidate credentials and +// returns the chosen descriptor-to-VC assignment. It is the single matching engine: it +// determines each descriptor's eligible credentials (step 1), searches for an assignment with +// consistent bindings (step 2), and applies the submission requirement rules (step 3). +// +// The vocabulary (binding, caller-bound, interchangeable, decisive assignment) is defined in +// the package documentation. The search follows these rules: +// +// - Binding consistency: equal field ids resolve to equal values in the chosen assignment, +// across descriptors and against the initial bindings. +// - Prefer fill over skip: an optional descriptor is left unfilled only after all its +// consistent candidates are exhausted; a required descriptor with no consistent candidate +// makes the search revise an earlier choice. +// - Interchangeability: credentials with identical binding tuples are a single choice; the +// first in candidate order is used. Fields the PD does not declare play no role. +// - Caller-bound multiplicity: a caller-bound descriptor must resolve to exactly one +// interchangeable set; more than one is ErrMultipleCredentials, and the remedy is always a +// bindable key. The bound field must actually resolve; an unresolved optional field does +// not satisfy a bound id. +// - Unresolved optional fields bind nothing: between descriptors, a field that resolves no +// value contributes no binding entry. +// - Ambiguity (Strict only): a rival assignment, one that fills a common descriptor with a +// different binding tuple, is ErrMultipleCredentials instead of a silent pick. +// +// Candidates are expected to be time-valid; the wallet filters expired and revoked credentials +// before calling the engine. +func Select(pd PresentationDefinition, candidates []vc.VerifiableCredential, opts ...Option) (result Result, err error) { + var options selectOptions + for _, opt := range opts { + opt(&options) + } + + result = Result{Candidates: make([]Candidate, len(pd.InputDescriptors))} + for i, descriptor := range pd.InputDescriptors { + result.Candidates[i].InputDescriptor = *descriptor + } + + required := requiredDescriptors(pd) + assignment := make([]*candidateGroup, len(pd.InputDescriptors)) + var ambiguous []string + if options.trace { + defer func() { + result.Report = buildReport(pd, candidates, required, assignment, ambiguous, result, err, options.initialBindings) + }() + } + + // Step 1: per-descriptor eligibility, indexed for the search. + pools := make([]descriptorPool, len(pd.InputDescriptors)) + for i, descriptor := range pd.InputDescriptors { + pool, poolErr := buildPool(pd, *descriptor, candidates) + if poolErr != nil { + return result, poolErr + } + pool.strictIDs = strictBoundIDs(*descriptor, options.initialBindings) + pools[i] = pool + } + + // A descriptor pinned by the caller's bindings must resolve to exactly one credential (the + // legacy field-selector contract). This is a per-descriptor check against the initial + // bindings only; it is deliberately separate from the whole-assignment ambiguity check. + var boundErr error + boundFailed := make([]bool, len(pools)) + for i := range pools { + if err := callerBoundError(pools[i], options.initialBindings); err != nil { + boundFailed[i] = true + // the pinned-but-ambiguous descriptors are the report's ambiguous descriptors + ambiguous = append(ambiguous, pools[i].descriptor.Id) + if boundErr == nil { + boundErr = err + } + } + } + + // Step 2: search for a complete binding-consistent assignment. + found := false + limited := false + if boundErr == nil { + s := &searcher{pools: pools, required: required, strict: options.strategy == Strict} + s.search(0, copyBindings(options.initialBindings), make([]*candidateGroup, len(pools))) + found = s.first != nil + ambiguous = s.ambiguous + limited = s.limited + if found { + copy(assignment, s.first) + } + } + if !found { + // Best-effort assignment for diagnostics: each descriptor on its own, first candidate + // consistent with the initial bindings. + for i := range pools { + assignment[i] = nil + if boundFailed[i] { + continue + } + if groups := pools[i].consistentGroups(options.initialBindings); len(groups) > 0 { + assignment[i] = &pools[i].groups[groups[0]] + } + } + } + for i := range assignment { + if assignment[i] != nil { + result.Candidates[i].VC = &assignment[i].creds[0] + } + } + if boundErr != nil { + return result, boundErr + } + if limited { + // A found-but-unproven assignment is not returned as a success: under Strict that would + // silently downgrade the ambiguity guarantee. + return result, fmt.Errorf("presentation definition '%s': search aborted, %w (%d)", pd.Id, ErrSearchLimitReached, searchNodeLimit) + } + if len(ambiguous) > 0 { + // The decisive assignment stays in Candidates so the caller can see what would have won. + return result, fmt.Errorf("ambiguous input descriptors %v: %w", ambiguous, ErrMultipleCredentials) + } + + // Step 3: enforce the submission-requirement rules. Candidates is returned even on error so + // callers can diagnose the assignment that failed. + if len(pd.SubmissionRequirements) == 0 { + // With no submission requirements every descriptor is required. + if err := requireAllFilled(result.Candidates); err != nil { + return result, err + } + } else { + applied, err := applySubmissionRequirements(pd, result.Candidates) + if err != nil { + return result, err + } + result.Candidates = applied + } + if !found { + // The search proved no consistent assignment exists; the diagnostic assignment satisfying + // the rules anyway (possible once bindings conflict across descriptors) is not a success. + return result, fmt.Errorf("no binding-consistent assignment found: %w", ErrNoCredentials) + } + + // Expose the resolved id-values of the surviving credentials, so a caller can chain them into + // a next Select (the two-VP composition) or report them. + result.Bindings = make(map[string]string) + for i := range assignment { + if assignment[i] == nil || result.Candidates[i].VC == nil { + continue + } + for id, value := range assignment[i].idValues { + result.Bindings[id] = value + } + } + + return result, nil +} + +// ErrSearchLimitReached is returned when the backtracking search exceeds its node-visit limit, a +// defense against pathological (counterparty-supplied) presentation definitions. It is a distinct +// failure class: neither "no credentials" nor "multiple credentials". +var ErrSearchLimitReached = errors.New("search node limit reached") + +// searchNodeLimit bounds the number of node visits of a single search. Presentation definitions +// arrive from the counterparty in wallet flows, so an unbounded backtracking search would be +// remotely triggerable CPU exhaustion; legitimate wallets stay orders of magnitude below this. +// A variable so tests can lower it. +var searchNodeLimit = 1_000_000 + +// searcher carries the inputs and outcome of the step-2 backtracking search. +type searcher struct { + pools []descriptorPool + required []bool + strict bool + // first is the first complete assignment found (the decisive one), nil until then. + first []*candidateGroup + // ambiguous holds, under Strict, the descriptors a rival assignment fills differently. + ambiguous []string + // visited counts node visits; when it exceeds searchNodeLimit, limited is set and the + // search unwinds. + visited int + limited bool +} + +// search explores binding-consistent choices per descriptor, depth-first in PD order, and reports +// whether the caller should stop descending. Candidates are preferred over skipping: an optional +// descriptor is left unfilled (nil) only after all its consistent candidates have been tried; a +// required descriptor with no consistent candidate fails the branch, which backtracks into a +// different choice at an earlier descriptor. The bindings map is mutated during descent and +// restored on backtrack. Under FirstMatch the search stops at the first complete assignment; +// under Strict it continues until a rival assignment is found or the space is exhausted. +func (s *searcher) search(i int, bindings map[string]string, current []*candidateGroup) bool { + s.visited++ + if s.visited > searchNodeLimit { + s.limited = true + return true + } + if i == len(s.pools) { + return s.emit(current) + } + for _, gi := range s.pools[i].consistentGroups(bindings) { + group := &s.pools[i].groups[gi] + current[i] = group + added := addBindings(bindings, group.idValues) + stop := s.search(i+1, bindings, current) + for _, key := range added { + delete(bindings, key) + } + if stop { + return true + } + } + current[i] = nil + if !s.required[i] { + return s.search(i+1, bindings, current) + } + return false +} + +// emit records a complete assignment. The first one is kept as the decisive assignment. Under +// Strict, a later assignment is a rival only when it fills a descriptor the first one also fills +// with a different binding tuple: interchangeable credentials share a group and can never differ, +// and a skipped descriptor is not an alternative to a filled one (the skip branch is the +// after-exhaustion path, which keeps either-or pick groups deterministic). +func (s *searcher) emit(current []*candidateGroup) bool { + if s.first == nil { + s.first = append([]*candidateGroup(nil), current...) + return !s.strict + } + var ambiguous []string + for i := range current { + if s.first[i] != nil && current[i] != nil && s.first[i] != current[i] { + ambiguous = append(ambiguous, s.pools[i].descriptor.Id) + } + } + if len(ambiguous) > 0 { + s.ambiguous = ambiguous + return true + } + return false +} + +// addBindings merges a chosen candidate's id-values into the running bindings and returns the keys +// it actually added, so the caller can restore the map on backtrack. Keys already bound are left +// untouched: the candidate passed the consistency check, so its values agree. +func addBindings(bindings map[string]string, idValues map[string]string) []string { + var added []string + for key, value := range idValues { + if _, bound := bindings[key]; !bound { + bindings[key] = value + added = append(added, key) + } + } + return added +} + +// requiredDescriptors derives, per input descriptor, whether the search must fill it. This is the +// coarse rule: with no submission requirements every descriptor is required; otherwise only +// descriptors whose group is demanded in full by an "all" rule are required. Members of "pick" +// groups may be left unfilled by the search; whether enough of them were filled is checked by the +// rule engine in step 3 (the documented pick-min-floor deferral). +func requiredDescriptors(pd PresentationDefinition) []bool { + required := make([]bool, len(pd.InputDescriptors)) + if len(pd.SubmissionRequirements) == 0 { + for i := range required { + required[i] = true + } + return required + } + allGroups := make(map[string]bool) + var collect func(requirement SubmissionRequirement) + collect = func(requirement SubmissionRequirement) { + if requirement.Rule != "all" { + // members under a "pick" are selectable, never individually required + return + } + if requirement.From != "" { + allGroups[requirement.From] = true + } + for _, nested := range requirement.FromNested { + collect(*nested) + } + } + for _, requirement := range pd.SubmissionRequirements { + collect(*requirement) + } + for i, descriptor := range pd.InputDescriptors { + for _, group := range descriptor.Group { + if allGroups[group] { + required[i] = true + break + } + } + } + return required +} + +// callerBoundError enforces the caller-bound multiplicity rule: a descriptor pinned by the +// caller's bindings must resolve to exactly one interchangeable set of credentials. Counted per +// binding tuple: matches that differ on some other declared field id are distinct choices and the +// error names a real remedy (bind that id too), while matches agreeing on every declared id are +// interchangeable and the first one is used. Zero matches is a soft failure: the descriptor is +// left unfilled and step 3 decides. +func callerBoundError(pool descriptorPool, initialBindings map[string]string) error { + if len(pool.strictIDs) == 0 { + return nil + } + if len(pool.consistentGroups(initialBindings)) > 1 { + return fmt.Errorf("input descriptor '%s': %w", pool.descriptor.Id, ErrMultipleCredentials) + } + return nil +} + +// candidateGroup is a set of credentials that are interchangeable for one descriptor: they passed +// its constraints with identical binding tuples (they are interchangeable). The search branches per +// group, not per credential; creds[0] represents the group in the final assignment. +type candidateGroup struct { + idValues map[string]string + creds []vc.VerifiableCredential // in candidate order +} + +// descriptorPool is the step-1 output for one descriptor: its eligible credentials grouped by +// binding tuple, with inverted indexes so that consistency filtering during the search is a lookup +// instead of a scan over the pool. +type descriptorPool struct { + descriptor InputDescriptor + groups []candidateGroup + // byValue maps field id -> resolved value -> ascending indices of groups carrying that value. + // Its key set is the pool's id universe: ids resolved by at least one group. + byValue map[string]map[string][]int + // lacking maps field id (from the same universe) -> ascending indices of groups that do not + // resolve the id. Such groups are consistent with any bound value for it. + lacking map[string][]int + // strictIDs are the descriptor's field ids bound by the caller. For these ids a group must + // resolve the field to the bound value; unresolved is not acceptable (legacy field-selector + // semantics). The unresolved-optional leniency covers only bindings accumulated during the search. + strictIDs []string + // all holds every group index in ascending order, shared across search-node visits so an + // unnarrowed consistency check allocates nothing. Callers must not mutate it. + all []int +} + +// strictBoundIDs returns the descriptor's field ids that appear in the caller's initial bindings. +func strictBoundIDs(descriptor InputDescriptor, initialBindings map[string]string) []string { + if descriptor.Constraints == nil { + return nil + } + var ids []string + for _, field := range descriptor.Constraints.Fields { + if field.Id == nil { + continue + } + if _, ok := initialBindings[*field.Id]; ok { + ids = append(ids, *field.Id) + } + } + return ids +} + +// buildPool evaluates every candidate against a single input descriptor (its constraints and both +// format gates) and indexes the eligible ones. This is step 1, independent of any cross-descriptor +// binding. +func buildPool(pd PresentationDefinition, descriptor InputDescriptor, candidates []vc.VerifiableCredential) (descriptorPool, error) { + eligible, err := eligibleCandidates(pd, descriptor, candidates) + if err != nil { + return descriptorPool{}, err + } + pool := descriptorPool{ + descriptor: descriptor, + byValue: make(map[string]map[string][]int), + lacking: make(map[string][]int), + } + groupIndex := make(map[string]int) + for _, candidate := range eligible { + key := tupleKey(candidate.idValues) + gi, ok := groupIndex[key] + if !ok { + gi = len(pool.groups) + groupIndex[key] = gi + pool.groups = append(pool.groups, candidateGroup{idValues: candidate.idValues}) + } + pool.groups[gi].creds = append(pool.groups[gi].creds, candidate.vc) + } + for gi, group := range pool.groups { + for id, value := range group.idValues { + values := pool.byValue[id] + if values == nil { + values = make(map[string][]int) + pool.byValue[id] = values + } + values[value] = append(values[value], gi) + } + } + for id := range pool.byValue { + for gi, group := range pool.groups { + if _, ok := group.idValues[id]; !ok { + pool.lacking[id] = append(pool.lacking[id], gi) + } + } + } + pool.all = make([]int, len(pool.groups)) + for i := range pool.all { + pool.all[i] = i + } + return pool, nil +} + +// consistentGroups returns the ascending indices of the groups that agree with the bindings on +// every shared id. Bound ids outside the pool's universe are irrelevant, except strict ids, which +// a group must resolve to the bound value. When at least one bound id is in the universe, the scan +// is narrowed to the smallest posting list (groups carrying the bound value, plus, for non-strict +// ids, groups not resolving the id at all) before the full per-group check. This runs on every +// search-node visit: the narrowest list is chosen by length alone, and only that one is +// materialized, so an unnarrowed visit allocates nothing. +func (p descriptorPool) consistentGroups(bindings map[string]string) []int { + narrowID := "" + narrowLen := -1 + narrowStrict := false + for _, id := range p.strictIDs { + bound, ok := bindings[id] + if !ok { + continue + } + // no lacking-list here: a group that does not resolve a strict id is out + list := p.byValue[id][bound] + if len(list) == 0 { + return nil + } + if narrowLen < 0 || len(list) < narrowLen { + narrowID, narrowLen, narrowStrict = id, len(list), true + } + } + // iterate the pool's id universe (bounded by the descriptor's declared ids) rather than the + // bindings map, which grows with search depth + for id := range p.byValue { + value, bound := bindings[id] + if !bound { + continue + } + mergedLen := len(p.byValue[id][value]) + len(p.lacking[id]) + if narrowLen < 0 || mergedLen < narrowLen { + narrowID, narrowLen, narrowStrict = id, mergedLen, false + } + } + var narrowed []int + switch { + case narrowLen < 0: + // no bound id touches this pool: every group is consistent (a strict id is always + // bound, so strictIDs is empty here) and the shared index is returned as-is + return p.all + case narrowStrict: + narrowed = p.byValue[narrowID][bindings[narrowID]] + default: + narrowed = mergeSorted(p.byValue[narrowID][bindings[narrowID]], p.lacking[narrowID]) + } + consistent := narrowed[:0:0] // nil-preserving; appended below, never mutates narrowed + for _, gi := range narrowed { + group := p.groups[gi] + if !consistentIDValues(group.idValues, bindings) { + continue + } + if !resolvesStrictIDs(group, p.strictIDs) { + continue + } + consistent = append(consistent, gi) + } + return consistent +} + +// resolvesStrictIDs reports whether the group resolves every strictly bound id. Equality with the +// bound value is covered by the consistency check; this guards only against unresolved fields. +func resolvesStrictIDs(group candidateGroup, strictIDs []string) bool { + for _, id := range strictIDs { + if _, ok := group.idValues[id]; !ok { + return false + } + } + return true +} + +// consistentIDValues reports whether resolved id-values agree with the bindings on every shared +// id. An id one side does not carry is irrelevant, so a stray binding key has no effect. +func consistentIDValues(idValues map[string]string, bindings map[string]string) bool { + for id, value := range idValues { + if bound, ok := bindings[id]; ok && bound != value { + return false + } + } + return true +} + +// mergeSorted merges two ascending, disjoint index slices into one ascending slice. +func mergeSorted(a, b []int) []int { + merged := make([]int, 0, len(a)+len(b)) + i, j := 0, 0 + for i < len(a) && j < len(b) { + if a[i] < b[j] { + merged = append(merged, a[i]) + i++ + } else { + merged = append(merged, b[j]) + j++ + } + } + merged = append(merged, a[i:]...) + merged = append(merged, b[j:]...) + return merged +} + +// tupleKey renders id-values as a canonical string so credentials with identical binding tuples +// land in the same group. +func tupleKey(idValues map[string]string) string { + ids := make([]string, 0, len(idValues)) + for id := range idValues { + ids = append(ids, id) + } + sort.Strings(ids) + var builder strings.Builder + for _, id := range ids { + builder.WriteString(strconv.Quote(id)) + builder.WriteByte(':') + builder.WriteString(strconv.Quote(idValues[id])) + builder.WriteByte(',') + } + return builder.String() +} + +// copyBindings clones the initial bindings so the search can mutate its working map freely. +// Not maps.Clone: that returns nil for a nil input, and the search needs a writable map. +func copyBindings(bindings map[string]string) map[string]string { + copied := make(map[string]string, len(bindings)) + maps.Copy(copied, bindings) + return copied +} + +// applySubmissionRequirements enforces the submission-requirement rules over the chosen assignment +// and returns it with rule-excluded descriptors cleared (VC=nil). +func applySubmissionRequirements(pd PresentationDefinition, candidates []Candidate) ([]Candidate, error) { + // Every group referenced by an input descriptor must be covered by a submission requirement. + availableGroups := make(map[string]groupCandidates) + for _, submissionRequirement := range pd.SubmissionRequirements { + for _, group := range submissionRequirement.groups() { + availableGroups[group] = groupCandidates{Name: group} + } + } + for _, group := range pd.groups() { + if _, ok := availableGroups[group.Name]; !ok { + return nil, fmt.Errorf("group '%s' is required but not available", group.Name) + } + } + for _, candidate := range candidates { + for _, group := range candidate.InputDescriptor.Group { + current := availableGroups[group] + current.Candidates = append(current.Candidates, candidate) + availableGroups[group] = current + } + } + + var selectedVCs []vc.VerifiableCredential + for _, submissionRequirement := range pd.SubmissionRequirements { + vcs, err := submissionRequirement.match(availableGroups) + if err != nil { + return nil, err + } + selectedVCs = append(selectedVCs, vcs...) + } + selectedVCs = deduplicate(selectedVCs) + + // Keep each selected credential on the first descriptor that carries it (PD order) and clear + // the rest. The rules select credentials, not descriptors: consuming the selection prevents a + // credential that fills several descriptors from multiplying past the rule's count, and + // matches the legacy first-descriptor mapping. + remaining := selectedVCs + result := make([]Candidate, len(candidates)) + for i, candidate := range candidates { + result[i] = candidate + result[i].VC = nil + if candidate.VC == nil { + continue + } + for j, selected := range remaining { + if vcEqual(selected, *candidate.VC) { + result[i].VC = candidate.VC + remaining = append(remaining[:j], remaining[j+1:]...) + break + } + } + } + return result, nil +} + +// requireAllFilled reports ErrNoCredentials when any descriptor was left unfilled. It encodes the +// rule that, with no submission requirements, every descriptor is mandatory. +func requireAllFilled(candidates []Candidate) error { + var unmatched []string + for _, candidate := range candidates { + if candidate.VC == nil { + unmatched = append(unmatched, fmt.Sprintf("no VC for InputDescriptor (%s)", candidate.InputDescriptor.Id)) + } + } + if len(unmatched) > 0 { + return errors.Join(ErrNoCredentials, fmt.Errorf("constraints not matched: %s", strings.Join(unmatched, ", "))) + } + return nil +} + +// eligibleCandidate is a credential that passed a descriptor on its own (step 1), paired with the +// field-id -> value bindings it resolves. The bindings drive cross-descriptor consistency. +type eligibleCandidate struct { + vc vc.VerifiableCredential + idValues map[string]string +} + +// eligibleCandidates returns the credentials that satisfy a single input descriptor on its own, +// each paired with its binding tuple. +func eligibleCandidates(pd PresentationDefinition, descriptor InputDescriptor, candidates []vc.VerifiableCredential) ([]eligibleCandidate, error) { + var eligible []eligibleCandidate + for _, candidate := range candidates { + ok, idValues, err := evaluateCandidate(pd, descriptor, candidate) + if err != nil { + return nil, err + } + if ok { + eligible = append(eligible, eligibleCandidate{vc: candidate, idValues: idValues}) + } + } + return eligible, nil +} + +// evaluateCandidate is the step-1 eligibility primitive for one credential and one descriptor: +// the descriptor's constraints (matchConstraint) and both the PD-level and descriptor-level +// format gates. The resolved field-id values are returned (stringified) as the candidate's +// binding tuple. Both the selection and the MatchReport use this single primitive, so a traced +// report cannot drift from what the selection actually did. +func evaluateCandidate(pd PresentationDefinition, descriptor InputDescriptor, credential vc.VerifiableCredential) (bool, map[string]string, error) { + idValues := make(map[string]string) + if descriptor.Constraints != nil { + isMatch, values, err := matchConstraint(descriptor.Constraints, credential) + if err != nil { + return false, nil, err + } + if !isMatch { + return false, nil, nil + } + for id, value := range values { + if s, ok := stringifyBindingValue(value); ok { + idValues[id] = s + } + } + } + // InputDescriptor formats must be a subset of the PresentationDefinition formats, so satisfy both. + if !matchFormat(pd.Format, credential) || !matchFormat(descriptor.Format, credential) { + return false, nil, nil + } + return true, idValues, nil +} + +// stringifyBindingValue renders a resolved field value as the string used for binding comparison: +// strings as-is, float64 without a trailing zero exponent, bool as true/false. Any other type +// (including nil, e.g. an unresolved optional field) is not bindable. +func stringifyBindingValue(value interface{}) (string, bool) { + switch v := value.(type) { + case string: + return v, true + case float64: + return strconv.FormatFloat(v, 'f', -1, 64), true + case bool: + return strconv.FormatBool(v), true + default: + return "", false + } +} diff --git a/vcr/pe/select_test.go b/vcr/pe/select_test.go new file mode 100644 index 0000000000..389a0cbe3b --- /dev/null +++ b/vcr/pe/select_test.go @@ -0,0 +1,1419 @@ +/* + * Copyright (C) 2026 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" + "strings" + "testing" + + "github.com/nuts-foundation/go-did/vc" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// parsePD unmarshals a presentation definition from its JSON wire form. JSON keeps the +// often deeply-nested PDs (input descriptors, constraints, submission requirements) readable. +// DisallowUnknownFields turns a misspelled key into a loud error instead of a silently dropped field. +func parsePD(t *testing.T, jsonStr string) PresentationDefinition { + t.Helper() + dec := json.NewDecoder(strings.NewReader(jsonStr)) + dec.DisallowUnknownFields() + var pd PresentationDefinition + require.NoError(t, dec.Decode(&pd)) + return pd +} + +// parseVC unmarshals a verifiable credential from its JSON wire form. Going through JSON gives +// the same runtime types the engine sees in production (notably JSON numbers become float64). +func parseVC(t *testing.T, jsonStr string) vc.VerifiableCredential { + t.Helper() + var cred vc.VerifiableCredential + require.NoError(t, json.Unmarshal([]byte(jsonStr), &cred)) + return cred +} + +func TestSelect(t *testing.T) { + t.Run("single descriptor with one matching candidate binds it", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "patient_credential", + "constraints": { + "fields": [{ + "id": "patient_id", + "path": ["$.credentialSubject.patientId"] + }] + } + }] + }`) + cred := parseVC(t, `{"id": "vc-1", "credentialSubject": {"patientId": "123"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{cred}) + + require.NoError(t, err) + require.Len(t, result.Candidates, 1) + assert.Equal(t, "patient_credential", result.Candidates[0].InputDescriptor.Id) + require.NotNil(t, result.Candidates[0].VC) + require.NotNil(t, result.Candidates[0].VC.ID) + assert.Equal(t, "vc-1", result.Candidates[0].VC.ID.String()) + }) + + t.Run("required descriptor with zero matches returns ErrNoCredentials", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "patient_credential", + "constraints": { + "fields": [{ + "id": "patient_id", + "path": ["$.credentialSubject.patientId"], + "filter": {"type": "string", "const": "999"} + }] + } + }] + }`) + cred := parseVC(t, `{"id": "vc-1", "credentialSubject": {"patientId": "123"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{cred}) + + assert.ErrorIs(t, err, ErrNoCredentials) + // Candidates stays populated on error: the unfilled descriptor is present for diagnostics. + require.Len(t, result.Candidates, 1) + assert.Equal(t, "patient_credential", result.Candidates[0].InputDescriptor.Id) + assert.Nil(t, result.Candidates[0].VC) + }) + + t.Run("multiple required descriptors all fill", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [ + {"id": "org_credential", "constraints": {"fields": [{"id": "ura", "path": ["$.credentialSubject.ura"]}]}}, + {"id": "patient_enrollment", "constraints": {"fields": [{"id": "bsn", "path": ["$.credentialSubject.bsn"]}]}} + ] + }`) + orgVC := parseVC(t, `{"id": "org-1", "credentialSubject": {"ura": "URA-001"}}`) + patientVC := parseVC(t, `{"id": "patient-1", "credentialSubject": {"bsn": "BSN-111"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{orgVC, patientVC}) + + require.NoError(t, err) + require.Len(t, result.Candidates, 2) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "org-1", result.Candidates[0].VC.ID.String()) + require.NotNil(t, result.Candidates[1].VC) + assert.Equal(t, "patient-1", result.Candidates[1].VC.ID.String()) + }) + + t.Run("multiple matching candidates picks the first, no error", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "patient_credential", + "constraints": {"fields": [{"id": "patient_id", "path": ["$.credentialSubject.patientId"]}]} + }] + }`) + first := parseVC(t, `{"id": "vc-first", "credentialSubject": {"patientId": "123"}}`) + second := parseVC(t, `{"id": "vc-second", "credentialSubject": {"patientId": "456"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{first, second}) + + require.NoError(t, err) + require.Len(t, result.Candidates, 1) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "vc-first", result.Candidates[0].VC.ID.String()) + }) + + t.Run("multi-field constraint requires every field to match", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "enrollment", + "constraints": {"fields": [ + {"id": "patient_id", "path": ["$.credentialSubject.patientId"]}, + {"id": "org_city", "path": ["$.credentialSubject.city"]} + ]} + }] + }`) + // matches only patient_id (missing city) -> not eligible + partial := parseVC(t, `{"id": "vc-partial", "credentialSubject": {"patientId": "123"}}`) + // matches both fields -> eligible + full := parseVC(t, `{"id": "vc-full", "credentialSubject": {"patientId": "123", "city": "Amsterdam"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{partial, full}) + + require.NoError(t, err) + require.Len(t, result.Candidates, 1) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "vc-full", result.Candidates[0].VC.ID.String()) + }) +} + +func TestSelect_Eligibility(t *testing.T) { + t.Run("PD-level format excludes a non-matching proof type", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "format": {"ldp_vc": {"proof_type": ["JsonWebSignature2020"]}}, + "input_descriptors": [{ + "id": "patient_credential", + "constraints": {"fields": [{"id": "patient_id", "path": ["$.credentialSubject.patientId"]}]} + }] + }`) + wrongProof := parseVC(t, `{"id": "vc-wrong", "credentialSubject": {"patientId": "123"}, "proof": [{"type": "RsaSignature2018"}]}`) + rightProof := parseVC(t, `{"id": "vc-right", "credentialSubject": {"patientId": "456"}, "proof": [{"type": "JsonWebSignature2020"}]}`) + + result, err := Select(pd, []vc.VerifiableCredential{wrongProof, rightProof}) + + require.NoError(t, err) + require.Len(t, result.Candidates, 1) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "vc-right", result.Candidates[0].VC.ID.String()) + }) + + t.Run("descriptor-level format excludes a non-matching proof type", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "patient_credential", + "format": {"ldp_vc": {"proof_type": ["JsonWebSignature2020"]}}, + "constraints": {"fields": [{"id": "patient_id", "path": ["$.credentialSubject.patientId"]}]} + }] + }`) + wrongProof := parseVC(t, `{"id": "vc-wrong", "credentialSubject": {"patientId": "123"}, "proof": [{"type": "RsaSignature2018"}]}`) + rightProof := parseVC(t, `{"id": "vc-right", "credentialSubject": {"patientId": "456"}, "proof": [{"type": "JsonWebSignature2020"}]}`) + + result, err := Select(pd, []vc.VerifiableCredential{wrongProof, rightProof}) + + require.NoError(t, err) + require.Len(t, result.Candidates, 1) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "vc-right", result.Candidates[0].VC.ID.String()) + }) + + t.Run("descriptor without constraints matches any credential", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{"id": "anything"}] + }`) + cred := parseVC(t, `{"id": "vc-1", "credentialSubject": {"foo": "bar"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{cred}) + + require.NoError(t, err) + require.Len(t, result.Candidates, 1) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "vc-1", result.Candidates[0].VC.ID.String()) + }) +} + +func TestSelect_InitialBindings(t *testing.T) { + t.Run("initial binding selects the credential by field value", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "patient_credential", + "constraints": {"fields": [{"id": "patient_id", "path": ["$.credentialSubject.patientId"]}]} + }] + }`) + vc1 := parseVC(t, `{"id": "vc-1", "credentialSubject": {"patientId": "123"}}`) + vc2 := parseVC(t, `{"id": "vc-2", "credentialSubject": {"patientId": "456"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{vc1, vc2}, WithInitialBindings(map[string]string{"patient_id": "456"})) + + require.NoError(t, err) + require.Len(t, result.Candidates, 1) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "vc-2", result.Candidates[0].VC.ID.String()) + }) + + t.Run("caller-bound matches agreeing on every declared field are interchangeable", func(t *testing.T) { + // The matches are identical on all declared field ids (a reissued duplicate); no + // credential_selection key could separate them, so the engine picks the first. + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "patient_credential", + "constraints": {"fields": [{"id": "patient_id", "path": ["$.credentialSubject.patientId"]}]} + }] + }`) + vcA := parseVC(t, `{"id": "vc-a", "credentialSubject": {"patientId": "456"}}`) + vcB := parseVC(t, `{"id": "vc-b", "credentialSubject": {"patientId": "456"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{vcA, vcB}, WithInitialBindings(map[string]string{"patient_id": "456"})) + + require.NoError(t, err) + require.Len(t, result.Candidates, 1) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "vc-a", result.Candidates[0].VC.ID.String()) + }) + + t.Run("caller-bound matches differing on another declared field return ErrMultipleCredentials", func(t *testing.T) { + // The consent scenario with consent_status declared: the caller pins the patient, but + // the survivors contradict each other on a declared id. Erroring is actionable: bind + // consent_status. + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "consent_credential", + "constraints": {"fields": [ + {"id": "patient_bsn", "path": ["$.credentialSubject.bsn"]}, + {"id": "consent_status", "path": ["$.credentialSubject.consentStatus"]} + ]} + }] + }`) + granted := parseVC(t, `{"id": "consent-granted", "credentialSubject": {"bsn": "999911234", "consentStatus": true}}`) + withdrawn := parseVC(t, `{"id": "consent-withdrawn", "credentialSubject": {"bsn": "999911234", "consentStatus": false}}`) + + result, err := Select(pd, []vc.VerifiableCredential{granted, withdrawn}, + WithInitialBindings(map[string]string{"patient_bsn": "999911234"})) + + assert.ErrorIs(t, err, ErrMultipleCredentials) + require.Len(t, result.Candidates, 1) + assert.Nil(t, result.Candidates[0].VC) + }) + + t.Run("caller-bound matches differing only in undeclared fields are interchangeable", func(t *testing.T) { + // The same consent scenario without consent_status in the PD: the PD is the data + // contract (undeclared fields are not introspectable), so the difference plays no role + // and the engine picks the first. The divergence is surfaced in the MatchReport. + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "consent_credential", + "constraints": {"fields": [{"id": "patient_bsn", "path": ["$.credentialSubject.bsn"]}]} + }] + }`) + granted := parseVC(t, `{"id": "consent-granted", "credentialSubject": {"bsn": "999911234", "consentStatus": true}}`) + withdrawn := parseVC(t, `{"id": "consent-withdrawn", "credentialSubject": {"bsn": "999911234", "consentStatus": false}}`) + + result, err := Select(pd, []vc.VerifiableCredential{granted, withdrawn}, + WithInitialBindings(map[string]string{"patient_bsn": "999911234"})) + + require.NoError(t, err) + require.Len(t, result.Candidates, 1) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "consent-granted", result.Candidates[0].VC.ID.String()) + }) + + t.Run("candidates stay full length when a later descriptor errors", func(t *testing.T) { + // The failing descriptor comes first (bound matches contradict on the declared status + // field); the descriptor after it must still appear in Result.Candidates (one entry per + // descriptor, PD order, on every path). + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [ + {"id": "patient_credential", "constraints": {"fields": [ + {"id": "patient_id", "path": ["$.credentialSubject.patientId"]}, + {"id": "status", "path": ["$.credentialSubject.status"]} + ]}}, + {"id": "org_credential", "constraints": {"fields": [{"id": "ura", "path": ["$.credentialSubject.ura"]}]}} + ] + }`) + patA := parseVC(t, `{"id": "pat-a", "credentialSubject": {"patientId": "456", "status": "active"}}`) + patB := parseVC(t, `{"id": "pat-b", "credentialSubject": {"patientId": "456", "status": "inactive"}}`) + org := parseVC(t, `{"id": "org-1", "credentialSubject": {"ura": "URA-001"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{patA, patB, org}, + WithInitialBindings(map[string]string{"patient_id": "456"})) + + assert.ErrorIs(t, err, ErrMultipleCredentials) + require.Len(t, result.Candidates, 2) + assert.Equal(t, "patient_credential", result.Candidates[0].InputDescriptor.Id) + assert.Nil(t, result.Candidates[0].VC) + // best-effort: the unaffected descriptor is still reported with its match + assert.Equal(t, "org_credential", result.Candidates[1].InputDescriptor.Id) + require.NotNil(t, result.Candidates[1].VC) + assert.Equal(t, "org-1", result.Candidates[1].VC.ID.String()) + }) + + t.Run("caller-bound descriptor with zero matches returns ErrNoCredentials", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "patient_credential", + "constraints": {"fields": [{"id": "patient_id", "path": ["$.credentialSubject.patientId"]}]} + }] + }`) + vc1 := parseVC(t, `{"id": "vc-1", "credentialSubject": {"patientId": "123"}}`) + vc2 := parseVC(t, `{"id": "vc-2", "credentialSubject": {"patientId": "456"}}`) + + _, err := Select(pd, []vc.VerifiableCredential{vc1, vc2}, WithInitialBindings(map[string]string{"patient_id": "nonexistent"})) + + assert.ErrorIs(t, err, ErrNoCredentials) + }) + + t.Run("numeric field value binding", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "room_access", + "constraints": {"fields": [{"id": "floor", "path": ["$.credentialSubject.floor"]}]} + }] + }`) + // JSON numbers unmarshal to float64; "3" must match float64(3). + vcA := parseVC(t, `{"id": "vc-a", "credentialSubject": {"floor": 1}}`) + vcB := parseVC(t, `{"id": "vc-b", "credentialSubject": {"floor": 3}}`) + + result, err := Select(pd, []vc.VerifiableCredential{vcA, vcB}, WithInitialBindings(map[string]string{"floor": "3"})) + + require.NoError(t, err) + require.Len(t, result.Candidates, 1) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "vc-b", result.Candidates[0].VC.ID.String()) + }) + + t.Run("boolean field value binding", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "consent", + "constraints": {"fields": [{"id": "granted", "path": ["$.credentialSubject.granted"]}]} + }] + }`) + vcTrue := parseVC(t, `{"id": "vc-true", "credentialSubject": {"granted": true}}`) + vcFalse := parseVC(t, `{"id": "vc-false", "credentialSubject": {"granted": false}}`) + + result, err := Select(pd, []vc.VerifiableCredential{vcTrue, vcFalse}, WithInitialBindings(map[string]string{"granted": "false"})) + + require.NoError(t, err) + require.Len(t, result.Candidates, 1) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "vc-false", result.Candidates[0].VC.ID.String()) + }) + + t.Run("multiple binding keys within a descriptor use AND", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "enrollment", + "constraints": {"fields": [ + {"id": "patient_id", "path": ["$.credentialSubject.patientId"]}, + {"id": "org_city", "path": ["$.credentialSubject.city"]} + ]} + }] + }`) + both := parseVC(t, `{"id": "vc-both", "credentialSubject": {"patientId": "123", "city": "Amsterdam"}}`) + // matches patient_id but not org_city -> inconsistent + partial := parseVC(t, `{"id": "vc-partial", "credentialSubject": {"patientId": "123", "city": "Rotterdam"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{partial, both}, WithInitialBindings(map[string]string{"patient_id": "123", "org_city": "Amsterdam"})) + + require.NoError(t, err) + require.Len(t, result.Candidates, 1) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "vc-both", result.Candidates[0].VC.ID.String()) + }) + + t.Run("multiple descriptors with independent binding keys", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [ + {"id": "org_credential", "constraints": {"fields": [{"id": "ura", "path": ["$.credentialSubject.ura"]}]}}, + {"id": "patient_enrollment", "constraints": {"fields": [{"id": "bsn", "path": ["$.credentialSubject.bsn"]}]}} + ] + }`) + orgA := parseVC(t, `{"id": "org-a", "credentialSubject": {"ura": "URA-001"}}`) + orgB := parseVC(t, `{"id": "org-b", "credentialSubject": {"ura": "URA-002"}}`) + patC := parseVC(t, `{"id": "pat-c", "credentialSubject": {"bsn": "BSN-111"}}`) + patD := parseVC(t, `{"id": "pat-d", "credentialSubject": {"bsn": "BSN-222"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{orgA, orgB, patC, patD}, + WithInitialBindings(map[string]string{"ura": "URA-002", "bsn": "BSN-111"})) + + require.NoError(t, err) + require.Len(t, result.Candidates, 2) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "org-b", result.Candidates[0].VC.ID.String()) + require.NotNil(t, result.Candidates[1].VC) + assert.Equal(t, "pat-c", result.Candidates[1].VC.ID.String()) + }) + + t.Run("unresolved optional field does not bind between descriptors", func(t *testing.T) { + // Unresolved-optional rule: descriptor A's optional foo does not resolve on its candidate, so it must + // not manufacture a binding that conflicts with descriptor B's resolved foo=Z. + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [ + {"id": "A", "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "ACredential"}}, + {"id": "foo", "path": ["$.credentialSubject.foo"], "optional": true} + ]}}, + {"id": "B", "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "BCredential"}}, + {"id": "foo", "path": ["$.credentialSubject.foo"]} + ]}} + ] + }`) + a1 := parseVC(t, `{"id": "a1", "type": ["VerifiableCredential", "ACredential"], "credentialSubject": {"bar": "unrelated"}}`) + b1 := parseVC(t, `{"id": "b1", "type": ["VerifiableCredential", "BCredential"], "credentialSubject": {"foo": "Z"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{a1, b1}) + + require.NoError(t, err) + require.Len(t, result.Candidates, 2) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "a1", result.Candidates[0].VC.ID.String()) + require.NotNil(t, result.Candidates[1].VC) + assert.Equal(t, "b1", result.Candidates[1].VC.ID.String()) + }) + + t.Run("caller-bound field must resolve: all candidates unresolved is ErrNoCredentials", func(t *testing.T) { + // Initial bindings are strict (legacy field-selector semantics): a caller who pins foo + // never receives a credential without a resolvable foo. Two such candidates must not + // drift into ErrMultipleCredentials via the unresolved-is-consistent leniency. + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "A", + "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "ACredential"}}, + {"id": "foo", "path": ["$.credentialSubject.foo"], "optional": true} + ]} + }] + }`) + a1 := parseVC(t, `{"id": "a1", "type": ["VerifiableCredential", "ACredential"], "credentialSubject": {"bar": "x"}}`) + a2 := parseVC(t, `{"id": "a2", "type": ["VerifiableCredential", "ACredential"], "credentialSubject": {"bar": "y"}}`) + + _, err := Select(pd, []vc.VerifiableCredential{a1, a2}, WithInitialBindings(map[string]string{"foo": "Z"})) + + assert.ErrorIs(t, err, ErrNoCredentials) + }) + + t.Run("caller-bound field must resolve: the resolving candidate wins over unresolved ones", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "A", + "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "ACredential"}}, + {"id": "foo", "path": ["$.credentialSubject.foo"], "optional": true} + ]} + }] + }`) + unresolved := parseVC(t, `{"id": "a-unresolved", "type": ["VerifiableCredential", "ACredential"], "credentialSubject": {"bar": "x"}}`) + resolving := parseVC(t, `{"id": "a-resolving", "type": ["VerifiableCredential", "ACredential"], "credentialSubject": {"foo": "Z"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{unresolved, resolving}, WithInitialBindings(map[string]string{"foo": "Z"})) + + require.NoError(t, err) + require.Len(t, result.Candidates, 1) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "a-resolving", result.Candidates[0].VC.ID.String()) + }) + + t.Run("caller-bound multiplicity errors even on an optional descriptor", func(t *testing.T) { + // Optionality governs zero-vs-some, not one-vs-many: skipping an optional descriptor + // because the caller's explicit selection turned out ambiguous would silently discard + // an instruction. The error names the descriptor; the remedy is a second key (status). + pd := parsePD(t, `{ + "id": "test-pd", + "submission_requirements": [ + {"rule": "all", "from": "GA"}, + {"rule": "pick", "from": "GB", "min": 0, "max": 1} + ], + "input_descriptors": [ + {"id": "A", "group": ["GA"], "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "ACredential"}}, + {"id": "a_id", "path": ["$.credentialSubject.aId"]} + ]}}, + {"id": "B", "group": ["GB"], "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "BCredential"}}, + {"id": "patient_bsn", "path": ["$.credentialSubject.bsn"]}, + {"id": "status", "path": ["$.credentialSubject.status"]} + ]}} + ] + }`) + a1 := parseVC(t, `{"id": "a1", "type": ["VerifiableCredential", "ACredential"], "credentialSubject": {"aId": "a"}}`) + b1 := parseVC(t, `{"id": "b1", "type": ["VerifiableCredential", "BCredential"], "credentialSubject": {"bsn": "999911234", "status": "active"}}`) + b2 := parseVC(t, `{"id": "b2", "type": ["VerifiableCredential", "BCredential"], "credentialSubject": {"bsn": "999911234", "status": "inactive"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{a1, b1, b2}, + WithInitialBindings(map[string]string{"patient_bsn": "999911234"})) + + assert.ErrorIs(t, err, ErrMultipleCredentials) + assert.ErrorContains(t, err, "B") + // full-length candidates: the pinned-but-ambiguous descriptor is unfilled, A is best-effort + require.Len(t, result.Candidates, 2) + require.NotNil(t, result.Candidates[0].VC) + assert.Nil(t, result.Candidates[1].VC) + }) + + t.Run("unknown binding key is silently dropped", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "patient_credential", + "constraints": {"fields": [{"id": "patient_id", "path": ["$.credentialSubject.patientId"]}]} + }] + }`) + vc1 := parseVC(t, `{"id": "vc-1", "credentialSubject": {"patientId": "123"}}`) + vc2 := parseVC(t, `{"id": "vc-2", "credentialSubject": {"patientId": "456"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{vc1, vc2}, + WithInitialBindings(map[string]string{"patient_id": "456", "nonexistent_field": "whatever"})) + + require.NoError(t, err) + require.Len(t, result.Candidates, 1) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "vc-2", result.Candidates[0].VC.ID.String()) + }) +} + +func TestSelect_SameIDBinding(t *testing.T) { + t.Run("same id across descriptors must agree, backtracking into a consistent pair", func(t *testing.T) { + // Same-id agreement worked example (design #4253): HCP-A (ura=1) is tried first, conflicts with the only + // delegation (ura=2), so the search backtracks and lands on (HCP-B, Delegation-X). + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [ + {"id": "id_healthcare_provider", "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "HCPCredential"}}, + {"id": "org_ura", "path": ["$.credentialSubject.ura"]} + ]}}, + {"id": "id_professional_delegation", "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "DelegationCredential"}}, + {"id": "org_ura", "path": ["$.credentialSubject.ura"]} + ]}} + ] + }`) + hcpA := parseVC(t, `{"id": "hcp-a", "type": ["VerifiableCredential", "HCPCredential"], "credentialSubject": {"ura": "1"}}`) + hcpB := parseVC(t, `{"id": "hcp-b", "type": ["VerifiableCredential", "HCPCredential"], "credentialSubject": {"ura": "2"}}`) + delegationX := parseVC(t, `{"id": "delegation-x", "type": ["VerifiableCredential", "DelegationCredential"], "credentialSubject": {"ura": "2"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{hcpA, hcpB, delegationX}) + + require.NoError(t, err) + require.Len(t, result.Candidates, 2) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "hcp-b", result.Candidates[0].VC.ID.String()) + require.NotNil(t, result.Candidates[1].VC) + assert.Equal(t, "delegation-x", result.Candidates[1].VC.ID.String()) + }) + + t.Run("optional descriptor is skipped only after exhausting alternatives elsewhere", func(t *testing.T) { + // Prefer-fill-over-skip worked example (design #4253): A1's binding admits B1 but kills C1; A2's binding kills B1 + // but admits C1. Skipping optional B under A1 does not save C, so the search must + // revise A. Expected: A=A2, B=nil, C=C1. + pd := parsePD(t, `{ + "id": "test-pd", + "submission_requirements": [ + {"rule": "all", "from": "GA"}, + {"rule": "pick", "from": "GB", "min": 0, "max": 1}, + {"rule": "all", "from": "GC"} + ], + "input_descriptors": [ + {"id": "A", "group": ["GA"], "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "ACredential"}}, + {"id": "foo", "path": ["$.credentialSubject.foo"]} + ]}}, + {"id": "B", "group": ["GB"], "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "BCredential"}}, + {"id": "foo", "path": ["$.credentialSubject.foo"]} + ]}}, + {"id": "C", "group": ["GC"], "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "CCredential"}}, + {"id": "foo", "path": ["$.credentialSubject.foo"]} + ]}} + ] + }`) + a1 := parseVC(t, `{"id": "a1", "type": ["VerifiableCredential", "ACredential"], "credentialSubject": {"foo": "1"}}`) + a2 := parseVC(t, `{"id": "a2", "type": ["VerifiableCredential", "ACredential"], "credentialSubject": {"foo": "2"}}`) + b1 := parseVC(t, `{"id": "b1", "type": ["VerifiableCredential", "BCredential"], "credentialSubject": {"foo": "1"}}`) + c1 := parseVC(t, `{"id": "c1", "type": ["VerifiableCredential", "CCredential"], "credentialSubject": {"foo": "2"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{a1, a2, b1, c1}) + + require.NoError(t, err) + require.Len(t, result.Candidates, 3) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "a2", result.Candidates[0].VC.ID.String()) + assert.Nil(t, result.Candidates[1].VC) + require.NotNil(t, result.Candidates[2].VC) + assert.Equal(t, "c1", result.Candidates[2].VC.ID.String()) + }) + + t.Run("either-or pick-1 sharing an id fills one descriptor and skips the other", func(t *testing.T) { + // Both descriptors carry doctor_id but resolve different values; a pick-1 group is + // satisfied by the first in PD order, without a binding conflict or ambiguity error. + pd := parsePD(t, `{ + "id": "test-pd", + "submission_requirements": [{"rule": "pick", "from": "doctor", "count": 1}], + "input_descriptors": [ + {"id": "enrollment", "group": ["doctor"], "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "EnrollmentCredential"}}, + {"id": "doctor_id", "path": ["$.credentialSubject.doctorId"]} + ]}}, + {"id": "consent", "group": ["doctor"], "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "ConsentCredential"}}, + {"id": "doctor_id", "path": ["$.credentialSubject.doctorId"]} + ]}} + ] + }`) + enrollment := parseVC(t, `{"id": "enrollment-1", "type": ["VerifiableCredential", "EnrollmentCredential"], "credentialSubject": {"doctorId": "A"}}`) + consent := parseVC(t, `{"id": "consent-1", "type": ["VerifiableCredential", "ConsentCredential"], "credentialSubject": {"doctorId": "B"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{enrollment, consent}) + + require.NoError(t, err) + require.Len(t, result.Candidates, 2) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "enrollment-1", result.Candidates[0].VC.ID.String()) + assert.Nil(t, result.Candidates[1].VC) + }) +} + +func TestSelect_Bindings(t *testing.T) { + t.Run("bindings expose the resolved id-values of the chosen assignment", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [ + {"id": "org_credential", "constraints": {"fields": [{"id": "ura", "path": ["$.credentialSubject.ura"]}]}}, + {"id": "patient_enrollment", "constraints": {"fields": [{"id": "bsn", "path": ["$.credentialSubject.bsn"]}]}} + ] + }`) + orgVC := parseVC(t, `{"id": "org-1", "credentialSubject": {"ura": "URA-001"}}`) + patientVC := parseVC(t, `{"id": "patient-1", "credentialSubject": {"bsn": "BSN-111"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{orgVC, patientVC}) + + require.NoError(t, err) + assert.Equal(t, map[string]string{"ura": "URA-001", "bsn": "BSN-111"}, result.Bindings) + }) + + t.Run("a descriptor cleared by a rule contributes no bindings", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "submission_requirements": [{"rule": "pick", "from": "doctor", "count": 1}], + "input_descriptors": [ + {"id": "enrollment", "group": ["doctor"], "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "EnrollmentCredential"}}, + {"id": "doctor_id", "path": ["$.credentialSubject.doctorId"]} + ]}}, + {"id": "consent", "group": ["doctor"], "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "ConsentCredential"}}, + {"id": "consent_id", "path": ["$.credentialSubject.consentId"]} + ]}} + ] + }`) + enrollment := parseVC(t, `{"id": "enrollment-1", "type": ["VerifiableCredential", "EnrollmentCredential"], "credentialSubject": {"doctorId": "A"}}`) + consent := parseVC(t, `{"id": "consent-1", "type": ["VerifiableCredential", "ConsentCredential"], "credentialSubject": {"consentId": "C"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{enrollment, consent}) + + require.NoError(t, err) + // pick-1 keeps enrollment and clears consent; only the surviving credential binds + assert.Equal(t, map[string]string{"doctor_id": "A"}, result.Bindings) + }) + + t.Run("unknown initial binding keys are not echoed", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "patient_credential", + "constraints": {"fields": [{"id": "patient_id", "path": ["$.credentialSubject.patientId"]}]} + }] + }`) + cred := parseVC(t, `{"id": "vc-1", "credentialSubject": {"patientId": "123"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{cred}, + WithInitialBindings(map[string]string{"patient_id": "123", "sp_only_key": "whatever"})) + + require.NoError(t, err) + assert.Equal(t, map[string]string{"patient_id": "123"}, result.Bindings) + }) + + t.Run("no bindings on error", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "patient_credential", + "constraints": {"fields": [{"id": "patient_id", "path": ["$.credentialSubject.patientId"], "filter": {"type": "string", "const": "999"}}]} + }] + }`) + cred := parseVC(t, `{"id": "vc-1", "credentialSubject": {"patientId": "123"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{cred}) + + assert.ErrorIs(t, err, ErrNoCredentials) + assert.Nil(t, result.Bindings) + }) + + t.Run("two-VP composition chains bindings through a field-id filter", func(t *testing.T) { + // Cross-VP capture as straight Go at the call site: build the org VP, filter its bindings down + // to the SP PD's field ids, seed the SP build with the survivors. + orgPD := parsePD(t, `{ + "id": "org-pd", + "input_descriptors": [ + {"id": "org", "constraints": {"fields": [{"id": "org_did", "path": ["$.credentialSubject.orgDid"]}]}}, + {"id": "patient", "constraints": {"fields": [{"id": "patient_bsn", "path": ["$.credentialSubject.bsn"]}]}} + ] + }`) + spPD := parsePD(t, `{ + "id": "sp-pd", + "input_descriptors": [ + {"id": "delegation", "constraints": {"fields": [{"id": "org_did", "path": ["$.credentialSubject.issuedTo"]}]}} + ] + }`) + orgVC := parseVC(t, `{"id": "org-1", "credentialSubject": {"orgDid": "did:web:org"}}`) + patientVC := parseVC(t, `{"id": "patient-1", "credentialSubject": {"bsn": "999911234"}}`) + delegationRight := parseVC(t, `{"id": "delegation-right", "credentialSubject": {"issuedTo": "did:web:org"}}`) + delegationWrong := parseVC(t, `{"id": "delegation-wrong", "credentialSubject": {"issuedTo": "did:web:other"}}`) + + orgResult, err := Select(orgPD, []vc.VerifiableCredential{orgVC, patientVC}) + require.NoError(t, err) + assert.Equal(t, map[string]string{"org_did": "did:web:org", "patient_bsn": "999911234"}, orgResult.Bindings) + + // caller-side filter: keep only keys that are field ids on the SP PD + spFieldIDs := map[string]bool{"org_did": true} + seed := make(map[string]string) + for key, value := range orgResult.Bindings { + if spFieldIDs[key] { + seed[key] = value + } + } + + spResult, err := Select(spPD, []vc.VerifiableCredential{delegationWrong, delegationRight}, WithInitialBindings(seed)) + require.NoError(t, err) + require.NotNil(t, spResult.Candidates[0].VC) + assert.Equal(t, "delegation-right", spResult.Candidates[0].VC.ID.String()) + }) +} + +func TestSelect_Strategy(t *testing.T) { + // Ambiguity worked example (design #4253): two genuinely different ways to satisfy the PD. + ambiguousPD := `{ + "id": "test-pd", + "input_descriptors": [ + {"id": "A", "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "ACredential"}}, + {"id": "foo", "path": ["$.credentialSubject.foo"]} + ]}}, + {"id": "B", "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "BCredential"}}, + {"id": "foo", "path": ["$.credentialSubject.foo"]} + ]}} + ] + }` + a1 := `{"id": "a1", "type": ["VerifiableCredential", "ACredential"], "credentialSubject": {"foo": "X"}}` + a2 := `{"id": "a2", "type": ["VerifiableCredential", "ACredential"], "credentialSubject": {"foo": "Y"}}` + b1 := `{"id": "b1", "type": ["VerifiableCredential", "BCredential"], "credentialSubject": {"foo": "X"}}` + b2 := `{"id": "b2", "type": ["VerifiableCredential", "BCredential"], "credentialSubject": {"foo": "Y"}}` + + t.Run("Strict reports a rival assignment naming the ambiguous descriptors", func(t *testing.T) { + pd := parsePD(t, ambiguousPD) + creds := []vc.VerifiableCredential{parseVC(t, a1), parseVC(t, a2), parseVC(t, b1), parseVC(t, b2)} + + result, err := Select(pd, creds, WithStrategy(Strict)) + + assert.ErrorIs(t, err, ErrMultipleCredentials) + assert.ErrorContains(t, err, "A") + assert.ErrorContains(t, err, "B") + // the decisive (first) assignment is still reported for diagnostics + require.Len(t, result.Candidates, 2) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "a1", result.Candidates[0].VC.ID.String()) + }) + + t.Run("FirstMatch returns the first assignment for the same PD", func(t *testing.T) { + pd := parsePD(t, ambiguousPD) + creds := []vc.VerifiableCredential{parseVC(t, a1), parseVC(t, a2), parseVC(t, b1), parseVC(t, b2)} + + result, err := Select(pd, creds) + + require.NoError(t, err) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "a1", result.Candidates[0].VC.ID.String()) + require.NotNil(t, result.Candidates[1].VC) + assert.Equal(t, "b1", result.Candidates[1].VC.ID.String()) + }) + + t.Run("Strict with a disambiguating initial binding succeeds", func(t *testing.T) { + pd := parsePD(t, ambiguousPD) + creds := []vc.VerifiableCredential{parseVC(t, a1), parseVC(t, a2), parseVC(t, b1), parseVC(t, b2)} + + result, err := Select(pd, creds, WithStrategy(Strict), WithInitialBindings(map[string]string{"foo": "X"})) + + require.NoError(t, err) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "a1", result.Candidates[0].VC.ID.String()) + require.NotNil(t, result.Candidates[1].VC) + assert.Equal(t, "b1", result.Candidates[1].VC.ID.String()) + }) + + t.Run("Strict does not flag interchangeable credentials", func(t *testing.T) { + // A reissued duplicate: identical binding tuples, so a single interchangeable choice. + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "A", + "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "ACredential"}}, + {"id": "foo", "path": ["$.credentialSubject.foo"]} + ]} + }] + }`) + first := parseVC(t, `{"id": "a-first", "type": ["VerifiableCredential", "ACredential"], "credentialSubject": {"foo": "X"}}`) + duplicate := parseVC(t, `{"id": "a-duplicate", "type": ["VerifiableCredential", "ACredential"], "credentialSubject": {"foo": "X"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{first, duplicate}, WithStrategy(Strict)) + + require.NoError(t, err) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "a-first", result.Candidates[0].VC.ID.String()) + }) + + t.Run("Strict flags a rival on an optional descriptor", func(t *testing.T) { + // Optionality does not exempt a descriptor from ambiguity: two materially different + // credentials can fill it, so the selection is ambiguous even though skipping it would + // also have been acceptable. + pd := parsePD(t, `{ + "id": "test-pd", + "submission_requirements": [ + {"rule": "all", "from": "GA"}, + {"rule": "pick", "from": "GB", "min": 0, "max": 1} + ], + "input_descriptors": [ + {"id": "A", "group": ["GA"], "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "ACredential"}}, + {"id": "a_id", "path": ["$.credentialSubject.aId"]} + ]}}, + {"id": "B", "group": ["GB"], "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "BCredential"}}, + {"id": "status", "path": ["$.credentialSubject.status"]} + ]}} + ] + }`) + a1 := parseVC(t, `{"id": "a1", "type": ["VerifiableCredential", "ACredential"], "credentialSubject": {"aId": "a"}}`) + b1 := parseVC(t, `{"id": "b1", "type": ["VerifiableCredential", "BCredential"], "credentialSubject": {"status": "active"}}`) + b2 := parseVC(t, `{"id": "b2", "type": ["VerifiableCredential", "BCredential"], "credentialSubject": {"status": "inactive"}}`) + + _, err := Select(pd, []vc.VerifiableCredential{a1, b1, b2}, WithStrategy(Strict)) + + assert.ErrorIs(t, err, ErrMultipleCredentials) + assert.ErrorContains(t, err, "B") + }) + + t.Run("Strict stays silent on fill-versus-skip for an optional descriptor", func(t *testing.T) { + // With a single candidate for the optional descriptor, the skip branch also produces a + // complete assignment, but fill is deterministically preferred and never rivals skip. + pd := parsePD(t, `{ + "id": "test-pd", + "submission_requirements": [ + {"rule": "all", "from": "GA"}, + {"rule": "pick", "from": "GB", "min": 0, "max": 1} + ], + "input_descriptors": [ + {"id": "A", "group": ["GA"], "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "ACredential"}}, + {"id": "a_id", "path": ["$.credentialSubject.aId"]} + ]}}, + {"id": "B", "group": ["GB"], "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "BCredential"}}, + {"id": "status", "path": ["$.credentialSubject.status"]} + ]}} + ] + }`) + a1 := parseVC(t, `{"id": "a1", "type": ["VerifiableCredential", "ACredential"], "credentialSubject": {"aId": "a"}}`) + b1 := parseVC(t, `{"id": "b1", "type": ["VerifiableCredential", "BCredential"], "credentialSubject": {"status": "active"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{a1, b1}, WithStrategy(Strict)) + + require.NoError(t, err) + require.Len(t, result.Candidates, 2) + require.NotNil(t, result.Candidates[1].VC) + assert.Equal(t, "b1", result.Candidates[1].VC.ID.String()) + }) + + t.Run("Strict does not flag an either-or pick-1 group", func(t *testing.T) { + // The alternatives live in different descriptors of a pick-1 group; no descriptor is + // filled by both assignments, so there is no rival. + pd := parsePD(t, `{ + "id": "test-pd", + "submission_requirements": [{"rule": "pick", "from": "doctor", "count": 1}], + "input_descriptors": [ + {"id": "enrollment", "group": ["doctor"], "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "EnrollmentCredential"}}, + {"id": "doctor_id", "path": ["$.credentialSubject.doctorId"]} + ]}}, + {"id": "consent", "group": ["doctor"], "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "ConsentCredential"}}, + {"id": "doctor_id", "path": ["$.credentialSubject.doctorId"]} + ]}} + ] + }`) + enrollment := parseVC(t, `{"id": "enrollment-1", "type": ["VerifiableCredential", "EnrollmentCredential"], "credentialSubject": {"doctorId": "A"}}`) + consent := parseVC(t, `{"id": "consent-1", "type": ["VerifiableCredential", "ConsentCredential"], "credentialSubject": {"doctorId": "B"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{enrollment, consent}, WithStrategy(Strict)) + + require.NoError(t, err) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "enrollment-1", result.Candidates[0].VC.ID.String()) + assert.Nil(t, result.Candidates[1].VC) + }) +} + +func TestSelect_Trace(t *testing.T) { + filterPD := `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "patient_credential", + "constraints": {"fields": [{"id": "patient_id", "path": ["$.credentialSubject.patientId"], "filter": {"type": "string", "const": "999"}}]} + }] + }` + + t.Run("trace off leaves Report nil", func(t *testing.T) { + pd := parsePD(t, filterPD) + cred := parseVC(t, `{"id": "vc-1", "credentialSubject": {"patientId": "999"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{cred}) + + require.NoError(t, err) + assert.Nil(t, result.Report) + }) + + t.Run("filter dismissal and selection are reported", func(t *testing.T) { + pd := parsePD(t, filterPD) + wrong := parseVC(t, `{"id": "vc-wrong", "credentialSubject": {"patientId": "123"}}`) + right := parseVC(t, `{"id": "vc-right", "credentialSubject": {"patientId": "999"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{wrong, right}, WithSelectionTrace()) + + require.NoError(t, err) + require.NotNil(t, result.Report) + assert.Equal(t, OutcomeMatched, result.Report.Outcome) + require.Len(t, result.Report.Descriptors, 1) + descriptor := result.Report.Descriptors[0] + assert.Equal(t, "patient_credential", descriptor.DescriptorID) + assert.Equal(t, "vc-right", descriptor.SelectedID) + assert.False(t, descriptor.Skipped) + require.Len(t, descriptor.Considered, 2) + dismissed := descriptor.Considered[0] + assert.Equal(t, "vc-wrong", dismissed.CredentialID) + assert.False(t, dismissed.Eligible) + require.NotNil(t, dismissed.Dismissal) + assert.Equal(t, ReasonFilter, dismissed.Dismissal.Reason) + selected := descriptor.Considered[1] + assert.True(t, selected.Eligible) + assert.Nil(t, selected.Dismissal) + }) + + t.Run("missing value is reported as constraint_no_value", func(t *testing.T) { + pd := parsePD(t, filterPD) + unrelated := parseVC(t, `{"id": "vc-unrelated", "credentialSubject": {"somethingElse": "x"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{unrelated}, WithSelectionTrace()) + + assert.ErrorIs(t, err, ErrNoCredentials) + require.NotNil(t, result.Report) + assert.Equal(t, OutcomeNoCredentials, result.Report.Outcome) + dismissed := result.Report.Descriptors[0].Considered[0] + require.NotNil(t, dismissed.Dismissal) + assert.Equal(t, ReasonNoValue, dismissed.Dismissal.Reason) + }) + + t.Run("format mismatch is reported", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "format": {"ldp_vc": {"proof_type": ["JsonWebSignature2020"]}}, + "input_descriptors": [{ + "id": "patient_credential", + "constraints": {"fields": [{"id": "patient_id", "path": ["$.credentialSubject.patientId"]}]} + }] + }`) + wrongProof := parseVC(t, `{"id": "vc-wrong", "credentialSubject": {"patientId": "123"}, "proof": [{"type": "RsaSignature2018"}]}`) + + result, err := Select(pd, []vc.VerifiableCredential{wrongProof}, WithSelectionTrace()) + + assert.ErrorIs(t, err, ErrNoCredentials) + require.NotNil(t, result.Report) + dismissed := result.Report.Descriptors[0].Considered[0] + require.NotNil(t, dismissed.Dismissal) + assert.Equal(t, ReasonFormat, dismissed.Dismissal.Reason) + }) + + t.Run("binding conflict on the decisive path is reported", func(t *testing.T) { + // Same-id agreement example under trace: HCP-A is eligible but disagrees with the decisive + // assignment's org_ura=2. + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [ + {"id": "id_healthcare_provider", "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "HCPCredential"}}, + {"id": "org_ura", "path": ["$.credentialSubject.ura"]} + ]}}, + {"id": "id_professional_delegation", "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "DelegationCredential"}}, + {"id": "org_ura", "path": ["$.credentialSubject.ura"]} + ]}} + ] + }`) + hcpA := parseVC(t, `{"id": "hcp-a", "type": ["VerifiableCredential", "HCPCredential"], "credentialSubject": {"ura": "1"}}`) + hcpB := parseVC(t, `{"id": "hcp-b", "type": ["VerifiableCredential", "HCPCredential"], "credentialSubject": {"ura": "2"}}`) + delegationX := parseVC(t, `{"id": "delegation-x", "type": ["VerifiableCredential", "DelegationCredential"], "credentialSubject": {"ura": "2"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{hcpA, hcpB, delegationX}, WithSelectionTrace()) + + require.NoError(t, err) + require.NotNil(t, result.Report) + assert.Equal(t, OutcomeMatched, result.Report.Outcome) + hcpReport := result.Report.Descriptors[0] + assert.Equal(t, "hcp-b", hcpReport.SelectedID) + require.Len(t, hcpReport.Considered, 3) + dismissed := hcpReport.Considered[0] + assert.Equal(t, "hcp-a", dismissed.CredentialID) + assert.True(t, dismissed.Eligible) + require.NotNil(t, dismissed.Dismissal) + assert.Equal(t, ReasonBindingConflict, dismissed.Dismissal.Reason) + assert.Equal(t, "org_ura", dismissed.Dismissal.FieldID) + assert.Equal(t, "2", dismissed.Dismissal.Expected) + assert.Equal(t, "1", dismissed.Dismissal.Found) + }) + + t.Run("trace off leaves Report nil on the error path too", func(t *testing.T) { + pd := parsePD(t, filterPD) + cred := parseVC(t, `{"id": "vc-1", "credentialSubject": {"patientId": "123"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{cred}) + + assert.ErrorIs(t, err, ErrNoCredentials) + assert.Nil(t, result.Report) + }) + + t.Run("an eligible loser is reported as not_selected", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "patient_credential", + "constraints": {"fields": [{"id": "patient_id", "path": ["$.credentialSubject.patientId"]}]} + }] + }`) + winner := parseVC(t, `{"id": "vc-winner", "credentialSubject": {"patientId": "123"}}`) + loser := parseVC(t, `{"id": "vc-loser", "credentialSubject": {"patientId": "456"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{winner, loser}, WithSelectionTrace()) + + require.NoError(t, err) + require.NotNil(t, result.Report) + loserReport := result.Report.Descriptors[0].Considered[1] + assert.True(t, loserReport.Eligible) + require.NotNil(t, loserReport.Dismissal) + assert.Equal(t, ReasonNotSelected, loserReport.Dismissal.Reason) + }) + + t.Run("a skipped optional descriptor is reported with a matched outcome", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "submission_requirements": [ + {"rule": "all", "from": "GA"}, + {"rule": "pick", "from": "GB", "min": 0, "max": 1} + ], + "input_descriptors": [ + {"id": "A", "group": ["GA"], "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "ACredential"}}, + {"id": "a_id", "path": ["$.credentialSubject.aId"]} + ]}}, + {"id": "B", "group": ["GB"], "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "BCredential"}}, + {"id": "status", "path": ["$.credentialSubject.status"]} + ]}} + ] + }`) + a1 := parseVC(t, `{"id": "a1", "type": ["VerifiableCredential", "ACredential"], "credentialSubject": {"aId": "a"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{a1}, WithSelectionTrace()) + + require.NoError(t, err) + require.NotNil(t, result.Report) + assert.Equal(t, OutcomeMatched, result.Report.Outcome) + aReport := result.Report.Descriptors[0] + assert.False(t, aReport.Optional) + assert.False(t, aReport.Skipped) + assert.Equal(t, "a1", aReport.SelectedID) + bReport := result.Report.Descriptors[1] + assert.True(t, bReport.Optional) + assert.True(t, bReport.Skipped) + assert.Empty(t, bReport.SelectedID) + }) + + t.Run("caller-bound multiplicity names the descriptor in AmbiguousDescriptors", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "consent_credential", + "constraints": {"fields": [ + {"id": "patient_bsn", "path": ["$.credentialSubject.bsn"]}, + {"id": "consent_status", "path": ["$.credentialSubject.consentStatus"]} + ]} + }] + }`) + granted := parseVC(t, `{"id": "consent-granted", "credentialSubject": {"bsn": "999911234", "consentStatus": true}}`) + withdrawn := parseVC(t, `{"id": "consent-withdrawn", "credentialSubject": {"bsn": "999911234", "consentStatus": false}}`) + + result, err := Select(pd, []vc.VerifiableCredential{granted, withdrawn}, + WithInitialBindings(map[string]string{"patient_bsn": "999911234"}), WithSelectionTrace()) + + assert.ErrorIs(t, err, ErrMultipleCredentials) + require.NotNil(t, result.Report) + assert.Equal(t, OutcomeMultipleCredentials, result.Report.Outcome) + assert.Equal(t, []string{"consent_credential"}, result.Report.AmbiguousDescriptors) + }) + + t.Run("a bound field that does not resolve is reported as a binding conflict", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "A", + "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "ACredential"}}, + {"id": "foo", "path": ["$.credentialSubject.foo"], "optional": true} + ]} + }] + }`) + unresolved := parseVC(t, `{"id": "a-unresolved", "type": ["VerifiableCredential", "ACredential"], "credentialSubject": {"bar": "x"}}`) + resolving := parseVC(t, `{"id": "a-resolving", "type": ["VerifiableCredential", "ACredential"], "credentialSubject": {"foo": "Z"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{unresolved, resolving}, + WithInitialBindings(map[string]string{"foo": "Z"}), WithSelectionTrace()) + + require.NoError(t, err) + require.NotNil(t, result.Report) + dismissed := result.Report.Descriptors[0].Considered[0] + assert.True(t, dismissed.Eligible) + require.NotNil(t, dismissed.Dismissal) + assert.Equal(t, ReasonBindingConflict, dismissed.Dismissal.Reason) + assert.Equal(t, "foo", dismissed.Dismissal.FieldID) + assert.Equal(t, "Z", dismissed.Dismissal.Expected) + assert.Empty(t, dismissed.Dismissal.Found) + }) + + t.Run("diverging interchangeable alternatives are flagged", func(t *testing.T) { + // Two consents for the same bsn differ in an undeclared field: interchangeable by + // contract, but the report flags the divergence for the operator. + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "consent_credential", + "constraints": {"fields": [{"id": "patient_bsn", "path": ["$.credentialSubject.bsn"]}]} + }] + }`) + granted := parseVC(t, `{"id": "consent-granted", "credentialSubject": {"bsn": "999911234", "consentStatus": true}}`) + withdrawn := parseVC(t, `{"id": "consent-withdrawn", "credentialSubject": {"bsn": "999911234", "consentStatus": false}}`) + + result, err := Select(pd, []vc.VerifiableCredential{granted, withdrawn}, WithSelectionTrace()) + + require.NoError(t, err) + require.NotNil(t, result.Report) + assert.True(t, result.Report.Descriptors[0].DivergingAlternatives) + }) + + t.Run("true duplicates are not flagged as diverging", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "consent_credential", + "constraints": {"fields": [{"id": "patient_bsn", "path": ["$.credentialSubject.bsn"]}]} + }] + }`) + original := parseVC(t, `{"id": "consent-1", "credentialSubject": {"bsn": "999911234", "consentStatus": true}}`) + reissued := parseVC(t, `{"id": "consent-2", "credentialSubject": {"bsn": "999911234", "consentStatus": true}, "proof": [{"type": "JsonWebSignature2020"}]}`) + + result, err := Select(pd, []vc.VerifiableCredential{original, reissued}, WithSelectionTrace()) + + require.NoError(t, err) + require.NotNil(t, result.Report) + assert.False(t, result.Report.Descriptors[0].DivergingAlternatives) + }) + + t.Run("ambiguity is reported with the ambiguous descriptors", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [ + {"id": "A", "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "ACredential"}}, + {"id": "foo", "path": ["$.credentialSubject.foo"]} + ]}}, + {"id": "B", "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "BCredential"}}, + {"id": "foo", "path": ["$.credentialSubject.foo"]} + ]}} + ] + }`) + creds := []vc.VerifiableCredential{ + parseVC(t, `{"id": "a1", "type": ["VerifiableCredential", "ACredential"], "credentialSubject": {"foo": "X"}}`), + parseVC(t, `{"id": "a2", "type": ["VerifiableCredential", "ACredential"], "credentialSubject": {"foo": "Y"}}`), + parseVC(t, `{"id": "b1", "type": ["VerifiableCredential", "BCredential"], "credentialSubject": {"foo": "X"}}`), + parseVC(t, `{"id": "b2", "type": ["VerifiableCredential", "BCredential"], "credentialSubject": {"foo": "Y"}}`), + } + + result, err := Select(pd, creds, WithStrategy(Strict), WithSelectionTrace()) + + assert.ErrorIs(t, err, ErrMultipleCredentials) + require.NotNil(t, result.Report) + assert.Equal(t, OutcomeMultipleCredentials, result.Report.Outcome) + assert.Equal(t, []string{"A", "B"}, result.Report.AmbiguousDescriptors) + }) +} + +func TestSelect_NodeLimit(t *testing.T) { + // A PD engineered to explode: descriptors with independent ids multiply combinations, and a + // final unfillable descriptor forces the search to visit all of them before concluding + // failure. PDs are counterparty-supplied in wallet flows, so the search must abort instead + // of burning CPU. + t.Run("a pathological search aborts with a limit error", func(t *testing.T) { + originalLimit := searchNodeLimit + searchNodeLimit = 100 + defer func() { searchNodeLimit = originalLimit }() + + descriptors := "" + var creds []vc.VerifiableCredential + for d := 0; d < 6; d++ { + descriptors += fmt.Sprintf(`{"id": "d%d", "constraints": {"fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "Type%d"}}, + {"id": "id%d", "path": ["$.credentialSubject.value"]} + ]}},`, d, d, d) + for c := 0; c < 3; c++ { + creds = append(creds, parseVC(t, fmt.Sprintf( + `{"id": "vc-%d-%d", "type": ["VerifiableCredential", "Type%d"], "credentialSubject": {"value": "v%d"}}`, d, c, d, c))) + } + } + pd := parsePD(t, `{ + "id": "exploding-pd", + "input_descriptors": [`+descriptors+` + {"id": "impossible", "constraints": {"fields": [ + {"path": ["$.credentialSubject.nonexistent"], "filter": {"type": "string", "const": "never"}} + ]}} + ] + }`) + + _, err := Select(pd, creds) + + assert.ErrorIs(t, err, ErrSearchLimitReached) + assert.NotErrorIs(t, err, ErrNoCredentials) + assert.ErrorContains(t, err, "exploding-pd") + }) + + t.Run("a realistic search stays far below the default limit", func(t *testing.T) { + // sanity check: an ordinary single-descriptor match never comes close to the cap + pd := parsePD(t, `{ + "id": "test-pd", + "input_descriptors": [{ + "id": "patient_credential", + "constraints": {"fields": [{"id": "patient_id", "path": ["$.credentialSubject.patientId"]}]} + }] + }`) + cred := parseVC(t, `{"id": "vc-1", "credentialSubject": {"patientId": "123"}}`) + + _, err := Select(pd, []vc.VerifiableCredential{cred}) + + require.NoError(t, err) + }) +} + +func TestSelect_SubmissionRequirements(t *testing.T) { + t.Run("all rule with an unfilled group member returns ErrNoCredentials", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "submission_requirements": [{"rule": "all", "from": "A"}], + "input_descriptors": [ + {"id": "d1", "group": ["A"], "constraints": {"fields": [{"id": "f1", "path": ["$.credentialSubject.f1"]}]}}, + {"id": "d2", "group": ["A"], "constraints": {"fields": [{"id": "f2", "path": ["$.credentialSubject.f2"]}]}} + ] + }`) + // only fills d1; nothing matches d2 + cred := parseVC(t, `{"id": "vc-1", "credentialSubject": {"f1": "x"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{cred}) + + assert.ErrorIs(t, err, ErrNoCredentials) + // Candidates stays populated on error: it carries the assignment the rule rejected. + require.Len(t, result.Candidates, 2) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "vc-1", result.Candidates[0].VC.ID.String()) + assert.Nil(t, result.Candidates[1].VC) + }) + + t.Run("all rule with every group member filled keeps all descriptors", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "submission_requirements": [{"rule": "all", "from": "A"}], + "input_descriptors": [ + {"id": "d1", "group": ["A"], "constraints": {"fields": [{"id": "f1", "path": ["$.credentialSubject.f1"]}]}}, + {"id": "d2", "group": ["A"], "constraints": {"fields": [{"id": "f2", "path": ["$.credentialSubject.f2"]}]}} + ] + }`) + vc1 := parseVC(t, `{"id": "vc-1", "credentialSubject": {"f1": "x"}}`) + vc2 := parseVC(t, `{"id": "vc-2", "credentialSubject": {"f2": "y"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{vc1, vc2}) + + require.NoError(t, err) + require.Len(t, result.Candidates, 2) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "vc-1", result.Candidates[0].VC.ID.String()) + require.NotNil(t, result.Candidates[1].VC) + assert.Equal(t, "vc-2", result.Candidates[1].VC.ID.String()) + }) + + t.Run("one credential filling two pick-group descriptors is kept on the first only", func(t *testing.T) { + // The rules select credentials, not descriptors: pick count 1 selects one credential, + // and a credential filling several descriptors must not multiply past that count. + pd := parsePD(t, `{ + "id": "test-pd", + "submission_requirements": [{"rule": "pick", "from": "A", "count": 1}], + "input_descriptors": [ + {"id": "d1", "group": ["A"], "constraints": {"fields": [{"id": "f1", "path": ["$.credentialSubject.f1"]}]}}, + {"id": "d2", "group": ["A"], "constraints": {"fields": [{"id": "f2", "path": ["$.credentialSubject.f2"]}]}} + ] + }`) + // satisfies both descriptors + cred := parseVC(t, `{"id": "vc-both", "credentialSubject": {"f1": "x", "f2": "y"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{cred}) + + require.NoError(t, err) + require.Len(t, result.Candidates, 2) + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "vc-both", result.Candidates[0].VC.ID.String()) + assert.Nil(t, result.Candidates[1].VC) + }) + + t.Run("pick rule selects a subset and clears the rest", func(t *testing.T) { + pd := parsePD(t, `{ + "id": "test-pd", + "submission_requirements": [{"rule": "pick", "from": "A", "min": 1, "max": 1}], + "input_descriptors": [ + {"id": "d1", "group": ["A"], "constraints": {"fields": [{"id": "f1", "path": ["$.credentialSubject.f1"]}]}}, + {"id": "d2", "group": ["A"], "constraints": {"fields": [{"id": "f2", "path": ["$.credentialSubject.f2"]}]}} + ] + }`) + vc1 := parseVC(t, `{"id": "vc-1", "credentialSubject": {"f1": "x"}}`) + vc2 := parseVC(t, `{"id": "vc-2", "credentialSubject": {"f2": "y"}}`) + + result, err := Select(pd, []vc.VerifiableCredential{vc1, vc2}) + + require.NoError(t, err) + require.Len(t, result.Candidates, 2) + // pick max 1 keeps the first group member in PD order and clears the rest + require.NotNil(t, result.Candidates[0].VC) + assert.Equal(t, "vc-1", result.Candidates[0].VC.ID.String()) + assert.Nil(t, result.Candidates[1].VC) + }) +} diff --git a/vcr/pe/types.go b/vcr/pe/types.go index 56253ded78..485b2a4b68 100644 --- a/vcr/pe/types.go +++ b/vcr/pe/types.go @@ -16,7 +16,6 @@ * */ -// Package pe stands for Presentation Exchange which includes Presentation Definition and Presentation Submission package pe import "errors"