diff --git a/vcr/pe/match_report.go b/vcr/pe/match_report.go index 6a333e415..28d47f472 100644 --- a/vcr/pe/match_report.go +++ b/vcr/pe/match_report.go @@ -147,10 +147,11 @@ type Dismissal struct { } // 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, +// and candidate (on the pre-converted credentials, so no repeated JSON conversion) 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 []evaluatedCredential, required []bool, assignment []*candidateGroup, ambiguous []string, result Result, err error, initialBindings map[string]string) *MatchReport { report := &MatchReport{ Outcome: OutcomeMatched, @@ -194,31 +195,36 @@ func buildReport(pd PresentationDefinition, candidates []vc.VerifiableCredential var selectedIDValues map[string]string selectedKey := "" if selected != nil { - _, selectedIDValues, _ = evaluateCandidate(pd, *descriptor, *selected) + for _, candidate := range candidates { + if vcEqual(candidate.vc, *selected) { + _, selectedIDValues, _ = evaluateCandidate(*descriptor, candidate) + break + } + } selectedKey = tupleKey(selectedIDValues) } selectedSeen := false for _, candidate := range candidates { candidateReport := CandidateReport{} - if candidate.ID != nil { - candidateReport.CredentialID = candidate.ID.String() + if candidate.vc.ID != nil { + candidateReport.CredentialID = candidate.vc.ID.String() } - if selected != nil && !selectedSeen && vcEqual(candidate, *selected) { + if selected != nil && !selectedSeen && vcEqual(candidate.vc, *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) + eligible, idValues, evalErr := evaluateCandidate(*descriptor, candidate) candidateReport.Eligible = eligible if !eligible { - candidateReport.Dismissal = explainIneligible(pd, *descriptor, candidate) + candidateReport.Dismissal = explainIneligible(*descriptor, candidate, evalErr) } 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) { + if selected != nil && tupleKey(idValues) == selectedKey && !subjectsEqual(candidate.vc, *selected) { descriptorReport.DivergingAlternatives = true } } @@ -237,41 +243,41 @@ func subjectsEqual(a, b vc.VerifiableCredential) bool { 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 { +// explainIneligible derives the dismissal for a credential that failed step 1 from the same +// primitive the selection uses: the failing field's outcome (matchConstraintOnMap), or the +// format gate. +func explainIneligible(descriptor InputDescriptor, candidate evaluatedCredential, evalErr error) *Dismissal { + if evalErr != nil { + return &Dismissal{Reason: ReasonNoValue, Message: evalErr.Error()} + } if descriptor.Constraints != nil { - credentialJSON, err := credentialAsMap(credential) + isMatch, fields, err := matchConstraintOnMap(descriptor.Constraints, candidate.json) 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) + if !isMatch { + // on a mismatch the last outcome is the failing field's + failed := len(fields) - 1 + return renderFieldDismissal(descriptor.Constraints.Fields[failed], fields[failed]) } } - 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 + return &Dismissal{ + Reason: ReasonFormat, + Message: fmt.Sprintf("credential format does not satisfy the format requirements of the presentation definition or input descriptor '%s'", descriptor.Id), + } } -// 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 { +// renderFieldDismissal renders a failing field outcome: a value was found but the filter rejected +// it, or no path produced a value. +func renderFieldDismissal(field Field, outcome fieldResult) *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 - } + if outcome.rejectedValue != nil { dismissal.Reason = ReasonFilter - dismissal.Path = path - dismissal.Found = fmt.Sprintf("%v", value) + dismissal.Path = outcome.rejectedPath + dismissal.Found = fmt.Sprintf("%v", outcome.rejectedValue) if field.Filter != nil { if field.Filter.Const != nil { dismissal.Expected = *field.Filter.Const @@ -279,7 +285,7 @@ func explainFieldMismatch(field Field, credentialJSON map[string]interface{}) *D 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) + dismissal.Message = fmt.Sprintf("value at %s does not satisfy the filter: expected %s, found %s", outcome.rejectedPath, dismissal.Expected, dismissal.Found) return dismissal } dismissal.Reason = ReasonNoValue diff --git a/vcr/pe/presentation_definition.go b/vcr/pe/presentation_definition.go index 26cdcb50b..05e3aefb9 100644 --- a/vcr/pe/presentation_definition.go +++ b/vcr/pe/presentation_definition.go @@ -398,27 +398,55 @@ 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) { - credentialAsMap, err := credentialAsMap(credential) + credentialJSON, err := credentialAsMap(credential) if err != nil { return false, nil, err } - - // for each field in constraint.fields: - // a vc must match the field + isMatch, fields, err := matchConstraintOnMap(constraint, credentialJSON) + if err != nil || !isMatch { + return false, nil, err + } values := make(map[string]interface{}) + for i, field := range constraint.Fields { + if field.Id != nil { + values[*field.Id] = fields[i].value + } + } + return true, values, nil +} + +// matchConstraintOnMap matches the constraint against a credential already converted to its JSON +// object form (credentialAsMap), returning the per-field outcomes. All fields need to match; the +// evaluation stops at the first field that does not, so on a mismatch the last element of the +// returned slice is the failing field's outcome. Conversion is the caller's job, so that one +// conversion can serve many constraint evaluations: the selection engine evaluates every input +// descriptor against every candidate credential. +func matchConstraintOnMap(constraint *Constraints, credentialJSON map[string]interface{}) (bool, []fieldResult, error) { + results := make([]fieldResult, 0, len(constraint.Fields)) for _, field := range constraint.Fields { - match, value, err := matchField(field, credentialAsMap) + result, err := matchField(field, credentialJSON) if err != nil { return false, nil, err } - if !match { - return false, nil, nil - } - if field.Id != nil { - values[*field.Id] = value + results = append(results, result) + if !result.match { + return false, results, nil } } - return true, values, nil + return true, results, nil +} + +// fieldResult is the outcome of evaluating one constraint field against a credential. It carries +// enough detail for diagnostics, so no second evaluation pass is needed to explain a mismatch. +type fieldResult struct { + // match is whether the field accepts the credential. + match bool + // value is the resolved value; nil for an unresolved optional field. + value interface{} + // rejectedPath and rejectedValue describe the first path where a value was found but the + // filter rejected it. rejectedValue is nil when no path produced a value at all. + rejectedPath string + rejectedValue interface{} } // credentialAsMap converts a VC to a plain JSON object, the form the JSONPath evaluation works on. @@ -439,44 +467,49 @@ func credentialAsMap(credential vc.VerifiableCredential) (map[string]interface{} } } -// 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. +// matchField matches the field against the VC (in its JSON object form). +// 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. -func matchField(field Field, credential map[string]interface{}) (bool, interface{}, error) { +func matchField(field Field, credential map[string]interface{}) (fieldResult, error) { // for each path in field.paths: // a vc must match one of the path + var result fieldResult var optionalInvalid int for _, path := range field.Path { // if path is not found continue value, err := getValueAtPath(path, credential) if err != nil { - return false, nil, fmt.Errorf("path %q: %w", path, err) + return fieldResult{}, fmt.Errorf("path %q: %w", path, err) } if value == nil { continue } if field.Filter == nil { - return true, value, nil + return fieldResult{match: true, value: value}, nil } // if filter at path matches return true match, matchedValue, err := matchFilter(*field.Filter, value) if err != nil { - return false, nil, fmt.Errorf("path %q: %w", path, err) + return fieldResult{}, fmt.Errorf("path %q: %w", path, err) } if match { - return true, matchedValue, nil + return fieldResult{match: true, value: matchedValue}, nil + } + // if filter at path does not match continue and set optionalInvalid; + // remember the first rejection for diagnostics + if optionalInvalid == 0 { + result.rejectedPath, result.rejectedValue = path, value } - // if filter at path does not match continue and set optionalInvalid optionalInvalid++ } // no matches, check optional. Optional is only valid if all paths returned no results // not if a filter did not match if field.Optional != nil && *field.Optional && optionalInvalid == 0 { - return true, nil, nil + return fieldResult{match: true}, nil } - return false, nil, nil + return result, nil } // getValueAtPath uses the JSON path expression to get the value from the VC diff --git a/vcr/pe/presentation_definition_test.go b/vcr/pe/presentation_definition_test.go index 0d8e19068..3f88b51ea 100644 --- a/vcr/pe/presentation_definition_test.go +++ b/vcr/pe/presentation_definition_test.go @@ -674,58 +674,58 @@ func Test_matchField(t *testing.T) { testCredentialMap, _ := remarshalToMap(testCredential) t.Run("single path match", func(t *testing.T) { - match, value, err := matchField(Field{Path: []string{"$.credentialSubject.field"}}, testCredentialMap) + result, err := matchField(Field{Path: []string{"$.credentialSubject.field"}}, testCredentialMap) require.NoError(t, err) - assert.Equal(t, "value", value) - assert.True(t, match) + assert.Equal(t, "value", result.value) + assert.True(t, result.match) }) t.Run("multi path match", func(t *testing.T) { - match, value, err := matchField(Field{Path: []string{"$.other", "$.credentialSubject.field"}}, testCredentialMap) + result, err := matchField(Field{Path: []string{"$.other", "$.credentialSubject.field"}}, testCredentialMap) require.NoError(t, err) - assert.Equal(t, "value", value) - assert.True(t, match) + assert.Equal(t, "value", result.value) + assert.True(t, result.match) }) t.Run("no match", func(t *testing.T) { - match, value, err := matchField(Field{Path: []string{"$.foo", "$.bar"}}, testCredentialMap) + result, err := matchField(Field{Path: []string{"$.foo", "$.bar"}}, testCredentialMap) require.NoError(t, err) - assert.Nil(t, value) - assert.False(t, match) + assert.Nil(t, result.value) + assert.False(t, result.match) }) t.Run("no match, but optional", func(t *testing.T) { trueVal := true - match, value, err := matchField(Field{Path: []string{"$.foo", "$.bar"}, Optional: &trueVal}, testCredentialMap) + result, err := matchField(Field{Path: []string{"$.foo", "$.bar"}, Optional: &trueVal}, testCredentialMap) require.NoError(t, err) - assert.Nil(t, value) - assert.True(t, match) + assert.Nil(t, result.value) + assert.True(t, result.match) }) t.Run("invalid match and optional", func(t *testing.T) { trueVal := true stringVal := "bar" - match, value, err := matchField(Field{Path: []string{"$.credentialSubject.field", "$.foo"}, Optional: &trueVal, Filter: &Filter{Const: &stringVal}}, testCredentialMap) + result, err := matchField(Field{Path: []string{"$.credentialSubject.field", "$.foo"}, Optional: &trueVal, Filter: &Filter{Const: &stringVal}}, testCredentialMap) require.NoError(t, err) - assert.Nil(t, value) - assert.False(t, match) + assert.Nil(t, result.value) + assert.False(t, result.match) }) t.Run("valid match with Filter", func(t *testing.T) { stringVal := "value" - match, value, err := matchField(Field{Path: []string{"$.credentialSubject.field"}, Filter: &Filter{Type: "string", Const: &stringVal}}, testCredentialMap) + result, err := matchField(Field{Path: []string{"$.credentialSubject.field"}, Filter: &Filter{Type: "string", Const: &stringVal}}, testCredentialMap) require.NoError(t, err) - assert.Equal(t, stringVal, value) - assert.True(t, match) + assert.Equal(t, stringVal, result.value) + assert.True(t, result.match) }) t.Run("match on type", func(t *testing.T) { stringVal := "VerifiableCredential" - match, value, err := matchField(Field{Path: []string{"$.type"}, Filter: &Filter{Type: "string", Const: &stringVal}}, testCredentialMap) + result, err := matchField(Field{Path: []string{"$.type"}, Filter: &Filter{Type: "string", Const: &stringVal}}, testCredentialMap) require.NoError(t, err) - assert.Equal(t, stringVal, value) - assert.True(t, match) + assert.Equal(t, stringVal, result.value) + assert.True(t, result.match) }) t.Run("match on type array", func(t *testing.T) { testCredentialString = ` @@ -737,28 +737,28 @@ func Test_matchField(t *testing.T) { }` _ = json.Unmarshal([]byte(testCredentialString), &testCredentialMap) stringVal := "VerifiableCredential" - match, value, err := matchField(Field{Path: []string{"$.type"}, Filter: &Filter{Type: "string", Const: &stringVal}}, testCredentialMap) + result, err := matchField(Field{Path: []string{"$.type"}, Filter: &Filter{Type: "string", Const: &stringVal}}, testCredentialMap) require.NoError(t, err) - assert.Equal(t, []interface{}{"VerifiableCredential"}, value) - assert.True(t, match) + assert.Equal(t, []interface{}{"VerifiableCredential"}, result.value) + assert.True(t, result.match) }) t.Run("errors", func(t *testing.T) { t.Run("invalid path", func(t *testing.T) { - match, value, err := matchField(Field{Path: []string{"$$"}}, testCredentialMap) + result, err := matchField(Field{Path: []string{"$$"}}, testCredentialMap) require.Error(t, err) - assert.Nil(t, value) - assert.False(t, match) + assert.Nil(t, result.value) + assert.False(t, result.match) }) t.Run("invalid pattern", func(t *testing.T) { pattern := "[" - match, value, err := matchField(Field{Path: []string{"$.credentialSubject.field"}, Filter: &Filter{Type: "string", Pattern: &pattern}}, testCredentialMap) + result, err := matchField(Field{Path: []string{"$.credentialSubject.field"}, Filter: &Filter{Type: "string", Pattern: &pattern}}, testCredentialMap) require.Error(t, err) - assert.Nil(t, value) - assert.False(t, match) + assert.Nil(t, result.value) + assert.False(t, result.match) }) }) } diff --git a/vcr/pe/select.go b/vcr/pe/select.go index 1a7b8a270..c9900826c 100644 --- a/vcr/pe/select.go +++ b/vcr/pe/select.go @@ -136,16 +136,23 @@ func Select(pd PresentationDefinition, candidates []vc.VerifiableCredential, opt required := requiredDescriptors(pd) assignment := make([]*candidateGroup, len(pd.InputDescriptors)) var ambiguous []string + var converted []evaluatedCredential if options.trace { defer func() { - result.Report = buildReport(pd, candidates, required, assignment, ambiguous, result, err, options.initialBindings) + result.Report = buildReport(pd, converted, required, assignment, ambiguous, result, err, options.initialBindings) }() } - // Step 1: per-descriptor eligibility, indexed for the search. + // Step 1: convert every candidate once (JSON form and PD-level format gate are the same for + // every descriptor), then index per-descriptor eligibility for the search. + var convErr error + converted, convErr = convertCandidates(pd, candidates) + if convErr != nil { + return result, convErr + } pools := make([]descriptorPool, len(pd.InputDescriptors)) for i, descriptor := range pd.InputDescriptors { - pool, poolErr := buildPool(pd, *descriptor, candidates) + pool, poolErr := buildPool(*descriptor, converted) if poolErr != nil { return result, poolErr } @@ -452,8 +459,8 @@ func strictBoundIDs(descriptor InputDescriptor, initialBindings map[string]strin // 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) +func buildPool(descriptor InputDescriptor, candidates []evaluatedCredential) (descriptorPool, error) { + eligible, err := eligibleCandidates(descriptor, candidates) if err != nil { return descriptorPool{}, err } @@ -703,45 +710,74 @@ type eligibleCandidate struct { idValues map[string]string } +// evaluatedCredential is a candidate prepared for evaluation: the JSON object form the JSONPath +// evaluation works on, and the PD-level format gate outcome. Both are the same for every input +// descriptor, so they are computed once per credential instead of once per descriptor. +type evaluatedCredential struct { + vc vc.VerifiableCredential + json map[string]interface{} + pdFormatOK bool +} + +// convertCandidates prepares every candidate once for evaluation against all descriptors. +func convertCandidates(pd PresentationDefinition, candidates []vc.VerifiableCredential) ([]evaluatedCredential, error) { + converted := make([]evaluatedCredential, len(candidates)) + for i, candidate := range candidates { + credentialJSON, err := credentialAsMap(candidate) + if err != nil { + return nil, err + } + converted[i] = evaluatedCredential{ + vc: candidate, + json: credentialJSON, + pdFormatOK: matchFormat(pd.Format, candidate), + } + } + return converted, nil +} + // 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) { +func eligibleCandidates(descriptor InputDescriptor, candidates []evaluatedCredential) ([]eligibleCandidate, error) { var eligible []eligibleCandidate for _, candidate := range candidates { - ok, idValues, err := evaluateCandidate(pd, descriptor, candidate) + ok, idValues, err := evaluateCandidate(descriptor, candidate) if err != nil { return nil, err } if ok { - eligible = append(eligible, eligibleCandidate{vc: candidate, idValues: idValues}) + eligible = append(eligible, eligibleCandidate{vc: candidate.vc, 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) { +// the descriptor's constraints (matchConstraintOnMap, on the pre-converted JSON) and both 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(descriptor InputDescriptor, candidate evaluatedCredential) (bool, map[string]string, error) { idValues := make(map[string]string) if descriptor.Constraints != nil { - isMatch, values, err := matchConstraint(descriptor.Constraints, credential) + isMatch, fields, err := matchConstraintOnMap(descriptor.Constraints, candidate.json) 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 + for i, field := range descriptor.Constraints.Fields { + if field.Id == nil { + continue + } + if s, ok := stringifyBindingValue(fields[i].value); ok { + idValues[*field.Id] = s } } } // InputDescriptor formats must be a subset of the PresentationDefinition formats, so satisfy both. - if !matchFormat(pd.Format, credential) || !matchFormat(descriptor.Format, credential) { + if !candidate.pdFormatOK || !matchFormat(descriptor.Format, candidate.vc) { return false, nil, nil } return true, idValues, nil