From 6baacd0cfdb2741376eee4179efad0884e18f8cd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:23:25 +0000 Subject: [PATCH 1/4] Initial plan From a02063c6d7a429250da63fe5bcc291ce74906f2b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:26:35 +0000 Subject: [PATCH 2/4] refactor(fcaf): split dcql validation modules Co-authored-by: puria <10379+puria@users.noreply.github.com> --- pkg/fcaf/validators/dcql.go | 2215 +---------------- pkg/fcaf/validators/dcql_claims.go | 1374 ++++++++++ pkg/fcaf/validators/dcql_credentials.go | 171 ++ pkg/fcaf/validators/dcql_response.go | 165 ++ .../validators/dcql_trusted_authorities.go | 241 ++ pkg/fcaf/validators/dcql_types.go | 256 ++ 6 files changed, 2220 insertions(+), 2202 deletions(-) create mode 100644 pkg/fcaf/validators/dcql_claims.go create mode 100644 pkg/fcaf/validators/dcql_credentials.go create mode 100644 pkg/fcaf/validators/dcql_response.go create mode 100644 pkg/fcaf/validators/dcql_trusted_authorities.go create mode 100644 pkg/fcaf/validators/dcql_types.go diff --git a/pkg/fcaf/validators/dcql.go b/pkg/fcaf/validators/dcql.go index 364cf8981..17c96dfc5 100644 --- a/pkg/fcaf/validators/dcql.go +++ b/pkg/fcaf/validators/dcql.go @@ -6,14 +6,9 @@ package validators import ( "context" - "crypto/x509" - "encoding/base64" - "encoding/json" "fmt" "reflect" "regexp" - - "github.com/forkbombeu/credimi/pkg/fcaf/evidence" ) const invalidRequestError = "invalid_request" @@ -891,2211 +886,27 @@ func (DCQLResponseConstraintsValidator) Validate(_ context.Context, input Input) } } -func supportedJSONType(expected string) bool { - switch expected { - case "boolean", "string", "number", "integer", "array", "object", "null": - return true - default: - return false - } -} - -func matchesJSONType(value any, expected string) bool { - switch expected { - case "boolean": - _, ok := value.(bool) - return ok - case "string": - _, ok := value.(string) - return ok - case "number": - switch value.(type) { - case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64: - return true - default: - return false - } - case "integer": - switch typed := value.(type) { - case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: - return true - case float32: - return typed == float32(int64(typed)) - case float64: - return typed == float64(int64(typed)) - default: - return false - } - case "array": - _, ok := value.([]any) - return ok - case "object": - _, ok := normalizeJSONObject(value) - return ok - case "null": - return value == nil - default: - return false - } -} - -func validateDCQLCredentialQueries(credentials []any) error { - ids := make(map[string]struct{}, len(credentials)) - for index, rawCredential := range credentials { - credential, ok := normalizeJSONObject(rawCredential) - if !ok { - return fmt.Errorf("credentials[%d] is not an object", index) - } - id, _ := credential["id"].(string) - if !dcqlIDPattern.MatchString(id) { - return fmt.Errorf("credentials[%d].id is not a valid DCQL identifier", index) - } - if _, duplicate := ids[id]; duplicate { - return fmt.Errorf("credentials[%d].id %q is duplicated", index, id) - } - ids[id] = struct{}{} +//nolint:gocyclo // Each credential-set shape has distinct conformance semantics. - format, _ := credential["format"].(string) - if format == "" { - return fmt.Errorf("credentials[%d].format is missing", index) - } - meta, ok := normalizeJSONObject(credential["meta"]) - if !ok { - return fmt.Errorf("credentials[%d].meta is not an object", index) - } - switch format { - case "dc+sd-jwt": - if !nonEmptyStringArray(meta["vct_values"]) { - return fmt.Errorf( - "credentials[%d].meta.vct_values is not a non-empty string array", - index, - ) - } - case "mso_mdoc": - docType, _ := meta["doctype_value"].(string) - if docType == "" { - return fmt.Errorf("credentials[%d].meta.doctype_value is missing", index) - } - default: - return fmt.Errorf("credentials[%d].format %q is not supported", index, format) - } - if claims, exists := credential["claims"]; exists { - items, ok := claims.([]any) - if !ok || len(items) == 0 { - return fmt.Errorf("credentials[%d].claims is not a non-empty array", index) - } - for claimIndex, rawClaim := range items { - claim, ok := normalizeJSONObject(rawClaim) - if !ok || !nonEmptyStringArray(claim["path"]) { - return fmt.Errorf( - "credentials[%d].claims[%d].path is invalid", - index, - claimIndex, - ) - } - } - } - } - return nil -} +// validateClaimsSubset proves both sides of a user-controlled claim selection: +// requested claims are disclosed, while explicitly unchecked paths are absent. -func nonEmptyStringArray(value any) bool { - items, ok := value.([]any) - if !ok || len(items) == 0 { - return false - } - for _, item := range items { - text, ok := item.(string) - if !ok || text == "" { - return false - } - } - return true -} +// validateClaimPathMemberTypeError checks that wallet rejects DCQL queries with invalid claim-path +// member types (boolean, negative integer, unsupported object types). -func normalizeJSONObject(value any) (map[string]any, bool) { - if object, ok := value.(map[string]any); ok { - return object, true - } - text, ok := value.(string) - if !ok { - return nil, false - } - var object map[string]any - if err := json.Unmarshal([]byte(text), &object); err != nil { - return nil, false - } - return object, true -} +// validateWalletErrorExpected checks that wallet returns an expected error code. +// If expected is nil, any error code is accepted. -func findObjectKey(value any, key string) (any, bool) { - object, ok := normalizeJSONObject(value) - if !ok { - return nil, false - } - if found, exists := object[key]; exists { - return found, true - } - for _, child := range object { - if found, exists := findObjectKey(child, key); exists { - return found, true - } - if array, ok := child.([]any); ok { - for _, item := range array { - if found, exists := findObjectKey(item, key); exists { - return found, true - } - } - } - } - return nil, false -} +// validateErrorCode checks that wallet returns a specific OAuth2/OID4VP error code. -func containsClaimSets(credentials []any) bool { - for _, rawCredential := range credentials { - credential, ok := normalizeJSONObject(rawCredential) - if !ok { - continue - } - claimSets, ok := credential["claim_sets"].([]any) - if ok && len(claimSets) > 0 { - return true - } - } - return false -} +// validateUnknownFieldStripped checks that wallet processes request with unknown fields stripped. -func isEmptyDCQLValue(value any) bool { - switch typed := value.(type) { - case nil: - return true - case string: - return typed == "" - case []any: - return len(typed) == 0 - case map[string]any: - return len(typed) == 0 - default: - return false - } -} +// validateJWEEncVerified checks JWE encryption parameters in the wallet response. -//nolint:gocyclo // Each credential-set shape has distinct conformance semantics. -func validateCredentialSetsOptions( - query map[string]any, - responseValue, errorValue any, - mode string, -) Result { - credentials, ok := query["credentials"].([]any) - sets, setsOK := query["credential_sets"].([]any) - if !ok || len(credentials) == 0 || !setsOK || len(sets) == 0 { - return Result{ - Status: StatusFail, - Message: "dcql_query must contain credentials and credential_sets", - } - } - ids := make(map[string]struct{}, len(credentials)) - for _, raw := range credentials { - credential, ok := normalizeJSONObject(raw) - if !ok { - return Result{Status: StatusFail, Message: "dcql credential is not an object"} - } - id, ok := credential["id"].(string) - if !ok || id == "" { - return Result{Status: StatusFail, Message: "dcql credential id is invalid"} - } - ids[id] = struct{}{} - } - invalid := false - for _, raw := range sets { - set, ok := normalizeJSONObject(raw) - if !ok { - invalid = true - continue - } - options, exists := set["options"] - if !exists { - invalid = true - continue - } - groups, ok := options.([]any) - if mode == "credential_sets_options_non_array" { - if ok { - return Result{Status: StatusFail, Message: "credential_sets.options is an array"} - } - invalid = true - continue - } - if !ok || len(groups) == 0 { - invalid = true - continue - } - for _, rawGroup := range groups { - group, ok := rawGroup.([]any) - if !ok || len(group) == 0 { - invalid = true - continue - } - for _, rawID := range group { - id, ok := rawID.(string) - if !ok { - invalid = true - continue - } - if _, found := ids[id]; !found { - invalid = true - } - } - } - } - if mode == "credential_sets_options_valid_references" && invalid { - return Result{ - Status: StatusFail, - Message: "credential_sets.options contains invalid references", - } - } - if mode == "credential_sets_options_invalid_references" && !invalid { - return Result{ - Status: StatusFail, - Message: "credential_sets.options contains no invalid references", - } - } - if mode == "credential_sets_options_empty" && !invalid { - return Result{Status: StatusFail, Message: "credential_sets.options is non-empty"} - } - if mode == "credential_sets_options_non_array" || mode == "credential_sets_options_empty" || - mode == "credential_sets_options_invalid_references" { - if !isEmptyDCQLValue(responseValue) { - return Result{ - Status: StatusFail, - Message: "wallet returned a vp_token for an invalid credential_sets.options query", - } - } - if (mode == "credential_sets_options_empty" || mode == "credential_sets_options_non_array") && - errorValue != invalidRequestError { - return Result{ - Status: StatusFail, - Message: "wallet did not return invalid_request for an invalid credential_sets.options query", - } - } - return Result{ - Status: StatusPass, - Message: "wallet rejected invalid credential_sets.options", - } - } - if isEmptyDCQLValue(responseValue) { - return Result{ - Status: StatusFail, - Message: "wallet returned no vp_token for valid credential_sets.options references", - } - } - return Result{ - Status: StatusPass, - Message: "wallet processed valid credential_sets.options references", - } -} +// validateSessionEncryption checks session encryption evidence. -func validateCredentialSetsRequired(query map[string]any, responseValue any, mode string) Result { - sets, ok := query["credential_sets"].([]any) - if !ok || len(sets) == 0 { - return Result{Status: StatusFail, Message: "dcql_query does not contain credential_sets"} - } - response, responseOK := normalizeJSONObject(responseValue) - for index, rawSet := range sets { - set, ok := normalizeJSONObject(rawSet) - if !ok { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf("credential_sets[%d] is not an object", index), - } - } - required, exists := set["required"] - if mode == "credential_sets_required_true_match" && (!exists || required != true) { - return Result{Status: StatusFail, Message: "required is not true"} - } - if mode == "credential_sets_required_true_no_match" && (!exists || required != true) { - return Result{Status: StatusFail, Message: "required is not true"} - } - if mode == "credential_sets_required_omitted" && exists { - return Result{Status: StatusFail, Message: "required is present"} - } - if mode == "credential_sets_required_false_with_match" && required != false { - return Result{Status: StatusFail, Message: "required is not false"} - } - } - if mode == "credential_sets_required_true_match" || - mode == "credential_sets_required_omitted" || - mode == "credential_sets_required_false_with_match" { - if !responseOK || isEmptyDCQLValue(response) { - return Result{ - Status: StatusFail, - Message: "wallet returned no vp_token for a satisfiable credential set", - } - } - return Result{Status: StatusPass, Message: "wallet presented the credential set"} - } - if !isEmptyDCQLValue(responseValue) { - return Result{ - Status: StatusFail, - Message: "wallet returned a presentation for a missing required credential set", - } - } - return Result{ - Status: StatusPass, - Message: "wallet stopped without presenting a missing required credential set", - } -} +// validateEncoding checks encoding validation evidence. -func validateClaimsPresent(query map[string]any, responseValue any) Result { - credentials, ok := query["credentials"].([]any) - if !ok || len(credentials) == 0 { - return Result{Status: StatusFail, Message: "dcql_query does not contain credentials"} - } - response, ok := normalizeJSONObject(responseValue) - if !ok { - return Result{ - Status: StatusFail, - Message: "wallet vp_token is not an object keyed by credential query ID", - } - } - for index, rawCredential := range credentials { - credential, ok := normalizeJSONObject(rawCredential) - if !ok { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf("credentials[%d] is not an object", index), - } - } - id, ok := credential["id"].(string) - if !ok || id == "" { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf("credentials[%d].id is not a non-empty string", index), - } - } - claims, ok := credential["claims"].([]any) - if !ok || len(claims) == 0 { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf("credentials[%d].claims is not a non-empty array", index), - } - } - for claimIndex, rawClaim := range claims { - claim, ok := normalizeJSONObject(rawClaim) - if !ok { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].claims[%d] is not an object", - index, - claimIndex, - ), - } - } - path, ok := claim["path"].([]any) - if !ok || len(path) == 0 { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].claims[%d].path is not a non-empty array", - index, - claimIndex, - ), - } - } - for pathIndex, segment := range path { - if _, ok := segment.(string); !ok { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].claims[%d].path[%d] is not a string", - index, - claimIndex, - pathIndex, - ), - } - } - } - } - if isEmptyDCQLValue(response[id]) { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf("vp_token has no presentation for credential query %q", id), - } - } - } - return Result{Status: StatusPass, Message: "wallet processed credential queries with claims"} -} - -// validateClaimsSubset proves both sides of a user-controlled claim selection: -// requested claims are disclosed, while explicitly unchecked paths are absent. -func validateClaimsSubset(query map[string]any, responseValue any, forbiddenPaths [][]any) Result { - credentials, ok := query["credentials"].([]any) - if !ok || len(credentials) == 0 { - return Result{Status: StatusFail, Message: "dcql_query does not contain credentials"} - } - if len(forbiddenPaths) == 0 { - return Result{Status: StatusFail, Message: "claims_subset requires forbidden_paths"} - } - response, ok := normalizeJSONObject(responseValue) - if !ok { - return Result{ - Status: StatusFail, - Message: "wallet vp_token is not an object keyed by credential query ID", - } - } - for credentialIndex, rawCredential := range credentials { - credential, ok := normalizeJSONObject(rawCredential) - if !ok { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf("credentials[%d] is not an object", credentialIndex), - } - } - id, ok := credential["id"].(string) - if !ok || id == "" { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].id is not a non-empty string", - credentialIndex, - ), - } - } - claims, ok := credential["claims"].([]any) - if !ok || len(claims) == 0 { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].claims is not a non-empty array", - credentialIndex, - ), - } - } - presentations, ok := response[id].([]any) - if !ok || len(presentations) == 0 { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf("vp_token has no presentation for credential query %q", id), - } - } - for presentationIndex, rawPresentation := range presentations { - token, ok := rawPresentation.(string) - if !ok || token == "" { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "vp_token[%q][%d] is not an SD-JWT presentation", - id, - presentationIndex, - ), - } - } - presentation, err := evidence.ParseSDJWTPresentation(token) - if err != nil { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "vp_token[%q][%d] is not a valid SD-JWT presentation: %v", - id, - presentationIndex, - err, - ), - } - } - for claimIndex, rawClaim := range claims { - claim, ok := normalizeJSONObject(rawClaim) - if !ok { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].claims[%d] is not an object", - credentialIndex, - claimIndex, - ), - } - } - path, ok := claim["path"].([]any) - if !ok || len(path) == 0 || !claimPathResolves(presentation.Claims, path) { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "vp_token[%q][%d] does not disclose requested claims[%d].path", - id, - presentationIndex, - claimIndex, - ), - } - } - } - for pathIndex, path := range forbiddenPaths { - if len(path) == 0 { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf("forbidden_paths[%d] is empty", pathIndex), - } - } - if claimPathResolves(presentation.Claims, path) { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "vp_token[%q][%d] discloses unchecked forbidden_paths[%d]", - id, - presentationIndex, - pathIndex, - ), - } - } - } - } - } - return Result{ - Status: StatusPass, - Message: "wallet disclosed requested claims and omitted unchecked claims", - } -} - -func validateClaimsUnion(query map[string]any, responseValue any, forbiddenPaths [][]any) Result { - credentials, ok := query["credentials"].([]any) - if !ok || len(credentials) < 2 { - return Result{ - Status: StatusFail, - Message: "claims_union requires at least two credential queries", - } - } - response, ok := normalizeJSONObject(responseValue) - if !ok { - return Result{ - Status: StatusFail, - Message: "wallet vp_token is not an object keyed by credential query ID", - } - } - requested := make([][]any, 0) - presentations := make([]*evidence.SDJWTPresentation, 0) - for index, rawCredential := range credentials { - credential, ok := normalizeJSONObject(rawCredential) - if !ok { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf("credentials[%d] is not an object", index), - } - } - id, ok := credential["id"].(string) - if !ok || id == "" { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf("credentials[%d].id is not a non-empty string", index), - } - } - claims, ok := credential["claims"].([]any) - if !ok || len(claims) == 0 { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf("credentials[%d].claims is not a non-empty array", index), - } - } - for claimIndex, rawClaim := range claims { - claim, ok := normalizeJSONObject(rawClaim) - if !ok { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].claims[%d] is not an object", - index, - claimIndex, - ), - } - } - path, ok := claim["path"].([]any) - if !ok || len(path) == 0 { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].claims[%d].path is not a non-empty array", - index, - claimIndex, - ), - } - } - requested = append(requested, path) - } - values, ok := response[id].([]any) - if !ok || len(values) == 0 { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf("vp_token has no presentation for credential query %q", id), - } - } - for presentationIndex, raw := range values { - token, ok := raw.(string) - if !ok || token == "" { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "vp_token[%q][%d] is not an SD-JWT presentation", - id, - presentationIndex, - ), - } - } - parsed, err := evidence.ParseSDJWTPresentation(token) - if err != nil { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "vp_token[%q][%d] is not a valid SD-JWT presentation: %v", - id, - presentationIndex, - err, - ), - } - } - presentations = append(presentations, parsed) - } - } - for pathIndex, path := range requested { - found := false - for _, presentation := range presentations { - if claimPathResolves(presentation.Claims, path) { - found = true - break - } - } - if !found { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "union response does not disclose requested claims[%d].path", - pathIndex, - ), - } - } - } - for pathIndex, path := range forbiddenPaths { - for _, presentation := range presentations { - if claimPathResolves(presentation.Claims, path) { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf("union response discloses forbidden_paths[%d]", pathIndex), - } - } - } - } - return Result{ - Status: StatusPass, - Message: "wallet returned the union of claims requested by multiple queries", - } -} - -func validateClaimsPathNoMatch(query map[string]any, responseValue any) Result { - credentials, ok := query["credentials"].([]any) - if !ok || len(credentials) == 0 { - return Result{Status: StatusFail, Message: "dcql_query does not contain credentials"} - } - for index, rawCredential := range credentials { - credential, ok := normalizeJSONObject(rawCredential) - if !ok { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf("credentials[%d] is not an object", index), - } - } - claims, ok := credential["claims"].([]any) - if !ok || len(claims) == 0 { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf("credentials[%d].claims is not a non-empty array", index), - } - } - for claimIndex, rawClaim := range claims { - claim, ok := normalizeJSONObject(rawClaim) - if !ok { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].claims[%d] is not an object", - index, - claimIndex, - ), - } - } - path, ok := claim["path"].([]any) - if !ok || len(path) == 0 { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].claims[%d].path is not a non-empty array", - index, - claimIndex, - ), - } - } - } - } - if !isEmptyDCQLValue(responseValue) { - return Result{ - Status: StatusFail, - Message: "wallet returned a credential for an unmatched claim path", - } - } - return Result{ - Status: StatusPass, - Message: "wallet returned no credential for the unmatched claim path", - } -} - -func validateClaimsValuesNoMatch(query map[string]any, responseValue any) Result { - credentials, ok := query["credentials"].([]any) - if !ok || len(credentials) == 0 { - return Result{Status: StatusFail, Message: "dcql_query does not contain credentials"} - } - for credentialIndex, rawCredential := range credentials { - credential, ok := normalizeJSONObject(rawCredential) - if !ok { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf("credentials[%d] is not an object", credentialIndex), - } - } - claims, ok := credential["claims"].([]any) - if !ok || len(claims) == 0 { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].claims is not a non-empty array", - credentialIndex, - ), - } - } - for claimIndex, rawClaim := range claims { - claim, ok := normalizeJSONObject(rawClaim) - if !ok { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].claims[%d] is not an object", - credentialIndex, - claimIndex, - ), - } - } - path, pathOK := claim["path"].([]any) - values, valuesOK := claim["values"].([]any) - if !pathOK || len(path) == 0 { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].claims[%d].path is not a non-empty array", - credentialIndex, - claimIndex, - ), - } - } - if !valuesOK || len(values) == 0 { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].claims[%d].values is not a non-empty array", - credentialIndex, - claimIndex, - ), - } - } - } - } - if !isEmptyDCQLValue(responseValue) { - return Result{ - Status: StatusFail, - Message: "wallet returned a credential for mismatched claim values", - } - } - return Result{ - Status: StatusPass, - Message: "wallet returned no credential for mismatched claim values", - } -} - -func validateMissingClaimIDWithClaimSets(query map[string]any, responseValue any) Result { - credentials, ok := query["credentials"].([]any) - if !ok || len(credentials) == 0 { - return Result{Status: StatusFail, Message: "dcql_query does not contain credentials"} - } - foundMissingID := false - for credentialIndex, rawCredential := range credentials { - credential, ok := normalizeJSONObject(rawCredential) - if !ok { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf("credentials[%d] is not an object", credentialIndex), - } - } - claimSets, ok := credential["claim_sets"].([]any) - if !ok || len(claimSets) == 0 { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].claim_sets is not a non-empty array", - credentialIndex, - ), - } - } - claims, ok := credential["claims"].([]any) - if !ok || len(claims) == 0 { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].claims is not a non-empty array", - credentialIndex, - ), - } - } - for _, rawClaim := range claims { - claim, ok := normalizeJSONObject(rawClaim) - if !ok { - continue - } - if _, exists := claim["id"]; !exists { - foundMissingID = true - } - } - } - if !foundMissingID { - return Result{Status: StatusFail, Message: "claims contain no missing id"} - } - if !isEmptyDCQLValue(responseValue) { - return Result{ - Status: StatusFail, - Message: "wallet returned a credential for claims missing id with claim_sets", - } - } - return Result{Status: StatusPass, Message: "wallet rejected claims missing id with claim_sets"} -} - -func validateClaimsWithoutIDWithoutClaimSets(query map[string]any, responseValue any) Result { - credentials, ok := query["credentials"].([]any) - if !ok || len(credentials) == 0 { - return Result{Status: StatusFail, Message: "dcql_query does not contain credentials"} - } - response, ok := normalizeJSONObject(responseValue) - if !ok { - return Result{ - Status: StatusFail, - Message: "wallet vp_token is not an object keyed by credential query ID", - } - } - for credentialIndex, rawCredential := range credentials { - credential, ok := normalizeJSONObject(rawCredential) - if !ok { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf("credentials[%d] is not an object", credentialIndex), - } - } - if _, exists := credential["claim_sets"]; exists { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf("credentials[%d] contains claim_sets", credentialIndex), - } - } - id, ok := credential["id"].(string) - if !ok || id == "" { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].id is not a non-empty string", - credentialIndex, - ), - } - } - claims, ok := credential["claims"].([]any) - if !ok || len(claims) == 0 { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].claims is not a non-empty array", - credentialIndex, - ), - } - } - for claimIndex, rawClaim := range claims { - claim, ok := normalizeJSONObject(rawClaim) - if !ok { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].claims[%d] is not an object", - credentialIndex, - claimIndex, - ), - } - } - if _, exists := claim["id"]; exists { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].claims[%d] contains id", - credentialIndex, - claimIndex, - ), - } - } - path, ok := claim["path"].([]any) - if !ok || len(path) == 0 { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].claims[%d].path is not a non-empty array", - credentialIndex, - claimIndex, - ), - } - } - for pathIndex, segment := range path { - if value, ok := segment.(string); !ok || value == "" { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].claims[%d].path[%d] is not a non-empty string", - credentialIndex, - claimIndex, - pathIndex, - ), - } - } - } - } - if isEmptyDCQLValue(response[id]) { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf("vp_token has no presentation for credential query %q", id), - } - } - } - return Result{ - Status: StatusPass, - Message: "wallet matched claims without ids when claim_sets was absent", - } -} - -func validateDuplicateClaimIDs(query map[string]any, responseValue any, errorValue any) Result { - credentials, ok := query["credentials"].([]any) - if !ok || len(credentials) == 0 { - return Result{Status: StatusFail, Message: "dcql_query does not contain credentials"} - } - foundDuplicate := false - for credentialIndex, rawCredential := range credentials { - credential, ok := normalizeJSONObject(rawCredential) - if !ok { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf("credentials[%d] is not an object", credentialIndex), - } - } - claims, ok := credential["claims"].([]any) - if !ok || len(claims) == 0 { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].claims is not a non-empty array", - credentialIndex, - ), - } - } - seen := make(map[string]struct{}, len(claims)) - for claimIndex, rawClaim := range claims { - claim, ok := normalizeJSONObject(rawClaim) - if !ok { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].claims[%d] is not an object", - credentialIndex, - claimIndex, - ), - } - } - id, ok := claim["id"].(string) - if !ok || id == "" { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].claims[%d].id is not a non-empty string", - credentialIndex, - claimIndex, - ), - } - } - if _, exists := seen[id]; exists { - foundDuplicate = true - } - seen[id] = struct{}{} - } - } - if !foundDuplicate { - return Result{ - Status: StatusFail, - Message: "no credential claims array contains a duplicate id", - } - } - if !isEmptyDCQLValue(responseValue) { - return Result{ - Status: StatusFail, - Message: "wallet returned a credential for duplicate claim ids", - } - } - if errorText, _ := errorValue.(string); errorText != invalidRequestError { - return Result{ - Status: StatusFail, - Message: "wallet did not return invalid_request for duplicate claim ids", - } - } - return Result{ - Status: StatusPass, - Message: "wallet rejected duplicate claim ids with invalid_request", - } -} - -func validateEmptyClaimID(query map[string]any, responseValue any, errorValue any) Result { - credentials, ok := query["credentials"].([]any) - if !ok || len(credentials) == 0 { - return Result{Status: StatusFail, Message: "dcql_query does not contain credentials"} - } - foundEmpty := false - for credentialIndex, rawCredential := range credentials { - credential, ok := normalizeJSONObject(rawCredential) - if !ok { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf("credentials[%d] is not an object", credentialIndex), - } - } - claims, ok := credential["claims"].([]any) - if !ok || len(claims) == 0 { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].claims is not a non-empty array", - credentialIndex, - ), - } - } - for claimIndex, rawClaim := range claims { - claim, ok := normalizeJSONObject(rawClaim) - if !ok { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].claims[%d] is not an object", - credentialIndex, - claimIndex, - ), - } - } - idValue, exists := claim["id"] - if !exists { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].claims[%d].id is missing", - credentialIndex, - claimIndex, - ), - } - } - id, ok := idValue.(string) - if !ok { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].claims[%d].id is not a string", - credentialIndex, - claimIndex, - ), - } - } - if id == "" { - foundEmpty = true - } - } - } - if !foundEmpty { - return Result{Status: StatusFail, Message: "no claim id is empty"} - } - if !isEmptyDCQLValue(responseValue) { - return Result{ - Status: StatusFail, - Message: "wallet returned a credential for an empty claim id", - } - } - if errorText, _ := errorValue.(string); errorText != invalidRequestError { - return Result{ - Status: StatusFail, - Message: "wallet did not return invalid_request for an empty claim id", - } - } - return Result{ - Status: StatusPass, - Message: "wallet rejected an empty claim id with invalid_request", - } -} - -func validateInvalidClaimIDCharacters( - query map[string]any, - responseValue any, - errorValue any, -) Result { - credentials, ok := query["credentials"].([]any) - if !ok || len(credentials) == 0 { - return Result{Status: StatusFail, Message: "dcql_query does not contain credentials"} - } - foundInvalid := false - for credentialIndex, rawCredential := range credentials { - credential, ok := normalizeJSONObject(rawCredential) - if !ok { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf("credentials[%d] is not an object", credentialIndex), - } - } - claims, ok := credential["claims"].([]any) - if !ok || len(claims) == 0 { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].claims is not a non-empty array", - credentialIndex, - ), - } - } - for claimIndex, rawClaim := range claims { - claim, ok := normalizeJSONObject(rawClaim) - if !ok { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].claims[%d] is not an object", - credentialIndex, - claimIndex, - ), - } - } - idValue, exists := claim["id"] - if !exists { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].claims[%d].id is missing", - credentialIndex, - claimIndex, - ), - } - } - id, ok := idValue.(string) - if !ok || id == "" { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].claims[%d].id is not a non-empty string", - credentialIndex, - claimIndex, - ), - } - } - if !dcqlIDPattern.MatchString(id) { - foundInvalid = true - } - } - } - if !foundInvalid { - return Result{Status: StatusFail, Message: "no claim id contains a forbidden character"} - } - if !isEmptyDCQLValue(responseValue) { - return Result{ - Status: StatusFail, - Message: "wallet returned a credential for a malformed claim id", - } - } - if errorText, _ := errorValue.(string); errorText != invalidRequestError { - return Result{ - Status: StatusFail, - Message: "wallet did not return invalid_request for a malformed claim id", - } - } - return Result{ - Status: StatusPass, - Message: "wallet rejected a malformed claim id with invalid_request", - } -} - -func validateMissingClaimPath(query map[string]any, responseValue any, errorValue any) Result { - credentials, ok := query["credentials"].([]any) - if !ok || len(credentials) == 0 { - return Result{Status: StatusFail, Message: "dcql_query does not contain credentials"} - } - foundMissing := false - for credentialIndex, rawCredential := range credentials { - credential, ok := normalizeJSONObject(rawCredential) - if !ok { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf("credentials[%d] is not an object", credentialIndex), - } - } - claims, ok := credential["claims"].([]any) - if !ok || len(claims) == 0 { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].claims is not a non-empty array", - credentialIndex, - ), - } - } - for claimIndex, rawClaim := range claims { - claim, ok := normalizeJSONObject(rawClaim) - if !ok { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].claims[%d] is not an object", - credentialIndex, - claimIndex, - ), - } - } - if _, exists := claim["path"]; !exists { - foundMissing = true - } - } - } - if !foundMissing { - return Result{Status: StatusFail, Message: "no claim is missing path"} - } - if !isEmptyDCQLValue(responseValue) { - return Result{ - Status: StatusFail, - Message: "wallet returned a credential for a claim missing path", - } - } - if errorText, _ := errorValue.(string); errorText != invalidRequestError { - return Result{ - Status: StatusFail, - Message: "wallet did not return invalid_request for a claim missing path", - } - } - return Result{ - Status: StatusPass, - Message: "wallet rejected a claim missing path with invalid_request", - } -} - -func validateEmptyClaimPath(query map[string]any, responseValue any, errorValue any) Result { - credentials, ok := query["credentials"].([]any) - if !ok || len(credentials) == 0 { - return Result{Status: StatusFail, Message: "dcql_query does not contain credentials"} - } - foundEmpty := false - for credentialIndex, rawCredential := range credentials { - credential, ok := normalizeJSONObject(rawCredential) - if !ok { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf("credentials[%d] is not an object", credentialIndex), - } - } - claims, ok := credential["claims"].([]any) - if !ok || len(claims) == 0 { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].claims is not a non-empty array", - credentialIndex, - ), - } - } - for claimIndex, rawClaim := range claims { - claim, ok := normalizeJSONObject(rawClaim) - if !ok { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].claims[%d] is not an object", - credentialIndex, - claimIndex, - ), - } - } - pathValue, exists := claim["path"] - if !exists { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].claims[%d].path is missing", - credentialIndex, - claimIndex, - ), - } - } - path, ok := pathValue.([]any) - if !ok { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].claims[%d].path is not an array", - credentialIndex, - claimIndex, - ), - } - } - if len(path) == 0 { - foundEmpty = true - } - } - } - if !foundEmpty { - return Result{Status: StatusFail, Message: "no claim path is empty"} - } - if !isEmptyDCQLValue(responseValue) { - return Result{ - Status: StatusFail, - Message: "wallet returned a credential for an empty claim path", - } - } - if errorText, _ := errorValue.(string); errorText != invalidRequestError { - return Result{ - Status: StatusFail, - Message: "wallet did not return invalid_request for an empty claim path", - } - } - return Result{ - Status: StatusPass, - Message: "wallet rejected an empty claim path with invalid_request", - } -} - -func validateNonArrayClaimPath(query map[string]any, responseValue any, errorValue any) Result { - credentials, ok := query["credentials"].([]any) - if !ok || len(credentials) == 0 { - return Result{Status: StatusFail, Message: "dcql_query does not contain credentials"} - } - foundNonArray := false - for credentialIndex, rawCredential := range credentials { - credential, ok := normalizeJSONObject(rawCredential) - if !ok { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf("credentials[%d] is not an object", credentialIndex), - } - } - claims, ok := credential["claims"].([]any) - if !ok || len(claims) == 0 { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].claims is not a non-empty array", - credentialIndex, - ), - } - } - for claimIndex, rawClaim := range claims { - claim, ok := normalizeJSONObject(rawClaim) - if !ok { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].claims[%d] is not an object", - credentialIndex, - claimIndex, - ), - } - } - pathValue, exists := claim["path"] - if !exists { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].claims[%d].path is missing", - credentialIndex, - claimIndex, - ), - } - } - if _, ok := pathValue.([]any); !ok { - foundNonArray = true - } - } - } - if !foundNonArray { - return Result{Status: StatusFail, Message: "no claim path has a non-array value"} - } - if !isEmptyDCQLValue(responseValue) { - return Result{ - Status: StatusFail, - Message: "wallet returned a credential for a non-array claim path", - } - } - if errorText, _ := errorValue.(string); errorText != invalidRequestError { - return Result{ - Status: StatusFail, - Message: "wallet did not return invalid_request for a non-array claim path", - } - } - return Result{ - Status: StatusPass, - Message: "wallet rejected a non-array claim path with invalid_request", - } -} - -func validateAllowedClaimPathComponents(query map[string]any, responseValue any) Result { - credentials, ok := query["credentials"].([]any) - if !ok || len(credentials) == 0 { - return Result{Status: StatusFail, Message: "dcql_query does not contain credentials"} - } - response, ok := normalizeJSONObject(responseValue) - if !ok { - return Result{ - Status: StatusFail, - Message: "wallet vp_token is not an object keyed by credential query ID", - } - } - seenString := false - seenNull := false - seenNonNegativeInteger := false - for credentialIndex, rawCredential := range credentials { - credential, ok := normalizeJSONObject(rawCredential) - if !ok { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf("credentials[%d] is not an object", credentialIndex), - } - } - id, ok := credential["id"].(string) - if !ok || id == "" { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].id is not a non-empty string", - credentialIndex, - ), - } - } - claims, ok := credential["claims"].([]any) - if !ok || len(claims) == 0 { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].claims is not a non-empty array", - credentialIndex, - ), - } - } - paths := make([][]any, 0, len(claims)) - for claimIndex, rawClaim := range claims { - claim, ok := normalizeJSONObject(rawClaim) - if !ok { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].claims[%d] is not an object", - credentialIndex, - claimIndex, - ), - } - } - path, ok := claim["path"].([]any) - if !ok || len(path) == 0 { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].claims[%d].path is not a non-empty array", - credentialIndex, - claimIndex, - ), - } - } - paths = append(paths, path) - for componentIndex, component := range path { - switch typed := component.(type) { - case string: - if typed == "" { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].claims[%d].path[%d] is an empty string", - credentialIndex, - claimIndex, - componentIndex, - ), - } - } - seenString = true - case nil: - seenNull = true - default: - if !isNonNegativeInteger(component) { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].claims[%d].path[%d] is not a string, null, or non-negative integer", - credentialIndex, - claimIndex, - componentIndex, - ), - } - } - seenNonNegativeInteger = true - } - } - } - presentations, ok := response[id].([]any) - if !ok || len(presentations) == 0 { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf("vp_token has no presentation for credential query %q", id), - } - } - for presentationIndex, rawPresentation := range presentations { - token, ok := rawPresentation.(string) - if !ok || token == "" { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "vp_token[%q][%d] is not an SD-JWT presentation", - id, - presentationIndex, - ), - } - } - presentation, err := evidence.ParseSDJWTPresentation(token) - if err != nil { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "vp_token[%q][%d] is not a valid SD-JWT presentation: %v", - id, - presentationIndex, - err, - ), - } - } - for pathIndex, path := range paths { - if !claimPathResolves(presentation.Claims, path) { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "vp_token[%q][%d] does not disclose a value resolved by claims[%d].path", - id, - presentationIndex, - pathIndex, - ), - } - } - } - } - } - if !seenString || !seenNull || !seenNonNegativeInteger { - return Result{ - Status: StatusFail, - Message: "claim paths do not cover string, null, and non-negative integer components", - } - } - return Result{ - Status: StatusPass, - Message: "wallet resolved claim paths with all allowed component types", - } -} - -func claimPathResolves(root any, path []any) bool { - values := []any{root} - for _, component := range path { - next := make([]any, 0) - for _, value := range values { - switch typed := component.(type) { - case string: - object, ok := value.(map[string]any) - if !ok { - continue - } - if resolved, exists := object[typed]; exists { - next = append(next, resolved) - } - case nil: - array, ok := value.([]any) - if ok { - next = append(next, array...) - } - default: - array, ok := value.([]any) - if !ok { - continue - } - index, ok := claimPathArrayIndex(typed, len(array)) - if ok { - next = append(next, array[index]) - } - } - } - if len(next) == 0 { - return false - } - values = next - } - return len(values) > 0 -} - -func claimPathArrayIndex(value any, length int) (int, bool) { - if !isNonNegativeInteger(value) { - return 0, false - } - var index uint64 - switch typed := value.(type) { - case int: - index = uint64(typed) - case int8: - index = uint64(typed) - case int16: - index = uint64(typed) - case int32: - index = uint64(typed) - case int64: - index = uint64(typed) - case uint: - index = uint64(typed) - case uint8: - index = uint64(typed) - case uint16: - index = uint64(typed) - case uint32: - index = uint64(typed) - case uint64: - index = typed - case float32: - index = uint64(typed) - case float64: - index = uint64(typed) - default: - return 0, false - } - if index >= uint64(length) { - return 0, false - } - return int(index), true -} - -func isNonNegativeInteger(value any) bool { - switch typed := value.(type) { - case int: - return typed >= 0 - case int8: - return typed >= 0 - case int16: - return typed >= 0 - case int32: - return typed >= 0 - case int64: - return typed >= 0 - case uint, uint8, uint16, uint32, uint64: - return true - case float32: - return typed >= 0 && typed == float32(int64(typed)) - case float64: - return typed >= 0 && typed == float64(int64(typed)) - default: - return false - } -} - -func validateClaimsWithoutValues(query map[string]any, responseValue any) Result { - credentials, ok := query["credentials"].([]any) - if !ok || len(credentials) == 0 { - return Result{Status: StatusFail, Message: "dcql_query does not contain credentials"} - } - response, ok := normalizeJSONObject(responseValue) - if !ok { - return Result{ - Status: StatusFail, - Message: "wallet vp_token is not an object keyed by credential query ID", - } - } - for credentialIndex, rawCredential := range credentials { - credential, ok := normalizeJSONObject(rawCredential) - if !ok { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf("credentials[%d] is not an object", credentialIndex), - } - } - id, ok := credential["id"].(string) - if !ok || id == "" { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].id is not a non-empty string", - credentialIndex, - ), - } - } - claims, ok := credential["claims"].([]any) - if !ok || len(claims) == 0 { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].claims is not a non-empty array", - credentialIndex, - ), - } - } - for claimIndex, rawClaim := range claims { - claim, ok := normalizeJSONObject(rawClaim) - if !ok { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].claims[%d] is not an object", - credentialIndex, - claimIndex, - ), - } - } - if _, exists := claim["values"]; exists { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].claims[%d] contains values", - credentialIndex, - claimIndex, - ), - } - } - if !nonEmptyStringArray(claim["path"]) { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].claims[%d].path is invalid", - credentialIndex, - claimIndex, - ), - } - } - } - if isEmptyDCQLValue(response[id]) { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf("vp_token has no presentation for credential query %q", id), - } - } - } - return Result{Status: StatusPass, Message: "wallet matched claims without values"} -} - -func validateTrustedAuthoritiesMatch(query map[string]any, responseValue any) Result { - credentials, ok := query["credentials"].([]any) - if !ok || len(credentials) == 0 { - return Result{Status: StatusFail, Message: "dcql_query does not contain credentials"} - } - if err := validateDCQLCredentialQueries(credentials); err != nil { - return Result{Status: StatusFail, Message: err.Error()} - } - response, ok := normalizeJSONObject(responseValue) - if !ok { - return Result{ - Status: StatusFail, - Message: "wallet vp_token is not an object keyed by credential query ID", - } - } - for credentialIndex, rawCredential := range credentials { - credential, _ := normalizeJSONObject(rawCredential) - id, _ := credential["id"].(string) - presentations, ok := response[id].([]any) - if !ok || len(presentations) == 0 { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf("vp_token has no presentation for credential query %q", id), - } - } - authorities, hasTA := credential["trusted_authorities"].([]any) - if !hasTA || len(authorities) == 0 { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d] does not contain trusted_authorities", - credentialIndex, - ), - } - } - for presentationIndex, rawPresentation := range presentations { - token, ok := rawPresentation.(string) - if !ok || token == "" { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "vp_token[%q][%d] is not an SD-JWT presentation", - id, - presentationIndex, - ), - } - } - presentation, err := evidence.ParseSDJWTPresentation(token) - if err != nil { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "vp_token[%q][%d] is not a valid SD-JWT: %v", - id, - presentationIndex, - err, - ), - } - } - if !credentialMatchesTrustedAuthorities(presentation, authorities) { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "vp_token[%q][%d] issuer does not match any trusted_authority", - id, - presentationIndex, - ), - } - } - } - } - return Result{ - Status: StatusPass, - Message: "every returned credential issuer matches at least one trusted_authority", - } -} - -func validateTrustedAuthoritiesNoMatch(query map[string]any, responseValue any) Result { - credentials, ok := query["credentials"].([]any) - if !ok || len(credentials) == 0 { - return Result{Status: StatusFail, Message: "dcql_query does not contain credentials"} - } - if err := validateDCQLCredentialQueries(credentials); err != nil { - return Result{Status: StatusFail, Message: err.Error()} - } - for index, rawCredential := range credentials { - credential, _ := normalizeJSONObject(rawCredential) - authorities, ok := credential["trusted_authorities"].([]any) - if !ok || len(authorities) == 0 { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf("credentials[%d] does not contain trusted_authorities", index), - } - } - for authorityIndex, rawAuthority := range authorities { - authority, ok := normalizeJSONObject(rawAuthority) - if !ok || authority["type"] != "aki" { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].trusted_authorities[%d] is not a valid aki authority", - index, - authorityIndex, - ), - } - } - values, ok := authority["values"].([]any) - if !ok || len(values) == 0 { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].trusted_authorities[%d].values is empty", - index, - authorityIndex, - ), - } - } - for valueIndex, rawValue := range values { - value, ok := rawValue.(string) - if !ok || value == "" { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].trusted_authorities[%d].values[%d] is not a string", - index, - authorityIndex, - valueIndex, - ), - } - } - decoded, err := base64.RawURLEncoding.DecodeString(value) - if err != nil || len(decoded) == 0 { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "credentials[%d].trusted_authorities[%d].values[%d] is not base64url", - index, - authorityIndex, - valueIndex, - ), - } - } - } - } - } - if !isEmptyDCQLValue(responseValue) { - return Result{ - Status: StatusFail, - Message: "wallet returned a credential for an unmatched trusted_authorities query", - } - } - return Result{ - Status: StatusPass, - Message: "wallet returned no credential for valid unmatched trusted_authorities", - } -} - -func credentialMatchesTrustedAuthorities( - presentation *evidence.SDJWTPresentation, - authorities []any, -) bool { - for _, rawAuthority := range authorities { - authority, ok := normalizeJSONObject(rawAuthority) - if !ok { - continue - } - authType, _ := authority["type"].(string) - if authType == "" { - continue - } - values, _ := authority["values"].([]any) - if len(values) == 0 { - continue - } - switch authType { - case "aki": - if sdjwtMatchesAKI(presentation, values) { - return true - } - default: - if sdjwtMatchesIssuerClaim(presentation, values) { - return true - } - } - } - return false -} - -func sdjwtMatchesAKI(presentation *evidence.SDJWTPresentation, values []any) bool { - rawChain, ok := presentation.ProtectedHeaders["x5c"].([]any) - if !ok || len(rawChain) == 0 { - return false - } - encoded, ok := rawChain[0].(string) - if !ok || encoded == "" { - return false - } - der, err := base64.StdEncoding.DecodeString(encoded) - if err != nil { - return false - } - cert, err := x509.ParseCertificate(der) - if err != nil { - return false - } - if len(cert.AuthorityKeyId) == 0 { - return false - } - encodedAKI := base64.RawURLEncoding.EncodeToString(cert.AuthorityKeyId) - for _, rawValue := range values { - value, ok := rawValue.(string) - if ok && value == encodedAKI { - return true - } - } - return false -} - -func sdjwtMatchesIssuerClaim(presentation *evidence.SDJWTPresentation, values []any) bool { - iss, _ := presentation.IssuerPayload["iss"].(string) - if iss == "" { - return false - } - for _, rawValue := range values { - value, ok := rawValue.(string) - if ok && value == iss { - return true - } - } - return false -} - -// validateClaimPathMemberTypeError checks that wallet rejects DCQL queries with invalid claim-path -// member types (boolean, negative integer, unsupported object types). -func validateClaimPathMemberTypeError(responseValue, errorValue any) Result { - if errStr := normalizeString(errorValue); errStr != "" { - if errStr == invalidRequestError { - return Result{ - Status: StatusPass, - Message: fmt.Sprintf( - "wallet returned %s for invalid claim-path member type", - errStr, - ), - } - } - return Result{ - Status: StatusPass, - Message: fmt.Sprintf( - "wallet returned error %s for invalid claim-path member type", - errStr, - ), - } - } - if !isEmptyDCQLValue(responseValue) { - return Result{ - Status: StatusFail, - Message: "wallet returned vp_token for query with invalid claim-path member type", - } - } - return Result{ - Status: StatusPass, - Message: "wallet did not return vp_token for invalid claim-path member type", - } -} - -// validateWalletErrorExpected checks that wallet returns an expected error code. -// If expected is nil, any error code is accepted. -func validateWalletErrorExpected(responseValue, errorValue, expected any) Result { - if errStr := normalizeString(errorValue); errStr != "" { - if expected != nil { - expectedStr, ok := expected.(string) - if ok && errStr == expectedStr { - return Result{ - Status: StatusPass, - Message: fmt.Sprintf("wallet returned expected error %s", errStr), - } - } - if ok { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf("wallet returned %s, expected %s", errStr, expectedStr), - } - } - } - return Result{Status: StatusPass, Message: fmt.Sprintf("wallet returned error %s", errStr)} - } - if !isEmptyDCQLValue(responseValue) { - return Result{Status: StatusFail, Message: "wallet returned vp_token, expected error"} - } - return Result{ - Status: StatusPass, - Message: "wallet did not return vp_token (expected error case)", - } -} - -// validateErrorCode checks that wallet returns a specific OAuth2/OID4VP error code. -func validateErrorCode(responseValue, errorValue any, expectedCode string) Result { - if errStr := normalizeString(errorValue); errStr != "" { - if errStr == expectedCode { - return Result{ - Status: StatusPass, - Message: fmt.Sprintf("wallet returned expected error %s", expectedCode), - } - } - return Result{ - Status: StatusFail, - Message: fmt.Sprintf("wallet returned error %s, expected %s", errStr, expectedCode), - } - } - if isEmptyDCQLValue(responseValue) { - return Result{ - Status: StatusPass, - Message: fmt.Sprintf("wallet did not return vp_token for %s case", expectedCode), - } - } - return Result{ - Status: StatusFail, - Message: fmt.Sprintf("wallet returned vp_token, expected error %s", expectedCode), - } -} - -// validateUnknownFieldStripped checks that wallet processes request with unknown fields stripped. -func validateUnknownFieldStripped(query, responseValue any) Result { - if errStr := normalizeString(query); errStr != "" { - return Result{ - Status: StatusFail, - Message: fmt.Sprintf( - "wallet returned error %s for unknown field (should have been stripped)", - errStr, - ), - } - } - if isEmptyDCQLValue(responseValue) { - return Result{ - Status: StatusFail, - Message: "wallet returned no vp_token for request with unknown fields", - } - } - return Result{ - Status: StatusPass, - Message: "wallet accepted request with unknown fields stripped", - } -} - -// validateJWEEncVerified checks JWE encryption parameters in the wallet response. -func validateJWEEncVerified(responseValue any) Result { - resp, _ := normalizeJSONObject(responseValue) - if len(resp) == 0 { - return Result{ - Status: StatusFail, - Message: "wallet returned empty vp_token, cannot verify JWE enc", - } - } - if _, exists := resp["response"]; exists { - return Result{Status: StatusPass, Message: "wallet response contains JWE response evidence"} - } - for _, v := range resp { - if str, ok := v.(string); ok && len(str) > 0 { - parts := 0 - for _, c := range str { - if c == '.' { - parts++ - } - } - if parts == 4 { - return Result{Status: StatusPass, Message: "wallet response contains compact JWE"} - } - } - } - return Result{Status: StatusPass, Message: "wallet returned response evidence"} -} - -func normalizeString(v any) string { - s, _ := v.(string) - return s -} - -// validateSessionEncryption checks session encryption evidence. -func validateSessionEncryption(responseValue any) Result { - if isEmptyDCQLValue(responseValue) { - return Result{Status: StatusFail, Message: "wallet returned no session encryption evidence"} - } - return Result{Status: StatusPass, Message: "wallet returned session encryption evidence"} -} - -// validateEncoding checks encoding validation evidence. -func validateEncoding(responseValue any) Result { - if isEmptyDCQLValue(responseValue) { - return Result{Status: StatusFail, Message: "wallet returned no encoding evidence"} - } - return Result{Status: StatusPass, Message: "wallet returned encoding evidence"} -} - -// validateInteractionCompleted checks that wallet interaction completed successfully. -func validateInteractionCompleted(responseValue any) Result { - if isEmptyDCQLValue(responseValue) { - return Result{Status: StatusFail, Message: "wallet interaction did not complete"} - } - return Result{Status: StatusPass, Message: "wallet interaction completed successfully"} -} +// validateInteractionCompleted checks that wallet interaction completed successfully. // validateEvidencePresent checks that evidence exists and no error occurred. -func validateEvidencePresent(responseValue, errorValue any) Result { - if errStr := normalizeString(errorValue); errStr != "" { - return Result{Status: StatusFail, Message: fmt.Sprintf("wallet returned error: %s", errStr)} - } - if isEmptyDCQLValue(responseValue) { - return Result{Status: StatusFail, Message: "wallet returned no evidence"} - } - return Result{Status: StatusPass, Message: "wallet evidence present"} -} diff --git a/pkg/fcaf/validators/dcql_claims.go b/pkg/fcaf/validators/dcql_claims.go new file mode 100644 index 000000000..4dbdeba2b --- /dev/null +++ b/pkg/fcaf/validators/dcql_claims.go @@ -0,0 +1,1374 @@ +// SPDX-FileCopyrightText: 2026 Forkbomb BV +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package validators + +import ( + "fmt" + + "github.com/forkbombeu/credimi/pkg/fcaf/evidence" +) + +func validateClaimsPresent(query map[string]any, responseValue any) Result { + credentials, ok := query["credentials"].([]any) + if !ok || len(credentials) == 0 { + return Result{Status: StatusFail, Message: "dcql_query does not contain credentials"} + } + response, ok := normalizeJSONObject(responseValue) + if !ok { + return Result{ + Status: StatusFail, + Message: "wallet vp_token is not an object keyed by credential query ID", + } + } + for index, rawCredential := range credentials { + credential, ok := normalizeJSONObject(rawCredential) + if !ok { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf("credentials[%d] is not an object", index), + } + } + id, ok := credential["id"].(string) + if !ok || id == "" { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf("credentials[%d].id is not a non-empty string", index), + } + } + claims, ok := credential["claims"].([]any) + if !ok || len(claims) == 0 { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf("credentials[%d].claims is not a non-empty array", index), + } + } + for claimIndex, rawClaim := range claims { + claim, ok := normalizeJSONObject(rawClaim) + if !ok { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].claims[%d] is not an object", + index, + claimIndex, + ), + } + } + path, ok := claim["path"].([]any) + if !ok || len(path) == 0 { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].claims[%d].path is not a non-empty array", + index, + claimIndex, + ), + } + } + for pathIndex, segment := range path { + if _, ok := segment.(string); !ok { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].claims[%d].path[%d] is not a string", + index, + claimIndex, + pathIndex, + ), + } + } + } + } + if isEmptyDCQLValue(response[id]) { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf("vp_token has no presentation for credential query %q", id), + } + } + } + return Result{Status: StatusPass, Message: "wallet processed credential queries with claims"} +} +func validateClaimsSubset(query map[string]any, responseValue any, forbiddenPaths [][]any) Result { + credentials, ok := query["credentials"].([]any) + if !ok || len(credentials) == 0 { + return Result{Status: StatusFail, Message: "dcql_query does not contain credentials"} + } + if len(forbiddenPaths) == 0 { + return Result{Status: StatusFail, Message: "claims_subset requires forbidden_paths"} + } + response, ok := normalizeJSONObject(responseValue) + if !ok { + return Result{ + Status: StatusFail, + Message: "wallet vp_token is not an object keyed by credential query ID", + } + } + for credentialIndex, rawCredential := range credentials { + credential, ok := normalizeJSONObject(rawCredential) + if !ok { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf("credentials[%d] is not an object", credentialIndex), + } + } + id, ok := credential["id"].(string) + if !ok || id == "" { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].id is not a non-empty string", + credentialIndex, + ), + } + } + claims, ok := credential["claims"].([]any) + if !ok || len(claims) == 0 { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].claims is not a non-empty array", + credentialIndex, + ), + } + } + presentations, ok := response[id].([]any) + if !ok || len(presentations) == 0 { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf("vp_token has no presentation for credential query %q", id), + } + } + for presentationIndex, rawPresentation := range presentations { + token, ok := rawPresentation.(string) + if !ok || token == "" { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "vp_token[%q][%d] is not an SD-JWT presentation", + id, + presentationIndex, + ), + } + } + presentation, err := evidence.ParseSDJWTPresentation(token) + if err != nil { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "vp_token[%q][%d] is not a valid SD-JWT presentation: %v", + id, + presentationIndex, + err, + ), + } + } + for claimIndex, rawClaim := range claims { + claim, ok := normalizeJSONObject(rawClaim) + if !ok { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].claims[%d] is not an object", + credentialIndex, + claimIndex, + ), + } + } + path, ok := claim["path"].([]any) + if !ok || len(path) == 0 || !claimPathResolves(presentation.Claims, path) { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "vp_token[%q][%d] does not disclose requested claims[%d].path", + id, + presentationIndex, + claimIndex, + ), + } + } + } + for pathIndex, path := range forbiddenPaths { + if len(path) == 0 { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf("forbidden_paths[%d] is empty", pathIndex), + } + } + if claimPathResolves(presentation.Claims, path) { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "vp_token[%q][%d] discloses unchecked forbidden_paths[%d]", + id, + presentationIndex, + pathIndex, + ), + } + } + } + } + } + return Result{ + Status: StatusPass, + Message: "wallet disclosed requested claims and omitted unchecked claims", + } +} +func validateClaimsUnion(query map[string]any, responseValue any, forbiddenPaths [][]any) Result { + credentials, ok := query["credentials"].([]any) + if !ok || len(credentials) < 2 { + return Result{ + Status: StatusFail, + Message: "claims_union requires at least two credential queries", + } + } + response, ok := normalizeJSONObject(responseValue) + if !ok { + return Result{ + Status: StatusFail, + Message: "wallet vp_token is not an object keyed by credential query ID", + } + } + requested := make([][]any, 0) + presentations := make([]*evidence.SDJWTPresentation, 0) + for index, rawCredential := range credentials { + credential, ok := normalizeJSONObject(rawCredential) + if !ok { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf("credentials[%d] is not an object", index), + } + } + id, ok := credential["id"].(string) + if !ok || id == "" { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf("credentials[%d].id is not a non-empty string", index), + } + } + claims, ok := credential["claims"].([]any) + if !ok || len(claims) == 0 { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf("credentials[%d].claims is not a non-empty array", index), + } + } + for claimIndex, rawClaim := range claims { + claim, ok := normalizeJSONObject(rawClaim) + if !ok { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].claims[%d] is not an object", + index, + claimIndex, + ), + } + } + path, ok := claim["path"].([]any) + if !ok || len(path) == 0 { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].claims[%d].path is not a non-empty array", + index, + claimIndex, + ), + } + } + requested = append(requested, path) + } + values, ok := response[id].([]any) + if !ok || len(values) == 0 { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf("vp_token has no presentation for credential query %q", id), + } + } + for presentationIndex, raw := range values { + token, ok := raw.(string) + if !ok || token == "" { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "vp_token[%q][%d] is not an SD-JWT presentation", + id, + presentationIndex, + ), + } + } + parsed, err := evidence.ParseSDJWTPresentation(token) + if err != nil { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "vp_token[%q][%d] is not a valid SD-JWT presentation: %v", + id, + presentationIndex, + err, + ), + } + } + presentations = append(presentations, parsed) + } + } + for pathIndex, path := range requested { + found := false + for _, presentation := range presentations { + if claimPathResolves(presentation.Claims, path) { + found = true + break + } + } + if !found { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "union response does not disclose requested claims[%d].path", + pathIndex, + ), + } + } + } + for pathIndex, path := range forbiddenPaths { + for _, presentation := range presentations { + if claimPathResolves(presentation.Claims, path) { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf("union response discloses forbidden_paths[%d]", pathIndex), + } + } + } + } + return Result{ + Status: StatusPass, + Message: "wallet returned the union of claims requested by multiple queries", + } +} +func validateClaimsPathNoMatch(query map[string]any, responseValue any) Result { + credentials, ok := query["credentials"].([]any) + if !ok || len(credentials) == 0 { + return Result{Status: StatusFail, Message: "dcql_query does not contain credentials"} + } + for index, rawCredential := range credentials { + credential, ok := normalizeJSONObject(rawCredential) + if !ok { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf("credentials[%d] is not an object", index), + } + } + claims, ok := credential["claims"].([]any) + if !ok || len(claims) == 0 { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf("credentials[%d].claims is not a non-empty array", index), + } + } + for claimIndex, rawClaim := range claims { + claim, ok := normalizeJSONObject(rawClaim) + if !ok { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].claims[%d] is not an object", + index, + claimIndex, + ), + } + } + path, ok := claim["path"].([]any) + if !ok || len(path) == 0 { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].claims[%d].path is not a non-empty array", + index, + claimIndex, + ), + } + } + } + } + if !isEmptyDCQLValue(responseValue) { + return Result{ + Status: StatusFail, + Message: "wallet returned a credential for an unmatched claim path", + } + } + return Result{ + Status: StatusPass, + Message: "wallet returned no credential for the unmatched claim path", + } +} +func validateClaimsValuesNoMatch(query map[string]any, responseValue any) Result { + credentials, ok := query["credentials"].([]any) + if !ok || len(credentials) == 0 { + return Result{Status: StatusFail, Message: "dcql_query does not contain credentials"} + } + for credentialIndex, rawCredential := range credentials { + credential, ok := normalizeJSONObject(rawCredential) + if !ok { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf("credentials[%d] is not an object", credentialIndex), + } + } + claims, ok := credential["claims"].([]any) + if !ok || len(claims) == 0 { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].claims is not a non-empty array", + credentialIndex, + ), + } + } + for claimIndex, rawClaim := range claims { + claim, ok := normalizeJSONObject(rawClaim) + if !ok { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].claims[%d] is not an object", + credentialIndex, + claimIndex, + ), + } + } + path, pathOK := claim["path"].([]any) + values, valuesOK := claim["values"].([]any) + if !pathOK || len(path) == 0 { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].claims[%d].path is not a non-empty array", + credentialIndex, + claimIndex, + ), + } + } + if !valuesOK || len(values) == 0 { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].claims[%d].values is not a non-empty array", + credentialIndex, + claimIndex, + ), + } + } + } + } + if !isEmptyDCQLValue(responseValue) { + return Result{ + Status: StatusFail, + Message: "wallet returned a credential for mismatched claim values", + } + } + return Result{ + Status: StatusPass, + Message: "wallet returned no credential for mismatched claim values", + } +} +func validateMissingClaimIDWithClaimSets(query map[string]any, responseValue any) Result { + credentials, ok := query["credentials"].([]any) + if !ok || len(credentials) == 0 { + return Result{Status: StatusFail, Message: "dcql_query does not contain credentials"} + } + foundMissingID := false + for credentialIndex, rawCredential := range credentials { + credential, ok := normalizeJSONObject(rawCredential) + if !ok { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf("credentials[%d] is not an object", credentialIndex), + } + } + claimSets, ok := credential["claim_sets"].([]any) + if !ok || len(claimSets) == 0 { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].claim_sets is not a non-empty array", + credentialIndex, + ), + } + } + claims, ok := credential["claims"].([]any) + if !ok || len(claims) == 0 { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].claims is not a non-empty array", + credentialIndex, + ), + } + } + for _, rawClaim := range claims { + claim, ok := normalizeJSONObject(rawClaim) + if !ok { + continue + } + if _, exists := claim["id"]; !exists { + foundMissingID = true + } + } + } + if !foundMissingID { + return Result{Status: StatusFail, Message: "claims contain no missing id"} + } + if !isEmptyDCQLValue(responseValue) { + return Result{ + Status: StatusFail, + Message: "wallet returned a credential for claims missing id with claim_sets", + } + } + return Result{Status: StatusPass, Message: "wallet rejected claims missing id with claim_sets"} +} +func validateClaimsWithoutIDWithoutClaimSets(query map[string]any, responseValue any) Result { + credentials, ok := query["credentials"].([]any) + if !ok || len(credentials) == 0 { + return Result{Status: StatusFail, Message: "dcql_query does not contain credentials"} + } + response, ok := normalizeJSONObject(responseValue) + if !ok { + return Result{ + Status: StatusFail, + Message: "wallet vp_token is not an object keyed by credential query ID", + } + } + for credentialIndex, rawCredential := range credentials { + credential, ok := normalizeJSONObject(rawCredential) + if !ok { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf("credentials[%d] is not an object", credentialIndex), + } + } + if _, exists := credential["claim_sets"]; exists { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf("credentials[%d] contains claim_sets", credentialIndex), + } + } + id, ok := credential["id"].(string) + if !ok || id == "" { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].id is not a non-empty string", + credentialIndex, + ), + } + } + claims, ok := credential["claims"].([]any) + if !ok || len(claims) == 0 { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].claims is not a non-empty array", + credentialIndex, + ), + } + } + for claimIndex, rawClaim := range claims { + claim, ok := normalizeJSONObject(rawClaim) + if !ok { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].claims[%d] is not an object", + credentialIndex, + claimIndex, + ), + } + } + if _, exists := claim["id"]; exists { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].claims[%d] contains id", + credentialIndex, + claimIndex, + ), + } + } + path, ok := claim["path"].([]any) + if !ok || len(path) == 0 { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].claims[%d].path is not a non-empty array", + credentialIndex, + claimIndex, + ), + } + } + for pathIndex, segment := range path { + if value, ok := segment.(string); !ok || value == "" { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].claims[%d].path[%d] is not a non-empty string", + credentialIndex, + claimIndex, + pathIndex, + ), + } + } + } + } + if isEmptyDCQLValue(response[id]) { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf("vp_token has no presentation for credential query %q", id), + } + } + } + return Result{ + Status: StatusPass, + Message: "wallet matched claims without ids when claim_sets was absent", + } +} +func validateDuplicateClaimIDs(query map[string]any, responseValue any, errorValue any) Result { + credentials, ok := query["credentials"].([]any) + if !ok || len(credentials) == 0 { + return Result{Status: StatusFail, Message: "dcql_query does not contain credentials"} + } + foundDuplicate := false + for credentialIndex, rawCredential := range credentials { + credential, ok := normalizeJSONObject(rawCredential) + if !ok { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf("credentials[%d] is not an object", credentialIndex), + } + } + claims, ok := credential["claims"].([]any) + if !ok || len(claims) == 0 { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].claims is not a non-empty array", + credentialIndex, + ), + } + } + seen := make(map[string]struct{}, len(claims)) + for claimIndex, rawClaim := range claims { + claim, ok := normalizeJSONObject(rawClaim) + if !ok { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].claims[%d] is not an object", + credentialIndex, + claimIndex, + ), + } + } + id, ok := claim["id"].(string) + if !ok || id == "" { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].claims[%d].id is not a non-empty string", + credentialIndex, + claimIndex, + ), + } + } + if _, exists := seen[id]; exists { + foundDuplicate = true + } + seen[id] = struct{}{} + } + } + if !foundDuplicate { + return Result{ + Status: StatusFail, + Message: "no credential claims array contains a duplicate id", + } + } + if !isEmptyDCQLValue(responseValue) { + return Result{ + Status: StatusFail, + Message: "wallet returned a credential for duplicate claim ids", + } + } + if errorText, _ := errorValue.(string); errorText != invalidRequestError { + return Result{ + Status: StatusFail, + Message: "wallet did not return invalid_request for duplicate claim ids", + } + } + return Result{ + Status: StatusPass, + Message: "wallet rejected duplicate claim ids with invalid_request", + } +} +func validateEmptyClaimID(query map[string]any, responseValue any, errorValue any) Result { + credentials, ok := query["credentials"].([]any) + if !ok || len(credentials) == 0 { + return Result{Status: StatusFail, Message: "dcql_query does not contain credentials"} + } + foundEmpty := false + for credentialIndex, rawCredential := range credentials { + credential, ok := normalizeJSONObject(rawCredential) + if !ok { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf("credentials[%d] is not an object", credentialIndex), + } + } + claims, ok := credential["claims"].([]any) + if !ok || len(claims) == 0 { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].claims is not a non-empty array", + credentialIndex, + ), + } + } + for claimIndex, rawClaim := range claims { + claim, ok := normalizeJSONObject(rawClaim) + if !ok { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].claims[%d] is not an object", + credentialIndex, + claimIndex, + ), + } + } + idValue, exists := claim["id"] + if !exists { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].claims[%d].id is missing", + credentialIndex, + claimIndex, + ), + } + } + id, ok := idValue.(string) + if !ok { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].claims[%d].id is not a string", + credentialIndex, + claimIndex, + ), + } + } + if id == "" { + foundEmpty = true + } + } + } + if !foundEmpty { + return Result{Status: StatusFail, Message: "no claim id is empty"} + } + if !isEmptyDCQLValue(responseValue) { + return Result{ + Status: StatusFail, + Message: "wallet returned a credential for an empty claim id", + } + } + if errorText, _ := errorValue.(string); errorText != invalidRequestError { + return Result{ + Status: StatusFail, + Message: "wallet did not return invalid_request for an empty claim id", + } + } + return Result{ + Status: StatusPass, + Message: "wallet rejected an empty claim id with invalid_request", + } +} +func validateInvalidClaimIDCharacters( + query map[string]any, + responseValue any, + errorValue any, +) Result { + credentials, ok := query["credentials"].([]any) + if !ok || len(credentials) == 0 { + return Result{Status: StatusFail, Message: "dcql_query does not contain credentials"} + } + foundInvalid := false + for credentialIndex, rawCredential := range credentials { + credential, ok := normalizeJSONObject(rawCredential) + if !ok { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf("credentials[%d] is not an object", credentialIndex), + } + } + claims, ok := credential["claims"].([]any) + if !ok || len(claims) == 0 { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].claims is not a non-empty array", + credentialIndex, + ), + } + } + for claimIndex, rawClaim := range claims { + claim, ok := normalizeJSONObject(rawClaim) + if !ok { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].claims[%d] is not an object", + credentialIndex, + claimIndex, + ), + } + } + idValue, exists := claim["id"] + if !exists { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].claims[%d].id is missing", + credentialIndex, + claimIndex, + ), + } + } + id, ok := idValue.(string) + if !ok || id == "" { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].claims[%d].id is not a non-empty string", + credentialIndex, + claimIndex, + ), + } + } + if !dcqlIDPattern.MatchString(id) { + foundInvalid = true + } + } + } + if !foundInvalid { + return Result{Status: StatusFail, Message: "no claim id contains a forbidden character"} + } + if !isEmptyDCQLValue(responseValue) { + return Result{ + Status: StatusFail, + Message: "wallet returned a credential for a malformed claim id", + } + } + if errorText, _ := errorValue.(string); errorText != invalidRequestError { + return Result{ + Status: StatusFail, + Message: "wallet did not return invalid_request for a malformed claim id", + } + } + return Result{ + Status: StatusPass, + Message: "wallet rejected a malformed claim id with invalid_request", + } +} +func validateMissingClaimPath(query map[string]any, responseValue any, errorValue any) Result { + credentials, ok := query["credentials"].([]any) + if !ok || len(credentials) == 0 { + return Result{Status: StatusFail, Message: "dcql_query does not contain credentials"} + } + foundMissing := false + for credentialIndex, rawCredential := range credentials { + credential, ok := normalizeJSONObject(rawCredential) + if !ok { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf("credentials[%d] is not an object", credentialIndex), + } + } + claims, ok := credential["claims"].([]any) + if !ok || len(claims) == 0 { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].claims is not a non-empty array", + credentialIndex, + ), + } + } + for claimIndex, rawClaim := range claims { + claim, ok := normalizeJSONObject(rawClaim) + if !ok { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].claims[%d] is not an object", + credentialIndex, + claimIndex, + ), + } + } + if _, exists := claim["path"]; !exists { + foundMissing = true + } + } + } + if !foundMissing { + return Result{Status: StatusFail, Message: "no claim is missing path"} + } + if !isEmptyDCQLValue(responseValue) { + return Result{ + Status: StatusFail, + Message: "wallet returned a credential for a claim missing path", + } + } + if errorText, _ := errorValue.(string); errorText != invalidRequestError { + return Result{ + Status: StatusFail, + Message: "wallet did not return invalid_request for a claim missing path", + } + } + return Result{ + Status: StatusPass, + Message: "wallet rejected a claim missing path with invalid_request", + } +} +func validateEmptyClaimPath(query map[string]any, responseValue any, errorValue any) Result { + credentials, ok := query["credentials"].([]any) + if !ok || len(credentials) == 0 { + return Result{Status: StatusFail, Message: "dcql_query does not contain credentials"} + } + foundEmpty := false + for credentialIndex, rawCredential := range credentials { + credential, ok := normalizeJSONObject(rawCredential) + if !ok { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf("credentials[%d] is not an object", credentialIndex), + } + } + claims, ok := credential["claims"].([]any) + if !ok || len(claims) == 0 { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].claims is not a non-empty array", + credentialIndex, + ), + } + } + for claimIndex, rawClaim := range claims { + claim, ok := normalizeJSONObject(rawClaim) + if !ok { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].claims[%d] is not an object", + credentialIndex, + claimIndex, + ), + } + } + pathValue, exists := claim["path"] + if !exists { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].claims[%d].path is missing", + credentialIndex, + claimIndex, + ), + } + } + path, ok := pathValue.([]any) + if !ok { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].claims[%d].path is not an array", + credentialIndex, + claimIndex, + ), + } + } + if len(path) == 0 { + foundEmpty = true + } + } + } + if !foundEmpty { + return Result{Status: StatusFail, Message: "no claim path is empty"} + } + if !isEmptyDCQLValue(responseValue) { + return Result{ + Status: StatusFail, + Message: "wallet returned a credential for an empty claim path", + } + } + if errorText, _ := errorValue.(string); errorText != invalidRequestError { + return Result{ + Status: StatusFail, + Message: "wallet did not return invalid_request for an empty claim path", + } + } + return Result{ + Status: StatusPass, + Message: "wallet rejected an empty claim path with invalid_request", + } +} +func validateNonArrayClaimPath(query map[string]any, responseValue any, errorValue any) Result { + credentials, ok := query["credentials"].([]any) + if !ok || len(credentials) == 0 { + return Result{Status: StatusFail, Message: "dcql_query does not contain credentials"} + } + foundNonArray := false + for credentialIndex, rawCredential := range credentials { + credential, ok := normalizeJSONObject(rawCredential) + if !ok { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf("credentials[%d] is not an object", credentialIndex), + } + } + claims, ok := credential["claims"].([]any) + if !ok || len(claims) == 0 { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].claims is not a non-empty array", + credentialIndex, + ), + } + } + for claimIndex, rawClaim := range claims { + claim, ok := normalizeJSONObject(rawClaim) + if !ok { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].claims[%d] is not an object", + credentialIndex, + claimIndex, + ), + } + } + pathValue, exists := claim["path"] + if !exists { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].claims[%d].path is missing", + credentialIndex, + claimIndex, + ), + } + } + if _, ok := pathValue.([]any); !ok { + foundNonArray = true + } + } + } + if !foundNonArray { + return Result{Status: StatusFail, Message: "no claim path has a non-array value"} + } + if !isEmptyDCQLValue(responseValue) { + return Result{ + Status: StatusFail, + Message: "wallet returned a credential for a non-array claim path", + } + } + if errorText, _ := errorValue.(string); errorText != invalidRequestError { + return Result{ + Status: StatusFail, + Message: "wallet did not return invalid_request for a non-array claim path", + } + } + return Result{ + Status: StatusPass, + Message: "wallet rejected a non-array claim path with invalid_request", + } +} +func validateAllowedClaimPathComponents(query map[string]any, responseValue any) Result { + credentials, ok := query["credentials"].([]any) + if !ok || len(credentials) == 0 { + return Result{Status: StatusFail, Message: "dcql_query does not contain credentials"} + } + response, ok := normalizeJSONObject(responseValue) + if !ok { + return Result{ + Status: StatusFail, + Message: "wallet vp_token is not an object keyed by credential query ID", + } + } + seenString := false + seenNull := false + seenNonNegativeInteger := false + for credentialIndex, rawCredential := range credentials { + credential, ok := normalizeJSONObject(rawCredential) + if !ok { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf("credentials[%d] is not an object", credentialIndex), + } + } + id, ok := credential["id"].(string) + if !ok || id == "" { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].id is not a non-empty string", + credentialIndex, + ), + } + } + claims, ok := credential["claims"].([]any) + if !ok || len(claims) == 0 { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].claims is not a non-empty array", + credentialIndex, + ), + } + } + paths := make([][]any, 0, len(claims)) + for claimIndex, rawClaim := range claims { + claim, ok := normalizeJSONObject(rawClaim) + if !ok { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].claims[%d] is not an object", + credentialIndex, + claimIndex, + ), + } + } + path, ok := claim["path"].([]any) + if !ok || len(path) == 0 { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].claims[%d].path is not a non-empty array", + credentialIndex, + claimIndex, + ), + } + } + paths = append(paths, path) + for componentIndex, component := range path { + switch typed := component.(type) { + case string: + if typed == "" { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].claims[%d].path[%d] is an empty string", + credentialIndex, + claimIndex, + componentIndex, + ), + } + } + seenString = true + case nil: + seenNull = true + default: + if !isNonNegativeInteger(component) { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].claims[%d].path[%d] is not a string, null, or non-negative integer", + credentialIndex, + claimIndex, + componentIndex, + ), + } + } + seenNonNegativeInteger = true + } + } + } + presentations, ok := response[id].([]any) + if !ok || len(presentations) == 0 { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf("vp_token has no presentation for credential query %q", id), + } + } + for presentationIndex, rawPresentation := range presentations { + token, ok := rawPresentation.(string) + if !ok || token == "" { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "vp_token[%q][%d] is not an SD-JWT presentation", + id, + presentationIndex, + ), + } + } + presentation, err := evidence.ParseSDJWTPresentation(token) + if err != nil { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "vp_token[%q][%d] is not a valid SD-JWT presentation: %v", + id, + presentationIndex, + err, + ), + } + } + for pathIndex, path := range paths { + if !claimPathResolves(presentation.Claims, path) { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "vp_token[%q][%d] does not disclose a value resolved by claims[%d].path", + id, + presentationIndex, + pathIndex, + ), + } + } + } + } + } + if !seenString || !seenNull || !seenNonNegativeInteger { + return Result{ + Status: StatusFail, + Message: "claim paths do not cover string, null, and non-negative integer components", + } + } + return Result{ + Status: StatusPass, + Message: "wallet resolved claim paths with all allowed component types", + } +} +func claimPathResolves(root any, path []any) bool { + values := []any{root} + for _, component := range path { + next := make([]any, 0) + for _, value := range values { + switch typed := component.(type) { + case string: + object, ok := value.(map[string]any) + if !ok { + continue + } + if resolved, exists := object[typed]; exists { + next = append(next, resolved) + } + case nil: + array, ok := value.([]any) + if ok { + next = append(next, array...) + } + default: + array, ok := value.([]any) + if !ok { + continue + } + index, ok := claimPathArrayIndex(typed, len(array)) + if ok { + next = append(next, array[index]) + } + } + } + if len(next) == 0 { + return false + } + values = next + } + return len(values) > 0 +} +func validateClaimsWithoutValues(query map[string]any, responseValue any) Result { + credentials, ok := query["credentials"].([]any) + if !ok || len(credentials) == 0 { + return Result{Status: StatusFail, Message: "dcql_query does not contain credentials"} + } + response, ok := normalizeJSONObject(responseValue) + if !ok { + return Result{ + Status: StatusFail, + Message: "wallet vp_token is not an object keyed by credential query ID", + } + } + for credentialIndex, rawCredential := range credentials { + credential, ok := normalizeJSONObject(rawCredential) + if !ok { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf("credentials[%d] is not an object", credentialIndex), + } + } + id, ok := credential["id"].(string) + if !ok || id == "" { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].id is not a non-empty string", + credentialIndex, + ), + } + } + claims, ok := credential["claims"].([]any) + if !ok || len(claims) == 0 { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].claims is not a non-empty array", + credentialIndex, + ), + } + } + for claimIndex, rawClaim := range claims { + claim, ok := normalizeJSONObject(rawClaim) + if !ok { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].claims[%d] is not an object", + credentialIndex, + claimIndex, + ), + } + } + if _, exists := claim["values"]; exists { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].claims[%d] contains values", + credentialIndex, + claimIndex, + ), + } + } + if !nonEmptyStringArray(claim["path"]) { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].claims[%d].path is invalid", + credentialIndex, + claimIndex, + ), + } + } + } + if isEmptyDCQLValue(response[id]) { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf("vp_token has no presentation for credential query %q", id), + } + } + } + return Result{Status: StatusPass, Message: "wallet matched claims without values"} +} diff --git a/pkg/fcaf/validators/dcql_credentials.go b/pkg/fcaf/validators/dcql_credentials.go new file mode 100644 index 000000000..cc13899de --- /dev/null +++ b/pkg/fcaf/validators/dcql_credentials.go @@ -0,0 +1,171 @@ +// SPDX-FileCopyrightText: 2026 Forkbomb BV +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package validators + +import "fmt" + +func validateCredentialSetsOptions( + query map[string]any, + responseValue, errorValue any, + mode string, +) Result { + credentials, ok := query["credentials"].([]any) + sets, setsOK := query["credential_sets"].([]any) + if !ok || len(credentials) == 0 || !setsOK || len(sets) == 0 { + return Result{ + Status: StatusFail, + Message: "dcql_query must contain credentials and credential_sets", + } + } + ids := make(map[string]struct{}, len(credentials)) + for _, raw := range credentials { + credential, ok := normalizeJSONObject(raw) + if !ok { + return Result{Status: StatusFail, Message: "dcql credential is not an object"} + } + id, ok := credential["id"].(string) + if !ok || id == "" { + return Result{Status: StatusFail, Message: "dcql credential id is invalid"} + } + ids[id] = struct{}{} + } + invalid := false + for _, raw := range sets { + set, ok := normalizeJSONObject(raw) + if !ok { + invalid = true + continue + } + options, exists := set["options"] + if !exists { + invalid = true + continue + } + groups, ok := options.([]any) + if mode == "credential_sets_options_non_array" { + if ok { + return Result{Status: StatusFail, Message: "credential_sets.options is an array"} + } + invalid = true + continue + } + if !ok || len(groups) == 0 { + invalid = true + continue + } + for _, rawGroup := range groups { + group, ok := rawGroup.([]any) + if !ok || len(group) == 0 { + invalid = true + continue + } + for _, rawID := range group { + id, ok := rawID.(string) + if !ok { + invalid = true + continue + } + if _, found := ids[id]; !found { + invalid = true + } + } + } + } + if mode == "credential_sets_options_valid_references" && invalid { + return Result{ + Status: StatusFail, + Message: "credential_sets.options contains invalid references", + } + } + if mode == "credential_sets_options_invalid_references" && !invalid { + return Result{ + Status: StatusFail, + Message: "credential_sets.options contains no invalid references", + } + } + if mode == "credential_sets_options_empty" && !invalid { + return Result{Status: StatusFail, Message: "credential_sets.options is non-empty"} + } + if mode == "credential_sets_options_non_array" || mode == "credential_sets_options_empty" || + mode == "credential_sets_options_invalid_references" { + if !isEmptyDCQLValue(responseValue) { + return Result{ + Status: StatusFail, + Message: "wallet returned a vp_token for an invalid credential_sets.options query", + } + } + if (mode == "credential_sets_options_empty" || mode == "credential_sets_options_non_array") && + errorValue != invalidRequestError { + return Result{ + Status: StatusFail, + Message: "wallet did not return invalid_request for an invalid credential_sets.options query", + } + } + return Result{ + Status: StatusPass, + Message: "wallet rejected invalid credential_sets.options", + } + } + if isEmptyDCQLValue(responseValue) { + return Result{ + Status: StatusFail, + Message: "wallet returned no vp_token for valid credential_sets.options references", + } + } + return Result{ + Status: StatusPass, + Message: "wallet processed valid credential_sets.options references", + } +} +func validateCredentialSetsRequired(query map[string]any, responseValue any, mode string) Result { + sets, ok := query["credential_sets"].([]any) + if !ok || len(sets) == 0 { + return Result{Status: StatusFail, Message: "dcql_query does not contain credential_sets"} + } + response, responseOK := normalizeJSONObject(responseValue) + for index, rawSet := range sets { + set, ok := normalizeJSONObject(rawSet) + if !ok { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf("credential_sets[%d] is not an object", index), + } + } + required, exists := set["required"] + if mode == "credential_sets_required_true_match" && (!exists || required != true) { + return Result{Status: StatusFail, Message: "required is not true"} + } + if mode == "credential_sets_required_true_no_match" && (!exists || required != true) { + return Result{Status: StatusFail, Message: "required is not true"} + } + if mode == "credential_sets_required_omitted" && exists { + return Result{Status: StatusFail, Message: "required is present"} + } + if mode == "credential_sets_required_false_with_match" && required != false { + return Result{Status: StatusFail, Message: "required is not false"} + } + } + if mode == "credential_sets_required_true_match" || + mode == "credential_sets_required_omitted" || + mode == "credential_sets_required_false_with_match" { + if !responseOK || isEmptyDCQLValue(response) { + return Result{ + Status: StatusFail, + Message: "wallet returned no vp_token for a satisfiable credential set", + } + } + return Result{Status: StatusPass, Message: "wallet presented the credential set"} + } + if !isEmptyDCQLValue(responseValue) { + return Result{ + Status: StatusFail, + Message: "wallet returned a presentation for a missing required credential set", + } + } + return Result{ + Status: StatusPass, + Message: "wallet stopped without presenting a missing required credential set", + } +} diff --git a/pkg/fcaf/validators/dcql_response.go b/pkg/fcaf/validators/dcql_response.go new file mode 100644 index 000000000..29bce52f2 --- /dev/null +++ b/pkg/fcaf/validators/dcql_response.go @@ -0,0 +1,165 @@ +// SPDX-FileCopyrightText: 2026 Forkbomb BV +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package validators + +import ( + "fmt" +) + +func validateClaimPathMemberTypeError(responseValue, errorValue any) Result { + if errStr := normalizeString(errorValue); errStr != "" { + if errStr == invalidRequestError { + return Result{ + Status: StatusPass, + Message: fmt.Sprintf( + "wallet returned %s for invalid claim-path member type", + errStr, + ), + } + } + return Result{ + Status: StatusPass, + Message: fmt.Sprintf( + "wallet returned error %s for invalid claim-path member type", + errStr, + ), + } + } + if !isEmptyDCQLValue(responseValue) { + return Result{ + Status: StatusFail, + Message: "wallet returned vp_token for query with invalid claim-path member type", + } + } + return Result{ + Status: StatusPass, + Message: "wallet did not return vp_token for invalid claim-path member type", + } +} +func validateWalletErrorExpected(responseValue, errorValue, expected any) Result { + if errStr := normalizeString(errorValue); errStr != "" { + if expected != nil { + expectedStr, ok := expected.(string) + if ok && errStr == expectedStr { + return Result{ + Status: StatusPass, + Message: fmt.Sprintf("wallet returned expected error %s", errStr), + } + } + if ok { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf("wallet returned %s, expected %s", errStr, expectedStr), + } + } + } + return Result{Status: StatusPass, Message: fmt.Sprintf("wallet returned error %s", errStr)} + } + if !isEmptyDCQLValue(responseValue) { + return Result{Status: StatusFail, Message: "wallet returned vp_token, expected error"} + } + return Result{ + Status: StatusPass, + Message: "wallet did not return vp_token (expected error case)", + } +} +func validateErrorCode(responseValue, errorValue any, expectedCode string) Result { + if errStr := normalizeString(errorValue); errStr != "" { + if errStr == expectedCode { + return Result{ + Status: StatusPass, + Message: fmt.Sprintf("wallet returned expected error %s", expectedCode), + } + } + return Result{ + Status: StatusFail, + Message: fmt.Sprintf("wallet returned error %s, expected %s", errStr, expectedCode), + } + } + if isEmptyDCQLValue(responseValue) { + return Result{ + Status: StatusPass, + Message: fmt.Sprintf("wallet did not return vp_token for %s case", expectedCode), + } + } + return Result{ + Status: StatusFail, + Message: fmt.Sprintf("wallet returned vp_token, expected error %s", expectedCode), + } +} +func validateUnknownFieldStripped(query, responseValue any) Result { + if errStr := normalizeString(query); errStr != "" { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "wallet returned error %s for unknown field (should have been stripped)", + errStr, + ), + } + } + if isEmptyDCQLValue(responseValue) { + return Result{ + Status: StatusFail, + Message: "wallet returned no vp_token for request with unknown fields", + } + } + return Result{ + Status: StatusPass, + Message: "wallet accepted request with unknown fields stripped", + } +} +func validateJWEEncVerified(responseValue any) Result { + resp, _ := normalizeJSONObject(responseValue) + if len(resp) == 0 { + return Result{ + Status: StatusFail, + Message: "wallet returned empty vp_token, cannot verify JWE enc", + } + } + if _, exists := resp["response"]; exists { + return Result{Status: StatusPass, Message: "wallet response contains JWE response evidence"} + } + for _, v := range resp { + if str, ok := v.(string); ok && len(str) > 0 { + parts := 0 + for _, c := range str { + if c == '.' { + parts++ + } + } + if parts == 4 { + return Result{Status: StatusPass, Message: "wallet response contains compact JWE"} + } + } + } + return Result{Status: StatusPass, Message: "wallet returned response evidence"} +} +func validateSessionEncryption(responseValue any) Result { + if isEmptyDCQLValue(responseValue) { + return Result{Status: StatusFail, Message: "wallet returned no session encryption evidence"} + } + return Result{Status: StatusPass, Message: "wallet returned session encryption evidence"} +} +func validateEncoding(responseValue any) Result { + if isEmptyDCQLValue(responseValue) { + return Result{Status: StatusFail, Message: "wallet returned no encoding evidence"} + } + return Result{Status: StatusPass, Message: "wallet returned encoding evidence"} +} +func validateInteractionCompleted(responseValue any) Result { + if isEmptyDCQLValue(responseValue) { + return Result{Status: StatusFail, Message: "wallet interaction did not complete"} + } + return Result{Status: StatusPass, Message: "wallet interaction completed successfully"} +} +func validateEvidencePresent(responseValue, errorValue any) Result { + if errStr := normalizeString(errorValue); errStr != "" { + return Result{Status: StatusFail, Message: fmt.Sprintf("wallet returned error: %s", errStr)} + } + if isEmptyDCQLValue(responseValue) { + return Result{Status: StatusFail, Message: "wallet returned no evidence"} + } + return Result{Status: StatusPass, Message: "wallet evidence present"} +} diff --git a/pkg/fcaf/validators/dcql_trusted_authorities.go b/pkg/fcaf/validators/dcql_trusted_authorities.go new file mode 100644 index 000000000..e59e716c2 --- /dev/null +++ b/pkg/fcaf/validators/dcql_trusted_authorities.go @@ -0,0 +1,241 @@ +// SPDX-FileCopyrightText: 2026 Forkbomb BV +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package validators + +import ( + "crypto/x509" + "encoding/base64" + "fmt" + + "github.com/forkbombeu/credimi/pkg/fcaf/evidence" +) + +func validateTrustedAuthoritiesMatch(query map[string]any, responseValue any) Result { + credentials, ok := query["credentials"].([]any) + if !ok || len(credentials) == 0 { + return Result{Status: StatusFail, Message: "dcql_query does not contain credentials"} + } + if err := validateDCQLCredentialQueries(credentials); err != nil { + return Result{Status: StatusFail, Message: err.Error()} + } + response, ok := normalizeJSONObject(responseValue) + if !ok { + return Result{ + Status: StatusFail, + Message: "wallet vp_token is not an object keyed by credential query ID", + } + } + for credentialIndex, rawCredential := range credentials { + credential, _ := normalizeJSONObject(rawCredential) + id, _ := credential["id"].(string) + presentations, ok := response[id].([]any) + if !ok || len(presentations) == 0 { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf("vp_token has no presentation for credential query %q", id), + } + } + authorities, hasTA := credential["trusted_authorities"].([]any) + if !hasTA || len(authorities) == 0 { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d] does not contain trusted_authorities", + credentialIndex, + ), + } + } + for presentationIndex, rawPresentation := range presentations { + token, ok := rawPresentation.(string) + if !ok || token == "" { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "vp_token[%q][%d] is not an SD-JWT presentation", + id, + presentationIndex, + ), + } + } + presentation, err := evidence.ParseSDJWTPresentation(token) + if err != nil { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "vp_token[%q][%d] is not a valid SD-JWT: %v", + id, + presentationIndex, + err, + ), + } + } + if !credentialMatchesTrustedAuthorities(presentation, authorities) { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "vp_token[%q][%d] issuer does not match any trusted_authority", + id, + presentationIndex, + ), + } + } + } + } + return Result{ + Status: StatusPass, + Message: "every returned credential issuer matches at least one trusted_authority", + } +} +func validateTrustedAuthoritiesNoMatch(query map[string]any, responseValue any) Result { + credentials, ok := query["credentials"].([]any) + if !ok || len(credentials) == 0 { + return Result{Status: StatusFail, Message: "dcql_query does not contain credentials"} + } + if err := validateDCQLCredentialQueries(credentials); err != nil { + return Result{Status: StatusFail, Message: err.Error()} + } + for index, rawCredential := range credentials { + credential, _ := normalizeJSONObject(rawCredential) + authorities, ok := credential["trusted_authorities"].([]any) + if !ok || len(authorities) == 0 { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf("credentials[%d] does not contain trusted_authorities", index), + } + } + for authorityIndex, rawAuthority := range authorities { + authority, ok := normalizeJSONObject(rawAuthority) + if !ok || authority["type"] != "aki" { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].trusted_authorities[%d] is not a valid aki authority", + index, + authorityIndex, + ), + } + } + values, ok := authority["values"].([]any) + if !ok || len(values) == 0 { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].trusted_authorities[%d].values is empty", + index, + authorityIndex, + ), + } + } + for valueIndex, rawValue := range values { + value, ok := rawValue.(string) + if !ok || value == "" { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].trusted_authorities[%d].values[%d] is not a string", + index, + authorityIndex, + valueIndex, + ), + } + } + decoded, err := base64.RawURLEncoding.DecodeString(value) + if err != nil || len(decoded) == 0 { + return Result{ + Status: StatusFail, + Message: fmt.Sprintf( + "credentials[%d].trusted_authorities[%d].values[%d] is not base64url", + index, + authorityIndex, + valueIndex, + ), + } + } + } + } + } + if !isEmptyDCQLValue(responseValue) { + return Result{ + Status: StatusFail, + Message: "wallet returned a credential for an unmatched trusted_authorities query", + } + } + return Result{ + Status: StatusPass, + Message: "wallet returned no credential for valid unmatched trusted_authorities", + } +} +func credentialMatchesTrustedAuthorities( + presentation *evidence.SDJWTPresentation, + authorities []any, +) bool { + for _, rawAuthority := range authorities { + authority, ok := normalizeJSONObject(rawAuthority) + if !ok { + continue + } + authType, _ := authority["type"].(string) + if authType == "" { + continue + } + values, _ := authority["values"].([]any) + if len(values) == 0 { + continue + } + switch authType { + case "aki": + if sdjwtMatchesAKI(presentation, values) { + return true + } + default: + if sdjwtMatchesIssuerClaim(presentation, values) { + return true + } + } + } + return false +} +func sdjwtMatchesAKI(presentation *evidence.SDJWTPresentation, values []any) bool { + rawChain, ok := presentation.ProtectedHeaders["x5c"].([]any) + if !ok || len(rawChain) == 0 { + return false + } + encoded, ok := rawChain[0].(string) + if !ok || encoded == "" { + return false + } + der, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return false + } + cert, err := x509.ParseCertificate(der) + if err != nil { + return false + } + if len(cert.AuthorityKeyId) == 0 { + return false + } + encodedAKI := base64.RawURLEncoding.EncodeToString(cert.AuthorityKeyId) + for _, rawValue := range values { + value, ok := rawValue.(string) + if ok && value == encodedAKI { + return true + } + } + return false +} +func sdjwtMatchesIssuerClaim(presentation *evidence.SDJWTPresentation, values []any) bool { + iss, _ := presentation.IssuerPayload["iss"].(string) + if iss == "" { + return false + } + for _, rawValue := range values { + value, ok := rawValue.(string) + if ok && value == iss { + return true + } + } + return false +} diff --git a/pkg/fcaf/validators/dcql_types.go b/pkg/fcaf/validators/dcql_types.go new file mode 100644 index 000000000..a4832047a --- /dev/null +++ b/pkg/fcaf/validators/dcql_types.go @@ -0,0 +1,256 @@ +// SPDX-FileCopyrightText: 2026 Forkbomb BV +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package validators + +import ( + "encoding/json" + "fmt" +) + +func supportedJSONType(expected string) bool { + switch expected { + case "boolean", "string", "number", "integer", "array", "object", "null": + return true + default: + return false + } +} +func matchesJSONType(value any, expected string) bool { + switch expected { + case "boolean": + _, ok := value.(bool) + return ok + case "string": + _, ok := value.(string) + return ok + case "number": + switch value.(type) { + case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64: + return true + default: + return false + } + case "integer": + switch typed := value.(type) { + case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: + return true + case float32: + return typed == float32(int64(typed)) + case float64: + return typed == float64(int64(typed)) + default: + return false + } + case "array": + _, ok := value.([]any) + return ok + case "object": + _, ok := normalizeJSONObject(value) + return ok + case "null": + return value == nil + default: + return false + } +} +func validateDCQLCredentialQueries(credentials []any) error { + ids := make(map[string]struct{}, len(credentials)) + for index, rawCredential := range credentials { + credential, ok := normalizeJSONObject(rawCredential) + if !ok { + return fmt.Errorf("credentials[%d] is not an object", index) + } + id, _ := credential["id"].(string) + if !dcqlIDPattern.MatchString(id) { + return fmt.Errorf("credentials[%d].id is not a valid DCQL identifier", index) + } + if _, duplicate := ids[id]; duplicate { + return fmt.Errorf("credentials[%d].id %q is duplicated", index, id) + } + ids[id] = struct{}{} + + format, _ := credential["format"].(string) + if format == "" { + return fmt.Errorf("credentials[%d].format is missing", index) + } + meta, ok := normalizeJSONObject(credential["meta"]) + if !ok { + return fmt.Errorf("credentials[%d].meta is not an object", index) + } + switch format { + case "dc+sd-jwt": + if !nonEmptyStringArray(meta["vct_values"]) { + return fmt.Errorf( + "credentials[%d].meta.vct_values is not a non-empty string array", + index, + ) + } + case "mso_mdoc": + docType, _ := meta["doctype_value"].(string) + if docType == "" { + return fmt.Errorf("credentials[%d].meta.doctype_value is missing", index) + } + default: + return fmt.Errorf("credentials[%d].format %q is not supported", index, format) + } + if claims, exists := credential["claims"]; exists { + items, ok := claims.([]any) + if !ok || len(items) == 0 { + return fmt.Errorf("credentials[%d].claims is not a non-empty array", index) + } + for claimIndex, rawClaim := range items { + claim, ok := normalizeJSONObject(rawClaim) + if !ok || !nonEmptyStringArray(claim["path"]) { + return fmt.Errorf( + "credentials[%d].claims[%d].path is invalid", + index, + claimIndex, + ) + } + } + } + } + return nil +} +func nonEmptyStringArray(value any) bool { + items, ok := value.([]any) + if !ok || len(items) == 0 { + return false + } + for _, item := range items { + text, ok := item.(string) + if !ok || text == "" { + return false + } + } + return true +} +func normalizeJSONObject(value any) (map[string]any, bool) { + if object, ok := value.(map[string]any); ok { + return object, true + } + text, ok := value.(string) + if !ok { + return nil, false + } + var object map[string]any + if err := json.Unmarshal([]byte(text), &object); err != nil { + return nil, false + } + return object, true +} +func findObjectKey(value any, key string) (any, bool) { + object, ok := normalizeJSONObject(value) + if !ok { + return nil, false + } + if found, exists := object[key]; exists { + return found, true + } + for _, child := range object { + if found, exists := findObjectKey(child, key); exists { + return found, true + } + if array, ok := child.([]any); ok { + for _, item := range array { + if found, exists := findObjectKey(item, key); exists { + return found, true + } + } + } + } + return nil, false +} +func containsClaimSets(credentials []any) bool { + for _, rawCredential := range credentials { + credential, ok := normalizeJSONObject(rawCredential) + if !ok { + continue + } + claimSets, ok := credential["claim_sets"].([]any) + if ok && len(claimSets) > 0 { + return true + } + } + return false +} +func isEmptyDCQLValue(value any) bool { + switch typed := value.(type) { + case nil: + return true + case string: + return typed == "" + case []any: + return len(typed) == 0 + case map[string]any: + return len(typed) == 0 + default: + return false + } +} +func claimPathArrayIndex(value any, length int) (int, bool) { + if !isNonNegativeInteger(value) { + return 0, false + } + var index uint64 + switch typed := value.(type) { + case int: + index = uint64(typed) + case int8: + index = uint64(typed) + case int16: + index = uint64(typed) + case int32: + index = uint64(typed) + case int64: + index = uint64(typed) + case uint: + index = uint64(typed) + case uint8: + index = uint64(typed) + case uint16: + index = uint64(typed) + case uint32: + index = uint64(typed) + case uint64: + index = typed + case float32: + index = uint64(typed) + case float64: + index = uint64(typed) + default: + return 0, false + } + if index >= uint64(length) { + return 0, false + } + return int(index), true +} +func isNonNegativeInteger(value any) bool { + switch typed := value.(type) { + case int: + return typed >= 0 + case int8: + return typed >= 0 + case int16: + return typed >= 0 + case int32: + return typed >= 0 + case int64: + return typed >= 0 + case uint, uint8, uint16, uint32, uint64: + return true + case float32: + return typed >= 0 && typed == float32(int64(typed)) + case float64: + return typed >= 0 && typed == float64(int64(typed)) + default: + return false + } +} +func normalizeString(v any) string { + s, _ := v.(string) + return s +} From a958f3af9ae32a601cc6fece83465de2639fb9ab Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:27:02 +0000 Subject: [PATCH 3/4] test(fcaf): cover extracted dcql helpers Co-authored-by: puria <10379+puria@users.noreply.github.com> --- pkg/fcaf/validators/dcql_refactor_test.go | 32 +++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 pkg/fcaf/validators/dcql_refactor_test.go diff --git a/pkg/fcaf/validators/dcql_refactor_test.go b/pkg/fcaf/validators/dcql_refactor_test.go new file mode 100644 index 000000000..693aecc82 --- /dev/null +++ b/pkg/fcaf/validators/dcql_refactor_test.go @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: 2026 Forkbomb BV +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package validators + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNormalizeJSONObjectAcceptsJSONText(t *testing.T) { + value, ok := normalizeJSONObject(`{"dcql_query":{"credentials":[]}}`) + + require.True(t, ok) + require.Equal(t, map[string]any{ + "dcql_query": map[string]any{"credentials": []any{}}, + }, value) +} + +func TestValidateDCQLCredentialQueriesRejectsDuplicateIDs(t *testing.T) { + credential := map[string]any{ + "id": "pid", + "format": "dc+sd-jwt", + "meta": map[string]any{"vct_values": []any{"urn:eudi:pid:1"}}, + } + + err := validateDCQLCredentialQueries([]any{credential, credential}) + + require.EqualError(t, err, `credentials[1].id "pid" is duplicated`) +} From 8dfcf70f5c3e32aaf96273f4e0bbed856d914b12 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 9 Sep 2026 08:34:40 +0000 Subject: [PATCH 4/4] Apply remaining changes Co-authored-by: puria <10379+puria@users.noreply.github.com> --- go.mod | 3 --- go.sum | 57 --------------------------------------------------------- 2 files changed, 60 deletions(-) diff --git a/go.mod b/go.mod index 2aedd4c95..2ecd0976c 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,6 @@ require ( github.com/SherClockHolmes/webpush-go v1.4.0 github.com/antchfx/htmlquery v1.3.6 github.com/forkbombeu/credimi-conformance-assessment v1.3.1 - github.com/forkbombeu/credimi-extra v1.14.4 github.com/forkbombeu/eudi-conformance-evidence v1.0.2 github.com/fxamacker/cbor/v2 v2.9.0 github.com/go-ozzo/ozzo-validation/v4 v4.3.0 @@ -133,7 +132,6 @@ require ( github.com/fatih/structtag v1.2.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/firefart/nonamedreturns v1.0.6 // indirect - github.com/forkbombeu/avdctl v0.10.3 // indirect github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/fzipp/gocyclo v0.6.0 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect @@ -308,7 +306,6 @@ require ( go.opentelemetry.io/otel v1.44.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect - go.opentelemetry.io/otel/log v0.18.0 // indirect go.opentelemetry.io/otel/metric v1.44.0 // indirect go.opentelemetry.io/otel/sdk v1.44.0 // indirect go.opentelemetry.io/otel/trace v1.44.0 // indirect diff --git a/go.sum b/go.sum index 9ae39be33..0334ff926 100644 --- a/go.sum +++ b/go.sum @@ -364,8 +364,6 @@ dev.gaijin.team/go/exhaustruct/v4 v4.0.0/go.mod h1:aZ/k2o4Y05aMJtiux15x8iXaumE88 dev.gaijin.team/go/golib v0.6.0 h1:v6nnznFTs4bppib/NyU1PQxobwDHwCXXl15P7DV5Zgo= dev.gaijin.team/go/golib v0.6.0/go.mod h1:uY1mShx8Z/aNHWDyAkZTkX+uCi5PdX7KsG1eDQa2AVE= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= -filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= -filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= gioui.org v0.0.0-20210308172011-57750fc8a0a6/go.mod h1:RSH6KIUZ0p2xy5zHDxgAM4zumjgTw83q2ge/PI+yyw8= git.sr.ht/~sbinet/gg v0.3.1/go.mod h1:KGYtlADtqsqANL9ueOFkWymvzUvLMQllU5Ixo+8v3pc= github.com/4meepo/tagalign v1.4.3 h1:Bnu7jGWwbfpAie2vyl63Zup5KuRv21olsPIha53BJr8= @@ -436,7 +434,6 @@ github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGW github.com/alecthomas/assert/v2 v2.2.2/go.mod h1:pXcQ2Asjp247dahGEmsZ6ru0UVwnkhktn7S0bBDLxvQ= github.com/alecthomas/assert/v2 v2.3.0/go.mod h1:pXcQ2Asjp247dahGEmsZ6ru0UVwnkhktn7S0bBDLxvQ= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= -github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.24.1 h1:m5ffpfZbIb++k8AqFEKy9uVgY12xIQtBsQlc6DfZJQM= github.com/alecthomas/chroma/v2 v2.24.1/go.mod h1:l+ohZ9xRXIbGe7cIW+YZgOGbvuVLjMps/FYN/CwuabI= github.com/alecthomas/go-check-sumtype v0.3.1 h1:u9aUvbGINJxLVXiFvHUlPEaD7VDULsrxJb4Aq31NLkU= @@ -445,7 +442,6 @@ github.com/alecthomas/participle/v2 v2.0.0/go.mod h1:rAKZdJldHu8084ojcWevWAL8KmE github.com/alecthomas/participle/v2 v2.1.0/go.mod h1:Y1+hAs8DHPmc3YUFzqllV+eSQ9ljPTk0ZkPMtEdAx2c= github.com/alecthomas/repr v0.2.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= -github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= @@ -494,9 +490,6 @@ github.com/bombsimon/wsl/v4 v4.7.0/go.mod h1:uV/+6BkffuzSAVYD+yGyld1AChO7/EuLrCF github.com/bombsimon/wsl/v5 v5.8.0 h1:JTkyfs4yl8SPejrCF2GdABXE+mO1WvM7iUYzRWlsxDs= github.com/bombsimon/wsl/v5 v5.8.0/go.mod h1:AbOLsulgkqP4ZnitHf9gwPtCOGlrzkk0jb0uNxRSY0o= github.com/bool64/dev v0.2.39 h1:kP8DnMGlWXhGYJEZE/J0l/gVBdbuhoPGL+MJG4QbofE= -github.com/bool64/dev v0.2.39/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= -github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= -github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs= github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/breml/bidichk v0.3.3 h1:WSM67ztRusf1sMoqH6/c4OBCUlRVTKq+CbSeo0R17sE= @@ -592,7 +585,6 @@ github.com/daixiang0/gci v0.13.7/go.mod h1:812WVN6JLFY9S6Tv76twqmNqevN0pa3SX3nih github.com/dave/dst v0.27.3 h1:P1HPoMza3cMEquVf9kKy8yXsFirry4zEnWOdYPOoIzY= github.com/dave/dst v0.27.3/go.mod h1:jHh6EOibnHgcUW3WjKHisiooEkYwqpHLBSX1iOBhEyc= github.com/dave/jennifer v1.7.1 h1:B4jJJDHelWcDhlRQxWeo0Npa/pYKBLrirAQoTN45txo= -github.com/dave/jennifer v1.7.1/go.mod h1:nXbxhEmQfOZhWml3D1cDK5M1FLnMSozpbFN/m3RmGZc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -671,16 +663,11 @@ github.com/firefart/nonamedreturns v1.0.6 h1:vmiBcKV/3EqKY3ZiPxCINmpS431OcE1S47A github.com/firefart/nonamedreturns v1.0.6/go.mod h1:R8NisJnSIpvPWheCq0mNRXJok6D8h7fagJTF8EMEwCo= github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= -github.com/forkbombeu/avdctl v0.10.3 h1:DmBy0VPnp1+BIrgq7+kQBN5UadLe//JXvKetpEFP24g= -github.com/forkbombeu/avdctl v0.10.3/go.mod h1:dvjgfYAJ1lbxk56F4tazRCiOBOC+/D23Ok/tNBm2H1I= github.com/forkbombeu/credimi-conformance-assessment v1.3.1 h1:SfKWEl3Wgegex76xiJT9SmhyJbUGHlUKC9s5OfK8+SU= github.com/forkbombeu/credimi-conformance-assessment v1.3.1/go.mod h1:1L+2vRyjOt1CN0MoYgsPG5q6lA5tmbTalc48EtxXfLo= -github.com/forkbombeu/credimi-extra v1.14.4 h1:2ZPutUSO6fFbFycgW0HRXMIk47Jr7h9eYWz69POVr90= -github.com/forkbombeu/credimi-extra v1.14.4/go.mod h1:g/MfDxFIHMfNqlw696g1JjoG8fuzWd+L/INRHrRZ320= github.com/forkbombeu/eudi-conformance-evidence v1.0.2 h1:EtGUTYi+3aAWDEFrn6jt10KEFGjDxDU3SJTxgEbU29w= github.com/forkbombeu/eudi-conformance-evidence v1.0.2/go.mod h1:APm92tPag1I5LPtMYny5EN1mqMw+YHO8THOCoAbVQ3M= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= -github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= @@ -730,7 +717,6 @@ github.com/go-pdf/fpdf v0.9.0 h1:PPvSaUuo1iMi9KkaAn90NuKi+P4gwMedWPHhj8YlJQw= github.com/go-pdf/fpdf v0.9.0/go.mod h1:oO8N111TkmKb9D7VvWGLvLJlaZUQVPM+6V42pp3iV4Y= github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= -github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= @@ -741,7 +727,6 @@ github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= -github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q= github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= github.com/go-sprout/sprout v1.0.0 h1:4uxG1fZbUxfXB2OsjwzyIOK4lZQEhsksib17vVAZqOs= @@ -750,8 +735,6 @@ github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= -github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= -github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-toolsmith/astcast v1.1.0 h1:+JN9xZV1A+Re+95pgnMgDboWNVnIMMQXwfBwLRPgSC8= github.com/go-toolsmith/astcast v1.1.0/go.mod h1:qdcuFWeGGS2xX5bLM/c3U9lewg7+Zu4mr+xPwZIB4ZU= github.com/go-toolsmith/astcopy v1.1.0 h1:YGwBN0WM+ekI/6SS6+52zLDEf8Yvp3n2seZITCUBt5s= @@ -765,7 +748,6 @@ github.com/go-toolsmith/astfmt v1.1.0/go.mod h1:OrcLlRwu0CuiIBp/8b5PYF9ktGVZUjlN github.com/go-toolsmith/astp v1.1.0 h1:dXPuCl6u2llURjdPLLDxJeZInAeZ0/eZwFJmqZMnpQA= github.com/go-toolsmith/astp v1.1.0/go.mod h1:0T1xFGz9hicKs8Z5MfAqSUitoUYS30pDMsRVIDHs8CA= github.com/go-toolsmith/pkgload v1.2.2 h1:0CtmHq/02QhxcF7E9N5LIFcYFsMR5rdovfqTtRKkgIk= -github.com/go-toolsmith/pkgload v1.2.2/go.mod h1:R2hxLNRKuAsiXCo2i5J6ZQPhnPMOVtU+f0arbFPWCus= github.com/go-toolsmith/strparse v1.0.0/go.mod h1:YI2nUKP9YGZnL/L1/DLFBfixrcjslWct4wyljWhSRy8= github.com/go-toolsmith/strparse v1.1.0 h1:GAioeZUK9TGxnLS+qfdqNbA4z0SSm5zVNtCQiyP2Bvw= github.com/go-toolsmith/strparse v1.1.0/go.mod h1:7ksGy58fsaQkGQlY8WVoBFNyEPMGuJin1rfoPS4lBSQ= @@ -869,7 +851,6 @@ github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl76 github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/flatbuffers v23.5.26+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786 h1:rcv+Ippz6RAtvaGgKxc+8FQIpxHgsF+HBzPyYL2cyVU= -github.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786/go.mod h1:apVn/GCasLZUVpAJ6oWAuyP7Ne7CEsQbTnc0plM3m+o= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -915,7 +896,6 @@ github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLe github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc= github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= -github.com/google/renameio v0.1.0 h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/s2a-go v0.1.0/go.mod h1:OJpEgntRZo8ugHpF9hkoLJbS5dSI20XZeXJ9JVywLlM= github.com/google/s2a-go v0.1.3/go.mod h1:Ej+mSEMGRnqRzjc7VtF+jdBwYG5fuJfiZ8ELkjEwM0A= @@ -986,7 +966,6 @@ github.com/gostaticanalysis/nilerr v0.1.2 h1:S6nk8a9N8g062nsx63kUkF6AzbHGw7zzyHM github.com/gostaticanalysis/nilerr v0.1.2/go.mod h1:A19UHhoY3y8ahoL7YKz6sdjDtduwTSI4CsymaC2htPA= github.com/gostaticanalysis/testutil v0.3.1-0.20210208050101-bfb5c8eec0e4/go.mod h1:D+FIZ+7OahH3ePw/izIEeH5I06eKs1IKI4Xr64/Am3M= github.com/gostaticanalysis/testutil v0.5.0 h1:Dq4wT1DdTwTGCQQv3rl3IvD5Ld0E6HiY+3Zh0sUGqw8= -github.com/gostaticanalysis/testutil v0.5.0/go.mod h1:OLQSbuM6zw2EvCcXTz1lVq5unyoNft372msDY0nY5Hs= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 h1:sGm2vDRFUrQJO/Veii4h4zG2vvqG6uWNkBHSTqXOZk0= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2/go.mod h1:wd1YpapPLivG6nQgbf7ZkG1hhSOXDhhn4MLTknx2aAc= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= @@ -999,7 +978,6 @@ github.com/hamba/avro/v2 v2.17.2/go.mod h1:Q9YK+qxAhtVrNqOhwlZTATLgLA8qxG2vtvkhK github.com/hashicorp/go-immutable-radix/v2 v2.1.0 h1:CUW5RYIcysz+D3B+l1mDeXrQ7fUvGGCwJfdASSzbrfo= github.com/hashicorp/go-immutable-radix/v2 v2.1.0/go.mod h1:hgdqLXA4f6NIjRVisM1TJ9aOJVNRqKZj+xDGF6m7PBw= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= -github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= @@ -1011,8 +989,6 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= -github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc= -github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE= github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= @@ -1063,11 +1039,9 @@ github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kulti/thelper v0.7.1 h1:fI8QITAoFVLx+y+vSyuLBP+rcVIB8jKooNSCT2EiI98= github.com/kulti/thelper v0.7.1/go.mod h1:NsMjfQEy6sd+9Kfw8kCP61W1I0nerGSYSFnGaxQkcbs= @@ -1188,16 +1162,12 @@ github.com/nishanths/predeclared v0.2.2/go.mod h1:RROzoN6TnGQupbC+lqggsOlcgysk3L github.com/nunnatsa/ginkgolinter v0.23.0 h1:x3o4DGYOWbBMP/VdNQKgSj+25aJKx2Pe6lHr8gBcgf8= github.com/nunnatsa/ginkgolinter v0.23.0/go.mod h1:9qN1+0akwXEccwV1CAcCDfcoBlWXHB+ML9884pL4SZ4= github.com/onsi/ginkgo/v2 v2.28.2 h1:DTrMfpqxiNUyQ3Y0zhn1n3cOO2euFgQPYIpkWwxVFps= -github.com/onsi/ginkgo/v2 v2.28.2/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= -github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw= -github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU= -github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w= github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE= github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs= github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo= @@ -1304,7 +1274,6 @@ github.com/sashamelentyev/usestdlibvars v1.29.0/go.mod h1:8PpnjHMk5VdeWlVb4wCdrB github.com/securego/gosec/v2 v2.26.1 h1:gdkttGhQFVehqRJ8grKH4DrpqM/QlPKNHBnl8QgcEC4= github.com/securego/gosec/v2 v2.26.1/go.mod h1:57UW4p0uoP3kxoTkhoo3axLdVAi+OWrLg/Ax/kdqtPE= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= -github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= @@ -1370,16 +1339,13 @@ github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= github.com/substrait-io/substrait-go v0.4.2/go.mod h1:qhpnLmrcvAnlZsUyPXZRqldiHapPTXC3t7xFgDi3aQg= github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7o2xQ= -github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU= github.com/swaggest/jsonschema-go v0.3.74 h1:hkAZBK3RxNWU013kPqj0Q/GHGzYCCm9WcUTnfg2yPp0= github.com/swaggest/jsonschema-go v0.3.74/go.mod h1:qp+Ym2DIXHlHzch3HKz50gPf2wJhKOrAB/VYqLS2oJU= github.com/swaggest/openapi-go v0.2.60 h1:kglHH/WIfqAglfuWL4tu0LPakqNYySzklUWx06SjSKo= github.com/swaggest/openapi-go v0.2.60/go.mod h1:jmFOuYdsWGtHU0BOuILlHZQJxLqHiAE6en+baE+QQUk= github.com/swaggest/refl v1.3.1 h1:XGplEkYftR7p9cz1lsiwXMM2yzmOymTE9vneVVpaOh4= github.com/swaggest/refl v1.3.1/go.mod h1:4uUVFVfPJ0NSX9FPwMPspeHos9wPFlCMGoPRllUbpvA= -github.com/tenntenn/modver v1.0.1 h1:2klLppGhDgzJrScMpkj9Ujy3rXPUspSjAcev9tSEBgA= github.com/tenntenn/modver v1.0.1/go.mod h1:bePIyQPb7UeioSRkw3Q0XeMhYZSMx9B8ePqg6SAMGH0= -github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3 h1:f+jULpRQGxTSkNYKJ51yaw6ChIqO+Je8UqsTKN/cDag= github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3/go.mod h1:ON8b8w4BN/kE1EOhwT0o+d62W65a6aPw1nouo9LMgyY= github.com/tetafro/godot v1.5.6 h1:IEkrFCwXaYHlOn4mGzGS3F3dkP6m9t0jpwqBFPIkKiA= github.com/tetafro/godot v1.5.6/go.mod h1:eOkMrVQurDui411nBY2FA05EYH01r14LuWY/NrVDVcU= @@ -1420,9 +1386,6 @@ github.com/yeya24/promlinter v0.3.0/go.mod h1:cDfJQQYv9uYciW60QT0eeHlFodotkYZlL+ github.com/ykadowak/zerologlint v0.1.5 h1:Gy/fMz1dFQN9JZTPjv1hxEk+sRWm05row04Yoolgdiw= github.com/ykadowak/zerologlint v0.1.5/go.mod h1:KaUskqF3e/v59oPmdq1U1DnKcuHokl2/K1U4pmIELKg= github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= -github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= -github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= -github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -1436,7 +1399,6 @@ github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaD gitlab.com/bosi/decorder v0.4.2 h1:qbQaV3zgwnBZ4zPMhGLW4KZe7A7NwxEhJx39R3shffo= gitlab.com/bosi/decorder v0.4.2/go.mod h1:muuhHoaJkA9QLcYHq4Mj8FJUwDZ+EirSHRiaTcTf6T8= go-simpler.org/assert v0.9.0 h1:PfpmcSvL7yAnWyChSjOz6Sp6m9j5lyK8Ok9pEL31YkQ= -go-simpler.org/assert v0.9.0/go.mod h1:74Eqh5eI6vCK6Y5l3PI8ZYFXG4Sa+tkr70OIPJAUr28= go-simpler.org/musttag v0.14.0 h1:XGySZATqQYSEV3/YTy+iX+aofbZZllJaqwFWs+RTtSo= go-simpler.org/musttag v0.14.0/go.mod h1:uP8EymctQjJ4Z1kUnjX0u2l60WfUdQxCwSNKzE1JEOE= go-simpler.org/sloglint v0.12.0 h1:UzWDlLWNE5FLqsvyq3tWYHuQMbqrervOhT8qPl4Mmw4= @@ -1511,8 +1473,6 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.29.0/go.mod h1:BLbf7zbNIONBLPwvFnwNHGj4zge8uTCM/UPIVW1Mq2I= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0/go.mod h1:U2R3XyVPzn0WX7wOIypPuptulsMcPDPs/oiSVOMVnHY= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0/go.mod h1:dowW6UsM9MKbJq5JTz2AMVp3/5iW5I/TStsk8S+CfHw= -go.opentelemetry.io/otel/log v0.18.0 h1:XgeQIIBjZZrliksMEbcwMZefoOSMI1hdjiLEiiB0bAg= -go.opentelemetry.io/otel/log v0.18.0/go.mod h1:KEV1kad0NofR3ycsiDH4Yjcoj0+8206I6Ox2QYFSNgI= go.opentelemetry.io/otel/metric v1.21.0/go.mod h1:o1p3CA8nNHW8j5yuQLdc1eeqEaPfzug24uvsyIEJRWM= go.opentelemetry.io/otel/metric v1.22.0/go.mod h1:evJGjVpZv0mQ5QBRJoBF64yMuOf4xCWdXjK8pzFvliY= go.opentelemetry.io/otel/metric v1.23.0/go.mod h1:MqUW2X2a6Q8RN96E2/nqNoT+z9BSms20Jb7Bbp+HiTo= @@ -1552,7 +1512,6 @@ go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6Yv go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= -go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= go.opentelemetry.io/otel/trace v1.21.0/go.mod h1:LGbsEB0f9LGjN+OZaQQ26sohbOmiMR+BaslueVtS/qQ= go.opentelemetry.io/otel/trace v1.22.0/go.mod h1:RbbHXVqKES9QhzZq/fE5UnOSILqRt40a21sPw2He1xo= go.opentelemetry.io/otel/trace v1.23.0/go.mod h1:GSGTbIClEsuZrGIzoEHqsVfxgn5UkggkflQwDScNUsk= @@ -2188,9 +2147,7 @@ golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= -golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= -golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= golang.org/x/vuln v1.4.0 h1:FpmTZiV4PyqY3lFfuCkz1JftEXb/+8M2NEkjJM5TF4g= golang.org/x/vuln v1.4.0/go.mod h1:FJ7XyKs83nAdxQ7PMsia2PoynwZMJ/QajXVMBBIgFe8= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -2208,7 +2165,6 @@ gonum.org/v1/gonum v0.8.2/go.mod h1:oe/vMfY3deqTw+1EZJhuvEW2iwGF1bW9wwu7XCu0+v0= gonum.org/v1/gonum v0.9.3/go.mod h1:TZumC3NeyVQskjXqmyWt4S3bINhy7B4eYwW69EbyX+0= gonum.org/v1/gonum v0.12.0/go.mod h1:73TDxJfAAHeA8Mk9mf8NlIppyhQNo5GLTcYeqgo2lvY= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= -gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw= gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b/go.mod h1:Wt8AAjI+ypCyYX3nZBvf6cAIx93T+c/OS2HFAYskSZc= gonum.org/v1/plot v0.9.0/go.mod h1:3Pcqqmp6RHvJI72kgb8fThyUnav364FOsdDo2aGW5lY= @@ -2694,18 +2650,13 @@ modernc.org/cc/v3 v3.37.0/go.mod h1:vtL+3mdHx/wcj3iEGz84rQa8vEqR6XM84v5Lcvfph20= modernc.org/cc/v3 v3.38.1/go.mod h1:vtL+3mdHx/wcj3iEGz84rQa8vEqR6XM84v5Lcvfph20= modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0= modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0= -modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v3 v3.0.0-20220904174949-82d86e1b6d56/go.mod h1:YSXjPL62P2AMSxBphRHPn7IkzhVHqkvOnRKAKh+W6ZI= modernc.org/ccgo/v3 v3.0.0-20220910160915-348f15de615a/go.mod h1:8p47QxPkdugex9J4n9P2tLZ9bK01yngIVp00g4nomW0= modernc.org/ccgo/v3 v3.16.13-0.20221017192402-261537637ce8/go.mod h1:fUB3Vn0nVPReA+7IG7yZDfjv1TMWjhQP8gCxrFAtL5g= modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY= modernc.org/ccgo/v4 v4.23.16 h1:Z2N+kk38b7SfySC1ZkpGLN2vthNJP1+ZzGZIlH7uBxo= -modernc.org/ccgo/v4 v4.23.16/go.mod h1:nNma8goMTY7aQZQNTyN9AIoJfxav4nvTnvKThAeMDdo= modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ= modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= -modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= -modernc.org/gc/v2 v2.6.3 h1:aJVhcqAte49LF+mGveZ5KPlsp4tdGdAOT4sipJXADjw= -modernc.org/gc/v2 v2.6.3/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM= modernc.org/libc v1.17.4/go.mod h1:WNg2ZH56rDEwdropAJeZPQkXmDwh+JCA1s/htl6r2fA= modernc.org/libc v1.18.0/go.mod h1:vj6zehR5bfc98ipowQOM2nIDUZnVew/wNC/2tOGS+q0= @@ -2726,19 +2677,12 @@ modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g= modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= -modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= -modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= -modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= -modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sqlite v1.21.2/go.mod h1:cxbLkB5WS32DnQqeH4h4o1B0eMr8W/y8/RGuxQ3JsC0= modernc.org/sqlite v1.36.2 h1:vjcSazuoFve9Wm0IVNHgmJECoOXLZM1KfMXbcX2axHA= modernc.org/sqlite v1.36.2/go.mod h1:ADySlx7K4FdY5MaJcEv86hTJ0PjedAloTUuif0YS3ws= modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw= -modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= -modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/tcl v1.15.1/go.mod h1:aEjeGJX2gz1oWKOLDVZ2tnEWLUrIn8H+GFu+akoDhqs= modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= -modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= modernc.org/z v1.7.0/go.mod h1:hVdgNMh8ggTuRG1rGU8x+xGRFfiQUIAw0ZqlPy8+HyQ= mvdan.cc/gofumpt v0.9.2 h1:zsEMWL8SVKGHNztrx6uZrXdp7AX8r421Vvp23sz7ik4= @@ -2746,7 +2690,6 @@ mvdan.cc/gofumpt v0.9.2/go.mod h1:iB7Hn+ai8lPvofHd9ZFGVg2GOr8sBUw1QUWjNbmIL/s= mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15 h1:ssMzja7PDPJV8FStj7hq9IKiuiKhgz9ErWw+m68e7DI= mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15/go.mod h1:4M5MMXl2kW6fivUT6yRGpLLPNfuGtU2Z0cPvFquGDYU= pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= -pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=