From 55f59eabac827eab908daeb591f2fa923bf35d3c Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Sat, 21 Mar 2026 18:18:59 +0100 Subject: [PATCH 01/21] =?UTF-8?q?#4087:=20DCQL=20parser=20=E2=80=94=20trac?= =?UTF-8?q?er=20bullet:=20single=20claim=20value=20matching?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces vcr/dcql package with CredentialQuery/ClaimsQuery types and Match function. First test: single claim with matching value on a flat credentialSubject returns the credential. Co-Authored-By: Claude Opus 4.6 (1M context) --- vcr/dcql/dcql.go | 112 ++++++++++++++++++++++++++++++++++++++++++ vcr/dcql/dcql_test.go | 50 +++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 vcr/dcql/dcql.go create mode 100644 vcr/dcql/dcql_test.go diff --git a/vcr/dcql/dcql.go b/vcr/dcql/dcql.go new file mode 100644 index 0000000000..7a97982db6 --- /dev/null +++ b/vcr/dcql/dcql.go @@ -0,0 +1,112 @@ +/* + * 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 Digital Credentials Query Language (DCQL) credential matching +// as specified in OpenID4VP section 6.1 and 6.3. +package dcql + +import ( + "github.com/nuts-foundation/go-did/vc" +) + +// 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 specifying the path to a claim within the credential. + Path []string `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. +func Match(query CredentialQuery, credentials []vc.VerifiableCredential) []vc.VerifiableCredential { + var result []vc.VerifiableCredential + for _, cred := range credentials { + if matchesAll(query.Claims, cred) { + result = append(result, cred) + } + } + return result +} + +func matchesAll(claims []ClaimsQuery, cred vc.VerifiableCredential) bool { + for _, claim := range claims { + if !matchesClaim(claim, cred) { + return false + } + } + return true +} + +func matchesClaim(claim ClaimsQuery, cred vc.VerifiableCredential) bool { + value := resolvePath(claim.Path, cred) + if value == nil { + return false + } + if len(claim.Values) == 0 { + return true + } + for _, expected := range claim.Values { + if value == expected { + return true + } + } + return false +} + +func resolvePath(path []string, cred vc.VerifiableCredential) any { + if len(path) == 0 { + return nil + } + // The first path element "credentialSubject" maps to the CredentialSubject field + if path[0] == "credentialSubject" { + if len(cred.CredentialSubject) == 0 { + return nil + } + return resolveInMap(path[1:], cred.CredentialSubject[0]) + } + return nil +} + +func resolveInMap(path []string, m map[string]any) any { + if len(path) == 0 || m == nil { + return nil + } + value, ok := m[path[0]] + if !ok { + return nil + } + if len(path) == 1 { + return value + } + nested, ok := value.(map[string]any) + if !ok { + return nil + } + return resolveInMap(path[1:], nested) +} diff --git a/vcr/dcql/dcql_test.go b/vcr/dcql/dcql_test.go new file mode 100644 index 0000000000..7dcb13589b --- /dev/null +++ b/vcr/dcql/dcql_test.go @@ -0,0 +1,50 @@ +/* + * 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 ( + "testing" + + "github.com/nuts-foundation/go-did/vc" + "github.com/stretchr/testify/assert" +) + +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: []string{"credentialSubject", "patientId"}, + Values: []any{"123456789"}, + }, + }, + } + + result := Match(query, []vc.VerifiableCredential{credential}) + + assert.Len(t, result, 1) + assert.Equal(t, credential, result[0]) + }) +} From 191a30f0a707cbafb1e8463fc6a4c61ad01d1e2c Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Sat, 21 Mar 2026 18:20:21 +0100 Subject: [PATCH 02/21] =?UTF-8?q?#4087:=20DCQL=20parser=20=E2=80=94=20test?= =?UTF-8?q?=20non-matching=20value=20returns=20empty?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- vcr/dcql/dcql_test.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/vcr/dcql/dcql_test.go b/vcr/dcql/dcql_test.go index 7dcb13589b..756e4a8ad7 100644 --- a/vcr/dcql/dcql_test.go +++ b/vcr/dcql/dcql_test.go @@ -47,4 +47,24 @@ func TestMatch(t *testing.T) { 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: []string{"credentialSubject", "patientId"}, + Values: []any{"999999999"}, + }, + }, + } + + result := Match(query, []vc.VerifiableCredential{credential}) + + assert.Empty(t, result) + }) } From f419d33e3b054ca59aa7b3e2177939e4009d5f47 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Sat, 21 Mar 2026 18:22:44 +0100 Subject: [PATCH 03/21] =?UTF-8?q?#4087:=20DCQL=20parser=20=E2=80=94=20test?= =?UTF-8?q?=20nested=20path=20resolution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- vcr/dcql/dcql_test.go | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/vcr/dcql/dcql_test.go b/vcr/dcql/dcql_test.go index 756e4a8ad7..77aa37f84f 100644 --- a/vcr/dcql/dcql_test.go +++ b/vcr/dcql/dcql_test.go @@ -67,4 +67,32 @@ func TestMatch(t *testing.T) { 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: []string{"credentialSubject", "hasEnrollment", "patient", "identifier", "value"}, + Values: []any{"123456789"}, + }, + }, + } + + result := Match(query, []vc.VerifiableCredential{credential}) + + assert.Len(t, result, 1) + }) } From 7451a541918f8804372221a9c94e2f2e1abfa212 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Sat, 21 Mar 2026 18:35:07 +0100 Subject: [PATCH 04/21] =?UTF-8?q?#4087:=20DCQL=20parser=20=E2=80=94=20test?= =?UTF-8?q?=20multiple=20values=20OR=20semantics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- vcr/dcql/dcql_test.go | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/vcr/dcql/dcql_test.go b/vcr/dcql/dcql_test.go index 77aa37f84f..de760838e5 100644 --- a/vcr/dcql/dcql_test.go +++ b/vcr/dcql/dcql_test.go @@ -95,4 +95,44 @@ func TestMatch(t *testing.T) { 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: []string{"credentialSubject", "postalCode"}, + Values: []any{"90210", "90211"}, + }, + }, + } + + result := Match(query, []vc.VerifiableCredential{credential}) + + 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: []string{"credentialSubject", "postalCode"}, + Values: []any{"90210", "90211"}, + }, + }, + } + + result := Match(query, []vc.VerifiableCredential{credential}) + + assert.Empty(t, result) + }) } From 1028bcb8628e7134e99f43610a3c5c3abd63505a Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Sat, 21 Mar 2026 18:36:05 +0100 Subject: [PATCH 05/21] =?UTF-8?q?#4087:=20DCQL=20parser=20=E2=80=94=20test?= =?UTF-8?q?=20multiple=20claims=20AND=20semantics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- vcr/dcql/dcql_test.go | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/vcr/dcql/dcql_test.go b/vcr/dcql/dcql_test.go index de760838e5..f7f77de9cb 100644 --- a/vcr/dcql/dcql_test.go +++ b/vcr/dcql/dcql_test.go @@ -133,6 +133,48 @@ func TestMatch(t *testing.T) { result := Match(query, []vc.VerifiableCredential{credential}) + 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: []string{"credentialSubject", "patientId"}, Values: []any{"123"}}, + {Path: []string{"credentialSubject", "type"}, Values: []any{"pharmacy"}}, + }, + } + + result := Match(query, []vc.VerifiableCredential{credential}) + + 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: []string{"credentialSubject", "patientId"}, Values: []any{"123"}}, + {Path: []string{"credentialSubject", "type"}, Values: []any{"pharmacy"}}, + }, + } + + result := Match(query, []vc.VerifiableCredential{credential}) + assert.Empty(t, result) }) } From f21ef00f5ef41e80d93d46204e56ce38dbfa8569 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Sat, 21 Mar 2026 18:36:47 +0100 Subject: [PATCH 06/21] =?UTF-8?q?#4087:=20DCQL=20parser=20=E2=80=94=20test?= =?UTF-8?q?=20missing=20field=20and=20empty=20credentialSubject?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- vcr/dcql/dcql_test.go | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/vcr/dcql/dcql_test.go b/vcr/dcql/dcql_test.go index f7f77de9cb..a09edf9891 100644 --- a/vcr/dcql/dcql_test.go +++ b/vcr/dcql/dcql_test.go @@ -175,6 +175,38 @@ func TestMatch(t *testing.T) { result := Match(query, []vc.VerifiableCredential{credential}) + 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: []string{"credentialSubject", "patientId"}, Values: []any{"123"}}, + }, + } + + result := Match(query, []vc.VerifiableCredential{credential}) + + 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: []string{"credentialSubject", "patientId"}, Values: []any{"123"}}, + }, + } + + result := Match(query, []vc.VerifiableCredential{credential}) + assert.Empty(t, result) }) } From aaf4d82fe393606a74ffc7f6fd1573b4249d601e Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Sat, 21 Mar 2026 18:37:26 +0100 Subject: [PATCH 07/21] =?UTF-8?q?#4087:=20DCQL=20parser=20=E2=80=94=20test?= =?UTF-8?q?=20empty=20list=20and=20multiple=20credentials=20filtering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- vcr/dcql/dcql_test.go | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/vcr/dcql/dcql_test.go b/vcr/dcql/dcql_test.go index a09edf9891..136ceff185 100644 --- a/vcr/dcql/dcql_test.go +++ b/vcr/dcql/dcql_test.go @@ -209,4 +209,33 @@ func TestMatch(t *testing.T) { assert.Empty(t, result) }) + t.Run("empty credentials list returns empty", func(t *testing.T) { + query := CredentialQuery{ + ID: "test", + Claims: []ClaimsQuery{{Path: []string{"credentialSubject", "id"}, Values: []any{"x"}}}, + } + + result := Match(query, []vc.VerifiableCredential{}) + + 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: []string{"credentialSubject", "patientId"}, Values: []any{"123"}}, + }, + } + + result := Match(query, []vc.VerifiableCredential{noMatch, match, noMatch}) + + assert.Len(t, result, 1) + assert.Equal(t, match, result[0]) + }) } From 9d7c511f668ccfb052f9d270f6fe7b3dc3b134e0 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Sat, 21 Mar 2026 18:38:18 +0100 Subject: [PATCH 08/21] =?UTF-8?q?#4087:=20DCQL=20parser=20=E2=80=94=20test?= =?UTF-8?q?=20integer=20and=20boolean=20value=20matching?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- vcr/dcql/dcql_test.go | 51 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/vcr/dcql/dcql_test.go b/vcr/dcql/dcql_test.go index 136ceff185..479bb64a60 100644 --- a/vcr/dcql/dcql_test.go +++ b/vcr/dcql/dcql_test.go @@ -238,4 +238,55 @@ func TestMatch(t *testing.T) { 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: []string{"credentialSubject", "age"}, Values: []any{float64(42)}}, + }, + } + + result := Match(query, []vc.VerifiableCredential{credential}) + + 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: []string{"credentialSubject", "active"}, Values: []any{true}}, + }, + } + + result := Match(query, []vc.VerifiableCredential{credential}) + + 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: []string{"credentialSubject", "active"}, Values: []any{true}}, + }, + } + + result := Match(query, []vc.VerifiableCredential{credential}) + + assert.Empty(t, result) + }) } From f14e4b4494bb4b0432edfe6f0db3e7edf973ff87 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Sat, 21 Mar 2026 18:38:59 +0100 Subject: [PATCH 09/21] =?UTF-8?q?#4087:=20DCQL=20parser=20=E2=80=94=20test?= =?UTF-8?q?=20claim=20without=20values=20(existence=20check)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- vcr/dcql/dcql_test.go | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/vcr/dcql/dcql_test.go b/vcr/dcql/dcql_test.go index 479bb64a60..bd19356e64 100644 --- a/vcr/dcql/dcql_test.go +++ b/vcr/dcql/dcql_test.go @@ -287,6 +287,40 @@ func TestMatch(t *testing.T) { result := Match(query, []vc.VerifiableCredential{credential}) + 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: []string{"credentialSubject", "patientId"}}, + }, + } + + result := Match(query, []vc.VerifiableCredential{credential}) + + 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: []string{"credentialSubject", "patientId"}}, + }, + } + + result := Match(query, []vc.VerifiableCredential{credential}) + assert.Empty(t, result) }) } From 6414127e4e76663b0cf785be486e2db62a51fd76 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Sun, 22 Mar 2026 11:48:03 +0100 Subject: [PATCH 10/21] =?UTF-8?q?#4087:=20DCQL=20parser=20=E2=80=94=20test?= =?UTF-8?q?=20JSON-deserialized=20query=20and=20credential?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verifies matching works when both query and credential come from JSON unmarshalling, catching type mismatches that Go literal tests would miss. Co-Authored-By: Claude Opus 4.6 (1M context) --- vcr/dcql/dcql_test.go | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/vcr/dcql/dcql_test.go b/vcr/dcql/dcql_test.go index bd19356e64..14ff353bc5 100644 --- a/vcr/dcql/dcql_test.go +++ b/vcr/dcql/dcql_test.go @@ -19,10 +19,12 @@ package dcql import ( + "encoding/json" "testing" "github.com/nuts-foundation/go-did/vc" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestMatch(t *testing.T) { @@ -323,4 +325,41 @@ func TestMatch(t *testing.T) { 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 := Match(query, []vc.VerifiableCredential{credential}) + + assert.Len(t, result, 1) + }) } From bd1ec4f3dbef56fc4ff7dbb3e6d5559963aad84d Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Sun, 22 Mar 2026 12:14:44 +0100 Subject: [PATCH 11/21] =?UTF-8?q?#4087:=20DCQL=20parser=20=E2=80=94=20add?= =?UTF-8?q?=20ID=20validation=20and=20error=20return?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match now returns ([]vc.VerifiableCredential, error). Validates credential query ID per spec: non-empty, alphanumeric/underscore/hyphen. Package documentation describes supported DCQL subset. Co-Authored-By: Claude Opus 4.6 (1M context) --- vcr/dcql/dcql.go | 49 +++++++++++++++++++++++--- vcr/dcql/dcql_test.go | 82 ++++++++++++++++++++++++++++++++++--------- 2 files changed, 110 insertions(+), 21 deletions(-) diff --git a/vcr/dcql/dcql.go b/vcr/dcql/dcql.go index 7a97982db6..a3f581aa92 100644 --- a/vcr/dcql/dcql.go +++ b/vcr/dcql/dcql.go @@ -16,14 +16,41 @@ * */ -// Package dcql implements Digital Credentials Query Language (DCQL) credential matching -// as specified in OpenID4VP section 6.1 and 6.3. +// 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 ( + "fmt" + "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, @@ -44,14 +71,28 @@ type ClaimsQuery struct { // Match evaluates a DCQL credential query against a list of verifiable credentials // and returns the credentials that match all claims in the query. -func Match(query CredentialQuery, credentials []vc.VerifiableCredential) []vc.VerifiableCredential { +// Returns an error if the query is invalid (e.g., invalid ID format). +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 { if matchesAll(query.Claims, cred) { result = append(result, cred) } } - return result + return result, nil +} + +func validateQuery(query CredentialQuery) error { + if query.ID == "" { + return fmt.Errorf("invalid credential query id: must be a non-empty string") + } + if !validIDPattern.MatchString(query.ID) { + return fmt.Errorf("invalid credential query id: must consist of alphanumeric, underscore, or hyphen characters") + } + return nil } func matchesAll(claims []ClaimsQuery, cred vc.VerifiableCredential) bool { diff --git a/vcr/dcql/dcql_test.go b/vcr/dcql/dcql_test.go index 14ff353bc5..1538ea6449 100644 --- a/vcr/dcql/dcql_test.go +++ b/vcr/dcql/dcql_test.go @@ -44,8 +44,9 @@ func TestMatch(t *testing.T) { }, } - result := Match(query, []vc.VerifiableCredential{credential}) + result, err := Match(query, []vc.VerifiableCredential{credential}) + require.NoError(t, err) assert.Len(t, result, 1) assert.Equal(t, credential, result[0]) }) @@ -65,8 +66,9 @@ func TestMatch(t *testing.T) { }, } - result := Match(query, []vc.VerifiableCredential{credential}) + 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) { @@ -93,8 +95,9 @@ func TestMatch(t *testing.T) { }, } - result := Match(query, []vc.VerifiableCredential{credential}) + 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) { @@ -113,8 +116,9 @@ func TestMatch(t *testing.T) { }, } - result := Match(query, []vc.VerifiableCredential{credential}) + 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) { @@ -133,8 +137,9 @@ func TestMatch(t *testing.T) { }, } - result := Match(query, []vc.VerifiableCredential{credential}) + 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) { @@ -154,8 +159,9 @@ func TestMatch(t *testing.T) { }, } - result := Match(query, []vc.VerifiableCredential{credential}) + 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) { @@ -175,8 +181,9 @@ func TestMatch(t *testing.T) { }, } - result := Match(query, []vc.VerifiableCredential{credential}) + 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) { @@ -192,8 +199,9 @@ func TestMatch(t *testing.T) { }, } - result := Match(query, []vc.VerifiableCredential{credential}) + 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) { @@ -207,8 +215,9 @@ func TestMatch(t *testing.T) { }, } - result := Match(query, []vc.VerifiableCredential{credential}) + 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) { @@ -217,8 +226,9 @@ func TestMatch(t *testing.T) { Claims: []ClaimsQuery{{Path: []string{"credentialSubject", "id"}, Values: []any{"x"}}}, } - result := Match(query, []vc.VerifiableCredential{}) + 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) { @@ -235,8 +245,9 @@ func TestMatch(t *testing.T) { }, } - result := Match(query, []vc.VerifiableCredential{noMatch, match, noMatch}) + result, err := Match(query, []vc.VerifiableCredential{noMatch, match, noMatch}) + require.NoError(t, err) assert.Len(t, result, 1) assert.Equal(t, match, result[0]) }) @@ -253,8 +264,9 @@ func TestMatch(t *testing.T) { }, } - result := Match(query, []vc.VerifiableCredential{credential}) + 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) { @@ -270,8 +282,9 @@ func TestMatch(t *testing.T) { }, } - result := Match(query, []vc.VerifiableCredential{credential}) + 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) { @@ -287,8 +300,9 @@ func TestMatch(t *testing.T) { }, } - result := Match(query, []vc.VerifiableCredential{credential}) + 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) { @@ -304,8 +318,9 @@ func TestMatch(t *testing.T) { }, } - result := Match(query, []vc.VerifiableCredential{credential}) + 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) { @@ -321,8 +336,9 @@ func TestMatch(t *testing.T) { }, } - result := Match(query, []vc.VerifiableCredential{credential}) + 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 @@ -358,8 +374,40 @@ func TestMatch(t *testing.T) { }`), &query) require.NoError(t, err) - result := Match(query, []vc.VerifiableCredential{credential}) + 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.EqualError(t, err, "invalid credential query id: must consist of alphanumeric, underscore, or hyphen characters") + }) + t.Run("empty credential query ID returns error", func(t *testing.T) { + query := CredentialQuery{ + ID: "", + Claims: []ClaimsQuery{}, + } + + _, err := Match(query, []vc.VerifiableCredential{}) + + assert.EqualError(t, err, "invalid credential query id: must be a non-empty string") + }) + 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) + }) } From bc02b1ac99e074728209eec81dd64af40d2ef2ab Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Sun, 22 Mar 2026 12:18:21 +0100 Subject: [PATCH 12/21] =?UTF-8?q?#4087:=20DCQL=20parser=20=E2=80=94=20root?= =?UTF-8?q?-level=20path=20resolution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Path now starts at the credential root (per OpenID4VP section 7), not hardcoded to credentialSubject. Credential is marshalled to a generic JSON map for path walking. Supports top-level fields like issuer and type. Co-Authored-By: Claude Opus 4.6 (1M context) --- vcr/dcql/dcql.go | 40 ++++++++++++++++++++++------------------ vcr/dcql/dcql_test.go | 17 +++++++++++++++++ 2 files changed, 39 insertions(+), 18 deletions(-) diff --git a/vcr/dcql/dcql.go b/vcr/dcql/dcql.go index a3f581aa92..0d4932f18b 100644 --- a/vcr/dcql/dcql.go +++ b/vcr/dcql/dcql.go @@ -43,6 +43,7 @@ package dcql import ( + "encoding/json" "fmt" "regexp" @@ -120,34 +121,37 @@ func matchesClaim(claim ClaimsQuery, cred vc.VerifiableCredential) bool { return false } +// resolvePath resolves a Claims Path Pointer (OpenID4VP section 7) against a credential. +// The path starts at the credential root. Returns nil if the path cannot be resolved. func resolvePath(path []string, cred vc.VerifiableCredential) any { if len(path) == 0 { return nil } - // The first path element "credentialSubject" maps to the CredentialSubject field - if path[0] == "credentialSubject" { - if len(cred.CredentialSubject) == 0 { - return nil - } - return resolveInMap(path[1:], cred.CredentialSubject[0]) - } - return nil -} - -func resolveInMap(path []string, m map[string]any) any { - if len(path) == 0 || m == nil { + // Marshal credential to a generic JSON map so we can walk it from the root + data, err := json.Marshal(cred) + if err != nil { return nil } - value, ok := m[path[0]] - if !ok { + var root map[string]any + if err := json.Unmarshal(data, &root); err != nil { return nil } - if len(path) == 1 { + return resolveInValue(path, root) +} + +// resolveInValue resolves a path against an arbitrary JSON value. +func resolveInValue(path []string, value any) any { + if len(path) == 0 { return value } - nested, ok := value.(map[string]any) - if !ok { + switch v := value.(type) { + case map[string]any: + child, ok := v[path[0]] + if !ok { + return nil + } + return resolveInValue(path[1:], child) + default: return nil } - return resolveInMap(path[1:], nested) } diff --git a/vcr/dcql/dcql_test.go b/vcr/dcql/dcql_test.go index 1538ea6449..82dd93c292 100644 --- a/vcr/dcql/dcql_test.go +++ b/vcr/dcql/dcql_test.go @@ -22,6 +22,7 @@ import ( "encoding/json" "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" @@ -379,6 +380,22 @@ func TestMatch(t *testing.T) { 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: []string{"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!", From 596e24170cd1d6a97c140fcf7283cedc87408bed Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 23 Mar 2026 06:24:43 +0100 Subject: [PATCH 13/21] =?UTF-8?q?#4087:=20DCQL=20parser=20=E2=80=94=20arra?= =?UTF-8?q?y=20index=20path=20support,=20Path=20type=20[]any?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Path elements now support strings (key lookup), integers (array index), and float64 (JSON-deserialized integers). credentialSubject is unwrapped from its Go array representation to a single object for ergonomic paths. Co-Authored-By: Claude Opus 4.6 (1M context) --- vcr/dcql/dcql.go | 50 ++++++++++++++++++++++----- vcr/dcql/dcql_test.go | 80 +++++++++++++++++++++++++++++++++---------- 2 files changed, 103 insertions(+), 27 deletions(-) diff --git a/vcr/dcql/dcql.go b/vcr/dcql/dcql.go index 0d4932f18b..d58287983a 100644 --- a/vcr/dcql/dcql.go +++ b/vcr/dcql/dcql.go @@ -63,8 +63,10 @@ type CredentialQuery struct { // ClaimsQuery represents a DCQL claims query as defined in OpenID4VP section 6.3. type ClaimsQuery struct { - // Path is a claims path pointer specifying the path to a claim within the credential. - Path []string `json:"path"` + // 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"` @@ -123,7 +125,9 @@ func matchesClaim(claim ClaimsQuery, cred vc.VerifiableCredential) bool { // resolvePath resolves a Claims Path Pointer (OpenID4VP section 7) against a credential. // The path starts at the credential root. Returns nil if the path cannot be resolved. -func resolvePath(path []string, cred vc.VerifiableCredential) any { +// credentialSubject is treated as a single object (the first element of the array), +// since in practice it always contains exactly one entry. +func resolvePath(path []any, cred vc.VerifiableCredential) any { if len(path) == 0 { return nil } @@ -136,22 +140,52 @@ func resolvePath(path []string, cred vc.VerifiableCredential) any { if err := json.Unmarshal(data, &root); err != nil { return nil } + // Unwrap credentialSubject from array to single object for ergonomic path access. + // The VC data model defines credentialSubject as an array, but in practice it always + // contains exactly one entry. This allows paths like ["credentialSubject", "patientId"] + // instead of ["credentialSubject", 0, "patientId"]. + if cs, ok := root["credentialSubject"]; ok { + if arr, ok := cs.([]any); ok && len(arr) == 1 { + root["credentialSubject"] = arr[0] + } + } return resolveInValue(path, root) } -// resolveInValue resolves a path against an arbitrary JSON value. -func resolveInValue(path []string, value any) any { +// 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). +func resolveInValue(path []any, value any) any { if len(path) == 0 { return value } - switch v := value.(type) { - case map[string]any: - child, ok := v[path[0]] + switch element := path[0].(type) { + case string: + m, ok := value.(map[string]any) + if !ok { + return nil + } + child, ok := m[element] if !ok { return nil } return resolveInValue(path[1:], child) + case int: + return resolveArrayIndex(path[1:], value, element) + case float64: + // JSON unmarshalling produces float64 for numbers + return resolveArrayIndex(path[1:], value, int(element)) default: return nil } } + +func resolveArrayIndex(remainingPath []any, value any, index int) any { + arr, ok := value.([]any) + if !ok { + return nil + } + if index < 0 || index >= len(arr) { + return nil + } + return resolveInValue(remainingPath, arr[index]) +} diff --git a/vcr/dcql/dcql_test.go b/vcr/dcql/dcql_test.go index 82dd93c292..af0578e1d3 100644 --- a/vcr/dcql/dcql_test.go +++ b/vcr/dcql/dcql_test.go @@ -39,7 +39,7 @@ func TestMatch(t *testing.T) { ID: "test", Claims: []ClaimsQuery{ { - Path: []string{"credentialSubject", "patientId"}, + Path: []any{"credentialSubject", "patientId"}, Values: []any{"123456789"}, }, }, @@ -61,7 +61,7 @@ func TestMatch(t *testing.T) { ID: "test", Claims: []ClaimsQuery{ { - Path: []string{"credentialSubject", "patientId"}, + Path: []any{"credentialSubject", "patientId"}, Values: []any{"999999999"}, }, }, @@ -90,7 +90,7 @@ func TestMatch(t *testing.T) { ID: "test", Claims: []ClaimsQuery{ { - Path: []string{"credentialSubject", "hasEnrollment", "patient", "identifier", "value"}, + Path: []any{"credentialSubject", "hasEnrollment", "patient", "identifier", "value"}, Values: []any{"123456789"}, }, }, @@ -111,7 +111,7 @@ func TestMatch(t *testing.T) { ID: "test", Claims: []ClaimsQuery{ { - Path: []string{"credentialSubject", "postalCode"}, + Path: []any{"credentialSubject", "postalCode"}, Values: []any{"90210", "90211"}, }, }, @@ -132,7 +132,7 @@ func TestMatch(t *testing.T) { ID: "test", Claims: []ClaimsQuery{ { - Path: []string{"credentialSubject", "postalCode"}, + Path: []any{"credentialSubject", "postalCode"}, Values: []any{"90210", "90211"}, }, }, @@ -155,8 +155,8 @@ func TestMatch(t *testing.T) { query := CredentialQuery{ ID: "test", Claims: []ClaimsQuery{ - {Path: []string{"credentialSubject", "patientId"}, Values: []any{"123"}}, - {Path: []string{"credentialSubject", "type"}, Values: []any{"pharmacy"}}, + {Path: []any{"credentialSubject", "patientId"}, Values: []any{"123"}}, + {Path: []any{"credentialSubject", "type"}, Values: []any{"pharmacy"}}, }, } @@ -177,8 +177,8 @@ func TestMatch(t *testing.T) { query := CredentialQuery{ ID: "test", Claims: []ClaimsQuery{ - {Path: []string{"credentialSubject", "patientId"}, Values: []any{"123"}}, - {Path: []string{"credentialSubject", "type"}, Values: []any{"pharmacy"}}, + {Path: []any{"credentialSubject", "patientId"}, Values: []any{"123"}}, + {Path: []any{"credentialSubject", "type"}, Values: []any{"pharmacy"}}, }, } @@ -196,7 +196,7 @@ func TestMatch(t *testing.T) { query := CredentialQuery{ ID: "test", Claims: []ClaimsQuery{ - {Path: []string{"credentialSubject", "patientId"}, Values: []any{"123"}}, + {Path: []any{"credentialSubject", "patientId"}, Values: []any{"123"}}, }, } @@ -212,7 +212,7 @@ func TestMatch(t *testing.T) { query := CredentialQuery{ ID: "test", Claims: []ClaimsQuery{ - {Path: []string{"credentialSubject", "patientId"}, Values: []any{"123"}}, + {Path: []any{"credentialSubject", "patientId"}, Values: []any{"123"}}, }, } @@ -224,7 +224,7 @@ func TestMatch(t *testing.T) { t.Run("empty credentials list returns empty", func(t *testing.T) { query := CredentialQuery{ ID: "test", - Claims: []ClaimsQuery{{Path: []string{"credentialSubject", "id"}, Values: []any{"x"}}}, + Claims: []ClaimsQuery{{Path: []any{"credentialSubject", "id"}, Values: []any{"x"}}}, } result, err := Match(query, []vc.VerifiableCredential{}) @@ -242,7 +242,7 @@ func TestMatch(t *testing.T) { query := CredentialQuery{ ID: "test", Claims: []ClaimsQuery{ - {Path: []string{"credentialSubject", "patientId"}, Values: []any{"123"}}, + {Path: []any{"credentialSubject", "patientId"}, Values: []any{"123"}}, }, } @@ -261,7 +261,7 @@ func TestMatch(t *testing.T) { query := CredentialQuery{ ID: "test", Claims: []ClaimsQuery{ - {Path: []string{"credentialSubject", "age"}, Values: []any{float64(42)}}, + {Path: []any{"credentialSubject", "age"}, Values: []any{float64(42)}}, }, } @@ -279,7 +279,7 @@ func TestMatch(t *testing.T) { query := CredentialQuery{ ID: "test", Claims: []ClaimsQuery{ - {Path: []string{"credentialSubject", "active"}, Values: []any{true}}, + {Path: []any{"credentialSubject", "active"}, Values: []any{true}}, }, } @@ -297,7 +297,7 @@ func TestMatch(t *testing.T) { query := CredentialQuery{ ID: "test", Claims: []ClaimsQuery{ - {Path: []string{"credentialSubject", "active"}, Values: []any{true}}, + {Path: []any{"credentialSubject", "active"}, Values: []any{true}}, }, } @@ -315,7 +315,7 @@ func TestMatch(t *testing.T) { query := CredentialQuery{ ID: "test", Claims: []ClaimsQuery{ - {Path: []string{"credentialSubject", "patientId"}}, + {Path: []any{"credentialSubject", "patientId"}}, }, } @@ -333,7 +333,7 @@ func TestMatch(t *testing.T) { query := CredentialQuery{ ID: "test", Claims: []ClaimsQuery{ - {Path: []string{"credentialSubject", "patientId"}}, + {Path: []any{"credentialSubject", "patientId"}}, }, } @@ -380,6 +380,48 @@ func TestMatch(t *testing.T) { 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("path resolves root-level fields", func(t *testing.T) { credential := vc.VerifiableCredential{ Issuer: ssi.MustParseURI("did:x509:0:sha256:abc123"), @@ -387,7 +429,7 @@ func TestMatch(t *testing.T) { query := CredentialQuery{ ID: "test", Claims: []ClaimsQuery{ - {Path: []string{"issuer"}, Values: []any{"did:x509:0:sha256:abc123"}}, + {Path: []any{"issuer"}, Values: []any{"did:x509:0:sha256:abc123"}}, }, } From dd825fd04c14862bee1a3d18386fa97b3ba4d5ec Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 23 Mar 2026 06:30:13 +0100 Subject: [PATCH 14/21] =?UTF-8?q?#4087:=20DCQL=20parser=20=E2=80=94=20null?= =?UTF-8?q?=20wildcard=20path=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Null path elements select all elements of an array (OpenID4VP section 7). Supports both wildcard at end of path (returns all values) and wildcard in middle (resolves remaining path for each element, collects results). Co-Authored-By: Claude Opus 4.6 (1M context) --- vcr/dcql/dcql.go | 39 +++++++++++++++++-- vcr/dcql/dcql_test.go | 91 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 3 deletions(-) diff --git a/vcr/dcql/dcql.go b/vcr/dcql/dcql.go index d58287983a..16b413c6c0 100644 --- a/vcr/dcql/dcql.go +++ b/vcr/dcql/dcql.go @@ -108,15 +108,27 @@ func matchesAll(claims []ClaimsQuery, cred vc.VerifiableCredential) bool { } func matchesClaim(claim ClaimsQuery, cred vc.VerifiableCredential) bool { - value := resolvePath(claim.Path, cred) - if value == nil { + resolved := resolvePath(claim.Path, cred) + if resolved == nil { return false } if len(claim.Values) == 0 { return true } + // If resolved is a slice (from null wildcard), check if any element matches any expected value + if values, ok := resolved.([]any); ok { + for _, v := range values { + for _, expected := range claim.Values { + if v == expected { + return true + } + } + } + return false + } + // Single value for _, expected := range claim.Values { - if value == expected { + if resolved == expected { return true } } @@ -174,6 +186,27 @@ func resolveInValue(path []any, value any) any { case float64: // JSON unmarshalling produces float64 for numbers 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 + } + if len(path) == 1 { + // Wildcard is the last element — return all array values + return arr + } + // Wildcard with remaining path — resolve each element and collect results + var results []any + for _, item := range arr { + if resolved := resolveInValue(path[1:], item); resolved != nil { + results = append(results, resolved) + } + } + if len(results) == 0 { + return nil + } + return results default: return nil } diff --git a/vcr/dcql/dcql_test.go b/vcr/dcql/dcql_test.go index af0578e1d3..9668f77524 100644 --- a/vcr/dcql/dcql_test.go +++ b/vcr/dcql/dcql_test.go @@ -422,6 +422,97 @@ func TestMatch(t *testing.T) { 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("path resolves root-level fields", func(t *testing.T) { credential := vc.VerifiableCredential{ Issuer: ssi.MustParseURI("did:x509:0:sha256:abc123"), From 4dc3fdd390042c6ffe14232ca06eeb63cc6b76ee Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 23 Mar 2026 06:39:28 +0100 Subject: [PATCH 15/21] =?UTF-8?q?#4087:=20DCQL=20parser=20=E2=80=94=20add?= =?UTF-8?q?=20benchmark=20for=202000=20credentials?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Benchmark shows ~14ms to match 1 of 2000 PatientEnrollmentCredentials (worst case). Cost dominated by json.Marshal/Unmarshal per credential for generic root-level path resolution. Co-Authored-By: Claude Opus 4.6 (1M context) --- vcr/dcql/dcql_test.go | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/vcr/dcql/dcql_test.go b/vcr/dcql/dcql_test.go index 9668f77524..20543b7ae6 100644 --- a/vcr/dcql/dcql_test.go +++ b/vcr/dcql/dcql_test.go @@ -20,6 +20,7 @@ package dcql import ( "encoding/json" + "fmt" "testing" ssi "github.com/nuts-foundation/go-did" @@ -561,3 +562,42 @@ func TestMatch(t *testing.T) { assert.Empty(t, result) }) } + +func BenchmarkMatch_2000Credentials(b *testing.B) { + const count = 2000 + credentials := make([]vc.VerifiableCredential, count) + for i := range credentials { + bsn := fmt.Sprintf("%09d", i) + credentials[i] = vc.VerifiableCredential{ + Type: []ssi.URI{ssi.MustParseURI("VerifiableCredential"), ssi.MustParseURI("PatientEnrollmentCredential")}, + CredentialSubject: []map[string]any{ + { + "hasEnrollment": map[string]any{ + "patient": map[string]any{ + "identifier": map[string]any{ + "system": "http://fhir.nl/fhir/NamingSystem/bsn", + "value": bsn, + }, + }, + }, + }, + }, + } + } + // Worst case: target is the last credential + targetBsn := fmt.Sprintf("%09d", count-1) + query := CredentialQuery{ + ID: "id_patient_enrollment", + Claims: []ClaimsQuery{ + { + Path: []any{"credentialSubject", "hasEnrollment", "patient", "identifier", "value"}, + Values: []any{targetBsn}, + }, + }, + } + + b.ResetTimer() + for b.Loop() { + Match(query, credentials) + } +} From 395f733f03cb671ba6031e77c05a7d8e90debe70 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Tue, 24 Mar 2026 06:57:21 +0100 Subject: [PATCH 16/21] =?UTF-8?q?#4087:=20DCQL=20parser=20=E2=80=94=20add?= =?UTF-8?q?=20README=20documenting=20supported=20subset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents the purpose (deterministic credential selection vs spec's privacy-hinted selective disclosure), supported/unsupported DCQL features, Claims Path Pointer support, examples, and benchmark results. Co-Authored-By: Claude Opus 4.6 (1M context) --- vcr/dcql/README.md | 150 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 vcr/dcql/README.md diff --git a/vcr/dcql/README.md b/vcr/dcql/README.md new file mode 100644 index 0000000000..ade0d0f8d8 --- /dev/null +++ b/vcr/dcql/README.md @@ -0,0 +1,150 @@ +# 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 error is returned. 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` is unwrapped from its Go array representation (`[]map[string]any`) to a +single object. This allows paths like `["credentialSubject", "patientId"]` instead of +`["credentialSubject", 0, "patientId"]`, since in practice `credentialSubject` always contains +exactly one entry. + +## 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): + +``` +BenchmarkMatch_2000Credentials ~14ms/op ~15MB/op ~290k 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. From b4ed18416bb79b9e752a19745af6206b51f9ddf5 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Tue, 24 Mar 2026 10:29:44 +0100 Subject: [PATCH 17/21] #4087: Address PR review feedback - Remove redundant empty ID check (regex already requires length >= 1) - Marshal credential once per Match call, not per claim - Validate float64 path elements are non-negative integers (error on 1.5) - Error on unsupported path element types (e.g., boolean) - Error on credential marshal/unmarshal failure (no silent skip) - Fix nested wildcards: recursive containsExpectedValue for nested slices - Fix README: Match returns empty slice, not error, on no matches - Fix benchmark: capture return values, add wildcards and multiple claims - Improve credentialSubject unwrap comment with spec references Co-Authored-By: Claude Opus 4.6 (1M context) --- vcr/dcql/README.md | 9 ++- vcr/dcql/dcql.go | 158 +++++++++++++++++++++++------------------- vcr/dcql/dcql_test.go | 111 +++++++++++++++++++++++++---- 3 files changed, 192 insertions(+), 86 deletions(-) diff --git a/vcr/dcql/README.md b/vcr/dcql/README.md index ade0d0f8d8..fbd1acace9 100644 --- a/vcr/dcql/README.md +++ b/vcr/dcql/README.md @@ -17,7 +17,9 @@ a best-effort way to improve user privacy, but MUST NOT rely on it for security 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 error is returned. There is no privacy negotiation involved. +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 @@ -140,10 +142,11 @@ Both claims must match for a credential to be selected. ## Performance -Benchmark on Apple M5, worst case (match last of 2000 credentials): +Benchmark on Apple M5, worst case (match last of 2000 credentials, 2 claims with wildcards, +each credential has multiple identifiers and qualifications): ``` -BenchmarkMatch_2000Credentials ~14ms/op ~15MB/op ~290k allocs/op +BenchmarkMatch_2000Credentials ~24ms/op ~24MB/op ~504k allocs/op ``` The cost is dominated by `json.Marshal`/`json.Unmarshal` per credential for generic root-level diff --git a/vcr/dcql/dcql.go b/vcr/dcql/dcql.go index 16b413c6c0..855a5f9188 100644 --- a/vcr/dcql/dcql.go +++ b/vcr/dcql/dcql.go @@ -45,6 +45,7 @@ package dcql import ( "encoding/json" "fmt" + "math" "regexp" "github.com/nuts-foundation/go-did/vc" @@ -74,14 +75,24 @@ type ClaimsQuery struct { // 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). +// 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 { - if matchesAll(query.Claims, cred) { + 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) } } @@ -89,136 +100,143 @@ func Match(query CredentialQuery, credentials []vc.VerifiableCredential) ([]vc.V } func validateQuery(query CredentialQuery) error { - if query.ID == "" { - return fmt.Errorf("invalid credential query id: must be a non-empty string") - } if !validIDPattern.MatchString(query.ID) { - return fmt.Errorf("invalid credential query id: must consist of alphanumeric, underscore, or hyphen characters") + return fmt.Errorf("invalid credential query id: must be a non-empty string consisting of alphanumeric, underscore, or hyphen characters") } return nil } -func matchesAll(claims []ClaimsQuery, cred vc.VerifiableCredential) bool { +// credentialToMap converts a credential to a generic JSON map for path resolution. +// The Go VC struct models credentialSubject as []map[string]any, which marshals to a +// JSON array. The DCQL spec examples treat credentialSubject as a single object — paths +// use ["credentialSubject", "family_name"] without an array index (see OpenID4VP appendix D +// and section B.1.2). We unwrap single-element arrays to match this convention. +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) + } + if cs, ok := root["credentialSubject"]; ok { + if arr, ok := cs.([]any); ok && len(arr) == 1 { + root["credentialSubject"] = arr[0] + } + } + return root, nil +} + +func matchesAll(claims []ClaimsQuery, root map[string]any) (bool, error) { for _, claim := range claims { - if !matchesClaim(claim, cred) { - return false + matched, err := matchesClaim(claim, root) + if err != nil { + return false, err + } + if !matched { + return false, nil } } - return true + return true, nil } -func matchesClaim(claim ClaimsQuery, cred vc.VerifiableCredential) bool { - resolved := resolvePath(claim.Path, cred) +func matchesClaim(claim ClaimsQuery, root map[string]any) (bool, error) { + resolved, err := resolveInValue(claim.Path, root) + if err != nil { + return false, err + } if resolved == nil { - return false + return false, nil } if len(claim.Values) == 0 { - return true - } - // If resolved is a slice (from null wildcard), check if any element matches any expected value - if values, ok := resolved.([]any); ok { - for _, v := range values { - for _, expected := range claim.Values { - if v == expected { - return true - } + 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 } - // Single value - for _, expected := range claim.Values { - if resolved == expected { + for _, expected := range expectedValues { + if value == expected { return true } } return false } -// resolvePath resolves a Claims Path Pointer (OpenID4VP section 7) against a credential. -// The path starts at the credential root. Returns nil if the path cannot be resolved. -// credentialSubject is treated as a single object (the first element of the array), -// since in practice it always contains exactly one entry. -func resolvePath(path []any, cred vc.VerifiableCredential) any { - if len(path) == 0 { - return nil - } - // Marshal credential to a generic JSON map so we can walk it from the root - data, err := json.Marshal(cred) - if err != nil { - return nil - } - var root map[string]any - if err := json.Unmarshal(data, &root); err != nil { - return nil - } - // Unwrap credentialSubject from array to single object for ergonomic path access. - // The VC data model defines credentialSubject as an array, but in practice it always - // contains exactly one entry. This allows paths like ["credentialSubject", "patientId"] - // instead of ["credentialSubject", 0, "patientId"]. - if cs, ok := root["credentialSubject"]; ok { - if arr, ok := cs.([]any); ok && len(arr) == 1 { - root["credentialSubject"] = arr[0] - } - } - return resolveInValue(path, root) -} - // 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). -func resolveInValue(path []any, value any) any { +// 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 + return value, nil } switch element := path[0].(type) { case string: m, ok := value.(map[string]any) if !ok { - return nil + return nil, nil } child, ok := m[element] if !ok { - return nil + return nil, nil } return resolveInValue(path[1:], child) case int: return resolveArrayIndex(path[1:], value, element) case float64: - // JSON unmarshalling produces float64 for numbers + // JSON unmarshalling produces float64 for numbers. Validate it represents + // a non-negative integer before converting, to avoid silent truncation. + if element < 0 || math.Trunc(element) != element { + 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 + return nil, nil } if len(path) == 1 { - // Wildcard is the last element — return all array values - return arr + return arr, nil } - // Wildcard with remaining path — resolve each element and collect results var results []any for _, item := range arr { - if resolved := resolveInValue(path[1:], item); resolved != nil { + 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 + return nil, nil } - return results + return results, nil default: - return nil + return nil, fmt.Errorf("invalid path element type: %T", element) } } -func resolveArrayIndex(remainingPath []any, value any, index int) any { +func resolveArrayIndex(remainingPath []any, value any, index int) (any, error) { arr, ok := value.([]any) if !ok { - return nil + return nil, nil } if index < 0 || index >= len(arr) { - return nil + return nil, nil } return resolveInValue(remainingPath, arr[index]) } diff --git a/vcr/dcql/dcql_test.go b/vcr/dcql/dcql_test.go index 20543b7ae6..0cd28f34e8 100644 --- a/vcr/dcql/dcql_test.go +++ b/vcr/dcql/dcql_test.go @@ -514,6 +514,42 @@ func TestMatch(t *testing.T) { 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"), @@ -538,7 +574,7 @@ func TestMatch(t *testing.T) { _, err := Match(query, []vc.VerifiableCredential{}) - assert.EqualError(t, err, "invalid credential query id: must consist of alphanumeric, underscore, or hyphen characters") + assert.ErrorContains(t, err, "invalid credential query id") }) t.Run("empty credential query ID returns error", func(t *testing.T) { query := CredentialQuery{ @@ -548,7 +584,41 @@ func TestMatch(t *testing.T) { _, err := Match(query, []vc.VerifiableCredential{}) - assert.EqualError(t, err, "invalid credential query id: must be a non-empty string") + 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("valid credential query ID with hyphens and underscores", func(t *testing.T) { query := CredentialQuery{ @@ -565,39 +635,54 @@ func TestMatch(t *testing.T) { 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")}, + 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{ { - "hasEnrollment": map[string]any{ - "patient": map[string]any{ - "identifier": map[string]any{ - "system": "http://fhir.nl/fhir/NamingSystem/bsn", - "value": bsn, - }, - }, + "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 + // 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", "hasEnrollment", "patient", "identifier", "value"}, + 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 for b.Loop() { - Match(query, credentials) + benchResult, _ = Match(query, credentials) + } + if len(benchResult) != 1 { + b.Fatalf("expected 1 match, got %d", len(benchResult)) } } From 238bdfb89e5988b1820171e1668346aef4fe77d5 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Wed, 25 Mar 2026 08:29:02 +0100 Subject: [PATCH 18/21] =?UTF-8?q?#4087:=20Address=20PR=20feedback=20?= =?UTF-8?q?=E2=80=94=20validate=20paths=20and=20credentialSubject=20arrays?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Negative int path elements now return error (consistent with float64) - float64 values exceeding math.MaxInt return error (prevent overflow) - Empty path in claims query validated upfront in validateQuery - String path element on array with >1 elements returns error with message to use an integer index (prevents ambiguous access on multi-element credentialSubject arrays) - Multiple credentialSubjects with explicit index works correctly Co-Authored-By: Claude Opus 4.6 (1M context) --- vcr/dcql/dcql.go | 17 ++++++++- vcr/dcql/dcql_test.go | 88 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 2 deletions(-) diff --git a/vcr/dcql/dcql.go b/vcr/dcql/dcql.go index 855a5f9188..46f63b5f82 100644 --- a/vcr/dcql/dcql.go +++ b/vcr/dcql/dcql.go @@ -103,6 +103,11 @@ 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 } @@ -186,6 +191,11 @@ func resolveInValue(path []any, value any) (any, error) { 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] @@ -194,11 +204,14 @@ func resolveInValue(path []any, value any) (any, error) { } 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 before converting, to avoid silent truncation. - if element < 0 || math.Trunc(element) != element { + // 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)) diff --git a/vcr/dcql/dcql_test.go b/vcr/dcql/dcql_test.go index 0cd28f34e8..da28601e74 100644 --- a/vcr/dcql/dcql_test.go +++ b/vcr/dcql/dcql_test.go @@ -620,6 +620,94 @@ func TestMatch(t *testing.T) { assert.ErrorContains(t, err, "invalid path element type") }) + 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("valid credential query ID with hyphens and underscores", func(t *testing.T) { query := CredentialQuery{ ID: "id_patient-enrollment_01", From 08a65af44a8f785ebe125b6028446bff98419614 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Wed, 25 Mar 2026 08:47:00 +0100 Subject: [PATCH 19/21] #4087: Document credentialSubject array handling in README Single credentialSubject is auto-unwrapped (no index needed). Multiple credentialSubjects require an explicit integer index. Co-Authored-By: Claude Opus 4.6 (1M context) --- vcr/dcql/README.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/vcr/dcql/README.md b/vcr/dcql/README.md index fbd1acace9..70c52ed47e 100644 --- a/vcr/dcql/README.md +++ b/vcr/dcql/README.md @@ -55,10 +55,15 @@ involved. The path starts at the credential root, supporting top-level fields (`issuer`, `type`, etc.) as well as nested `credentialSubject` fields. -`credentialSubject` is unwrapped from its Go array representation (`[]map[string]any`) to a -single object. This allows paths like `["credentialSubject", "patientId"]` instead of -`["credentialSubject", 0, "patientId"]`, since in practice `credentialSubject` always contains -exactly one entry. +`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 From 57b287918aa321cc90cdcc7b3e314fd965dee1cf Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Wed, 25 Mar 2026 09:01:45 +0100 Subject: [PATCH 20/21] #4087: Handle credentialSubject as both object and array normalizeCredentialSubjectPath adjusts the path based on how credentialSubject was serialized: - Single element (object): strips explicit index 0 if present - Single element (array): inserts index 0 if missing - Multiple elements (array): requires explicit index, errors without Co-Authored-By: Claude Opus 4.6 (1M context) --- vcr/dcql/dcql.go | 69 +++++++++++++++++++++++++++++++++++++------ vcr/dcql/dcql_test.go | 18 +++++++++++ 2 files changed, 78 insertions(+), 9 deletions(-) diff --git a/vcr/dcql/dcql.go b/vcr/dcql/dcql.go index 46f63b5f82..dd4bb88dcc 100644 --- a/vcr/dcql/dcql.go +++ b/vcr/dcql/dcql.go @@ -112,10 +112,6 @@ func validateQuery(query CredentialQuery) error { } // credentialToMap converts a credential to a generic JSON map for path resolution. -// The Go VC struct models credentialSubject as []map[string]any, which marshals to a -// JSON array. The DCQL spec examples treat credentialSubject as a single object — paths -// use ["credentialSubject", "family_name"] without an array index (see OpenID4VP appendix D -// and section B.1.2). We unwrap single-element arrays to match this convention. func credentialToMap(cred vc.VerifiableCredential) (map[string]any, error) { data, err := json.Marshal(cred) if err != nil { @@ -125,12 +121,66 @@ func credentialToMap(cred vc.VerifiableCredential) (map[string]any, error) { if err := json.Unmarshal(data, &root); err != nil { return nil, fmt.Errorf("failed to unmarshal credential: %w", err) } - if cs, ok := root["credentialSubject"]; ok { - if arr, ok := cs.([]any); ok && len(arr) == 1 { - root["credentialSubject"] = arr[0] + 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 { + // 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 } - return root, nil } func matchesAll(claims []ClaimsQuery, root map[string]any) (bool, error) { @@ -147,7 +197,8 @@ func matchesAll(claims []ClaimsQuery, root map[string]any) (bool, error) { } func matchesClaim(claim ClaimsQuery, root map[string]any) (bool, error) { - resolved, err := resolveInValue(claim.Path, root) + path := normalizeCredentialSubjectPath(claim.Path, root) + resolved, err := resolveInValue(path, root) if err != nil { return false, err } diff --git a/vcr/dcql/dcql_test.go b/vcr/dcql/dcql_test.go index da28601e74..a40e67c1da 100644 --- a/vcr/dcql/dcql_test.go +++ b/vcr/dcql/dcql_test.go @@ -620,6 +620,24 @@ func TestMatch(t *testing.T) { 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{ From 7e5d625e7e04c355db70671f5f63118d9c1ee97a Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Wed, 25 Mar 2026 10:40:16 +0100 Subject: [PATCH 21/21] #4087: Fix non-zero index on single credentialSubject, add coverage tests normalizeCredentialSubjectPath now only strips index 0 when credentialSubject is serialized as a single object. Non-zero indices correctly produce no match instead of silently resolving. Added tests for edge cases: path ending at object, null wildcard on non-array, integer index on non-array, out-of-bounds index, string key on scalar. Fixed benchmark to capture error return value. Co-Authored-By: Claude Opus 4.6 (1M context) --- vcr/dcql/dcql.go | 11 ++++ vcr/dcql/dcql_test.go | 129 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 136 insertions(+), 4 deletions(-) diff --git a/vcr/dcql/dcql.go b/vcr/dcql/dcql.go index dd4bb88dcc..5b09870fa4 100644 --- a/vcr/dcql/dcql.go +++ b/vcr/dcql/dcql.go @@ -157,6 +157,17 @@ func normalizeCredentialSubjectPath(path []any, root map[string]any) []any { 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) diff --git a/vcr/dcql/dcql_test.go b/vcr/dcql/dcql_test.go index a40e67c1da..18217b7572 100644 --- a/vcr/dcql/dcql_test.go +++ b/vcr/dcql/dcql_test.go @@ -726,6 +726,127 @@ func TestMatch(t *testing.T) { 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", @@ -785,10 +906,10 @@ func BenchmarkMatch_2000Credentials(b *testing.B) { b.ResetTimer() var benchResult []vc.VerifiableCredential + var benchErr error for b.Loop() { - benchResult, _ = Match(query, credentials) - } - if len(benchResult) != 1 { - b.Fatalf("expected 1 match, got %d", len(benchResult)) + benchResult, benchErr = Match(query, credentials) } + require.NoError(b, benchErr) + require.Len(b, benchResult, 1) }