diff --git a/vcr/dcql/README.md b/vcr/dcql/README.md new file mode 100644 index 0000000000..70c52ed47e --- /dev/null +++ b/vcr/dcql/README.md @@ -0,0 +1,158 @@ +# DCQL — Digital Credentials Query Language + +This package implements a subset of the Digital Credentials Query Language (DCQL) +as specified in [OpenID for Verifiable Presentations 1.0](https://openid.net/specs/openid-4-verifiable-presentations-1_0.html), +sections 6.1, 6.3, and 7. + +## Purpose + +DCQL is used in this codebase for **deterministic credential selection** from the wallet. +When multiple credentials of the same type exist (e.g., multiple PatientEnrollmentCredentials +for different patients), the EHR provides a DCQL credential query to specify which one to present. + +This differs from the spec's primary use case, where DCQL is used by a Verifier to request +selective disclosure from a Wallet. In that context, the `values` parameter is a best-effort +privacy hint — the spec states: *"Verifiers MUST treat restrictions expressed using values as +a best-effort way to improve user privacy, but MUST NOT rely on it for security checks."* + +In our context, the node is selecting from its own wallet on behalf of the EHR. The matching +is deterministic: if a credential matches the query, it is selected. If no credential matches, +an empty result is returned. The caller (e.g., the `CredentialSelector` in the PD matcher) is +responsible for deciding whether an empty result is an error. There is no privacy negotiation +involved. + +## Supported subset + +### Credential Query (section 6.1) + +| Field | Status | Notes | +|-------|--------|-------| +| `id` | Supported | Validated: non-empty, alphanumeric/underscore/hyphen | +| `claims` | Supported | Array of claims queries | +| `format` | Not supported | Format selection handled by Presentation Definition matching | +| `meta` | Not supported | Metadata constraints handled by Presentation Definition matching | +| `multiple` | Not supported | Handled by `match_policy` in the filter chain | +| `claim_sets` | Not supported | Not needed for value-based selection | +| `trusted_authorities` | Not supported | Trust handled by PD matching and DID resolution | +| `require_cryptographic_holder_binding` | Not supported | Handled by VP verification | + +### Claims Query (section 6.3) + +| Field | Status | Notes | +|-------|--------|-------| +| `path` | Supported | Claims Path Pointer per section 7 | +| `values` | Supported | Exact value matching with OR semantics | +| `id` | Not supported | Only needed with `claim_sets` | + +### Claims Path Pointer (section 7) + +| Element type | Status | Notes | +|-------------|--------|-------| +| String | Supported | Key lookup in JSON objects | +| Non-negative integer | Supported | Array index lookup | +| Null | Supported | Wildcard — selects all array elements | + +The path starts at the credential root, supporting top-level fields (`issuer`, `type`, etc.) +as well as nested `credentialSubject` fields. + +`credentialSubject` handling: the W3C VC data model allows `credentialSubject` to be either a +single object or an array. The Go VC struct always models it as `[]map[string]any`. The DCQL +spec examples use paths without an array index (e.g., `["credentialSubject", "family_name"]`). + +- **Single credentialSubject** (common case): the array is automatically unwrapped to a single + object, so paths like `["credentialSubject", "patientId"]` work without an index. +- **Multiple credentialSubjects** (rare): the path must include an explicit integer index to + select which subject, e.g., `["credentialSubject", 0, "patientId"]`. Using a string key on + an array with multiple elements returns an error. + +## Examples + +### Select a PatientEnrollmentCredential by BSN + +```json +{ + "id": "id_patient_enrollment", + "claims": [ + { + "path": ["credentialSubject", "hasEnrollment", "patient", "identifier", "value"], + "values": ["123456789"] + } + ] +} +``` + +### Select a credential by multiple possible values (OR) + +```json +{ + "id": "id_patient_enrollment", + "claims": [ + { + "path": ["credentialSubject", "hasEnrollment", "patient", "identifier", "value"], + "values": ["123456789", "987654321"] + } + ] +} +``` + +### Select by issuer DID + +```json +{ + "id": "id_provider", + "claims": [ + { + "path": ["issuer"], + "values": ["did:x509:0:sha256:abc123::san:otherName:12345678"] + } + ] +} +``` + +### Match a value anywhere in an array (null wildcard) + +```json +{ + "id": "id_delegation", + "claims": [ + { + "path": ["credentialSubject", "qualifications", null, "roleCode"], + "values": ["30.000"] + } + ] +} +``` + +This matches if any element in the `qualifications` array has `roleCode` equal to `"30.000"`. + +### Multiple claims (AND semantics) + +```json +{ + "id": "id_enrollment", + "claims": [ + { + "path": ["credentialSubject", "hasEnrollment", "patient", "identifier", "value"], + "values": ["123456789"] + }, + { + "path": ["credentialSubject", "hasEnrollment", "enrolledBy", "identifier", "value"], + "values": ["87654321"] + } + ] +} +``` + +Both claims must match for a credential to be selected. + +## Performance + +Benchmark on Apple M5, worst case (match last of 2000 credentials, 2 claims with wildcards, +each credential has multiple identifiers and qualifications): + +``` +BenchmarkMatch_2000Credentials ~24ms/op ~24MB/op ~504k allocs/op +``` + +The cost is dominated by `json.Marshal`/`json.Unmarshal` per credential for generic root-level +path resolution. For typical use cases (tens of credentials, not thousands) this is negligible. diff --git a/vcr/dcql/dcql.go b/vcr/dcql/dcql.go new file mode 100644 index 0000000000..5b09870fa4 --- /dev/null +++ b/vcr/dcql/dcql.go @@ -0,0 +1,317 @@ +/* + * 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 dcql implements a subset of the Digital Credentials Query Language (DCQL) +// as specified in OpenID4VP sections 6.1, 6.3, and 7. +// +// # Supported DCQL features +// +// Credential Query (section 6.1): +// - id: validated per spec (non-empty, alphanumeric/underscore/hyphen) +// - claims: array of claims queries +// +// Claims Query (section 6.3): +// - path: Claims Path Pointer per section 7 (strings, integers, null) +// - values: exact value matching with OR semantics +// +// Claims Path Pointer (section 7): +// - String elements: key lookup in JSON objects +// - Non-negative integer elements: array index lookup +// - Null elements: wildcard, selects all elements of an array +// - Path starts at the credential root +// +// # Unsupported features +// +// The following Credential Query fields are not supported as they are handled +// by other layers (PD matching, VP verification, filter chain): +// format, meta, multiple, claim_sets, trusted_authorities, require_cryptographic_holder_binding. +package dcql + +import ( + "encoding/json" + "fmt" + "math" + "regexp" + + "github.com/nuts-foundation/go-did/vc" +) + +var validIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) + +// CredentialQuery represents a DCQL credential query as defined in OpenID4VP section 6.1. +type CredentialQuery struct { + // ID is an identifier for the credential query. When used with PD matching, + // this maps to a PD input descriptor ID. + ID string `json:"id"` + // Claims specifies the claim requirements for matching credentials. + Claims []ClaimsQuery `json:"claims"` +} + +// ClaimsQuery represents a DCQL claims query as defined in OpenID4VP section 6.3. +type ClaimsQuery struct { + // Path is a Claims Path Pointer (OpenID4VP section 7) specifying the path to a claim + // within the credential. Elements can be strings (key lookup), non-negative integers + // (array index), or nil (array wildcard). + Path []any `json:"path"` + // Values specifies the expected values of the claim. If present, the credential + // matches only if the claim value equals at least one of the values (OR semantics). + Values []any `json:"values,omitempty"` +} + +// Match evaluates a DCQL credential query against a list of verifiable credentials +// and returns the credentials that match all claims in the query. +// Returns an error if the query is invalid (e.g., invalid ID format) or if a credential +// cannot be processed. +// Returns an empty slice (not an error) when no credentials match. +func Match(query CredentialQuery, credentials []vc.VerifiableCredential) ([]vc.VerifiableCredential, error) { + if err := validateQuery(query); err != nil { + return nil, err + } + var result []vc.VerifiableCredential + for _, cred := range credentials { + root, err := credentialToMap(cred) + if err != nil { + return nil, fmt.Errorf("failed to process credential: %w", err) + } + matched, err := matchesAll(query.Claims, root) + if err != nil { + return nil, err + } + if matched { + result = append(result, cred) + } + } + return result, nil +} + +func validateQuery(query CredentialQuery) error { + if !validIDPattern.MatchString(query.ID) { + return fmt.Errorf("invalid credential query id: must be a non-empty string consisting of alphanumeric, underscore, or hyphen characters") + } + for i, claim := range query.Claims { + if len(claim.Path) == 0 { + return fmt.Errorf("claims[%d]: path must be a non-empty array", i) + } + } + return nil +} + +// credentialToMap converts a credential to a generic JSON map for path resolution. +func credentialToMap(cred vc.VerifiableCredential) (map[string]any, error) { + data, err := json.Marshal(cred) + if err != nil { + return nil, fmt.Errorf("failed to marshal credential: %w", err) + } + var root map[string]any + if err := json.Unmarshal(data, &root); err != nil { + return nil, fmt.Errorf("failed to unmarshal credential: %w", err) + } + return root, nil +} + +// normalizeCredentialSubjectPath adjusts a claims path for the credentialSubject field. +// The Go VC struct models credentialSubject as []map[string]any. When marshalled to JSON: +// - Single element: serialized as a plain object (not an array) +// - Multiple elements: serialized as an array +// +// This function ensures the path works regardless of how credentialSubject was serialized: +// - Path without index on a single object: works as-is (no change needed) +// - Path with index 0 on a single object: strips the index so the path resolves against the object +// - Path without index on an array: inserts index 0 for single-element arrays +// - Path with index on an array: works as-is (no change needed) +func normalizeCredentialSubjectPath(path []any, root map[string]any) []any { + if len(path) < 2 { + return path + } + key, ok := path[0].(string) + if !ok || key != "credentialSubject" { + return path + } + cs, ok := root["credentialSubject"] + if !ok { + return path + } + + hasIndex := false + switch path[1].(type) { + case int, float64: + hasIndex = true + } + + switch cs.(type) { + case map[string]any: + // Single credentialSubject serialized as object + if hasIndex { + // Only strip index 0 — higher indices cannot refer to a single object + isZero := false + switch idx := path[1].(type) { + case int: + isZero = idx == 0 + case float64: + isZero = idx == 0 + } + if !isZero { + return path + } + // Strip the index — path like ["credentialSubject", 0, "field"] + // becomes ["credentialSubject", "field"] + normalized := make([]any, 0, len(path)-1) + normalized = append(normalized, path[0]) + normalized = append(normalized, path[2:]...) + return normalized + } + // No index needed, path resolves directly against the object + return path + case []any: + // Multiple credentialSubjects serialized as array + arr := cs.([]any) + if !hasIndex && len(arr) == 1 { + // Insert index 0 for single-element array + normalized := make([]any, 0, len(path)+1) + normalized = append(normalized, path[0], 0) + normalized = append(normalized, path[1:]...) + return normalized + } + // Either already has index, or multiple elements (require index from caller) + return path + default: + return path + } +} + +func matchesAll(claims []ClaimsQuery, root map[string]any) (bool, error) { + for _, claim := range claims { + matched, err := matchesClaim(claim, root) + if err != nil { + return false, err + } + if !matched { + return false, nil + } + } + return true, nil +} + +func matchesClaim(claim ClaimsQuery, root map[string]any) (bool, error) { + path := normalizeCredentialSubjectPath(claim.Path, root) + resolved, err := resolveInValue(path, root) + if err != nil { + return false, err + } + if resolved == nil { + return false, nil + } + if len(claim.Values) == 0 { + return true, nil + } + return containsExpectedValue(resolved, claim.Values), nil +} + +// containsExpectedValue checks whether the resolved value matches any of the expected values. +// If the value is a []any (from wildcard path resolution, possibly nested from multiple +// wildcards), it recursively searches all levels. +func containsExpectedValue(value any, expectedValues []any) bool { + if slice, ok := value.([]any); ok { + for _, elem := range slice { + if containsExpectedValue(elem, expectedValues) { + return true + } + } + return false + } + for _, expected := range expectedValues { + if value == expected { + return true + } + } + return false +} + +// resolveInValue resolves a Claims Path Pointer (OpenID4VP section 7) against a JSON value. +// Path elements can be: string (object key lookup), float64/int (array index), or nil (array wildcard). +// Returns an error for invalid path elements (non-integer float, unsupported type). +func resolveInValue(path []any, value any) (any, error) { + if len(path) == 0 { + return value, nil + } + switch element := path[0].(type) { + case string: + m, ok := value.(map[string]any) + if !ok { + // If the value is an array with >1 elements and the path uses a string key, + // the path is ambiguous — it needs an integer index to select an element. + if arr, isArr := value.([]any); isArr && len(arr) > 1 { + return nil, fmt.Errorf("path uses key '%s' on array with %d elements: use an integer index to select an element", element, len(arr)) + } + return nil, nil + } + child, ok := m[element] + if !ok { + return nil, nil + } + return resolveInValue(path[1:], child) + case int: + if element < 0 { + return nil, fmt.Errorf("invalid path element: %v is not a non-negative integer", element) + } + return resolveArrayIndex(path[1:], value, element) + case float64: + // JSON unmarshalling produces float64 for numbers. Validate it represents + // a non-negative integer within int range before converting. + if element < 0 || math.Trunc(element) != element || element > float64(math.MaxInt) { + return nil, fmt.Errorf("invalid path element: %v is not a non-negative integer", element) + } + return resolveArrayIndex(path[1:], value, int(element)) + case nil: + // Null wildcard: select all elements of the array + arr, ok := value.([]any) + if !ok { + return nil, nil + } + if len(path) == 1 { + return arr, nil + } + var results []any + for _, item := range arr { + resolved, err := resolveInValue(path[1:], item) + if err != nil { + return nil, err + } + if resolved != nil { + results = append(results, resolved) + } + } + if len(results) == 0 { + return nil, nil + } + return results, nil + default: + return nil, fmt.Errorf("invalid path element type: %T", element) + } +} + +func resolveArrayIndex(remainingPath []any, value any, index int) (any, error) { + arr, ok := value.([]any) + if !ok { + return nil, nil + } + if index < 0 || index >= len(arr) { + return nil, nil + } + return resolveInValue(remainingPath, arr[index]) +} diff --git a/vcr/dcql/dcql_test.go b/vcr/dcql/dcql_test.go new file mode 100644 index 0000000000..18217b7572 --- /dev/null +++ b/vcr/dcql/dcql_test.go @@ -0,0 +1,915 @@ +/* + * 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 dcql + +import ( + "encoding/json" + "fmt" + "testing" + + ssi "github.com/nuts-foundation/go-did" + "github.com/nuts-foundation/go-did/vc" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMatch(t *testing.T) { + t.Run("single claim with matching value returns credential", func(t *testing.T) { + credential := vc.VerifiableCredential{ + CredentialSubject: []map[string]any{ + {"patientId": "123456789"}, + }, + } + query := CredentialQuery{ + ID: "test", + Claims: []ClaimsQuery{ + { + Path: []any{"credentialSubject", "patientId"}, + Values: []any{"123456789"}, + }, + }, + } + + result, err := Match(query, []vc.VerifiableCredential{credential}) + + require.NoError(t, err) + assert.Len(t, result, 1) + assert.Equal(t, credential, result[0]) + }) + t.Run("single claim with non-matching value returns empty", func(t *testing.T) { + credential := vc.VerifiableCredential{ + CredentialSubject: []map[string]any{ + {"patientId": "123456789"}, + }, + } + query := CredentialQuery{ + ID: "test", + Claims: []ClaimsQuery{ + { + Path: []any{"credentialSubject", "patientId"}, + Values: []any{"999999999"}, + }, + }, + } + + result, err := Match(query, []vc.VerifiableCredential{credential}) + + require.NoError(t, err) + assert.Empty(t, result) + }) + t.Run("nested path resolves correctly", func(t *testing.T) { + credential := vc.VerifiableCredential{ + CredentialSubject: []map[string]any{ + { + "hasEnrollment": map[string]any{ + "patient": map[string]any{ + "identifier": map[string]any{ + "value": "123456789", + }, + }, + }, + }, + }, + } + query := CredentialQuery{ + ID: "test", + Claims: []ClaimsQuery{ + { + Path: []any{"credentialSubject", "hasEnrollment", "patient", "identifier", "value"}, + Values: []any{"123456789"}, + }, + }, + } + + result, err := Match(query, []vc.VerifiableCredential{credential}) + + require.NoError(t, err) + assert.Len(t, result, 1) + }) + t.Run("multiple values use OR semantics", func(t *testing.T) { + credential := vc.VerifiableCredential{ + CredentialSubject: []map[string]any{ + {"postalCode": "90210"}, + }, + } + query := CredentialQuery{ + ID: "test", + Claims: []ClaimsQuery{ + { + Path: []any{"credentialSubject", "postalCode"}, + Values: []any{"90210", "90211"}, + }, + }, + } + + result, err := Match(query, []vc.VerifiableCredential{credential}) + + require.NoError(t, err) + assert.Len(t, result, 1) + }) + t.Run("multiple values none matching returns empty", func(t *testing.T) { + credential := vc.VerifiableCredential{ + CredentialSubject: []map[string]any{ + {"postalCode": "12345"}, + }, + } + query := CredentialQuery{ + ID: "test", + Claims: []ClaimsQuery{ + { + Path: []any{"credentialSubject", "postalCode"}, + Values: []any{"90210", "90211"}, + }, + }, + } + + result, err := Match(query, []vc.VerifiableCredential{credential}) + + require.NoError(t, err) + assert.Empty(t, result) + }) + t.Run("multiple claims use AND semantics — all match", func(t *testing.T) { + credential := vc.VerifiableCredential{ + CredentialSubject: []map[string]any{ + { + "patientId": "123", + "type": "pharmacy", + }, + }, + } + query := CredentialQuery{ + ID: "test", + Claims: []ClaimsQuery{ + {Path: []any{"credentialSubject", "patientId"}, Values: []any{"123"}}, + {Path: []any{"credentialSubject", "type"}, Values: []any{"pharmacy"}}, + }, + } + + result, err := Match(query, []vc.VerifiableCredential{credential}) + + require.NoError(t, err) + assert.Len(t, result, 1) + }) + t.Run("multiple claims use AND semantics — one fails", func(t *testing.T) { + credential := vc.VerifiableCredential{ + CredentialSubject: []map[string]any{ + { + "patientId": "123", + "type": "hospital", + }, + }, + } + query := CredentialQuery{ + ID: "test", + Claims: []ClaimsQuery{ + {Path: []any{"credentialSubject", "patientId"}, Values: []any{"123"}}, + {Path: []any{"credentialSubject", "type"}, Values: []any{"pharmacy"}}, + }, + } + + result, err := Match(query, []vc.VerifiableCredential{credential}) + + require.NoError(t, err) + assert.Empty(t, result) + }) + t.Run("missing field in credential does not match", func(t *testing.T) { + credential := vc.VerifiableCredential{ + CredentialSubject: []map[string]any{ + {"otherField": "value"}, + }, + } + query := CredentialQuery{ + ID: "test", + Claims: []ClaimsQuery{ + {Path: []any{"credentialSubject", "patientId"}, Values: []any{"123"}}, + }, + } + + result, err := Match(query, []vc.VerifiableCredential{credential}) + + require.NoError(t, err) + assert.Empty(t, result) + }) + t.Run("empty credentialSubject does not match", func(t *testing.T) { + credential := vc.VerifiableCredential{ + CredentialSubject: []map[string]any{}, + } + query := CredentialQuery{ + ID: "test", + Claims: []ClaimsQuery{ + {Path: []any{"credentialSubject", "patientId"}, Values: []any{"123"}}, + }, + } + + result, err := Match(query, []vc.VerifiableCredential{credential}) + + require.NoError(t, err) + assert.Empty(t, result) + }) + t.Run("empty credentials list returns empty", func(t *testing.T) { + query := CredentialQuery{ + ID: "test", + Claims: []ClaimsQuery{{Path: []any{"credentialSubject", "id"}, Values: []any{"x"}}}, + } + + result, err := Match(query, []vc.VerifiableCredential{}) + + require.NoError(t, err) + assert.Empty(t, result) + }) + t.Run("multiple credentials returns only matching ones", func(t *testing.T) { + match := vc.VerifiableCredential{ + CredentialSubject: []map[string]any{{"patientId": "123"}}, + } + noMatch := vc.VerifiableCredential{ + CredentialSubject: []map[string]any{{"patientId": "456"}}, + } + query := CredentialQuery{ + ID: "test", + Claims: []ClaimsQuery{ + {Path: []any{"credentialSubject", "patientId"}, Values: []any{"123"}}, + }, + } + + result, err := Match(query, []vc.VerifiableCredential{noMatch, match, noMatch}) + + require.NoError(t, err) + assert.Len(t, result, 1) + assert.Equal(t, match, result[0]) + }) + t.Run("integer value matching", func(t *testing.T) { + credential := vc.VerifiableCredential{ + CredentialSubject: []map[string]any{ + {"age": float64(42)}, + }, + } + query := CredentialQuery{ + ID: "test", + Claims: []ClaimsQuery{ + {Path: []any{"credentialSubject", "age"}, Values: []any{float64(42)}}, + }, + } + + result, err := Match(query, []vc.VerifiableCredential{credential}) + + require.NoError(t, err) + assert.Len(t, result, 1) + }) + t.Run("boolean value matching", func(t *testing.T) { + credential := vc.VerifiableCredential{ + CredentialSubject: []map[string]any{ + {"active": true}, + }, + } + query := CredentialQuery{ + ID: "test", + Claims: []ClaimsQuery{ + {Path: []any{"credentialSubject", "active"}, Values: []any{true}}, + }, + } + + result, err := Match(query, []vc.VerifiableCredential{credential}) + + require.NoError(t, err) + assert.Len(t, result, 1) + }) + t.Run("boolean value mismatch", func(t *testing.T) { + credential := vc.VerifiableCredential{ + CredentialSubject: []map[string]any{ + {"active": false}, + }, + } + query := CredentialQuery{ + ID: "test", + Claims: []ClaimsQuery{ + {Path: []any{"credentialSubject", "active"}, Values: []any{true}}, + }, + } + + result, err := Match(query, []vc.VerifiableCredential{credential}) + + require.NoError(t, err) + assert.Empty(t, result) + }) + t.Run("claim without values matches if field exists", func(t *testing.T) { + credential := vc.VerifiableCredential{ + CredentialSubject: []map[string]any{ + {"patientId": "anything"}, + }, + } + query := CredentialQuery{ + ID: "test", + Claims: []ClaimsQuery{ + {Path: []any{"credentialSubject", "patientId"}}, + }, + } + + result, err := Match(query, []vc.VerifiableCredential{credential}) + + require.NoError(t, err) + assert.Len(t, result, 1) + }) + t.Run("claim without values does not match if field missing", func(t *testing.T) { + credential := vc.VerifiableCredential{ + CredentialSubject: []map[string]any{ + {"otherField": "value"}, + }, + } + query := CredentialQuery{ + ID: "test", + Claims: []ClaimsQuery{ + {Path: []any{"credentialSubject", "patientId"}}, + }, + } + + result, err := Match(query, []vc.VerifiableCredential{credential}) + + require.NoError(t, err) + assert.Empty(t, result) + }) + // This test verifies that matching works correctly when both the query and credential + // are deserialized from JSON, as they would be in production. JSON unmarshalling can + // produce different Go types than hand-constructed structs (e.g., nested maps vs typed + // fields), so this catches type mismatches that unit tests with Go literals would miss. + t.Run("JSON-deserialized query matches JSON-deserialized credential", func(t *testing.T) { + var credential vc.VerifiableCredential + err := credential.UnmarshalJSON([]byte(`{ + "type": ["VerifiableCredential", "PatientEnrollmentCredential"], + "credentialSubject": [{ + "hasEnrollment": { + "patient": { + "identifier": { + "system": "http://fhir.nl/fhir/NamingSystem/bsn", + "value": "123456789" + } + } + } + }] + }`)) + require.NoError(t, err) + + var query CredentialQuery + err = json.Unmarshal([]byte(`{ + "id": "id_patient_enrollment", + "claims": [ + { + "path": ["credentialSubject", "hasEnrollment", "patient", "identifier", "value"], + "values": ["123456789"] + } + ] + }`), &query) + require.NoError(t, err) + + result, err := Match(query, []vc.VerifiableCredential{credential}) + + require.NoError(t, err) + assert.Len(t, result, 1) + }) + t.Run("path with integer element resolves array index", func(t *testing.T) { + credential := vc.VerifiableCredential{ + CredentialSubject: []map[string]any{ + { + "roleCodes": []any{"01.015", "30.000", "17.000"}, + }, + }, + } + query := CredentialQuery{ + ID: "test", + Claims: []ClaimsQuery{ + // Select the second role code (30.000 = Verpleegkundige) + {Path: []any{"credentialSubject", "roleCodes", 1}, Values: []any{"30.000"}}, + }, + } + + result, err := Match(query, []vc.VerifiableCredential{credential}) + + require.NoError(t, err) + assert.Len(t, result, 1) + }) + t.Run("path with integer element does not match wrong index", func(t *testing.T) { + credential := vc.VerifiableCredential{ + CredentialSubject: []map[string]any{ + { + "roleCodes": []any{"01.015", "30.000", "17.000"}, + }, + }, + } + query := CredentialQuery{ + ID: "test", + Claims: []ClaimsQuery{ + // Index 1 is "30.000" (Verpleegkundige), not "17.000" (Apotheker) + {Path: []any{"credentialSubject", "roleCodes", 1}, Values: []any{"17.000"}}, + }, + } + + result, err := Match(query, []vc.VerifiableCredential{credential}) + + require.NoError(t, err) + assert.Empty(t, result) + }) + t.Run("null wildcard matches value anywhere in array", func(t *testing.T) { + credential := vc.VerifiableCredential{ + CredentialSubject: []map[string]any{ + { + "roleCodes": []any{"01.015", "30.000", "17.000"}, + }, + }, + } + query := CredentialQuery{ + ID: "test", + Claims: []ClaimsQuery{ + // null wildcard selects all elements — match if "30.000" (Verpleegkundige) is anywhere in the array + {Path: []any{"credentialSubject", "roleCodes", nil}, Values: []any{"30.000"}}, + }, + } + + result, err := Match(query, []vc.VerifiableCredential{credential}) + + require.NoError(t, err) + assert.Len(t, result, 1) + }) + t.Run("null wildcard does not match when value absent from array", func(t *testing.T) { + credential := vc.VerifiableCredential{ + CredentialSubject: []map[string]any{ + { + "roleCodes": []any{"01.015", "30.000", "17.000"}, + }, + }, + } + query := CredentialQuery{ + ID: "test", + Claims: []ClaimsQuery{ + // "89.000" (Dietist) is not in the array + {Path: []any{"credentialSubject", "roleCodes", nil}, Values: []any{"89.000"}}, + }, + } + + result, err := Match(query, []vc.VerifiableCredential{credential}) + + require.NoError(t, err) + assert.Empty(t, result) + }) + t.Run("null wildcard in middle of path matches nested field in array elements", func(t *testing.T) { + credential := vc.VerifiableCredential{ + CredentialSubject: []map[string]any{ + { + "qualifications": []any{ + map[string]any{"roleCode": "01.015", "name": "Huisarts"}, + map[string]any{"roleCode": "30.000", "name": "Verpleegkundige"}, + map[string]any{"roleCode": "17.000", "name": "Apotheker"}, + }, + }, + }, + } + query := CredentialQuery{ + ID: "test", + Claims: []ClaimsQuery{ + // Wildcard selects all qualifications, then match on nested roleCode + {Path: []any{"credentialSubject", "qualifications", nil, "roleCode"}, Values: []any{"30.000"}}, + }, + } + + result, err := Match(query, []vc.VerifiableCredential{credential}) + + require.NoError(t, err) + assert.Len(t, result, 1) + }) + t.Run("null wildcard in middle of path does not match when nested field absent", func(t *testing.T) { + credential := vc.VerifiableCredential{ + CredentialSubject: []map[string]any{ + { + "qualifications": []any{ + map[string]any{"roleCode": "01.015", "name": "Huisarts"}, + map[string]any{"roleCode": "30.000", "name": "Verpleegkundige"}, + }, + }, + }, + } + query := CredentialQuery{ + ID: "test", + Claims: []ClaimsQuery{ + // "89.000" (Dietist) is not in any qualification's roleCode + {Path: []any{"credentialSubject", "qualifications", nil, "roleCode"}, Values: []any{"89.000"}}, + }, + } + + result, err := Match(query, []vc.VerifiableCredential{credential}) + + require.NoError(t, err) + assert.Empty(t, result) + }) + t.Run("nested wildcards match values in arrays of arrays", func(t *testing.T) { + // A credential with departments, each containing multiple employees with role codes + credential := vc.VerifiableCredential{ + CredentialSubject: []map[string]any{ + { + "departments": []any{ + map[string]any{ + "employees": []any{ + map[string]any{"roleCode": "01.015"}, + map[string]any{"roleCode": "30.000"}, + }, + }, + map[string]any{ + "employees": []any{ + map[string]any{"roleCode": "17.000"}, + map[string]any{"roleCode": "04.000"}, + }, + }, + }, + }, + }, + } + query := CredentialQuery{ + ID: "test", + Claims: []ClaimsQuery{ + // Two wildcards: all departments → all employees → roleCode + // Should find "17.000" (Apotheker) in the second department + {Path: []any{"credentialSubject", "departments", nil, "employees", nil, "roleCode"}, Values: []any{"17.000"}}, + }, + } + + result, err := Match(query, []vc.VerifiableCredential{credential}) + + require.NoError(t, err) + assert.Len(t, result, 1) + }) + t.Run("path resolves root-level fields", func(t *testing.T) { + credential := vc.VerifiableCredential{ + Issuer: ssi.MustParseURI("did:x509:0:sha256:abc123"), + } + query := CredentialQuery{ + ID: "test", + Claims: []ClaimsQuery{ + {Path: []any{"issuer"}, Values: []any{"did:x509:0:sha256:abc123"}}, + }, + } + + result, err := Match(query, []vc.VerifiableCredential{credential}) + + require.NoError(t, err) + assert.Len(t, result, 1) + }) + t.Run("invalid credential query ID returns error", func(t *testing.T) { + query := CredentialQuery{ + ID: "invalid id!", + Claims: []ClaimsQuery{}, + } + + _, err := Match(query, []vc.VerifiableCredential{}) + + assert.ErrorContains(t, err, "invalid credential query id") + }) + t.Run("empty credential query ID returns error", func(t *testing.T) { + query := CredentialQuery{ + ID: "", + Claims: []ClaimsQuery{}, + } + + _, err := Match(query, []vc.VerifiableCredential{}) + + assert.ErrorContains(t, err, "invalid credential query id") + }) + t.Run("float path element that is not an integer returns error", func(t *testing.T) { + credential := vc.VerifiableCredential{ + CredentialSubject: []map[string]any{ + {"roles": []any{"01.015", "30.000"}}, + }, + } + query := CredentialQuery{ + ID: "test", + Claims: []ClaimsQuery{ + {Path: []any{"credentialSubject", "roles", float64(1.5)}, Values: []any{"30.000"}}, + }, + } + + _, err := Match(query, []vc.VerifiableCredential{credential}) + + assert.ErrorContains(t, err, "invalid path element") + }) + t.Run("boolean path element returns error", func(t *testing.T) { + credential := vc.VerifiableCredential{ + CredentialSubject: []map[string]any{ + {"field": "value"}, + }, + } + query := CredentialQuery{ + ID: "test", + Claims: []ClaimsQuery{ + {Path: []any{"credentialSubject", true}, Values: []any{"value"}}, + }, + } + + _, err := Match(query, []vc.VerifiableCredential{credential}) + + assert.ErrorContains(t, err, "invalid path element type") + }) + t.Run("single credentialSubject with explicit index 0 works", func(t *testing.T) { + credential := vc.VerifiableCredential{ + CredentialSubject: []map[string]any{ + {"patientId": "123"}, + }, + } + query := CredentialQuery{ + ID: "test", + Claims: []ClaimsQuery{ + {Path: []any{"credentialSubject", 0, "patientId"}, Values: []any{"123"}}, + }, + } + + result, err := Match(query, []vc.VerifiableCredential{credential}) + + require.NoError(t, err) + assert.Len(t, result, 1) + }) + t.Run("multiple credentialSubjects without index returns error", func(t *testing.T) { + credential := vc.VerifiableCredential{ + CredentialSubject: []map[string]any{ + {"patientId": "123"}, + {"patientId": "456"}, + }, + } + query := CredentialQuery{ + ID: "test", + Claims: []ClaimsQuery{ + {Path: []any{"credentialSubject", "patientId"}, Values: []any{"123"}}, + }, + } + + _, err := Match(query, []vc.VerifiableCredential{credential}) + + assert.ErrorContains(t, err, "index") + }) + t.Run("multiple credentialSubjects with index works", func(t *testing.T) { + credential := vc.VerifiableCredential{ + CredentialSubject: []map[string]any{ + {"patientId": "123"}, + {"patientId": "456"}, + }, + } + query := CredentialQuery{ + ID: "test", + Claims: []ClaimsQuery{ + {Path: []any{"credentialSubject", 1, "patientId"}, Values: []any{"456"}}, + }, + } + + result, err := Match(query, []vc.VerifiableCredential{credential}) + + require.NoError(t, err) + assert.Len(t, result, 1) + }) + t.Run("negative int path element returns error", func(t *testing.T) { + credential := vc.VerifiableCredential{ + CredentialSubject: []map[string]any{ + {"roles": []any{"01.015", "30.000"}}, + }, + } + query := CredentialQuery{ + ID: "test", + Claims: []ClaimsQuery{ + {Path: []any{"credentialSubject", "roles", -1}, Values: []any{"30.000"}}, + }, + } + + _, err := Match(query, []vc.VerifiableCredential{credential}) + + assert.ErrorContains(t, err, "invalid path element") + }) + t.Run("float64 exceeding MaxInt returns error", func(t *testing.T) { + credential := vc.VerifiableCredential{ + CredentialSubject: []map[string]any{ + {"roles": []any{"01.015"}}, + }, + } + query := CredentialQuery{ + ID: "test", + Claims: []ClaimsQuery{ + {Path: []any{"credentialSubject", "roles", float64(1e19)}, Values: []any{"01.015"}}, + }, + } + + _, err := Match(query, []vc.VerifiableCredential{credential}) + + assert.ErrorContains(t, err, "invalid path element") + }) + t.Run("empty path in claims query returns error", func(t *testing.T) { + credential := vc.VerifiableCredential{ + CredentialSubject: []map[string]any{ + {"field": "value"}, + }, + } + query := CredentialQuery{ + ID: "test", + Claims: []ClaimsQuery{ + {Path: []any{}, Values: []any{"value"}}, + }, + } + + _, err := Match(query, []vc.VerifiableCredential{credential}) + + assert.ErrorContains(t, err, "path must be a non-empty array") + }) + t.Run("path ending at object does not panic on value comparison", func(t *testing.T) { + // When the path resolves to a map (non-comparable type), containsExpectedValue + // must not panic on == comparison. It should simply not match. + credential := vc.VerifiableCredential{ + CredentialSubject: []map[string]any{ + { + "hasEnrollment": map[string]any{ + "patient": map[string]any{ + "name": "John", + }, + }, + }, + }, + } + query := CredentialQuery{ + ID: "test", + Claims: []ClaimsQuery{ + // Path stops at the object level, resolved value is a map + {Path: []any{"credentialSubject", "hasEnrollment", "patient"}, Values: []any{"John"}}, + }, + } + + result, err := Match(query, []vc.VerifiableCredential{credential}) + + require.NoError(t, err) + assert.Empty(t, result) + }) + t.Run("single credentialSubject with non-zero index does not match", func(t *testing.T) { + // When credentialSubject is a single object and the path uses index 5, + // this should not match — only index 0 is valid for a single element. + credential := vc.VerifiableCredential{ + CredentialSubject: []map[string]any{ + {"patientId": "123"}, + }, + } + query := CredentialQuery{ + ID: "test", + Claims: []ClaimsQuery{ + {Path: []any{"credentialSubject", 5, "patientId"}, Values: []any{"123"}}, + }, + } + + result, err := Match(query, []vc.VerifiableCredential{credential}) + + require.NoError(t, err) + assert.Empty(t, result) + }) + t.Run("null wildcard on non-array value does not match", func(t *testing.T) { + credential := vc.VerifiableCredential{ + CredentialSubject: []map[string]any{ + {"name": "John"}, + }, + } + query := CredentialQuery{ + ID: "test", + Claims: []ClaimsQuery{ + // null wildcard on a string value — not an array + {Path: []any{"credentialSubject", "name", nil}, Values: []any{"John"}}, + }, + } + + result, err := Match(query, []vc.VerifiableCredential{credential}) + + require.NoError(t, err) + assert.Empty(t, result) + }) + t.Run("integer index on non-array value does not match", func(t *testing.T) { + credential := vc.VerifiableCredential{ + CredentialSubject: []map[string]any{ + {"name": "John"}, + }, + } + query := CredentialQuery{ + ID: "test", + Claims: []ClaimsQuery{ + {Path: []any{"credentialSubject", "name", 0}, Values: []any{"John"}}, + }, + } + + result, err := Match(query, []vc.VerifiableCredential{credential}) + + require.NoError(t, err) + assert.Empty(t, result) + }) + t.Run("out-of-bounds array index does not match", func(t *testing.T) { + credential := vc.VerifiableCredential{ + CredentialSubject: []map[string]any{ + {"roles": []any{"01.015"}}, + }, + } + query := CredentialQuery{ + ID: "test", + Claims: []ClaimsQuery{ + {Path: []any{"credentialSubject", "roles", 99}, Values: []any{"01.015"}}, + }, + } + + result, err := Match(query, []vc.VerifiableCredential{credential}) + + require.NoError(t, err) + assert.Empty(t, result) + }) + t.Run("string key on non-map non-array value does not match", func(t *testing.T) { + credential := vc.VerifiableCredential{ + CredentialSubject: []map[string]any{ + {"count": float64(42)}, + }, + } + query := CredentialQuery{ + ID: "test", + Claims: []ClaimsQuery{ + // Trying to use a string key on a number + {Path: []any{"credentialSubject", "count", "sub"}, Values: []any{"x"}}, + }, + } + + result, err := Match(query, []vc.VerifiableCredential{credential}) + + require.NoError(t, err) + assert.Empty(t, result) + }) + t.Run("valid credential query ID with hyphens and underscores", func(t *testing.T) { + query := CredentialQuery{ + ID: "id_patient-enrollment_01", + Claims: []ClaimsQuery{}, + } + + result, err := Match(query, []vc.VerifiableCredential{}) + + require.NoError(t, err) + assert.Empty(t, result) + }) +} + +func BenchmarkMatch_2000Credentials(b *testing.B) { + const count = 2000 + roleCodes := []string{"01.015", "30.000", "17.000", "04.000", "89.000"} + credentials := make([]vc.VerifiableCredential, count) + for i := range credentials { + bsn := fmt.Sprintf("%09d", i) + ura := fmt.Sprintf("URA-%05d", i) + // Each credential has multiple identifiers and multiple qualifications with role codes + credentials[i] = vc.VerifiableCredential{ + Type: []ssi.URI{ssi.MustParseURI("VerifiableCredential"), ssi.MustParseURI("PatientEnrollmentCredential")}, + Issuer: ssi.MustParseURI(fmt.Sprintf("did:x509:0:sha256:hash%d", i)), + CredentialSubject: []map[string]any{ + { + "identifier": []any{ + map[string]any{"system": "http://fhir.nl/fhir/NamingSystem/ura", "value": ura}, + map[string]any{"system": "http://fhir.nl/fhir/NamingSystem/bsn", "value": bsn}, + }, + "qualifications": []any{ + map[string]any{"roleCode": roleCodes[i%len(roleCodes)], "name": "Role A"}, + map[string]any{"roleCode": roleCodes[(i+1)%len(roleCodes)], "name": "Role B"}, + map[string]any{"roleCode": roleCodes[(i+2)%len(roleCodes)], "name": "Role C"}, + }, + }, + }, + } + } + // Worst case: target is the last credential. Multiple claims with wildcards. + targetBsn := fmt.Sprintf("%09d", count-1) + query := CredentialQuery{ + ID: "id_patient_enrollment", + Claims: []ClaimsQuery{ + // Wildcard over identifiers to find BSN + { + Path: []any{"credentialSubject", "identifier", nil, "value"}, + Values: []any{targetBsn}, + }, + // Wildcard over qualifications to find a specific role code + { + Path: []any{"credentialSubject", "qualifications", nil, "roleCode"}, + Values: []any{roleCodes[(count-1)%len(roleCodes)]}, + }, + }, + } + + b.ResetTimer() + var benchResult []vc.VerifiableCredential + var benchErr error + for b.Loop() { + benchResult, benchErr = Match(query, credentials) + } + require.NoError(b, benchErr) + require.Len(b, benchResult, 1) +}