From 11049dc8608635eded37949a856b1ff182ccc5c2 Mon Sep 17 00:00:00 2001 From: Leif Johansson Date: Wed, 14 Oct 2026 11:36:15 +0200 Subject: [PATCH 01/10] authzen: return verifier identifiers in the evaluation response context Add an optional, informational context member verifier_ids: a list of {verifier_id_type, verifier_id, basis} entries the PDP derives from authenticated material, so a wallet answering a circuit_zk request with a pseudonym function does not have to parse certificates, resolve DIDs or walk federation chains itself. Off by default (policy constraints.return_verifier_ids; unknown type names are rejected at config load). A request context member verifier_id_type narrows the response to one type and can never enable one. Types: semantic (access certificate only), dns and uri (x509_san_dns / x509_san_uri, only after VerifyLeafBinding), did (resolved document + key match), oidf_entity (validated trust chain + key match). did_scid, service and spki_sha256 are deferred. Fail closed: nothing is emitted for a deny, when profile validation failed, or when the identifier was not verified. An unverified client_id is never echoed. semantic is validated against the semantic-id syntax and its type must fit the subject; a serialNumber that is not a semantic identifier is not emitted. For intermediary requests semantic comes from the RP leaf and dns/uri are omitted. Narrow fix in WRPACProfile.ExtractIdentity: a natural-person certificate (NCP-n/QCP-n) keeps its serialNumber as the person's identifier (ETSI TS 119 475 5.1.5) instead of relabelling it organization_identifier and classifying the certificate legal_person when it names an affiliated organisation. Refs #203 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01VS8tGFQncGyEhFwkQecjPB --- README.md | 44 +++ cmd/gt/main.go | 8 + cmd/gt/policy_config_test.go | 1 + docs/CONFIGURATION.md | 1 + docs/swagger/docs.go | 30 +- docs/swagger/swagger.json | 30 +- docs/swagger/swagger.yaml | 30 +- example/config.yaml | 7 + pkg/authzen/types.go | 84 ++++- pkg/authzen/verifierids_test.go | 41 +++ pkg/config/config.go | 25 ++ pkg/config/verifierids_test.go | 64 ++++ pkg/registry/did/resolver.go | 6 +- pkg/registry/did/verifierid_test.go | 51 +++ pkg/registry/didjwks/didjwks_registry.go | 6 +- pkg/registry/didjwks/verifierid_test.go | 59 ++++ pkg/registry/didweb/didweb_registry.go | 6 +- pkg/registry/didweb/verifierid_test.go | 82 +++++ pkg/registry/didwebvh/didwebvh_registry.go | 6 +- pkg/registry/manager.go | 11 + pkg/registry/oidfed/oidfed_registry.go | 12 +- pkg/registry/oidfed/verifierid_test.go | 98 ++++++ pkg/registry/policy.go | 21 ++ pkg/registry/policy_verifierids_test.go | 17 + pkg/registry/rpcert/semanticid.go | 127 +++++++ pkg/registry/rpcert/semanticid_test.go | 129 +++++++ pkg/registry/rpcert/wrpac.go | 13 +- pkg/registry/verifierid.go | 94 +++++ pkg/registry/verifierid_manager_test.go | 114 ++++++ pkg/registry/verifierid_test.go | 388 +++++++++++++++++++++ pkg/registry/x5c_enrichment.go | 87 +++++ pkg/testserver/verifierids_test.go | 48 +++ 32 files changed, 1725 insertions(+), 15 deletions(-) create mode 100644 pkg/authzen/verifierids_test.go create mode 100644 pkg/config/verifierids_test.go create mode 100644 pkg/registry/did/verifierid_test.go create mode 100644 pkg/registry/didjwks/verifierid_test.go create mode 100644 pkg/registry/didweb/verifierid_test.go create mode 100644 pkg/registry/oidfed/verifierid_test.go create mode 100644 pkg/registry/policy_verifierids_test.go create mode 100644 pkg/registry/rpcert/semanticid.go create mode 100644 pkg/registry/rpcert/semanticid_test.go create mode 100644 pkg/registry/verifierid.go create mode 100644 pkg/registry/verifierid_manager_test.go create mode 100644 pkg/registry/verifierid_test.go create mode 100644 pkg/testserver/verifierids_test.go diff --git a/README.md b/README.md index d1e9028d..c471a462 100644 --- a/README.md +++ b/README.md @@ -821,6 +821,50 @@ When enabled, the response includes the relying party's identity extracted from X5C enrichment is supported by both ETSI TSL and LoTE registries. +### Verifier Identifiers (`verifier_ids`) + +A wallet that answers a request with a pseudonym function needs a stable *verifier identifier* string for the relying party. Go-Trust can return those in the evaluation response `context.verifier_ids`, derived only from material it has authenticated, so the wallet does not have to parse certificates, resolve DIDs or walk federation chains itself. The member is informational (never critical) and **off by default**: existing responses are unchanged unless a policy enables it. + +```yaml +policies: + policies: + credential-verifier: + constraints: + return_verifier_ids: [semantic, dns] # unknown type names are rejected at startup +``` + +```json +{ + "decision": true, + "context": { + "verifier_ids": [ + {"verifier_id_type": "semantic", "verifier_id": "semantic_id:VATSE-556000000101", "basis": "x5c-chain"}, + {"verifier_id_type": "dns", "verifier_id": "rp.example.com", "basis": "client-id-binding"} + ] + } +} +``` + +The response lists everything the policy enables and go-trust can derive; a type it cannot establish is omitted. A request may narrow the response to one type with the optional request `context` member `verifier_id_type` (for example `"context": {"verifier_id_type": "dns"}`). The hint only narrows: it cannot enable a type the policy did not. Clients treat `verifier_id_type` as opaque; the order of entries carries no meaning. + +Types go-trust can derive (the exact value form is fixed per type): + +| Type | Value | Basis | Registries | +|---|---|---|---| +| `semantic` | `semantic_id:` + the ETSI semantic identifier from the **access certificate** leaf (ETSI TS 119 475 5.1.3 legal persons: `organizationIdentifier`; 5.1.5 natural persons: `serialNumber`). Must match `<3 letters><2 letters>-`; the value is kept verbatim. | `x5c-chain` | x5c registries (`etsi`, `lote`) | +| `dns` | the `client_id` of an `x509_san_dns` request, lower-case, no trailing dot | `client-id-binding` | x5c registries | +| `uri` | the `client_id` of an `x509_san_uri` request, byte-for-byte (no case folding, no slash normalisation, not re-serialised through `net/url`) | `client-id-binding` | x5c registries | +| `did` | the full DID of the resolved DID document, without fragment | `did-resolution` | `didweb`, `didwebvh`, `didjwks`, `did` | +| `oidf_entity` | the Entity ID as in the validated trust chain, byte-for-byte | `oidf-trust-chain` | `oidfed` | + +Rules: + +- **Fail closed.** Nothing is emitted for a deny decision, when the matched profile's validation failed (normally only a warning), or when the identifier was not verified: `dns`/`uri` need `VerifyLeafBinding` to pass, `did` needs a successful resolution and a matching key, `oidf_entity` needs a validated trust chain and a matching key. An unverified `client_id` is never echoed. Resolution-only responses carry no `verifier_ids`. +- **`semantic` comes from the access certificate only.** The registration certificate is not an input today, so wallets must not assume registration-certificate precedence. A `serialNumber` that is not a semantic identifier (for example a plain certificate serial) is never emitted as `semantic`. For a certificate classified by its WRPAC policy OIDs, the type must fit the subject: natural-person types (`PNO`, `IDC`, `PAS`, `TAX`, `TIN`) are read from `serialNumber` of natural-person certificates only, and refused for legal-person ones. +- **Intermediary requests** (`allow_intermediaries`): the identifier is the end relying party's. `semantic` is taken from the RP leaf; `dns` and `uri` are omitted because the `client_id` of a brokered request cannot be shown to name the end relying party. The intermediary's own, unvalidated chain is never a source. +- The member is carried through by the strategies that return the matching registry's own response (`first_match`, `sequential`); the aggregating `all` strategy and composite registries build a new response and do not carry it. +- Not offered: `did_scid`, `service` and `spki_sha256` are not implemented yet and are rejected in `return_verifier_ids`. + ## Deployment ### Docker diff --git a/cmd/gt/main.go b/cmd/gt/main.go index dfdd213a..aef149db 100644 --- a/cmd/gt/main.go +++ b/cmd/gt/main.go @@ -1305,6 +1305,7 @@ func configurePoliciesFromConfig(cfg *config.Config, registryMgr *registry.Regis policy.Constraints = registry.PolicyConstraints{ AllowedKeyTypes: policyCfg.Constraints.AllowedKeyTypes, RequireKeyBinding: policyCfg.Constraints.RequireKeyBinding, + ReturnVerifierIDs: policyCfg.Constraints.ReturnVerifierIDs, } } @@ -1373,6 +1374,13 @@ func configurePoliciesFromConfig(cfg *config.Config, registryMgr *registry.Regis } } + // config.Validate rejects unknown verifier id types at startup; this + // keeps any other caller from installing an unvalidated policy. + if err := policy.Constraints.Validate(); err != nil { + logger.Fatal("Invalid policy constraints", + logging.F("policy", name), logging.F("error", err.Error())) + } + policyMgr.RegisterPolicy(policy) policyCount++ diff --git a/cmd/gt/policy_config_test.go b/cmd/gt/policy_config_test.go index fa6a50e6..2a36dcbf 100644 --- a/cmd/gt/policy_config_test.go +++ b/cmd/gt/policy_config_test.go @@ -92,6 +92,7 @@ func TestConfigurePoliciesFromConfigCopiesEveryField(t *testing.T) { Constraints: &config.PolicyConstraintsConfig{ AllowedKeyTypes: []string{"x5c"}, RequireKeyBinding: true, + ReturnVerifierIDs: []string{"semantic", "dns"}, }, ETSI: &config.ETSIPolicyConfig{ ServiceTypes: []string{"http://uri.etsi.org/TrstSvc/Svctype/CA/QC"}, diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 759bb660..4c405d14 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -73,6 +73,7 @@ A few `server` settings can also be set via CLI flag (`gt -host`, `-port`, `-ext | `policies.policies..registries` | — | string list | Registries limits evaluation to specific registry names. If empty, all registries are considered. | | `policies.policies..constraints.require_key_binding` | — | boolean | RequireKeyBinding requires that a key be provided and validated. | | `policies.policies..constraints.allowed_key_types` | — | string list | AllowedKeyTypes restricts accepted key types (e.g., ["x5c", "jwk"]) | +| `policies.policies..constraints.return_verifier_ids` | — | string list | ReturnVerifierIDs lists the verifier identifier types to return in the evaluation response context member verifier_ids. Supported types: semantic, dns, uri, did, oidf_entity. Empty (default) returns none. Unknown names are rejected at startup. | | `policies.policies..oidfed.required_trust_marks` | — | string list | RequiredTrustMarks specifies trust mark types that MUST be present | | `policies.policies..oidfed.entity_types` | — | string list | EntityTypes filters by OpenID Federation entity types | | `policies.policies..oidfed.max_chain_depth` | — | integer | MaxChainDepth limits trust chain resolution depth | diff --git a/docs/swagger/docs.go b/docs/swagger/docs.go index 5935d77d..e6afe9dc 100644 --- a/docs/swagger/docs.go +++ b/docs/swagger/docs.go @@ -274,7 +274,7 @@ const docTemplate = `{ ] }, "context": { - "description": "Optional context (MUST NOT be critical)", + "description": "Optional context (MUST NOT be critical). Recognised member: verifier_id_type narrows context.verifier_ids in the response to that one type", "type": "object", "additionalProperties": { "type": "string" @@ -333,6 +333,13 @@ const docTemplate = `{ "trust_metadata": { "description": "TrustMetadata contains the resolved trust metadata for the subject.\nFor DID resolution: contains the DID Document\nFor OpenID Federation: contains the Entity Configuration\nThis field is set when trust_metadata is requested or for resolution-only requests.", "type": "object" + }, + "verifier_ids": { + "description": "VerifierIDs lists verifier identifiers the PDP derived from authenticated\nmaterial, for wallets that need a stable identifier string for the relying\nparty (for example as input to a pseudonym function). It is present only\nwhen the policy enables it (constraints.return_verifier_ids), the decision\nis true, and the identifier was verified. Informational, never critical.", + "type": "array", + "items": { + "$ref": "#/definitions/authzen.VerifierID" + } } } }, @@ -421,6 +428,27 @@ const docTemplate = `{ "example": "key" } } + }, + "authzen.VerifierID": { + "description": "A verifier identifier derived by the PDP from authenticated material", + "type": "object", + "properties": { + "basis": { + "description": "Basis states how the value was verified, for example \"x5c-chain\",\n\"client-id-binding\", \"did-resolution\" or \"oidf-trust-chain\".", + "type": "string", + "example": "x5c-chain" + }, + "verifier_id": { + "description": "ID is the identifier in the canonical form defined for the type.", + "type": "string", + "example": "semantic_id:VATSE-556000000101" + }, + "verifier_id_type": { + "description": "Type is an opaque type string, for example \"semantic\" or \"dns\".", + "type": "string", + "example": "semantic" + } + } } }, "tags": [ diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index bbf22d3d..2584023e 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -294,7 +294,7 @@ ] }, "context": { - "description": "Optional context (MUST NOT be critical)", + "description": "Optional context (MUST NOT be critical). Recognised member: verifier_id_type narrows context.verifier_ids in the response to that one type", "type": "object", "additionalProperties": { "type": "string" @@ -353,6 +353,13 @@ "trust_metadata": { "description": "TrustMetadata contains the resolved trust metadata for the subject.\nFor DID resolution: contains the DID Document\nFor OpenID Federation: contains the Entity Configuration\nThis field is set when trust_metadata is requested or for resolution-only requests.", "type": "object" + }, + "verifier_ids": { + "description": "VerifierIDs lists verifier identifiers the PDP derived from authenticated\nmaterial, for wallets that need a stable identifier string for the relying\nparty (for example as input to a pseudonym function). It is present only\nwhen the policy enables it (constraints.return_verifier_ids), the decision\nis true, and the identifier was verified. Informational, never critical.", + "type": "array", + "items": { + "$ref": "#/definitions/authzen.VerifierID" + } } } }, @@ -441,6 +448,27 @@ "example": "key" } } + }, + "authzen.VerifierID": { + "description": "A verifier identifier derived by the PDP from authenticated material", + "type": "object", + "properties": { + "basis": { + "description": "Basis states how the value was verified, for example \"x5c-chain\",\n\"client-id-binding\", \"did-resolution\" or \"oidf-trust-chain\".", + "type": "string", + "example": "x5c-chain" + }, + "verifier_id": { + "description": "ID is the identifier in the canonical form defined for the type.", + "type": "string", + "example": "semantic_id:VATSE-556000000101" + }, + "verifier_id_type": { + "description": "Type is an opaque type string, for example \"semantic\" or \"dns\".", + "type": "string", + "example": "semantic" + } + } } }, "tags": [ diff --git a/docs/swagger/swagger.yaml b/docs/swagger/swagger.yaml index 21a81f61..a977a126 100644 --- a/docs/swagger/swagger.yaml +++ b/docs/swagger/swagger.yaml @@ -46,7 +46,7 @@ definitions: context: additionalProperties: type: string - description: Optional context (MUST NOT be critical) + description: Optional context (MUST NOT be critical). Recognised member: verifier_id_type narrows context.verifier_ids in the response to that one type type: object resource: allOf: @@ -86,6 +86,16 @@ definitions: For OpenID Federation: contains the Entity Configuration This field is set when trust_metadata is requested or for resolution-only requests. type: object + verifier_ids: + description: |- + VerifierIDs lists verifier identifiers the PDP derived from authenticated + material, for wallets that need a stable identifier string for the relying + party (for example as input to a pseudonym function). It is present only + when the policy enables it (constraints.return_verifier_ids), the decision + is true, and the identifier was verified. Informational, never critical. + items: + $ref: '#/definitions/authzen.VerifierID' + type: array type: object authzen.PDPMetadata: description: Policy Decision Point metadata for service discovery @@ -165,6 +175,24 @@ definitions: example: key type: string type: object + authzen.VerifierID: + description: A verifier identifier derived by the PDP from authenticated material + properties: + basis: + description: |- + Basis states how the value was verified, for example "x5c-chain", + "client-id-binding", "did-resolution" or "oidf-trust-chain". + example: x5c-chain + type: string + verifier_id: + description: ID is the identifier in the canonical form defined for the type. + example: semantic_id:VATSE-556000000101 + type: string + verifier_id_type: + description: Type is an opaque type string, for example "semantic" or "dns". + example: semantic + type: string + type: object host: localhost:6001 info: contact: diff --git a/example/config.yaml b/example/config.yaml index a25ace95..96be7aac 100644 --- a/example/config.yaml +++ b/example/config.yaml @@ -539,6 +539,13 @@ policies: # # Reject resolution-only requests, which return resolved metadata # # without any key having been presented. # require_key_binding: true + # # Verifier identifiers returned in the response context + # # (verifier_ids), for wallets that need a stable identifier for the + # # relying party. Off by default. Types: semantic (access certificate + # # only), dns, uri, did, oidf_entity. Unknown names are rejected at + # # startup. A request can narrow the response to one type with + # # context.verifier_id_type. + # return_verifier_ids: [semantic, dns] etsi: service_types: diff --git a/pkg/authzen/types.go b/pkg/authzen/types.go index 1b4856a5..48f73b75 100644 --- a/pkg/authzen/types.go +++ b/pkg/authzen/types.go @@ -55,7 +55,7 @@ type EvaluationRequest struct { Subject Subject `json:"subject"` // The name to be bound to the key Resource Resource `json:"resource"` // The public key to be validated Action *Action `json:"action,omitempty"` // Optional role constraint - Context map[string]interface{} `json:"context,omitempty" swaggertype:"object,string"` // Optional context (MUST NOT be critical) + Context map[string]interface{} `json:"context,omitempty" swaggertype:"object,string"` // Optional context (MUST NOT be critical). Recognised member: verifier_id_type narrows context.verifier_ids in the response to that one type } // EvaluationResponse represents the authorization decision response in the AuthZEN protocol. @@ -78,6 +78,88 @@ type EvaluationResponseContext struct { // For OpenID Federation: contains the Entity Configuration // This field is set when trust_metadata is requested or for resolution-only requests. TrustMetadata interface{} `json:"trust_metadata,omitempty" swaggertype:"object"` // DID Document or Entity Configuration + // VerifierIDs lists verifier identifiers the PDP derived from authenticated + // material, for wallets that need a stable identifier string for the relying + // party (for example as input to a pseudonym function). It is present only + // when the policy enables it (constraints.return_verifier_ids), the decision + // is true, and the identifier was verified. Informational, never critical. + VerifierIDs []VerifierID `json:"verifier_ids,omitempty"` // Verified verifier identifiers +} + +// Verifier identifier types a PDP can return in EvaluationResponseContext.VerifierIDs. +// Clients treat the type string as opaque; the PDP defines the exact form of each +// value (see VerifierID). +const ( + // VerifierIDTypeSemantic is "semantic_id:" followed by the ETSI semantic + // identifier (EN 319 412-1 5.1.3) from the access certificate. + VerifierIDTypeSemantic = "semantic" + // VerifierIDTypeDNS is the dNSName SAN value bound by x509_san_dns, + // lower-cased and without a trailing dot. + VerifierIDTypeDNS = "dns" + // VerifierIDTypeURI is the uniformResourceIdentifier SAN value bound by + // x509_san_uri, byte-for-byte as bound. + VerifierIDTypeURI = "uri" + // VerifierIDTypeDID is the full DID (without fragment) of a resolved DID + // document whose verification method matched the presented key. + VerifierIDTypeDID = "did" + // VerifierIDTypeOIDFEntity is the OpenID Federation Entity ID (https URL), + // byte-for-byte as in the validated trust chain. + VerifierIDTypeOIDFEntity = "oidf_entity" +) + +// Basis values describe how a verifier identifier was verified. +const ( + VerifierIDBasisX5CChain = "x5c-chain" + VerifierIDBasisClientIDBinding = "client-id-binding" + VerifierIDBasisDIDResolution = "did-resolution" + VerifierIDBasisOIDFTrustChain = "oidf-trust-chain" +) + +// VerifierIDSemanticPrefix precedes the ETSI semantic identifier in a +// "semantic" verifier identifier. +const VerifierIDSemanticPrefix = "semantic_id:" + +// Request/context keys of the verifier_ids feature. +const ( + // VerifierIDHintContextKey is the optional request context member that + // narrows the response to one verifier identifier type. + VerifierIDHintContextKey = "verifier_id_type" + // VerifierIDsPolicyContextKey carries the policy's enabled types to the + // registries. Server-controlled: a client cannot set it. + VerifierIDsPolicyContextKey = "return_verifier_ids" +) + +// SupportedVerifierIDTypes lists the verifier identifier types go-trust can derive. +func SupportedVerifierIDTypes() []string { + return []string{ + VerifierIDTypeSemantic, + VerifierIDTypeDNS, + VerifierIDTypeURI, + VerifierIDTypeDID, + VerifierIDTypeOIDFEntity, + } +} + +// IsSupportedVerifierIDType reports whether t is a type go-trust can derive. +func IsSupportedVerifierIDType(t string) bool { + for _, s := range SupportedVerifierIDTypes() { + if s == t { + return true + } + } + return false +} + +// VerifierID is one verifier identifier in the evaluation response context. +// @Description A verifier identifier derived by the PDP from authenticated material +type VerifierID struct { + // Type is an opaque type string, for example "semantic" or "dns". + Type string `json:"verifier_id_type" example:"semantic"` + // ID is the identifier in the canonical form defined for the type. + ID string `json:"verifier_id" example:"semantic_id:VATSE-556000000101"` + // Basis states how the value was verified, for example "x5c-chain", + // "client-id-binding", "did-resolution" or "oidf-trust-chain". + Basis string `json:"basis,omitempty" example:"x5c-chain"` } // Validate checks if the EvaluationRequest is compliant with the AuthZEN Trust Registry Profile. diff --git a/pkg/authzen/verifierids_test.go b/pkg/authzen/verifierids_test.go new file mode 100644 index 00000000..b486db3e --- /dev/null +++ b/pkg/authzen/verifierids_test.go @@ -0,0 +1,41 @@ +package authzen + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVerifierIDsJSONShape(t *testing.T) { + resp := EvaluationResponse{Decision: true, Context: &EvaluationResponseContext{ + VerifierIDs: []VerifierID{ + {Type: VerifierIDTypeSemantic, ID: "semantic_id:VATSE-556000000101", Basis: VerifierIDBasisX5CChain}, + {Type: VerifierIDTypeDNS, ID: "rp.example.com"}, + }, + }} + b, err := json.Marshal(resp) + require.NoError(t, err) + assert.JSONEq(t, `{"decision":true,"context":{"verifier_ids":[ + {"verifier_id_type":"semantic","verifier_id":"semantic_id:VATSE-556000000101","basis":"x5c-chain"}, + {"verifier_id_type":"dns","verifier_id":"rp.example.com"}]}}`, string(b), "basis is optional") + + // Omitted entirely when empty, so existing responses are byte-identical. + b, err = json.Marshal(EvaluationResponse{Decision: true, Context: &EvaluationResponseContext{ID: "x"}}) + require.NoError(t, err) + assert.NotContains(t, string(b), "verifier_ids") + + var back EvaluationResponse + require.NoError(t, json.Unmarshal([]byte(`{"decision":true,"context":{"verifier_ids":[{"verifier_id_type":"did","verifier_id":"did:web:x"}]}}`), &back)) + assert.Equal(t, []VerifierID{{Type: "did", ID: "did:web:x"}}, back.Context.VerifierIDs) +} + +func TestSupportedVerifierIDTypes(t *testing.T) { + for _, ty := range SupportedVerifierIDTypes() { + assert.True(t, IsSupportedVerifierIDType(ty), ty) + } + for _, ty := range []string{"", "DNS", "did_scid", "service", "spki_sha256", "cert_sha256"} { + assert.False(t, IsSupportedVerifierIDType(ty), ty) + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index d7de192a..621ef6b6 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -12,6 +12,7 @@ import ( "strings" "github.com/sirosfoundation/g119612/pkg/validation" + "github.com/sirosfoundation/go-trust/pkg/authzen" "gopkg.in/yaml.v3" ) @@ -460,6 +461,12 @@ type PolicyConstraintsConfig struct { // AllowedKeyTypes restricts accepted key types (e.g., ["x5c", "jwk"]) AllowedKeyTypes []string `yaml:"allowed_key_types,omitempty"` + + // ReturnVerifierIDs lists the verifier identifier types to return in the + // evaluation response context member verifier_ids. Supported types: + // semantic, dns, uri, did, oidf_entity. Empty (default) returns none. + // Unknown names are rejected at startup. + ReturnVerifierIDs []string `yaml:"return_verifier_ids,omitempty"` } // OIDFedPolicyConfig contains OpenID Federation-specific policy constraints. @@ -802,6 +809,9 @@ func (c *Config) Validate() error { if err := p.EMRTD.validate(); err != nil { return fmt.Errorf("policy %q: %w", name, err) } + if err := p.Constraints.validate(); err != nil { + return fmt.Errorf("policy %q: %w", name, err) + } } } @@ -829,3 +839,18 @@ func (e *EMRTDPolicyConfig) validate() error { } return nil } + +// validate rejects unknown return_verifier_ids type names, so a typo fails at +// startup rather than silently returning nothing. +func (c *PolicyConstraintsConfig) validate() error { + if c == nil { + return nil + } + for _, t := range c.ReturnVerifierIDs { + if !authzen.IsSupportedVerifierIDType(t) { + return fmt.Errorf("constraints.return_verifier_ids: unknown verifier id type %q (supported: %s)", + t, strings.Join(authzen.SupportedVerifierIDTypes(), ", ")) + } + } + return nil +} diff --git a/pkg/config/verifierids_test.go b/pkg/config/verifierids_test.go new file mode 100644 index 00000000..9d09cd7a --- /dev/null +++ b/pkg/config/verifierids_test.go @@ -0,0 +1,64 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestValidateReturnVerifierIDs(t *testing.T) { + cases := []struct { + name string + c *PolicyConstraintsConfig + wantErr string + }{ + {"absent", nil, ""}, + {"default off", &PolicyConstraintsConfig{}, ""}, + {"all supported", &PolicyConstraintsConfig{ReturnVerifierIDs: []string{"semantic", "dns", "uri", "did", "oidf_entity"}}, ""}, + {"unknown", &PolicyConstraintsConfig{ReturnVerifierIDs: []string{"semantic", "sematic"}}, `"sematic"`}, + {"deferred type is not accepted yet", &PolicyConstraintsConfig{ReturnVerifierIDs: []string{"did_scid"}}, `"did_scid"`}, + {"wrong case", &PolicyConstraintsConfig{ReturnVerifierIDs: []string{"DNS"}}, `"DNS"`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cfg := DefaultConfig() + cfg.Policies.Policies = map[string]*PolicyConfig{"rp": {Constraints: tc.c}} + err := cfg.Validate() + if tc.wantErr == "" { + if err != nil { + t.Fatalf("Validate() = %v, want nil", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tc.wantErr) || !strings.Contains(err.Error(), `policy "rp"`) { + t.Fatalf("Validate() = %v, want error naming the policy and containing %s", err, tc.wantErr) + } + }) + } +} + +func TestLoadConfigReturnVerifierIDs(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + content := ` +policies: + policies: + rp: + constraints: + return_verifier_ids: [semantic, dns] +` + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + cfg, err := LoadConfig(path) + if err != nil { + t.Fatal(err) + } + got := cfg.Policies.Policies["rp"].Constraints.ReturnVerifierIDs + if len(got) != 2 || got[0] != "semantic" || got[1] != "dns" { + t.Fatalf("return_verifier_ids = %v", got) + } + if err := cfg.Validate(); err != nil { + t.Fatal(err) + } +} diff --git a/pkg/registry/did/resolver.go b/pkg/registry/did/resolver.go index 1f691cb0..408fc7ac 100644 --- a/pkg/registry/did/resolver.go +++ b/pkg/registry/did/resolver.go @@ -187,7 +187,7 @@ func (r *GenericDIDRegistry) Evaluate(ctx context.Context, req *authzen.Evaluati }, nil } - return &authzen.EvaluationResponse{ + resp := &authzen.EvaluationResponse{ Decision: true, Context: &authzen.EvaluationResponseContext{ Reason: map[string]interface{}{ @@ -199,7 +199,9 @@ func (r *GenericDIDRegistry) Evaluate(ctx context.Context, req *authzen.Evaluati }, TrustMetadata: didDocumentToTrustMetadata(didDoc), }, - }, nil + } + registry.AddDIDVerifierID(resp, req, didDoc.ID) + return resp, nil } // checkDIDPolicyConstraints validates DID policy constraints from request context. diff --git a/pkg/registry/did/verifierid_test.go b/pkg/registry/did/verifierid_test.go new file mode 100644 index 00000000..ad711460 --- /dev/null +++ b/pkg/registry/did/verifierid_test.go @@ -0,0 +1,51 @@ +package did + +import ( + "context" + "testing" + + "github.com/sirosfoundation/go-trust/pkg/authzen" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGenericDIDRegistry_VerifierIDs(t *testing.T) { + const did = "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" + reg := NewGenericDIDRegistryWithKeyMethod(GenericDIDRegistryConfig{}) + ctx := context.Background() + + doc, err := NewDIDKeyResolver().Resolve(ctx, did) + require.NoError(t, err) + require.NotEmpty(t, doc.VerificationMethod) + jwk := doc.VerificationMethod[0].PublicKeyJwk + require.NotNil(t, jwk) + + req := func(withKey bool, c map[string]interface{}) *authzen.EvaluationRequest { + rq := &authzen.EvaluationRequest{ + Subject: authzen.Subject{Type: "key", ID: did}, + Resource: authzen.Resource{ID: did}, + Context: c, + } + if withKey { + rq.Resource.Type = "jwk" + rq.Resource.Key = []interface{}{map[string]interface{}(jwk)} + } + return rq + } + on := map[string]interface{}{authzen.VerifierIDsPolicyContextKey: []string{"did"}} + + resp, err := reg.Evaluate(ctx, req(true, on)) + require.NoError(t, err) + require.True(t, resp.Decision, "%v", resp.Context.Reason) + assert.Equal(t, []authzen.VerifierID{{Type: "did", ID: did, Basis: "did-resolution"}}, resp.Context.VerifierIDs) + + resp, err = reg.Evaluate(ctx, req(true, nil)) + require.NoError(t, err) + require.True(t, resp.Decision) + assert.Empty(t, resp.Context.VerifierIDs, "policy off") + + resp, err = reg.Evaluate(ctx, req(false, on)) + require.NoError(t, err) + require.True(t, resp.Decision) + assert.Empty(t, resp.Context.VerifierIDs, "resolution-only: no key matched") +} diff --git a/pkg/registry/didjwks/didjwks_registry.go b/pkg/registry/didjwks/didjwks_registry.go index 68f3e88e..879463c1 100644 --- a/pkg/registry/didjwks/didjwks_registry.go +++ b/pkg/registry/didjwks/didjwks_registry.go @@ -180,7 +180,7 @@ func (r *Registry) Evaluate(ctx context.Context, req *authzen.EvaluationRequest) }, nil } - return &authzen.EvaluationResponse{ + resp := &authzen.EvaluationResponse{ Decision: true, Context: &authzen.EvaluationResponseContext{ Reason: map[string]interface{}{ @@ -192,7 +192,9 @@ func (r *Registry) Evaluate(ctx context.Context, req *authzen.EvaluationRequest) }, TrustMetadata: didDocToTrustMetadata(didDoc), }, - }, nil + } + registry.AddDIDVerifierID(resp, req, didDoc.ID) + return resp, nil } // SupportedResourceTypes returns the resource types this registry can handle. diff --git a/pkg/registry/didjwks/verifierid_test.go b/pkg/registry/didjwks/verifierid_test.go new file mode 100644 index 00000000..322a3d37 --- /dev/null +++ b/pkg/registry/didjwks/verifierid_test.go @@ -0,0 +1,59 @@ +package didjwks + +import ( + "context" + "fmt" + "testing" + + "github.com/sirosfoundation/go-trust/pkg/authzen" +) + +func TestEvaluateVerifierIDs(t *testing.T) { + server := newRoutingServer(sampleJWKS(), "/.well-known/jwks.json") + defer server.Close() + + reg := registryWith(server) + did := fmt.Sprintf("did:jwks:%s", testDomain(server)) + + req := func(ctx map[string]interface{}, withKey bool) *authzen.EvaluationRequest { + rq := &authzen.EvaluationRequest{ + Subject: authzen.Subject{Type: "key", ID: did + "#frag"}, + Resource: authzen.Resource{ID: did}, + Context: ctx, + } + if withKey { + rq.Resource.Type = "jwk" + rq.Resource.Key = []interface{}{ecSigningJWK()} + } + return rq + } + enabled := map[string]interface{}{authzen.VerifierIDsPolicyContextKey: []string{"did"}} + + // The fragment names a verification method that is not in the document, so + // use the bare DID for the matching case. + rq := req(enabled, true) + rq.Subject.ID = did + resp, err := reg.Evaluate(context.Background(), rq) + if err != nil { + t.Fatal(err) + } + if !resp.Decision { + t.Fatalf("expected allow: %v", resp.Context.Reason) + } + want := []authzen.VerifierID{{Type: "did", ID: did, Basis: "did-resolution"}} + if len(resp.Context.VerifierIDs) != 1 || resp.Context.VerifierIDs[0] != want[0] { + t.Fatalf("verifier_ids = %v, want %v", resp.Context.VerifierIDs, want) + } + + // Policy off, and resolution-only: nothing. + rq = req(nil, true) + rq.Subject.ID = did + if resp, _ = reg.Evaluate(context.Background(), rq); len(resp.Context.VerifierIDs) != 0 { + t.Fatalf("policy off must emit nothing, got %v", resp.Context.VerifierIDs) + } + rq = req(enabled, false) + rq.Subject.ID = did + if resp, _ = reg.Evaluate(context.Background(), rq); !resp.Decision || len(resp.Context.VerifierIDs) != 0 { + t.Fatalf("resolution-only must emit nothing, got %v", resp.Context.VerifierIDs) + } +} diff --git a/pkg/registry/didweb/didweb_registry.go b/pkg/registry/didweb/didweb_registry.go index f55b2932..cbbd24dc 100644 --- a/pkg/registry/didweb/didweb_registry.go +++ b/pkg/registry/didweb/didweb_registry.go @@ -198,7 +198,7 @@ func (r *DIDWebRegistry) Evaluate(ctx context.Context, req *authzen.EvaluationRe } // Success - key binding is valid - return &authzen.EvaluationResponse{ + resp := &authzen.EvaluationResponse{ Decision: true, Context: &authzen.EvaluationResponseContext{ Reason: map[string]interface{}{ @@ -210,7 +210,9 @@ func (r *DIDWebRegistry) Evaluate(ctx context.Context, req *authzen.EvaluationRe }, TrustMetadata: r.didDocumentToTrustMetadata(didDoc), }, - }, nil + } + registry.AddDIDVerifierID(resp, req, didDoc.ID) + return resp, nil } // buildResolutionOnlyResponse creates an EvaluationResponse for resolution-only requests. diff --git a/pkg/registry/didweb/verifierid_test.go b/pkg/registry/didweb/verifierid_test.go new file mode 100644 index 00000000..87748444 --- /dev/null +++ b/pkg/registry/didweb/verifierid_test.go @@ -0,0 +1,82 @@ +package didweb + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/sirosfoundation/go-trust/pkg/authzen" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEvaluate_VerifierIDs(t *testing.T) { + jwk := map[string]interface{}{ + "kty": "EC", "crv": "P-256", + "x": "WbbOWOeX3VLvj5t8kdnj4a7z1S_M5OOa3yiAqcVGhKU", + "y": "0yLk7PZsv6UvW7J_N-N28RYT0gPBY6xw7_q1VJTg0Xg", + } + var did string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "@context": []interface{}{"https://www.w3.org/ns/did/v1"}, + "id": did, + "verificationMethod": []interface{}{map[string]interface{}{ + "id": did + "#key-1", "type": "JsonWebKey2020", "controller": did, "publicKeyJwk": jwk, + }}, + }) + })) + defer srv.Close() + did = "did:web:" + strings.Replace(strings.TrimPrefix(srv.URL, "http://"), ":", "%3A", 1) + + r, err := NewDIDWebRegistry(Config{AllowHTTP: true, AllowPrivateIPs: true}) + require.NoError(t, err) + + req := func(subject string, key interface{}, ctx map[string]interface{}) *authzen.EvaluationRequest { + rq := &authzen.EvaluationRequest{ + Subject: authzen.Subject{Type: "key", ID: subject}, + Resource: authzen.Resource{ID: subject}, + Context: ctx, + } + if key != nil { + rq.Resource.Type = "jwk" + rq.Resource.Key = []interface{}{key} + } + return rq + } + enabled := map[string]interface{}{authzen.VerifierIDsPolicyContextKey: []string{"did"}} + + // Key-bound allow with the policy on: the DID, without the fragment. + resp, err := r.Evaluate(context.Background(), req(did+"#key-1", jwk, enabled)) + require.NoError(t, err) + require.True(t, resp.Decision, "%v", resp.Context.Reason) + assert.Equal(t, []authzen.VerifierID{{Type: "did", ID: did, Basis: "did-resolution"}}, resp.Context.VerifierIDs) + + // Policy off: unchanged. + resp, err = r.Evaluate(context.Background(), req(did, jwk, nil)) + require.NoError(t, err) + require.True(t, resp.Decision) + assert.Empty(t, resp.Context.VerifierIDs) + + // Hint for another type narrows to nothing. + hint := map[string]interface{}{authzen.VerifierIDsPolicyContextKey: []string{"did"}, authzen.VerifierIDHintContextKey: "dns"} + resp, err = r.Evaluate(context.Background(), req(did, jwk, hint)) + require.NoError(t, err) + assert.Empty(t, resp.Context.VerifierIDs) + + // Resolution only: no key was matched, so no identifier. + resp, err = r.Evaluate(context.Background(), req(did, nil, enabled)) + require.NoError(t, err) + require.True(t, resp.Decision) + assert.Empty(t, resp.Context.VerifierIDs) + + // Key that does not match: deny, nothing. + other := map[string]interface{}{"kty": "EC", "crv": "P-256", "x": "AAAA", "y": "BBBB"} + resp, err = r.Evaluate(context.Background(), req(did, other, enabled)) + require.NoError(t, err) + require.False(t, resp.Decision) + assert.Empty(t, resp.Context.VerifierIDs) +} diff --git a/pkg/registry/didwebvh/didwebvh_registry.go b/pkg/registry/didwebvh/didwebvh_registry.go index 6ebc8a3a..4bcb88bc 100644 --- a/pkg/registry/didwebvh/didwebvh_registry.go +++ b/pkg/registry/didwebvh/didwebvh_registry.go @@ -266,7 +266,7 @@ func (r *DIDWebVHRegistry) Evaluate(ctx context.Context, req *authzen.Evaluation } // Success - key binding is valid - return &authzen.EvaluationResponse{ + resp := &authzen.EvaluationResponse{ Decision: true, Context: &authzen.EvaluationResponseContext{ Reason: map[string]interface{}{ @@ -280,7 +280,9 @@ func (r *DIDWebVHRegistry) Evaluate(ctx context.Context, req *authzen.Evaluation }, TrustMetadata: r.didDocumentToTrustMetadata(didDoc, metadata), }, - }, nil + } + registry.AddDIDVerifierID(resp, req, didDoc.ID) + return resp, nil } // buildResolutionOnlyResponse creates an EvaluationResponse for resolution-only requests. diff --git a/pkg/registry/manager.go b/pkg/registry/manager.go index 7c0900ff..c6a95a56 100644 --- a/pkg/registry/manager.go +++ b/pkg/registry/manager.go @@ -324,6 +324,12 @@ func (m *RegistryManager) applyPolicyToRequest(req *authzen.EvaluationRequest, p return } + // Verifier identifiers the policy enables. Registries read it through + // VerifierIDTypesEnabled; absent means the member is not returned. + if len(policyCtx.Policy.Constraints.ReturnVerifierIDs) > 0 { + req.Context[authzen.VerifierIDsPolicyContextKey] = policyCtx.Policy.Constraints.ReturnVerifierIDs + } + // Apply OIDF constraints if policyCtx.Policy.OIDFed != nil { oidfed := policyCtx.Policy.OIDFed @@ -473,6 +479,11 @@ var clientSuppliableContextKeys = map[string]bool{ // Informational / audit "purpose": true, // presentation purpose + // verifier_id_type only ever narrows the verifier_ids the policy enabled + // to one type; it cannot enable a type (return_verifier_ids is a policy + // control, set only by applyPolicyToRequest). + authzen.VerifierIDHintContextKey: true, + // signing_time is the eMRTD document signing time (RFC 3339) at which the // DSC/CSCA validity is evaluated (ICAO 9303 Part 12). It is data about // the document, which only the PEP knows. The emrtd registry parses it diff --git a/pkg/registry/oidfed/oidfed_registry.go b/pkg/registry/oidfed/oidfed_registry.go index 3216f458..ed264bd2 100644 --- a/pkg/registry/oidfed/oidfed_registry.go +++ b/pkg/registry/oidfed/oidfed_registry.go @@ -859,13 +859,21 @@ func (r *OIDFedRegistry) Evaluate(ctx context.Context, req *authzen.EvaluationRe } } - return &authzen.EvaluationResponse{ + resp := &authzen.EvaluationResponse{ Decision: true, Context: &authzen.EvaluationResponseContext{ Reason: reasonData, TrustMetadata: trustMetadata, }, - }, nil + } + // The Entity ID is returned only when the validated chain's leaf statement + // is about that very entity, and byte-for-byte as in the chain: an entity + // ID is compared exactly, so a trailing slash or case change names another + // entity. It is not re-serialised through net/url. + if len(chain) > 0 && chain[0].Subject == entityID { + registry.AddVerifierID(resp, req, authzen.VerifierIDTypeOIDFEntity, chain[0].Subject, authzen.VerifierIDBasisOIDFTrustChain) + } + return resp, nil } // verifyKeyBinding checks if the key in the request matches a key in the entity's JWKS. diff --git a/pkg/registry/oidfed/verifierid_test.go b/pkg/registry/oidfed/verifierid_test.go new file mode 100644 index 00000000..835b3247 --- /dev/null +++ b/pkg/registry/oidfed/verifierid_test.go @@ -0,0 +1,98 @@ +package oidfed + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "encoding/json" + "testing" + + oidfed "github.com/go-oidfed/lib" + oidfedjwx "github.com/go-oidfed/lib/jwx" + "github.com/lestrrat-go/jwx/v4/jwk" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/sirosfoundation/go-trust/pkg/authzen" +) + +// TestEvaluate_VerifierIDs_OIDFEntity serves a validated trust chain from the +// cache (no network) and checks the entity identifier is returned only for a +// key-bound allow, byte-for-byte, with the policy's say-so. +func TestEvaluate_VerifierIDs_OIDFEntity(t *testing.T) { + const entityID = "https://rp.example.com" + + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + key, err := jwk.Import[jwk.Key](&priv.PublicKey) + require.NoError(t, err) + set := jwk.NewSet() + require.NoError(t, set.AddKey(key)) + + reg, err := NewOIDFedRegistry(Config{TrustAnchors: []TrustAnchorConfig{{EntityID: "https://ta.example.com"}}}) + require.NoError(t, err) + + chainFor := func(subject string) oidfed.TrustChain { + return oidfed.TrustChain{ + &oidfed.EntityStatement{EntityStatementPayload: oidfed.EntityStatementPayload{ + Issuer: subject, Subject: subject, JWKS: oidfedjwx.JWKS{Set: set}, + }}, + &oidfed.EntityStatement{EntityStatementPayload: oidfed.EntityStatementPayload{ + Issuer: "https://ta.example.com", Subject: "https://ta.example.com", + }}, + } + } + + keyJSON, err := json.Marshal(key) + require.NoError(t, err) + var jwkMap map[string]interface{} + require.NoError(t, json.Unmarshal(keyJSON, &jwkMap)) + + request := func(ctx map[string]interface{}, withKey bool) *authzen.EvaluationRequest { + rq := &authzen.EvaluationRequest{ + Subject: authzen.Subject{Type: "key", ID: entityID}, + Resource: authzen.Resource{ID: entityID}, + Context: ctx, + } + if withKey { + rq.Resource.Type = "jwk" + rq.Resource.Key = []interface{}{jwkMap} + } + return rq + } + enabled := map[string]interface{}{authzen.VerifierIDsPolicyContextKey: []string{"oidf_entity"}} + + reg.cache.Set(entityID, nil, nil, []oidfed.TrustChain{chainFor(entityID)}, "https://ta.example.com") + + resp, err := reg.Evaluate(context.Background(), request(enabled, true)) + require.NoError(t, err) + require.True(t, resp.Decision, "%v", resp.Context.Reason) + assert.Equal(t, []authzen.VerifierID{{Type: "oidf_entity", ID: entityID, Basis: "oidf-trust-chain"}}, resp.Context.VerifierIDs) + + // Policy off: unchanged. + resp, err = reg.Evaluate(context.Background(), request(nil, true)) + require.NoError(t, err) + require.True(t, resp.Decision) + assert.Empty(t, resp.Context.VerifierIDs) + + // Hint narrowing to another type: nothing. + hint := map[string]interface{}{authzen.VerifierIDsPolicyContextKey: []string{"oidf_entity"}, authzen.VerifierIDHintContextKey: "did"} + resp, err = reg.Evaluate(context.Background(), request(hint, true)) + require.NoError(t, err) + assert.Empty(t, resp.Context.VerifierIDs) + + // Resolution-only: validated chain, but no key bound to the entity. + resp, err = reg.Evaluate(context.Background(), request(enabled, false)) + require.NoError(t, err) + require.True(t, resp.Decision) + assert.Empty(t, resp.Context.VerifierIDs) + + // A chain whose leaf statement is about a different entity is never echoed + // under the requested entity's name. + reg.cache.Set(entityID, nil, nil, []oidfed.TrustChain{chainFor("https://other.example.com")}, "https://ta.example.com") + resp, err = reg.Evaluate(context.Background(), request(enabled, true)) + require.NoError(t, err) + require.True(t, resp.Decision, "the decision path is unchanged; only the identifier is withheld") + assert.Empty(t, resp.Context.VerifierIDs) +} diff --git a/pkg/registry/policy.go b/pkg/registry/policy.go index 7a2a6d54..d1a0288b 100644 --- a/pkg/registry/policy.go +++ b/pkg/registry/policy.go @@ -4,7 +4,10 @@ package registry import ( "fmt" + "strings" "sync" + + "github.com/sirosfoundation/go-trust/pkg/authzen" ) // maxWarnedUnknownActions bounds the warning-deduplication set. action.name is @@ -58,6 +61,24 @@ type PolicyConstraints struct { // validated. When true, resolution-only requests (no resource.type or no // resource.key) are rejected rather than answered with resolved metadata. RequireKeyBinding bool `json:"require_key_binding,omitempty" yaml:"require_key_binding,omitempty"` + + // ReturnVerifierIDs lists the verifier identifier types to return in + // response.Context.VerifierIDs (see authzen.SupportedVerifierIDTypes). + // Empty (the default) returns none, so existing responses are unchanged. + // Entries are emitted only for an allow decision whose identifier was + // verified; unknown type names are rejected at config load. + ReturnVerifierIDs []string `json:"return_verifier_ids,omitempty" yaml:"return_verifier_ids,omitempty"` +} + +// Validate rejects unknown return_verifier_ids type names. +func (c PolicyConstraints) Validate() error { + for _, t := range c.ReturnVerifierIDs { + if !authzen.IsSupportedVerifierIDType(t) { + return fmt.Errorf("return_verifier_ids: unknown verifier id type %q (supported: %s)", + t, strings.Join(authzen.SupportedVerifierIDTypes(), ", ")) + } + } + return nil } // OIDFedPolicyConstraints contains OpenID Federation-specific constraints. diff --git a/pkg/registry/policy_verifierids_test.go b/pkg/registry/policy_verifierids_test.go new file mode 100644 index 00000000..738d64d6 --- /dev/null +++ b/pkg/registry/policy_verifierids_test.go @@ -0,0 +1,17 @@ +package registry + +import ( + "testing" + + "github.com/sirosfoundation/go-trust/pkg/authzen" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPolicyConstraintsValidate_VerifierIDs(t *testing.T) { + assert.NoError(t, PolicyConstraints{}.Validate()) + assert.NoError(t, PolicyConstraints{ReturnVerifierIDs: authzen.SupportedVerifierIDTypes()}.Validate()) + err := PolicyConstraints{ReturnVerifierIDs: []string{"dns", "bogus"}}.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), `"bogus"`) +} diff --git a/pkg/registry/rpcert/semanticid.go b/pkg/registry/rpcert/semanticid.go new file mode 100644 index 00000000..d0d56a6c --- /dev/null +++ b/pkg/registry/rpcert/semanticid.go @@ -0,0 +1,127 @@ +package rpcert + +import ( + "crypto/x509" + "regexp" +) + +// SubjectKind classifies a WRPAC subject as a natural or legal person. +type SubjectKind int + +const ( + // SubjectKindUnknown means the certificate carries no (or contradictory) + // WRPAC policy OIDs, so its kind cannot be told from the policy. + SubjectKindUnknown SubjectKind = iota + // SubjectKindNaturalPerson is a certificate under NCP-n-eudiwrp or QCP-n-eudiwrp. + SubjectKindNaturalPerson + // SubjectKindLegalPerson is a certificate under NCP-l-eudiwrp or QCP-l-eudiwrp. + SubjectKindLegalPerson +) + +// SubjectKindFromPolicies classifies cert by its WRPAC certificate policy OIDs +// (ETSI TS 119 411-8: the -n and -l policies are what distinguish natural from +// legal person certificates). A certificate carrying both kinds, or neither, +// is SubjectKindUnknown. +func SubjectKindFromPolicies(cert *x509.Certificate) SubjectKind { + if cert == nil { + return SubjectKindUnknown + } + var natural, legal bool + for _, oid := range CertPolicyOIDStrings(cert) { + switch oid { + case OIDNCPNaturalPerson, OIDQCPNaturalPerson: + natural = true + case OIDNCPLegalPerson, OIDQCPLegalPerson: + legal = true + } + } + switch { + case natural && !legal: + return SubjectKindNaturalPerson + case legal && !natural: + return SubjectKindLegalPerson + } + return SubjectKindUnknown +} + +// semanticIDPattern is the EN 319 412-1 clause 5.1.3 semantics identifier +// syntax: a 3-letter identifier type, a 2-letter country code, a hyphen-minus +// and a non-empty value without leading or trailing whitespace. +var semanticIDPattern = regexp.MustCompile(`^[A-Z]{3}[A-Z]{2}-[\x21-\x7E]([\x20-\x7E]*[\x21-\x7E])?$`) + +// naturalPersonSemanticTypes are the identifier types EN 319 412-1 5.1.3 defines +// for natural persons: passport, identity card, personal number, tax and +// taxpayer identification number. Every other type names a legal person. +var naturalPersonSemanticTypes = map[string]bool{ + "PAS": true, "IDC": true, "PNO": true, "TAX": true, "TIN": true, +} + +// IsSemanticIdentifier reports whether s matches the semantic identifier +// syntax (3-letter type, 2-letter country, hyphen, value). +func IsSemanticIdentifier(s string) bool { + return semanticIDPattern.MatchString(s) +} + +// realOrganizationIdentifier returns only the organizationIdentifier attribute +// (2.5.4.97), without the serialNumber fallback of SubjectOrganizationIdentifier. +func realOrganizationIdentifier(cert *x509.Certificate) string { + for _, atv := range cert.Subject.Names { + if atv.Type.Equal(oidOrganizationIdentifier) { + if v, ok := atv.Value.(string); ok && v != "" { + return v + } + } + } + return "" +} + +// SemanticIdentifier returns the ETSI semantic identifier of the certificate's +// subject, and false when there is none that can be trusted as one. +// +// Where it lives depends on the subject (ETSI TS 119 475 5.1.3 and 5.1.5, which +// refer to EN 319 412-1 5.1.3): +// - legal person: organizationIdentifier (2.5.4.97), per EN 319 412-3 4.2.1. +// The serialNumber fallback exists for older certificates and fixtures that +// put it there. +// - natural person: Subject serialNumber (2.5.4.5), never +// organizationIdentifier, which would name an affiliated organization. +// +// The value must match the semantic identifier syntax, and its type must fit +// the subject: a natural-person type (PNO, IDC, PAS, TAX, TIN) is refused for a +// legal-person certificate and any other type for a natural-person one, when the +// policy OIDs tell the two apart. A serialNumber that is merely a certificate +// serial (or anything else non-semantic) is therefore never returned. An +// organizationIdentifier that is present but not valid is not papered over by +// falling back to serialNumber. +func SemanticIdentifier(cert *x509.Certificate) (string, bool) { + if cert == nil { + return "", false + } + kind := SubjectKindFromPolicies(cert) + + var candidate string + if kind == SubjectKindNaturalPerson { + candidate = cert.Subject.SerialNumber + } else { + candidate = realOrganizationIdentifier(cert) + if candidate == "" { + candidate = cert.Subject.SerialNumber + } + } + + if !IsSemanticIdentifier(candidate) { + return "", false + } + isNaturalType := naturalPersonSemanticTypes[candidate[:3]] + switch kind { + case SubjectKindNaturalPerson: + if !isNaturalType { + return "", false + } + case SubjectKindLegalPerson: + if isNaturalType { + return "", false + } + } + return candidate, true +} diff --git a/pkg/registry/rpcert/semanticid_test.go b/pkg/registry/rpcert/semanticid_test.go new file mode 100644 index 00000000..621f67dd --- /dev/null +++ b/pkg/registry/rpcert/semanticid_test.go @@ -0,0 +1,129 @@ +package rpcert + +import ( + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "testing" +) + +// orgIDName builds a Subject carrying organizationIdentifier (2.5.4.97), which +// pkix.Name has no field for, so it goes in ExtraNames. +func orgIDName(orgID, serial string) pkix.Name { + n := pkix.Name{Country: []string{"SE"}, Organization: []string{"Test AB"}, SerialNumber: serial} + if orgID != "" { + n.ExtraNames = []pkix.AttributeTypeAndValue{{Type: oidOrganizationIdentifier, Value: orgID}} + } + return n +} + +func TestSemanticIdentifier(t *testing.T) { + legal := []asn1.ObjectIdentifier{parseOID(OIDNCPLegalPerson)} + natural := []asn1.ObjectIdentifier{parseOID(OIDNCPNaturalPerson)} + + tests := []struct { + name string + subject pkix.Name + policies []asn1.ObjectIdentifier + want string + wantOK bool + }{ + {"legal person, organizationIdentifier (5.1.3)", orgIDName("VATSE-556000000101", ""), legal, "VATSE-556000000101", true}, + {"legal person, legacy serialNumber fallback", orgIDName("", "NTRSE-5560000001"), legal, "NTRSE-5560000001", true}, + {"no policy OIDs, organizationIdentifier", orgIDName("LEIXG-529900T8BM49AURSDO55", ""), nil, "LEIXG-529900T8BM49AURSDO55", true}, + {"value with an internal space", orgIDName("NTRDE-HRB 12345", ""), legal, "NTRDE-HRB 12345", true}, + // ETSI TS 119 475 5.1.5: a natural person's identifier is in serialNumber. + {"natural person, serialNumber (5.1.5)", orgIDName("", "PNOSE-196001010101"), natural, "PNOSE-196001010101", true}, + {"natural person, passport", orgIDName("", "PASFR-12AB34567"), natural, "PASFR-12AB34567", true}, + { + "natural person, organizationIdentifier is the affiliation, not the person", + orgIDName("VATSE-556000000101", "PNOSE-196001010101"), natural, "PNOSE-196001010101", true, + }, + {"natural person with only an affiliation", orgIDName("VATSE-556000000101", ""), natural, "", false}, + {"natural person, legal-person type in serialNumber", orgIDName("", "VATSE-556000000101"), natural, "", false}, + {"legal person, natural-person type", orgIDName("PNOSE-196001010101", ""), legal, "", false}, + // A serialNumber that is not a semantic identifier must not be emitted. + {"serialNumber is a plain number", orgIDName("", "123456"), legal, "", false}, + {"serialNumber is a plain number, no policy", orgIDName("", "123456"), nil, "", false}, + {"lower-case type", orgIDName("vatse-556000000101", ""), legal, "", false}, + {"three-letter country", orgIDName("VATSWE-556000000101", ""), legal, "", false}, + {"missing hyphen", orgIDName("VATSE556000000101", ""), legal, "", false}, + {"empty value", orgIDName("VATSE-", ""), legal, "", false}, + {"trailing space", orgIDName("VATSE-5560 ", ""), legal, "", false}, + {"control character", orgIDName("VATSE-55\x01", ""), legal, "", false}, + {"nothing at all", orgIDName("", ""), legal, "", false}, + // An invalid organizationIdentifier is not papered over by serialNumber. + {"invalid organizationIdentifier does not fall back", orgIDName("garbage", "VATSE-556000000101"), legal, "", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cert := makeCert(t, certOpts{subject: tt.subject, policyOIDs: tt.policies}) + got, ok := SemanticIdentifier(cert) + if ok != tt.wantOK || got != tt.want { + t.Fatalf("SemanticIdentifier() = (%q, %v), want (%q, %v)", got, ok, tt.want, tt.wantOK) + } + }) + } + + if got, ok := SemanticIdentifier(nil); ok || got != "" { + t.Fatalf("nil cert: got (%q, %v)", got, ok) + } +} + +func TestSubjectKindFromPolicies(t *testing.T) { + cases := []struct { + name string + policies []string + want SubjectKind + }{ + {"none", nil, SubjectKindUnknown}, + {"NCP-n", []string{OIDNCPNaturalPerson}, SubjectKindNaturalPerson}, + {"QCP-n", []string{OIDQCPNaturalPerson}, SubjectKindNaturalPerson}, + {"NCP-l", []string{OIDNCPLegalPerson}, SubjectKindLegalPerson}, + {"QCP-l", []string{OIDQCPLegalPerson}, SubjectKindLegalPerson}, + {"both is contradictory", []string{OIDNCPNaturalPerson, OIDNCPLegalPerson}, SubjectKindUnknown}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var oids []asn1.ObjectIdentifier + for _, p := range c.policies { + oids = append(oids, parseOID(p)) + } + cert := makeCert(t, certOpts{subject: pkix.Name{CommonName: "x"}, policyOIDs: oids}) + if got := SubjectKindFromPolicies(cert); got != c.want { + t.Fatalf("got %v, want %v", got, c.want) + } + }) + } + if SubjectKindFromPolicies(nil) != SubjectKindUnknown { + t.Fatal("nil cert must be unknown") + } +} + +// TestWRPACProfile_ExtractIdentity_NaturalPersonSerialNumber pins the narrow +// fix to ExtractIdentity: a natural-person certificate's serialNumber is the +// person's semantic identifier (TS 119 475 5.1.5), not an organisation +// identifier, and a natural person affiliated with an organisation is still a +// natural person. +func TestWRPACProfile_ExtractIdentity_NaturalPersonSerialNumber(t *testing.T) { + p := NewWRPACProfile() + cert := makeCert(t, certOpts{ + subject: orgIDName("", "PNOSE-196001010101"), // Organization is set: an affiliation + keyUsage: x509.KeyUsageContentCommitment, + policyOIDs: []asn1.ObjectIdentifier{parseOID(OIDNCPNaturalPerson)}, + emails: []string{"alice@example.test"}, + }) + identity, err := p.ExtractIdentity(cert) + if err != nil { + t.Fatal(err) + } + if identity["subject_type"] != "natural_person" { + t.Errorf("subject_type = %v, want natural_person", identity["subject_type"]) + } + if _, has := identity["organization_identifier"]; has { + t.Errorf("serialNumber must not be relabelled organization_identifier: %v", identity["organization_identifier"]) + } + if identity["serial_number"] != "PNOSE-196001010101" { + t.Errorf("serial_number = %v, want the person's identifier kept", identity["serial_number"]) + } +} diff --git a/pkg/registry/rpcert/wrpac.go b/pkg/registry/rpcert/wrpac.go index 03e81bf1..c896ba99 100644 --- a/pkg/registry/rpcert/wrpac.go +++ b/pkg/registry/rpcert/wrpac.go @@ -191,7 +191,18 @@ func (p *WRPACProfile) ExtractIdentity(credential interface{}) (map[string]inter // Subject.Names, so it has to be read out by OID. Fall back to // serialNumber (2.5.4.5), which earlier certificates and test fixtures // used, so both continue to resolve. + // + // The serialNumber fallback does not apply to a natural-person certificate + // (NCP-n/QCP-n policy). ETSI TS 119 475 5.1.5 puts a natural person's + // semantic identifier (e.g. PNOSE-...) in serialNumber; that identifies the + // person, so relabelling it organization_identifier - and classifying the + // certificate legal_person when it also names an affiliated organisation - + // was wrong. + naturalPerson := SubjectKindFromPolicies(cert) == SubjectKindNaturalPerson orgID := SubjectOrganizationIdentifier(cert) + if naturalPerson { + orgID = realOrganizationIdentifier(cert) + } if orgID != "" { identity["organization_identifier"] = orgID delete(identity, "serial_number") @@ -202,7 +213,7 @@ func (p *WRPACProfile) ExtractIdentity(credential interface{}) (map[string]inter // directly classified a conformant legal-person certificate, which // carries the value in 2.5.4.97, as a natural person. Reusing the one // value keeps the two decisions from ever disagreeing. - if len(cert.Subject.Organization) > 0 && orgID != "" { + if !naturalPerson && len(cert.Subject.Organization) > 0 && orgID != "" { identity["subject_type"] = "legal_person" } else { identity["subject_type"] = "natural_person" diff --git a/pkg/registry/verifierid.go b/pkg/registry/verifierid.go new file mode 100644 index 00000000..73c27a9e --- /dev/null +++ b/pkg/registry/verifierid.go @@ -0,0 +1,94 @@ +package registry + +import ( + "strings" + + "github.com/sirosfoundation/go-trust/pkg/authzen" +) + +// VerifierIDTypesEnabled returns the verifier identifier types the policy +// enabled for this request (context key return_verifier_ids, written only by +// applyPolicyToRequest), narrowed to hint when the client sent the optional +// verifier_id_type context member. A nil result means the feature is off. +// +// A hint naming a type the policy did not enable yields an empty, non-nil +// result: the hint only ever narrows, it can never enable a type. +func VerifierIDTypesEnabled(req *authzen.EvaluationRequest) []string { + if req == nil || req.Context == nil { + return nil + } + enabled := extractStringSliceFromContext(req.Context, authzen.VerifierIDsPolicyContextKey) + if len(enabled) == 0 { + return nil + } + hint, hasHint := req.Context[authzen.VerifierIDHintContextKey].(string) + if !hasHint || hint == "" { + return enabled + } + narrowed := []string{} + for _, t := range enabled { + if t == hint { + narrowed = append(narrowed, t) + } + } + return narrowed +} + +// verifierIDEnabled reports whether type t may be emitted for req. +func verifierIDEnabled(req *authzen.EvaluationRequest, t string) bool { + for _, e := range VerifierIDTypesEnabled(req) { + if e == t { + return true + } + } + return false +} + +// AddVerifierID appends one verified identifier to resp.Context.VerifierIDs. +// It is the single emission point for registries that derive an identifier +// themselves (DID, OpenID Federation), and fails closed: nothing is added +// unless the decision is true, the policy enabled the type, the hint (if any) +// matches, and id is non-empty. Duplicate (type, id) pairs are not added twice. +func AddVerifierID(resp *authzen.EvaluationResponse, req *authzen.EvaluationRequest, idType, id, basis string) { + if resp == nil || !resp.Decision || id == "" || !verifierIDEnabled(req, idType) { + return + } + if resp.Context == nil { + resp.Context = &authzen.EvaluationResponseContext{} + } + for _, v := range resp.Context.VerifierIDs { + if v.Type == idType && v.ID == id { + return + } + } + resp.Context.VerifierIDs = append(resp.Context.VerifierIDs, authzen.VerifierID{Type: idType, ID: id, Basis: basis}) +} + +// CanonicalDNSName returns the canonical verifier form of a DNS name: ASCII +// lower case, no trailing dot. DNS names are case-insensitive, so two spellings +// of one name must not become two pseudonyms. +func CanonicalDNSName(name string) string { + return strings.TrimSuffix(strings.ToLower(name), ".") +} + +// CanonicalDID returns the DID without any fragment, i.e. the DID itself rather +// than a verification method reference. Nothing else is altered: the +// method-specific part can be case-sensitive (did:web path segments). +func CanonicalDID(did string) string { + if i := strings.IndexByte(did, '#'); i >= 0 { + return did[:i] + } + return did +} + +// AddDIDVerifierID emits the "did" verifier identifier for a DID registry's +// successful key-bound decision. docID is the id of the resolved DID document; +// it is emitted only when it equals the DID the request named (fragment +// ignored), so a resolver that returned a different document can never put its +// own identifier in the response. The match is exact. +func AddDIDVerifierID(resp *authzen.EvaluationResponse, req *authzen.EvaluationRequest, docID string) { + if req == nil || CanonicalDID(docID) != CanonicalDID(req.Subject.ID) { + return + } + AddVerifierID(resp, req, authzen.VerifierIDTypeDID, CanonicalDID(docID), authzen.VerifierIDBasisDIDResolution) +} diff --git a/pkg/registry/verifierid_manager_test.go b/pkg/registry/verifierid_manager_test.go new file mode 100644 index 00000000..ca0611df --- /dev/null +++ b/pkg/registry/verifierid_manager_test.go @@ -0,0 +1,114 @@ +package registry + +import ( + "context" + "crypto/x509" + "encoding/json" + "testing" + "time" + + "github.com/sirosfoundation/go-trust/pkg/authzen" + "github.com/sirosfoundation/go-trust/pkg/registry/rpcert" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// enrichingRegistry stands in for an x5c registry (etsi/lote): after its own +// chain validation it runs the shared enrichment against a fixed leaf. +type enrichingRegistry struct { + *mockRegistry + leaf *x509.Certificate +} + +func (e *enrichingRegistry) Evaluate(_ context.Context, req *authzen.EvaluationRequest) (*authzen.EvaluationResponse, error) { + enrichment := EnrichX5CResponseWithProfiles(req, e.leaf, rpcert.DefaultProfileRegistry()) + resp := &authzen.EvaluationResponse{Decision: enrichment.Decision} + ApplyEnrichmentToResponse(resp, enrichment) + return resp, nil +} + +func newEnrichingManager(t *testing.T, leaf *x509.Certificate, constraints PolicyConstraints) *RegistryManager { + t.Helper() + mgr := NewRegistryManager(FirstMatch, 10*time.Second) + mgr.Register(&enrichingRegistry{ + mockRegistry: &mockRegistry{name: "etsi-like", resourceTypes: []string{"x5c"}, healthy: true}, + leaf: leaf, + }) + pm := NewPolicyManager() + pm.RegisterPolicy(&Policy{Name: "rp", Constraints: constraints}) + mgr.SetPolicyManager(pm) + return mgr +} + +func vidManagerRequest(ctx map[string]interface{}) *authzen.EvaluationRequest { + req := x5cRequest(ctx) + req.Subject.ID = "x509_san_dns:rp.example.com" + req.Resource.ID = "x509_san_dns:rp.example.com" + req.Action = &authzen.Action{Name: "rp"} + return req +} + +func TestManager_VerifierIDs_PolicyEnablesAndHintNarrows(t *testing.T) { + leaf := makeVIDCert(t, vidCertOpts{orgID: "VATSE-556000000101", policy: rpcert.OIDNCPLegalPerson, dns: []string{"rp.example.com"}}) + mgr := newEnrichingManager(t, leaf, PolicyConstraints{ReturnVerifierIDs: []string{"semantic", "dns"}}) + + // Subject.ID is rewritten by Evaluate ("x509_san_dns:host" -> "https://host"); + // the dns identifier is still derived, from the original claim. + resp, err := mgr.Evaluate(context.Background(), vidManagerRequest(nil)) + require.NoError(t, err) + require.True(t, resp.Decision) + require.NotNil(t, resp.Context) + got := idsOf(resp.Context.VerifierIDs) + assert.Equal(t, "semantic_id:VATSE-556000000101", got["semantic"].ID) + assert.Equal(t, "rp.example.com", got["dns"].ID) + assert.Len(t, resp.Context.VerifierIDs, 2) + + // The client's optional hint narrows to one entry. + resp, err = mgr.Evaluate(context.Background(), + vidManagerRequest(map[string]interface{}{authzen.VerifierIDHintContextKey: "semantic"})) + require.NoError(t, err) + require.Len(t, resp.Context.VerifierIDs, 1) + assert.Equal(t, "semantic", resp.Context.VerifierIDs[0].Type) +} + +func TestManager_VerifierIDs_DefaultOff(t *testing.T) { + leaf := makeVIDCert(t, vidCertOpts{orgID: "VATSE-556000000101", policy: rpcert.OIDNCPLegalPerson, dns: []string{"rp.example.com"}}) + mgr := newEnrichingManager(t, leaf, PolicyConstraints{}) + + resp, err := mgr.Evaluate(context.Background(), vidManagerRequest(nil)) + require.NoError(t, err) + require.True(t, resp.Decision) + assert.Empty(t, resp.Context.VerifierIDs) + + // The member is omitted from the JSON entirely. + b, err := json.Marshal(resp) + require.NoError(t, err) + assert.NotContains(t, string(b), "verifier_ids") +} + +// A client cannot turn the feature on, nor widen what the policy enabled: the +// hint only narrows, and return_verifier_ids is a policy control. +func TestManager_VerifierIDs_ClientCannotEnableOrWiden(t *testing.T) { + leaf := makeVIDCert(t, vidCertOpts{orgID: "VATSE-556000000101", policy: rpcert.OIDNCPLegalPerson, dns: []string{"rp.example.com"}}) + + off := newEnrichingManager(t, leaf, PolicyConstraints{}) + resp, err := off.Evaluate(context.Background(), vidManagerRequest(map[string]interface{}{ + authzen.VerifierIDsPolicyContextKey: []string{"semantic", "dns"}, + authzen.VerifierIDHintContextKey: "semantic", + })) + require.NoError(t, err) + assert.Empty(t, resp.Context.VerifierIDs, "client-set return_verifier_ids must be dropped") + + narrow := newEnrichingManager(t, leaf, PolicyConstraints{ReturnVerifierIDs: []string{"semantic"}}) + resp, err = narrow.Evaluate(context.Background(), vidManagerRequest(map[string]interface{}{ + authzen.VerifierIDsPolicyContextKey: []string{"semantic", "dns"}, + authzen.VerifierIDHintContextKey: "dns", + })) + require.NoError(t, err) + assert.Empty(t, resp.Context.VerifierIDs, "hint for a type the policy did not enable returns nothing") +} + +func TestManager_VerifierIDs_ContextKeys(t *testing.T) { + assert.False(t, clientSuppliableContextKey(authzen.VerifierIDsPolicyContextKey), "policy control") + assert.True(t, clientSuppliableContextKey(authzen.VerifierIDHintContextKey), "request hint") +} diff --git a/pkg/registry/verifierid_test.go b/pkg/registry/verifierid_test.go new file mode 100644 index 00000000..d1737761 --- /dev/null +++ b/pkg/registry/verifierid_test.go @@ -0,0 +1,388 @@ +package registry + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/json" + "math/big" + "net/url" + "testing" + "time" + + "github.com/sirosfoundation/go-trust/pkg/authzen" + "github.com/sirosfoundation/go-trust/pkg/registry/rpcert" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// vidCertOpts describes a leaf for the verifier_ids tests. +type vidCertOpts struct { + orgID string // organizationIdentifier (2.5.4.97) + serial string // Subject serialNumber + policy string // WRPAC policy OID, "" for none + dns []string + uris []string + noKU bool // omit nonRepudiation so WRPAC profile validation fails + noEmails bool +} + +// makeVIDCert builds a parsed leaf, so organizationIdentifier is visible in +// Subject.Names exactly as it is on a certificate off the wire. +func makeVIDCert(t *testing.T, o vidCertOpts) *x509.Certificate { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + subject := pkix.Name{Country: []string{"SE"}, Organization: []string{"Test AB"}, CommonName: "rp", SerialNumber: o.serial} + if o.orgID != "" { + subject.ExtraNames = []pkix.AttributeTypeAndValue{{Type: asn1.ObjectIdentifier{2, 5, 4, 97}, Value: o.orgID}} + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(time.Now().UnixNano()), + Subject: subject, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + DNSNames: o.dns, + } + if !o.noKU { + tmpl.KeyUsage = x509.KeyUsageContentCommitment + } + if !o.noEmails { + tmpl.EmailAddresses = []string{"admin@rp.example"} + } + for _, u := range o.uris { + parsed, err := url.Parse(u) + require.NoError(t, err) + tmpl.URIs = append(tmpl.URIs, parsed) + } + if o.policy != "" { + oid, err := x509.ParseOID(o.policy) + require.NoError(t, err) + tmpl.Policies = []x509.OID{oid} + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + require.NoError(t, err) + cert, err := x509.ParseCertificate(der) + require.NoError(t, err) + return cert +} + +// vidReq builds a request whose context carries what applyPolicyToRequest +// would write for a policy enabling the given types. +func vidReq(subjectID string, types []string, extra map[string]interface{}) *authzen.EvaluationRequest { + ctx := map[string]interface{}{} + if types != nil { + ctx[authzen.VerifierIDsPolicyContextKey] = types + } + for k, v := range extra { + ctx[k] = v + } + return &authzen.EvaluationRequest{ + Subject: authzen.Subject{Type: "key", ID: subjectID}, + Resource: authzen.Resource{Type: "x5c"}, + Context: ctx, + } +} + +func idsOf(ids []authzen.VerifierID) map[string]authzen.VerifierID { + m := map[string]authzen.VerifierID{} + for _, v := range ids { + m[v.Type] = v + } + return m +} + +func TestVerifierIDTypesEnabled(t *testing.T) { + assert.Nil(t, VerifierIDTypesEnabled(nil)) + assert.Nil(t, VerifierIDTypesEnabled(&authzen.EvaluationRequest{})) + assert.Nil(t, VerifierIDTypesEnabled(vidReq("x", nil, nil)), "policy off") + + assert.Equal(t, []string{"semantic", "dns"}, VerifierIDTypesEnabled(vidReq("x", []string{"semantic", "dns"}, nil))) + // From JSON the policy list is []interface{}. + req := vidReq("x", nil, map[string]interface{}{authzen.VerifierIDsPolicyContextKey: []interface{}{"uri"}}) + assert.Equal(t, []string{"uri"}, VerifierIDTypesEnabled(req)) + + hint := func(h string) map[string]interface{} { + return map[string]interface{}{authzen.VerifierIDHintContextKey: h} + } + assert.Equal(t, []string{"dns"}, VerifierIDTypesEnabled(vidReq("x", []string{"semantic", "dns"}, hint("dns")))) + // The hint only narrows: it cannot enable a type the policy did not. + assert.Empty(t, VerifierIDTypesEnabled(vidReq("x", []string{"semantic"}, hint("dns")))) + assert.NotNil(t, VerifierIDTypesEnabled(vidReq("x", []string{"semantic"}, hint("dns")))) + // ...and with the policy off, a hint enables nothing. + assert.Nil(t, VerifierIDTypesEnabled(vidReq("x", nil, hint("dns")))) + // Empty / non-string hint is ignored. + assert.Equal(t, []string{"dns"}, VerifierIDTypesEnabled(vidReq("x", []string{"dns"}, hint("")))) +} + +func TestAddVerifierID_FailsClosed(t *testing.T) { + req := vidReq("x", []string{"dns"}, nil) + + allow := &authzen.EvaluationResponse{Decision: true} + AddVerifierID(allow, req, "dns", "rp.example", "client-id-binding") + require.NotNil(t, allow.Context) + assert.Equal(t, []authzen.VerifierID{{Type: "dns", ID: "rp.example", Basis: "client-id-binding"}}, allow.Context.VerifierIDs) + + AddVerifierID(allow, req, "dns", "rp.example", "client-id-binding") + assert.Len(t, allow.Context.VerifierIDs, 1, "duplicates are not added twice") + + deny := &authzen.EvaluationResponse{Decision: false} + AddVerifierID(deny, req, "dns", "rp.example", "client-id-binding") + assert.Nil(t, deny.Context, "nothing on a deny") + + notEnabled := &authzen.EvaluationResponse{Decision: true} + AddVerifierID(notEnabled, req, "uri", "https://rp.example", "client-id-binding") + assert.Nil(t, notEnabled.Context, "type the policy did not enable") + + empty := &authzen.EvaluationResponse{Decision: true} + AddVerifierID(empty, req, "dns", "", "client-id-binding") + assert.Nil(t, empty.Context) + + AddVerifierID(nil, req, "dns", "x", "b") // must not panic +} + +func TestAddDIDVerifierID_RequiresSameDID(t *testing.T) { + req := vidReq("did:web:rp.example#key-1", []string{"did"}, nil) + ok := &authzen.EvaluationResponse{Decision: true} + AddDIDVerifierID(ok, req, "did:web:rp.example") + require.NotNil(t, ok.Context) + assert.Equal(t, []authzen.VerifierID{{Type: "did", ID: "did:web:rp.example", Basis: "did-resolution"}}, ok.Context.VerifierIDs, + "the fragment is dropped: the identifier is the DID, not a verification method") + + other := &authzen.EvaluationResponse{Decision: true} + AddDIDVerifierID(other, req, "did:web:other.example") + assert.Nil(t, other.Context, "a document with a different id is never emitted") + + AddDIDVerifierID(&authzen.EvaluationResponse{Decision: true}, nil, "did:web:rp.example") // must not panic +} + +func TestCanonicalForms(t *testing.T) { + assert.Equal(t, "rp.example.com", CanonicalDNSName("RP.Example.COM.")) + assert.Equal(t, "rp.example.com", CanonicalDNSName("rp.example.com")) + assert.Equal(t, "did:web:Example.com:Path", CanonicalDID("did:web:Example.com:Path#key-1"), "no case folding in a DID") + assert.Equal(t, "did:web:example.com", CanonicalDID("did:web:example.com")) +} + +// --------------------------------------------------------------------------- +// Enrichment: x5c-derived identifiers +// --------------------------------------------------------------------------- + +func TestEnrichVerifierIDs_PolicyOffEmitsNothing(t *testing.T) { + cert := makeVIDCert(t, vidCertOpts{orgID: "VATSE-556000000101", policy: rpcert.OIDNCPLegalPerson, dns: []string{"rp.example.com"}}) + res := EnrichX5CResponseWithProfiles(vidReq("x509_san_dns:rp.example.com", nil, nil), cert, rpcert.DefaultProfileRegistry()) + assert.Empty(t, res.VerifierIDs) + + resp := &authzen.EvaluationResponse{Decision: true} + ApplyEnrichmentToResponse(resp, res) + assert.Empty(t, resp.Context.VerifierIDs) +} + +func TestEnrichVerifierIDs_Semantic_LegalPerson(t *testing.T) { + cert := makeVIDCert(t, vidCertOpts{orgID: "VATSE-556000000101", policy: rpcert.OIDNCPLegalPerson}) + req := vidReq("https://rp.example.com", []string{"semantic"}, nil) + res := EnrichX5CResponseWithProfiles(req, cert, rpcert.DefaultProfileRegistry()) + + require.True(t, res.Decision) + assert.Equal(t, []authzen.VerifierID{{ + Type: "semantic", ID: "semantic_id:VATSE-556000000101", Basis: "x5c-chain", + }}, res.VerifierIDs) + + resp := &authzen.EvaluationResponse{Decision: true} + ApplyEnrichmentToResponse(resp, res) + assert.Equal(t, res.VerifierIDs, resp.Context.VerifierIDs) +} + +// ETSI TS 119 475 5.1.5: natural-person semantic identifiers live in serialNumber. +func TestEnrichVerifierIDs_Semantic_NaturalPerson(t *testing.T) { + cert := makeVIDCert(t, vidCertOpts{serial: "PNOSE-196001010101", policy: rpcert.OIDNCPNaturalPerson}) + res := EnrichX5CResponseWithProfiles(vidReq("https://rp.example.com", []string{"semantic"}, nil), cert, rpcert.DefaultProfileRegistry()) + require.True(t, res.Decision) + assert.Equal(t, []authzen.VerifierID{{ + Type: "semantic", ID: "semantic_id:PNOSE-196001010101", Basis: "x5c-chain", + }}, res.VerifierIDs) +} + +// A serialNumber that is not a semantic identifier must not be emitted as +// `semantic`, for a legal or a natural person, with or without a profile. +func TestEnrichVerifierIDs_Semantic_NonSemanticSerialNumberNotEmitted(t *testing.T) { + for _, policy := range []string{rpcert.OIDNCPLegalPerson, rpcert.OIDNCPNaturalPerson, ""} { + cert := makeVIDCert(t, vidCertOpts{serial: "123456", policy: policy}) + res := EnrichX5CResponseWithProfiles(vidReq("https://rp.example.com", []string{"semantic"}, nil), cert, rpcert.DefaultProfileRegistry()) + assert.True(t, res.Decision) + assert.Empty(t, res.VerifierIDs, "policy %q", policy) + } + // And with no profile registry at all. + cert := makeVIDCert(t, vidCertOpts{serial: "123456"}) + assert.Empty(t, EnrichX5CResponse(vidReq("https://rp.example.com", []string{"semantic"}, nil), cert).VerifierIDs) +} + +func TestEnrichVerifierIDs_Semantic_WithoutProfileRegistry(t *testing.T) { + cert := makeVIDCert(t, vidCertOpts{orgID: "VATSE-556000000101"}) + res := EnrichX5CResponse(vidReq("https://rp.example.com", []string{"semantic"}, nil), cert) + require.Len(t, res.VerifierIDs, 1) + assert.Equal(t, "semantic_id:VATSE-556000000101", res.VerifierIDs[0].ID) +} + +func TestEnrichVerifierIDs_DNS_CanonicalFormAndBasis(t *testing.T) { + cert := makeVIDCert(t, vidCertOpts{dns: []string{"rp.example.com"}}) + // Mixed case and a trailing dot are one name: it must be one identifier. + // VerifyLeafBinding is an exact match, so the cert carries the spelling used. + certMixed := makeVIDCert(t, vidCertOpts{dns: []string{"RP.Example.com."}}) + + res := EnrichX5CResponse(vidReq("x509_san_dns:rp.example.com", []string{"dns"}, nil), cert) + assert.Equal(t, []authzen.VerifierID{{Type: "dns", ID: "rp.example.com", Basis: "client-id-binding"}}, res.VerifierIDs) + + res = EnrichX5CResponse(vidReq("x509_san_dns:RP.Example.com.", []string{"dns"}, nil), certMixed) + assert.Equal(t, []authzen.VerifierID{{Type: "dns", ID: "rp.example.com", Basis: "client-id-binding"}}, res.VerifierIDs) +} + +func TestEnrichVerifierIDs_URI_ByteForByte(t *testing.T) { + cert := makeVIDCert(t, vidCertOpts{uris: []string{"https://rp.example.com/Path/"}}) + res := EnrichX5CResponse(vidReq("x509_san_uri:https://rp.example.com/Path/", []string{"uri"}, nil), cert) + assert.Equal(t, []authzen.VerifierID{{Type: "uri", ID: "https://rp.example.com/Path/", Basis: "client-id-binding"}}, res.VerifierIDs, + "case and trailing slash are kept: not re-serialised") + + // A "//" in the value (the credential-offer regression) survives. + cert = makeVIDCert(t, vidCertOpts{uris: []string{"https://rp.example.com//a"}}) + res = EnrichX5CResponse(vidReq("x509_san_uri:https://rp.example.com//a", []string{"uri"}, nil), cert) + require.Len(t, res.VerifierIDs, 1) + assert.Equal(t, "https://rp.example.com//a", res.VerifierIDs[0].ID) +} + +func TestEnrichVerifierIDs_UnverifiedClientIDNeverEchoed(t *testing.T) { + cert := makeVIDCert(t, vidCertOpts{dns: []string{"rp.example.com"}, uris: []string{"https://rp.example.com"}, orgID: "VATSE-1"}) + types := []string{"dns", "uri"} + + // client_id names a host that is not a SAN of the leaf. + res := EnrichX5CResponse(vidReq("x509_san_dns:evil.example.net", types, nil), cert) + assert.Empty(t, res.VerifierIDs) + res = EnrichX5CResponse(vidReq("x509_san_uri:https://evil.example.net", types, nil), cert) + assert.Empty(t, res.VerifierIDs) + + // A scheme with no SAN binding (x509_hash) and a plain URL carry nothing. + res = EnrichX5CResponse(vidReq("x509_hash:abcd", types, nil), cert) + assert.Empty(t, res.VerifierIDs) + res = EnrichX5CResponse(vidReq("https://rp.example.com", types, nil), cert) + assert.Empty(t, res.VerifierIDs) + + // Wildcard client_id is not an identifier of one RP, even if the SAN is literally the same. + wild := makeVIDCert(t, vidCertOpts{dns: []string{"*.example.com"}}) + res = EnrichX5CResponse(vidReq("x509_san_dns:*.example.com", []string{"dns"}, nil), wild) + assert.Empty(t, res.VerifierIDs) + + // A dns binding does not satisfy the uri type and vice versa. + res = EnrichX5CResponse(vidReq("x509_san_dns:rp.example.com", []string{"uri"}, nil), cert) + assert.Empty(t, res.VerifierIDs) +} + +// Nothing is emitted when profile validation failed, even though that is only a +// warning for the decision. +func TestEnrichVerifierIDs_ProfileValidationFailureEmitsNothing(t *testing.T) { + cert := makeVIDCert(t, vidCertOpts{orgID: "VATSE-556000000101", policy: rpcert.OIDNCPLegalPerson, dns: []string{"rp.example.com"}, noKU: true}) + req := vidReq("x509_san_dns:rp.example.com", []string{"semantic", "dns"}, nil) + res := EnrichX5CResponseWithProfiles(req, cert, rpcert.DefaultProfileRegistry()) + + require.True(t, res.Decision, "profile failure is a warning, not a deny") + require.NotEmpty(t, res.ProfileValidationError) + assert.Empty(t, res.VerifierIDs) + + // Apply must not smuggle identifiers in either, even if a caller set them. + res.VerifierIDs = []authzen.VerifierID{{Type: "semantic", ID: "semantic_id:VATSE-1"}} + resp := &authzen.EvaluationResponse{Decision: true} + ApplyEnrichmentToResponse(resp, res) + assert.Empty(t, resp.Context.VerifierIDs) +} + +func TestEnrichVerifierIDs_DenyEmitsNothing(t *testing.T) { + cert := makeVIDCert(t, vidCertOpts{orgID: "VATSE-556000000101", policy: rpcert.OIDNCPLegalPerson}) + + // Enrichment denies (required policy OID missing): nothing is derived. + req := vidReq("https://rp.example.com", []string{"semantic"}, map[string]interface{}{ + "required_cert_policy_oids": []string{"1.2.3.4"}, + }) + res := EnrichX5CResponseWithProfiles(req, cert, rpcert.DefaultProfileRegistry()) + assert.False(t, res.Decision) + assert.Empty(t, res.VerifierIDs) + + // A deny response never gets identifiers, whatever the enrichment says. + good := EnrichX5CResponseWithProfiles(vidReq("https://rp.example.com", []string{"semantic"}, nil), cert, rpcert.DefaultProfileRegistry()) + require.NotEmpty(t, good.VerifierIDs) + deny := &authzen.EvaluationResponse{Decision: false} + ApplyEnrichmentToResponse(deny, good) + assert.Empty(t, deny.Context.VerifierIDs) + + // An enrichment that itself denied (e.g. one a caller built by hand). + good.Decision = false + allow := &authzen.EvaluationResponse{Decision: true} + ApplyEnrichmentToResponse(allow, good) + assert.Empty(t, allow.Context.VerifierIDs) +} + +// Intermediary request: the identifier is the end RP's. The leaf is the end +// RP's certificate (semantic is established); the client_id of a brokered +// request is not shown to name the end RP, so dns/uri are omitted; the +// intermediary's own (unvalidated) chain is never a source. +func TestEnrichVerifierIDs_Intermediary(t *testing.T) { + cert := makeVIDCert(t, vidCertOpts{orgID: "VATSE-556000000101", policy: rpcert.OIDNCPLegalPerson, dns: []string{"rp.example.com"}}) + extra := map[string]interface{}{ + "allow_intermediaries": true, + "intermediary_x5c": []string{"MIIBintermediary"}, + } + res := EnrichX5CResponseWithProfiles( + vidReq("x509_san_dns:rp.example.com", []string{"semantic", "dns"}, extra), cert, rpcert.DefaultProfileRegistry()) + + require.True(t, res.Decision) + require.True(t, res.IsIntermediaryRequest) + got := idsOf(res.VerifierIDs) + require.Contains(t, got, "semantic") + assert.Equal(t, "semantic_id:VATSE-556000000101", got["semantic"].ID) + assert.NotContains(t, got, "dns", "dns cannot be established as the end RP's for an intermediary") + + // Same request without the intermediary: dns is there, so the omission is + // the intermediary's doing. + res = EnrichX5CResponseWithProfiles( + vidReq("x509_san_dns:rp.example.com", []string{"semantic", "dns"}, nil), cert, rpcert.DefaultProfileRegistry()) + assert.Contains(t, idsOf(res.VerifierIDs), "dns") + + // Intermediary with a leaf that has no semantic id: nothing at all. + bare := makeVIDCert(t, vidCertOpts{serial: "123456", dns: []string{"rp.example.com"}}) + res = EnrichX5CResponseWithProfiles( + vidReq("x509_san_dns:rp.example.com", []string{"semantic", "dns"}, extra), bare, rpcert.DefaultProfileRegistry()) + assert.Empty(t, res.VerifierIDs) +} + +func TestEnrichVerifierIDs_HintNarrowsToOneEntry(t *testing.T) { + cert := makeVIDCert(t, vidCertOpts{orgID: "VATSE-556000000101", policy: rpcert.OIDNCPLegalPerson, dns: []string{"rp.example.com"}}) + types := []string{"semantic", "dns"} + profiles := rpcert.DefaultProfileRegistry() + + all := EnrichX5CResponseWithProfiles(vidReq("x509_san_dns:rp.example.com", types, nil), cert, profiles) + assert.Len(t, all.VerifierIDs, 2, "everything the policy enables") + + hinted := map[string]interface{}{authzen.VerifierIDHintContextKey: "dns"} + one := EnrichX5CResponseWithProfiles(vidReq("x509_san_dns:rp.example.com", types, hinted), cert, profiles) + require.Len(t, one.VerifierIDs, 1) + assert.Equal(t, "dns", one.VerifierIDs[0].Type) + + // A hint for a type the policy does not enable returns nothing. + hinted = map[string]interface{}{authzen.VerifierIDHintContextKey: "uri"} + none := EnrichX5CResponseWithProfiles(vidReq("x509_san_dns:rp.example.com", types, hinted), cert, profiles) + assert.Empty(t, none.VerifierIDs) +} + +func TestApplyEnrichmentToResponse_VerifierIDsJSONShape(t *testing.T) { + cert := makeVIDCert(t, vidCertOpts{orgID: "VATSE-556000000101", policy: rpcert.OIDNCPLegalPerson, dns: []string{"rp.example.com"}}) + res := EnrichX5CResponseWithProfiles(vidReq("x509_san_dns:rp.example.com", []string{"semantic", "dns"}, nil), cert, rpcert.DefaultProfileRegistry()) + resp := &authzen.EvaluationResponse{Decision: true} + ApplyEnrichmentToResponse(resp, res) + + b, err := json.Marshal(resp) + require.NoError(t, err) + assert.JSONEq(t, `{"decision":true,"context":{"reason":{"matched_profile":"wrpac"},"trust_metadata":{"rp_profile":"wrpac"},"verifier_ids":[ + {"verifier_id_type":"semantic","verifier_id":"semantic_id:VATSE-556000000101","basis":"x5c-chain"}, + {"verifier_id_type":"dns","verifier_id":"rp.example.com","basis":"client-id-binding"}]}}`, string(b)) +} diff --git a/pkg/registry/x5c_enrichment.go b/pkg/registry/x5c_enrichment.go index 662c7e72..6a4c258c 100644 --- a/pkg/registry/x5c_enrichment.go +++ b/pkg/registry/x5c_enrichment.go @@ -2,6 +2,7 @@ package registry import ( "crypto/x509" + "strings" "github.com/sirosfoundation/go-trust/pkg/authzen" "github.com/sirosfoundation/go-trust/pkg/registry/rpcert" @@ -54,6 +55,12 @@ type X5CEnrichmentResult struct { // organisation-level binding check. WRPACServiceID string + // VerifierIDs are the verifier identifiers derived from the verified leaf + // certificate, for the types the policy enabled (return_verifier_ids). It is + // empty unless Decision is true and ProfileValidationError is empty: nothing + // is derived from a certificate that failed profile validation. + VerifierIDs []authzen.VerifierID + // FailureReason is set when Decision is false, describing why. FailureReason map[string]interface{} } @@ -194,6 +201,13 @@ func EnrichX5CResponseWithProfiles(req *authzen.EvaluationRequest, leaf *x509.Ce } } + // Verifier identifiers: only from a certificate that passed every check + // above, including the matched profile's validation. A profile failure is + // otherwise only a warning, so it has to be tested for explicitly here. + if result.Decision && result.ProfileValidationError == "" { + result.VerifierIDs = deriveX5CVerifierIDs(req, leaf, result.IsIntermediaryRequest) + } + return result } @@ -249,6 +263,12 @@ func ApplyEnrichmentToResponse(resp *authzen.EvaluationResponse, enrichment *X5C } } + // Verifier identifiers, fail closed: never on a deny, never when the profile + // validation failed, never from a failed enrichment. + if resp.Decision && enrichment.Decision && enrichment.ProfileValidationError == "" { + resp.Context.VerifierIDs = append(resp.Context.VerifierIDs, enrichment.VerifierIDs...) + } + // Add profile validation warnings if any if enrichment.ProfileValidationError != "" { resp.Context.Reason["profile_validation_warning"] = enrichment.ProfileValidationError @@ -443,3 +463,70 @@ func isIntermediariesAllowed(req *authzen.EvaluationRequest) bool { b, ok := v.(bool) return ok && b } + +// deriveX5CVerifierIDs derives the verifier identifiers of the relying party +// from its verified leaf certificate, for the types the policy enabled. +// The chain has already been validated by the caller. +// +// - semantic: the ETSI semantic identifier from the leaf (basis x5c-chain), +// validated against the semantic-id syntax; "semantic_id:" + the value. +// - dns / uri: the client_id of an x509_san_dns / x509_san_uri request, but +// only after VerifyLeafBinding confirms it is a SAN of this leaf (basis +// client-id-binding). The client_id is never echoed otherwise. +// +// For an intermediary request the identifier must be the end relying party's. +// The leaf is the end RP's certificate (the intermediary's own chain is +// unvalidated and is never read here), so semantic is established. The +// client_id of a brokered request cannot be shown to name the end RP, so dns +// and uri are omitted. +func deriveX5CVerifierIDs(req *authzen.EvaluationRequest, leaf *x509.Certificate, intermediary bool) []authzen.VerifierID { + enabled := VerifierIDTypesEnabled(req) + if len(enabled) == 0 || leaf == nil { + return nil + } + want := make(map[string]bool, len(enabled)) + for _, t := range enabled { + want[t] = true + } + + var ids []authzen.VerifierID + if want[authzen.VerifierIDTypeSemantic] { + if sem, ok := rpcert.SemanticIdentifier(leaf); ok { + ids = append(ids, authzen.VerifierID{ + Type: authzen.VerifierIDTypeSemantic, + ID: authzen.VerifierIDSemanticPrefix + sem, + Basis: authzen.VerifierIDBasisX5CChain, + }) + } + } + + if intermediary || (!want[authzen.VerifierIDTypeDNS] && !want[authzen.VerifierIDTypeURI]) { + return ids + } + scheme, value, ok := ParseClientIDScheme(OriginalSubjectID(req)) + if !ok || value == "" { + return ids + } + switch scheme { + case ClientIDSchemeX509SANDNS: + // A wildcard is not an identifier of one RP. + if want[authzen.VerifierIDTypeDNS] && !strings.Contains(value, "*") && VerifyLeafBinding(scheme, value, leaf) == nil { + ids = append(ids, authzen.VerifierID{ + Type: authzen.VerifierIDTypeDNS, + ID: CanonicalDNSName(value), + Basis: authzen.VerifierIDBasisClientIDBinding, + }) + } + case ClientIDSchemeX509SANURI: + // Byte-for-byte: URISANMatches is an exact comparison, so the bound + // value already is the SAN. Not re-serialised through net/url. + if want[authzen.VerifierIDTypeURI] && VerifyLeafBinding(scheme, value, leaf) == nil { + ids = append(ids, authzen.VerifierID{ + Type: authzen.VerifierIDTypeURI, + ID: value, + Basis: authzen.VerifierIDBasisClientIDBinding, + }) + } + } + return ids +} diff --git a/pkg/testserver/verifierids_test.go b/pkg/testserver/verifierids_test.go new file mode 100644 index 00000000..e714d971 --- /dev/null +++ b/pkg/testserver/verifierids_test.go @@ -0,0 +1,48 @@ +package testserver_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/sirosfoundation/go-trust/pkg/authzen" + "github.com/sirosfoundation/go-trust/pkg/authzenclient" + "github.com/sirosfoundation/go-trust/pkg/testserver" +) + +// A DecisionFunc can set verifier_ids, and a Go caller reads them through +// authzenclient after a real HTTP round trip through the typed structs on both +// sides, which is where an unknown member would be dropped. +func TestDecisionFunc_VerifierIDsRoundTrip(t *testing.T) { + var sawHint interface{} + srv := testserver.New(testserver.WithDecisionFunc(func(req *authzen.EvaluationRequest) (*authzen.EvaluationResponse, error) { + sawHint = req.Context[authzen.VerifierIDHintContextKey] + return &authzen.EvaluationResponse{ + Decision: true, + Context: &authzen.EvaluationResponseContext{ + VerifierIDs: []authzen.VerifierID{ + {Type: authzen.VerifierIDTypeSemantic, ID: "semantic_id:VATSE-556000000101", Basis: authzen.VerifierIDBasisX5CChain}, + {Type: authzen.VerifierIDTypeDNS, ID: "rp.example.com", Basis: authzen.VerifierIDBasisClientIDBinding}, + }, + }, + }, nil + })) + defer srv.Close() + + client := authzenclient.New(srv.URL()) + resp, err := client.Evaluate(context.Background(), &authzen.EvaluationRequest{ + Subject: authzen.Subject{Type: "key", ID: "https://rp.example.com"}, + Resource: authzen.Resource{Type: "x5c", ID: "https://rp.example.com", Key: []interface{}{"MIIC"}}, + Context: map[string]interface{}{authzen.VerifierIDHintContextKey: "dns"}, + }) + require.NoError(t, err) + require.True(t, resp.Decision) + require.NotNil(t, resp.Context) + assert.Equal(t, []authzen.VerifierID{ + {Type: "semantic", ID: "semantic_id:VATSE-556000000101", Basis: "x5c-chain"}, + {Type: "dns", ID: "rp.example.com", Basis: "client-id-binding"}, + }, resp.Context.VerifierIDs) + assert.Equal(t, "dns", sawHint, "the request hint reaches the PDP") +} From 34f06b3f9e62e74bad5f71d0eae4783f1f6edc9d Mon Sep 17 00:00:00 2001 From: Leif Johansson Date: Wed, 14 Oct 2026 11:51:36 +0200 Subject: [PATCH 02/10] authzen: address review findings on verifier_ids - Contradictory WRPAC policy OIDs (natural and legal) are now their own SubjectKind and SemanticIdentifier refuses them, instead of treating them like a policy-less certificate. - WRPACProfile.ExtractIdentity keeps a natural person's serialNumber when the certificate also carries an affiliated organisation's 2.5.4.97, and exposes both. - PolicyManager.RegisterPolicy and SetDefaultPolicy validate the constraints and return an error, so the programmatic installation path rejects unknown return_verifier_ids types as the config path does. Refs #203 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01VS8tGFQncGyEhFwkQecjPB --- cmd/gt/main.go | 15 ++++---- pkg/registry/policy.go | 22 +++++++++--- pkg/registry/policy_verifierids_test.go | 23 +++++++++++++ pkg/registry/rpcert/semanticid.go | 22 +++++++++--- pkg/registry/rpcert/semanticid_test.go | 46 ++++++++++++++++++++++++- pkg/registry/rpcert/wrpac.go | 7 +++- 6 files changed, 116 insertions(+), 19 deletions(-) diff --git a/cmd/gt/main.go b/cmd/gt/main.go index aef149db..3b783e35 100644 --- a/cmd/gt/main.go +++ b/cmd/gt/main.go @@ -1374,14 +1374,12 @@ func configurePoliciesFromConfig(cfg *config.Config, registryMgr *registry.Regis } } - // config.Validate rejects unknown verifier id types at startup; this - // keeps any other caller from installing an unvalidated policy. - if err := policy.Constraints.Validate(); err != nil { - logger.Fatal("Invalid policy constraints", + // config.Validate rejects unknown verifier id types at startup; + // RegisterPolicy re-checks so no caller can install an unvalidated one. + if err := policyMgr.RegisterPolicy(policy); err != nil { + logger.Fatal("Invalid policy", logging.F("policy", name), logging.F("error", err.Error())) } - - policyMgr.RegisterPolicy(policy) policyCount++ logger.Debug("Registered policy from config", @@ -1395,7 +1393,10 @@ func configurePoliciesFromConfig(cfg *config.Config, registryMgr *registry.Regis if cfg.Policies.DefaultPolicy != "" { defaultPolicy := policyMgr.GetPolicy(cfg.Policies.DefaultPolicy) if defaultPolicy != nil { - policyMgr.SetDefaultPolicy(defaultPolicy) + if err := policyMgr.SetDefaultPolicy(defaultPolicy); err != nil { + logger.Fatal("Invalid default policy", + logging.F("policy", cfg.Policies.DefaultPolicy), logging.F("error", err.Error())) + } logger.Info("Default policy set from config", logging.F("policy", cfg.Policies.DefaultPolicy)) } else { diff --git a/pkg/registry/policy.go b/pkg/registry/policy.go index d1a0288b..fbcf707b 100644 --- a/pkg/registry/policy.go +++ b/pkg/registry/policy.go @@ -311,18 +311,30 @@ func NewPolicyManager() *PolicyManager { } } -// RegisterPolicy adds a policy to the manager. -func (pm *PolicyManager) RegisterPolicy(policy *Policy) { +// RegisterPolicy adds a policy to the manager. A policy whose constraints fail +// validation (an unknown return_verifier_ids type) is not registered and the +// error is returned, so that a mis-typed policy is rejected at installation +// rather than silently returning nothing. +func (pm *PolicyManager) RegisterPolicy(policy *Policy) error { + if err := policy.Constraints.Validate(); err != nil { + return fmt.Errorf("policy %q: %w", policy.Name, err) + } pm.policies[policy.Name] = policy if len(policy.Registries) > 0 { pm.registryFilter[policy.Name] = policy.Registries } + return nil } -// SetDefaultPolicy sets the policy used when action.name is not specified. -func (pm *PolicyManager) SetDefaultPolicy(policy *Policy) { +// SetDefaultPolicy sets the policy used when action.name is not specified. It +// returns the validation error of RegisterPolicy, in which case the default is +// left unchanged. +func (pm *PolicyManager) SetDefaultPolicy(policy *Policy) error { + if err := pm.RegisterPolicy(policy); err != nil { + return err + } pm.defaultPolicy = policy - pm.RegisterPolicy(policy) + return nil } // GetPolicy returns the policy for the given action name. diff --git a/pkg/registry/policy_verifierids_test.go b/pkg/registry/policy_verifierids_test.go index 738d64d6..e506e444 100644 --- a/pkg/registry/policy_verifierids_test.go +++ b/pkg/registry/policy_verifierids_test.go @@ -8,6 +8,29 @@ import ( "github.com/stretchr/testify/require" ) +// The public installation path validates, so a library user cannot install a +// policy whose unknown type would silently return nothing. +func TestPolicyManager_RegisterPolicyRejectsUnknownVerifierIDType(t *testing.T) { + pm := NewPolicyManager() + + bad := &Policy{Name: "bad", Constraints: PolicyConstraints{ReturnVerifierIDs: []string{"semantic", "sematic"}}} + err := pm.RegisterPolicy(bad) + require.Error(t, err) + assert.Contains(t, err.Error(), `"sematic"`) + assert.Contains(t, err.Error(), `"bad"`) + assert.Nil(t, pm.GetPolicy("bad"), "a rejected policy is not installed") + + require.NoError(t, pm.RegisterPolicy(&Policy{Name: "ok", Constraints: PolicyConstraints{ReturnVerifierIDs: []string{"dns"}}})) + assert.NotNil(t, pm.GetPolicy("ok")) + + // SetDefaultPolicy goes through the same check and leaves the default alone on error. + require.Error(t, pm.SetDefaultPolicy(bad)) + assert.Nil(t, pm.GetPolicy("bad")) + good := &Policy{Name: "dflt"} + require.NoError(t, pm.SetDefaultPolicy(good)) + assert.NotNil(t, pm.GetPolicy("dflt")) +} + func TestPolicyConstraintsValidate_VerifierIDs(t *testing.T) { assert.NoError(t, PolicyConstraints{}.Validate()) assert.NoError(t, PolicyConstraints{ReturnVerifierIDs: authzen.SupportedVerifierIDTypes()}.Validate()) diff --git a/pkg/registry/rpcert/semanticid.go b/pkg/registry/rpcert/semanticid.go index d0d56a6c..93b06710 100644 --- a/pkg/registry/rpcert/semanticid.go +++ b/pkg/registry/rpcert/semanticid.go @@ -12,6 +12,10 @@ const ( // SubjectKindUnknown means the certificate carries no (or contradictory) // WRPAC policy OIDs, so its kind cannot be told from the policy. SubjectKindUnknown SubjectKind = iota + // SubjectKindContradictory means the certificate carries both natural- and + // legal-person WRPAC policy OIDs. It is not "unclassified": consumers that + // accept unclassified certificates must still refuse this one. + SubjectKindContradictory // SubjectKindNaturalPerson is a certificate under NCP-n-eudiwrp or QCP-n-eudiwrp. SubjectKindNaturalPerson // SubjectKindLegalPerson is a certificate under NCP-l-eudiwrp or QCP-l-eudiwrp. @@ -20,8 +24,8 @@ const ( // SubjectKindFromPolicies classifies cert by its WRPAC certificate policy OIDs // (ETSI TS 119 411-8: the -n and -l policies are what distinguish natural from -// legal person certificates). A certificate carrying both kinds, or neither, -// is SubjectKindUnknown. +// legal person certificates). A certificate carrying neither kind is +// SubjectKindUnknown; one carrying both is SubjectKindContradictory. func SubjectKindFromPolicies(cert *x509.Certificate) SubjectKind { if cert == nil { return SubjectKindUnknown @@ -36,9 +40,11 @@ func SubjectKindFromPolicies(cert *x509.Certificate) SubjectKind { } } switch { - case natural && !legal: + case natural && legal: + return SubjectKindContradictory + case natural: return SubjectKindNaturalPerson - case legal && !natural: + case legal: return SubjectKindLegalPerson } return SubjectKindUnknown @@ -92,12 +98,18 @@ func realOrganizationIdentifier(cert *x509.Certificate) string { // policy OIDs tell the two apart. A serialNumber that is merely a certificate // serial (or anything else non-semantic) is therefore never returned. An // organizationIdentifier that is present but not valid is not papered over by -// falling back to serialNumber. +// falling back to serialNumber. A certificate carrying both natural- and +// legal-person policy OIDs has no semantic identifier. func SemanticIdentifier(cert *x509.Certificate) (string, bool) { if cert == nil { return "", false } kind := SubjectKindFromPolicies(cert) + if kind == SubjectKindContradictory { + // Both a natural- and a legal-person policy: which identifier is the + // subject's cannot be told, so none is returned (fail closed). + return "", false + } var candidate string if kind == SubjectKindNaturalPerson { diff --git a/pkg/registry/rpcert/semanticid_test.go b/pkg/registry/rpcert/semanticid_test.go index 621f67dd..f8ede595 100644 --- a/pkg/registry/rpcert/semanticid_test.go +++ b/pkg/registry/rpcert/semanticid_test.go @@ -70,6 +70,26 @@ func TestSemanticIdentifier(t *testing.T) { } } +// Contradictory policies are not "unclassified": a policy-less certificate may +// yield a semantic identifier, one claiming to be both natural and legal may not. +func TestSemanticIdentifier_ContradictoryPoliciesFailClosed(t *testing.T) { + both := []asn1.ObjectIdentifier{parseOID(OIDNCPNaturalPerson), parseOID(OIDNCPLegalPerson)} + for _, subject := range []pkix.Name{ + orgIDName("VATSE-556000000101", ""), + orgIDName("", "PNOSE-196001010101"), + orgIDName("", "VATSE-556000000101"), + } { + cert := makeCert(t, certOpts{subject: subject, policyOIDs: both}) + if got, ok := SemanticIdentifier(cert); ok || got != "" { + t.Fatalf("contradictory policies must not yield a semantic id, got (%q, %v)", got, ok) + } + // Same subject without any policy OID is accepted: the refusal is the contradiction's. + if _, ok := SemanticIdentifier(makeCert(t, certOpts{subject: subject})); !ok { + t.Fatalf("policy-less certificate with %v should still yield one", subject) + } + } +} + func TestSubjectKindFromPolicies(t *testing.T) { cases := []struct { name string @@ -81,7 +101,7 @@ func TestSubjectKindFromPolicies(t *testing.T) { {"QCP-n", []string{OIDQCPNaturalPerson}, SubjectKindNaturalPerson}, {"NCP-l", []string{OIDNCPLegalPerson}, SubjectKindLegalPerson}, {"QCP-l", []string{OIDQCPLegalPerson}, SubjectKindLegalPerson}, - {"both is contradictory", []string{OIDNCPNaturalPerson, OIDNCPLegalPerson}, SubjectKindUnknown}, + {"both is contradictory", []string{OIDNCPNaturalPerson, OIDNCPLegalPerson}, SubjectKindContradictory}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -105,6 +125,30 @@ func TestSubjectKindFromPolicies(t *testing.T) { // person's semantic identifier (TS 119 475 5.1.5), not an organisation // identifier, and a natural person affiliated with an organisation is still a // natural person. +func TestWRPACProfile_ExtractIdentity_NaturalPersonWithAffiliationID(t *testing.T) { + p := NewWRPACProfile() + cert := makeCert(t, certOpts{ + // serialNumber is the person's identifier; 2.5.4.97 names the affiliated organisation. + subject: orgIDName("VATSE-556000000101", "PNOSE-196001010101"), + keyUsage: x509.KeyUsageContentCommitment, + policyOIDs: []asn1.ObjectIdentifier{parseOID(OIDNCPNaturalPerson)}, + emails: []string{"alice@example.test"}, + }) + identity, err := p.ExtractIdentity(cert) + if err != nil { + t.Fatal(err) + } + if identity["subject_type"] != "natural_person" { + t.Errorf("subject_type = %v, want natural_person", identity["subject_type"]) + } + if identity["serial_number"] != "PNOSE-196001010101" { + t.Errorf("serial_number = %v, want the person's identifier preserved", identity["serial_number"]) + } + if identity["organization_identifier"] != "VATSE-556000000101" { + t.Errorf("organization_identifier = %v, want the real organisation identifier exposed", identity["organization_identifier"]) + } +} + func TestWRPACProfile_ExtractIdentity_NaturalPersonSerialNumber(t *testing.T) { p := NewWRPACProfile() cert := makeCert(t, certOpts{ diff --git a/pkg/registry/rpcert/wrpac.go b/pkg/registry/rpcert/wrpac.go index c896ba99..b17bced0 100644 --- a/pkg/registry/rpcert/wrpac.go +++ b/pkg/registry/rpcert/wrpac.go @@ -205,7 +205,12 @@ func (p *WRPACProfile) ExtractIdentity(credential interface{}) (map[string]inter } if orgID != "" { identity["organization_identifier"] = orgID - delete(identity, "serial_number") + // Only an organisation identifier that came from serialNumber replaces + // it. A natural person's serialNumber is their own identifier and stays, + // next to the affiliated organisation's real identifier. + if !naturalPerson { + delete(identity, "serial_number") + } } // Determine subject type (natural vs legal person). Keyed off the same From 61086095d9264fd54224f66548f843520e0e9113 Mon Sep 17 00:00:00 2001 From: Leif Johansson Date: Wed, 14 Oct 2026 12:04:00 +0200 Subject: [PATCH 03/10] authzen: compare oidf_entity with the raw requested identifier extractEntityID strips trailing slashes for resolution, so comparing the chain's leaf subject with it could emit an identifier that differs from what the request named. Compare against the raw requested identifier instead. Also split deriveX5CVerifierIDs and ExtractIdentity's subject_type decision into helpers to bring their cognitive complexity under the Sonar threshold. Refs #203 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01VS8tGFQncGyEhFwkQecjPB --- pkg/registry/oidfed/oidfed_registry.go | 23 ++++++++++++--- pkg/registry/oidfed/verifierid_test.go | 11 +++++++ pkg/registry/rpcert/wrpac.go | 16 +++++++---- pkg/registry/x5c_enrichment.go | 40 +++++++++++++++----------- 4 files changed, 65 insertions(+), 25 deletions(-) diff --git a/pkg/registry/oidfed/oidfed_registry.go b/pkg/registry/oidfed/oidfed_registry.go index ed264bd2..c14ae525 100644 --- a/pkg/registry/oidfed/oidfed_registry.go +++ b/pkg/registry/oidfed/oidfed_registry.go @@ -867,10 +867,12 @@ func (r *OIDFedRegistry) Evaluate(ctx context.Context, req *authzen.EvaluationRe }, } // The Entity ID is returned only when the validated chain's leaf statement - // is about that very entity, and byte-for-byte as in the chain: an entity - // ID is compared exactly, so a trailing slash or case change names another - // entity. It is not re-serialised through net/url. - if len(chain) > 0 && chain[0].Subject == entityID { + // is about the entity as the request named it, byte-for-byte. An entity ID + // is compared exactly, so a trailing slash or case change names another + // entity; entityID above has had trailing slashes stripped for resolution, + // so the raw requested identifier is compared instead. Not re-serialised + // through net/url. + if len(chain) > 0 && chain[0].Subject == rawRequestedEntityID(req) { registry.AddVerifierID(resp, req, authzen.VerifierIDTypeOIDFEntity, chain[0].Subject, authzen.VerifierIDBasisOIDFTrustChain) } return resp, nil @@ -1436,6 +1438,19 @@ func (r *OIDFedRegistry) extractEntityID(req *authzen.EvaluationRequest) (string return "", fmt.Errorf("no entity_id found in request subject or resource") } +// rawRequestedEntityID returns the entity identifier exactly as the request +// named it (the same field extractEntityID reads, without its trailing-slash +// normalisation), or "" if the request names none. +func rawRequestedEntityID(req *authzen.EvaluationRequest) string { + if req.Subject.Type == "key" && (strings.HasPrefix(req.Subject.ID, "http://") || strings.HasPrefix(req.Subject.ID, "https://")) { + return req.Subject.ID + } + if strings.HasPrefix(req.Resource.ID, "http://") || strings.HasPrefix(req.Resource.ID, "https://") { + return req.Resource.ID + } + return "" +} + // extractMetadata extracts useful metadata from the trust chain. func (r *OIDFedRegistry) extractMetadata(chain oidfed.TrustChain) map[string]interface{} { metadata := make(map[string]interface{}) diff --git a/pkg/registry/oidfed/verifierid_test.go b/pkg/registry/oidfed/verifierid_test.go index 835b3247..1e5574e3 100644 --- a/pkg/registry/oidfed/verifierid_test.go +++ b/pkg/registry/oidfed/verifierid_test.go @@ -88,6 +88,17 @@ func TestEvaluate_VerifierIDs_OIDFEntity(t *testing.T) { require.True(t, resp.Decision) assert.Empty(t, resp.Context.VerifierIDs) + // A request that names the entity with a trailing slash resolves the same + // chain (the slash is stripped for resolution) but must not get an + // identifier that differs from what it asked for byte-for-byte. + slash := request(enabled, true) + slash.Subject.ID = entityID + "/" + slash.Resource.ID = entityID + "/" + resp, err = reg.Evaluate(context.Background(), slash) + require.NoError(t, err) + require.True(t, resp.Decision, "resolution still normalises the identifier") + assert.Empty(t, resp.Context.VerifierIDs) + // A chain whose leaf statement is about a different entity is never echoed // under the requested entity's name. reg.cache.Set(entityID, nil, nil, []oidfed.TrustChain{chainFor("https://other.example.com")}, "https://ta.example.com") diff --git a/pkg/registry/rpcert/wrpac.go b/pkg/registry/rpcert/wrpac.go index b17bced0..7b0f8570 100644 --- a/pkg/registry/rpcert/wrpac.go +++ b/pkg/registry/rpcert/wrpac.go @@ -218,11 +218,7 @@ func (p *WRPACProfile) ExtractIdentity(credential interface{}) (map[string]inter // directly classified a conformant legal-person certificate, which // carries the value in 2.5.4.97, as a natural person. Reusing the one // value keeps the two decisions from ever disagreeing. - if !naturalPerson && len(cert.Subject.Organization) > 0 && orgID != "" { - identity["subject_type"] = "legal_person" - } else { - identity["subject_type"] = "natural_person" - } + identity["subject_type"] = wrpacSubjectType(cert, orgID, naturalPerson) // Certificate policy classification policyOIDStrs := CertPolicyOIDStrings(cert) @@ -352,3 +348,13 @@ func SubjectOrganizationIdentifier(cert *x509.Certificate) string { } return cert.Subject.SerialNumber } + +// wrpacSubjectType classifies the subject as "legal_person" or "natural_person". +// A certificate whose policy OIDs say natural person is one; otherwise it is a +// legal person when it names an organisation and has an organisation identifier. +func wrpacSubjectType(cert *x509.Certificate, orgID string, naturalPerson bool) string { + if !naturalPerson && len(cert.Subject.Organization) > 0 && orgID != "" { + return "legal_person" + } + return "natural_person" +} diff --git a/pkg/registry/x5c_enrichment.go b/pkg/registry/x5c_enrichment.go index 6a4c258c..03461f36 100644 --- a/pkg/registry/x5c_enrichment.go +++ b/pkg/registry/x5c_enrichment.go @@ -500,33 +500,41 @@ func deriveX5CVerifierIDs(req *authzen.EvaluationRequest, leaf *x509.Certificate } } - if intermediary || (!want[authzen.VerifierIDTypeDNS] && !want[authzen.VerifierIDTypeURI]) { + // dns / uri: not for an intermediary request (see above). + if intermediary { return ids } + if id, ok := deriveSANVerifierID(req, leaf, want); ok { + ids = append(ids, id) + } + return ids +} + +// deriveSANVerifierID derives the dns or uri identifier from the client_id of +// an x509_san_dns / x509_san_uri request, only when the type is wanted and +// VerifyLeafBinding confirms the value is a SAN of leaf. +func deriveSANVerifierID(req *authzen.EvaluationRequest, leaf *x509.Certificate, want map[string]bool) (authzen.VerifierID, bool) { scheme, value, ok := ParseClientIDScheme(OriginalSubjectID(req)) if !ok || value == "" { - return ids + return authzen.VerifierID{}, false } + var idType, id string switch scheme { case ClientIDSchemeX509SANDNS: // A wildcard is not an identifier of one RP. - if want[authzen.VerifierIDTypeDNS] && !strings.Contains(value, "*") && VerifyLeafBinding(scheme, value, leaf) == nil { - ids = append(ids, authzen.VerifierID{ - Type: authzen.VerifierIDTypeDNS, - ID: CanonicalDNSName(value), - Basis: authzen.VerifierIDBasisClientIDBinding, - }) + if strings.Contains(value, "*") { + return authzen.VerifierID{}, false } + idType, id = authzen.VerifierIDTypeDNS, CanonicalDNSName(value) case ClientIDSchemeX509SANURI: // Byte-for-byte: URISANMatches is an exact comparison, so the bound // value already is the SAN. Not re-serialised through net/url. - if want[authzen.VerifierIDTypeURI] && VerifyLeafBinding(scheme, value, leaf) == nil { - ids = append(ids, authzen.VerifierID{ - Type: authzen.VerifierIDTypeURI, - ID: value, - Basis: authzen.VerifierIDBasisClientIDBinding, - }) - } + idType, id = authzen.VerifierIDTypeURI, value + default: + return authzen.VerifierID{}, false } - return ids + if !want[idType] || VerifyLeafBinding(scheme, value, leaf) != nil { + return authzen.VerifierID{}, false + } + return authzen.VerifierID{Type: idType, ID: id, Basis: authzen.VerifierIDBasisClientIDBinding}, true } From 4b963c43ba6fa5916abf373247f091ae6c3406b7 Mon Sep 17 00:00:00 2001 From: Leif Johansson Date: Wed, 14 Oct 2026 12:16:16 +0200 Subject: [PATCH 04/10] authzen: do not fall back to serialNumber for a malformed organizationIdentifier SemanticIdentifier used the serialNumber fallback whenever the 2.5.4.97 value was empty, which also covered an attribute that is present but empty or not a string. Track presence separately so only an absent attribute falls back. Refs #203 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01VS8tGFQncGyEhFwkQecjPB --- pkg/registry/rpcert/semanticid.go | 28 +++++++++++++++++++------- pkg/registry/rpcert/semanticid_test.go | 20 ++++++++++++++++++ 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/pkg/registry/rpcert/semanticid.go b/pkg/registry/rpcert/semanticid.go index 93b06710..a063ec95 100644 --- a/pkg/registry/rpcert/semanticid.go +++ b/pkg/registry/rpcert/semanticid.go @@ -68,17 +68,28 @@ func IsSemanticIdentifier(s string) bool { return semanticIDPattern.MatchString(s) } -// realOrganizationIdentifier returns only the organizationIdentifier attribute -// (2.5.4.97), without the serialNumber fallback of SubjectOrganizationIdentifier. -func realOrganizationIdentifier(cert *x509.Certificate) string { +// organizationIdentifierAttr reports whether the certificate carries an +// organizationIdentifier attribute (2.5.4.97) at all, and its value when it is a +// non-empty string. present is true for an attribute that is empty or not a +// string, with value "": presence is what tells "absent" from "malformed", and +// only the former may fall back to serialNumber. +func organizationIdentifierAttr(cert *x509.Certificate) (value string, present bool) { for _, atv := range cert.Subject.Names { if atv.Type.Equal(oidOrganizationIdentifier) { if v, ok := atv.Value.(string); ok && v != "" { - return v + return v, true } + present = true } } - return "" + return "", present +} + +// realOrganizationIdentifier returns only the organizationIdentifier attribute +// (2.5.4.97), without the serialNumber fallback of SubjectOrganizationIdentifier. +func realOrganizationIdentifier(cert *x509.Certificate) string { + v, _ := organizationIdentifierAttr(cert) + return v } // SemanticIdentifier returns the ETSI semantic identifier of the certificate's @@ -115,8 +126,11 @@ func SemanticIdentifier(cert *x509.Certificate) (string, bool) { if kind == SubjectKindNaturalPerson { candidate = cert.Subject.SerialNumber } else { - candidate = realOrganizationIdentifier(cert) - if candidate == "" { + var present bool + candidate, present = organizationIdentifierAttr(cert) + if !present { + // Legacy certificates and fixtures: serialNumber, but only when + // 2.5.4.97 is absent. A malformed one is not papered over. candidate = cert.Subject.SerialNumber } } diff --git a/pkg/registry/rpcert/semanticid_test.go b/pkg/registry/rpcert/semanticid_test.go index f8ede595..ce648c21 100644 --- a/pkg/registry/rpcert/semanticid_test.go +++ b/pkg/registry/rpcert/semanticid_test.go @@ -90,6 +90,26 @@ func TestSemanticIdentifier_ContradictoryPoliciesFailClosed(t *testing.T) { } } +// An organizationIdentifier that is present but empty or not a string is +// malformed, not absent: it must not be papered over by a valid serialNumber. +func TestSemanticIdentifier_MalformedOrganizationIdentifierDoesNotFallBack(t *testing.T) { + for name, value := range map[string]interface{}{"empty string": "", "non-string value": 42} { + for _, policies := range [][]asn1.ObjectIdentifier{nil, {parseOID(OIDNCPLegalPerson)}} { + subject := pkix.Name{ + Country: []string{"SE"}, Organization: []string{"Test AB"}, SerialNumber: "VATSE-556000000101", + ExtraNames: []pkix.AttributeTypeAndValue{{Type: oidOrganizationIdentifier, Value: value}}, + } + cert := makeCert(t, certOpts{subject: subject, policyOIDs: policies}) + if _, present := organizationIdentifierAttr(cert); !present { + t.Fatalf("%s: fixture must carry the attribute", name) + } + if got, ok := SemanticIdentifier(cert); ok || got != "" { + t.Fatalf("%s (policies %v): got (%q, %v), want none", name, policies, got, ok) + } + } + } +} + func TestSubjectKindFromPolicies(t *testing.T) { cases := []struct { name string From 8b175e8e672aa10b014e301d3d360d16cb4ccf0e Mon Sep 17 00:00:00 2001 From: Leif Johansson Date: Wed, 14 Oct 2026 12:25:14 +0200 Subject: [PATCH 05/10] docs: list best_match among strategies that drop verifier_ids; dedupe URL prefix check best_match delegates to the aggregating strategy and builds a new response, so it does not carry verifier_ids; say so in the README. Replace the repeated http/https prefix literals in the oidfed registry with a helper (Sonar S1192). Refs #203 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01VS8tGFQncGyEhFwkQecjPB --- README.md | 2 +- pkg/registry/oidfed/oidfed_registry.go | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index c471a462..fd5ce83c 100644 --- a/README.md +++ b/README.md @@ -862,7 +862,7 @@ Rules: - **Fail closed.** Nothing is emitted for a deny decision, when the matched profile's validation failed (normally only a warning), or when the identifier was not verified: `dns`/`uri` need `VerifyLeafBinding` to pass, `did` needs a successful resolution and a matching key, `oidf_entity` needs a validated trust chain and a matching key. An unverified `client_id` is never echoed. Resolution-only responses carry no `verifier_ids`. - **`semantic` comes from the access certificate only.** The registration certificate is not an input today, so wallets must not assume registration-certificate precedence. A `serialNumber` that is not a semantic identifier (for example a plain certificate serial) is never emitted as `semantic`. For a certificate classified by its WRPAC policy OIDs, the type must fit the subject: natural-person types (`PNO`, `IDC`, `PAS`, `TAX`, `TIN`) are read from `serialNumber` of natural-person certificates only, and refused for legal-person ones. - **Intermediary requests** (`allow_intermediaries`): the identifier is the end relying party's. `semantic` is taken from the RP leaf; `dns` and `uri` are omitted because the `client_id` of a brokered request cannot be shown to name the end relying party. The intermediary's own, unvalidated chain is never a source. -- The member is carried through by the strategies that return the matching registry's own response (`first_match`, `sequential`); the aggregating `all` strategy and composite registries build a new response and do not carry it. +- The member is carried through by the strategies that return the matching registry's own response (`first_match`, `sequential`); the aggregating `all` and `best_match` strategies and composite registries build a new response and do not carry it. - Not offered: `did_scid`, `service` and `spki_sha256` are not implemented yet and are rejected in `return_verifier_ids`. ## Deployment diff --git a/pkg/registry/oidfed/oidfed_registry.go b/pkg/registry/oidfed/oidfed_registry.go index c14ae525..cef1d22b 100644 --- a/pkg/registry/oidfed/oidfed_registry.go +++ b/pkg/registry/oidfed/oidfed_registry.go @@ -1423,14 +1423,14 @@ func (r *OIDFedRegistry) extractEntityID(req *authzen.EvaluationRequest) (string // Try subject.entity_id or subject.id first if req.Subject.Type == "key" && req.Subject.ID != "" { // Check if ID looks like a URL (entity identifier) - if strings.HasPrefix(req.Subject.ID, "http://") || strings.HasPrefix(req.Subject.ID, "https://") { + if isHTTPURL(req.Subject.ID) { return strings.TrimRight(req.Subject.ID, "/"), nil } } // Try resource.entity_id or resource.id if req.Resource.ID != "" { - if strings.HasPrefix(req.Resource.ID, "http://") || strings.HasPrefix(req.Resource.ID, "https://") { + if isHTTPURL(req.Resource.ID) { return strings.TrimRight(req.Resource.ID, "/"), nil } } @@ -1438,14 +1438,19 @@ func (r *OIDFedRegistry) extractEntityID(req *authzen.EvaluationRequest) (string return "", fmt.Errorf("no entity_id found in request subject or resource") } +// isHTTPURL reports whether s looks like an entity identifier (an http or https URL). +func isHTTPURL(s string) bool { + return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") +} + // rawRequestedEntityID returns the entity identifier exactly as the request // named it (the same field extractEntityID reads, without its trailing-slash // normalisation), or "" if the request names none. func rawRequestedEntityID(req *authzen.EvaluationRequest) string { - if req.Subject.Type == "key" && (strings.HasPrefix(req.Subject.ID, "http://") || strings.HasPrefix(req.Subject.ID, "https://")) { + if req.Subject.Type == "key" && isHTTPURL(req.Subject.ID) { return req.Subject.ID } - if strings.HasPrefix(req.Resource.ID, "http://") || strings.HasPrefix(req.Resource.ID, "https://") { + if isHTTPURL(req.Resource.ID) { return req.Resource.ID } return "" From a34b4bdb6d2c21481ef979ddcff39b9b0b5a7802 Mon Sep 17 00:00:00 2001 From: Leif Johansson Date: Wed, 14 Oct 2026 21:59:25 +0200 Subject: [PATCH 06/10] test(registry): trigger WRPAC profile failure via missing contact SAN #210 removed the keyUsage/contentCommitment check, so noKU no longer fails validation. Use the contact-information check instead. Co-Authored-By: Claude Sonnet 5.5 --- pkg/registry/verifierid_test.go | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/pkg/registry/verifierid_test.go b/pkg/registry/verifierid_test.go index d1737761..40dca2d1 100644 --- a/pkg/registry/verifierid_test.go +++ b/pkg/registry/verifierid_test.go @@ -26,8 +26,7 @@ type vidCertOpts struct { policy string // WRPAC policy OID, "" for none dns []string uris []string - noKU bool // omit nonRepudiation so WRPAC profile validation fails - noEmails bool + noEmails bool // omit the contact SAN so WRPAC profile validation fails } // makeVIDCert builds a parsed leaf, so organizationIdentifier is visible in @@ -48,9 +47,7 @@ func makeVIDCert(t *testing.T, o vidCertOpts) *x509.Certificate { NotAfter: time.Now().Add(time.Hour), DNSNames: o.dns, } - if !o.noKU { - tmpl.KeyUsage = x509.KeyUsageContentCommitment - } + tmpl.KeyUsage = x509.KeyUsageDigitalSignature if !o.noEmails { tmpl.EmailAddresses = []string{"admin@rp.example"} } @@ -282,7 +279,7 @@ func TestEnrichVerifierIDs_UnverifiedClientIDNeverEchoed(t *testing.T) { // Nothing is emitted when profile validation failed, even though that is only a // warning for the decision. func TestEnrichVerifierIDs_ProfileValidationFailureEmitsNothing(t *testing.T) { - cert := makeVIDCert(t, vidCertOpts{orgID: "VATSE-556000000101", policy: rpcert.OIDNCPLegalPerson, dns: []string{"rp.example.com"}, noKU: true}) + cert := makeVIDCert(t, vidCertOpts{orgID: "VATSE-556000000101", policy: rpcert.OIDNCPLegalPerson, dns: []string{"rp.example.com"}, noEmails: true}) req := vidReq("x509_san_dns:rp.example.com", []string{"semantic", "dns"}, nil) res := EnrichX5CResponseWithProfiles(req, cert, rpcert.DefaultProfileRegistry()) From 61e5f2d8d5a4274aeeb60dde7c19cd761b6a9cf1 Mon Sep 17 00:00:00 2001 From: Leif Johansson Date: Thu, 15 Oct 2026 10:13:24 +0200 Subject: [PATCH 07/10] authzen: name verifier_id types after client_id_type (#203) Per the trust-framework alignment on #203, type names follow the OpenID4VP client_id scheme names; semantic is the only exception: dns -> x509_san_dns, uri -> x509_san_uri, did -> decentralized_identifier, oidf_entity -> openid_federation No alias for the old names: #204 is unreleased, and unknown names are still rejected in return_verifier_ids. Value forms are unchanged. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 22 +++---- cmd/gt/policy_config_test.go | 2 +- docs/CONFIGURATION.md | 2 +- docs/swagger/docs.go | 2 +- docs/swagger/swagger.json | 2 +- docs/swagger/swagger.yaml | 2 +- example/config.yaml | 6 +- pkg/authzen/types.go | 16 ++--- pkg/authzen/verifierids_test.go | 6 +- pkg/config/config.go | 2 +- pkg/config/verifierids_test.go | 6 +- pkg/registry/did/verifierid_test.go | 4 +- pkg/registry/didjwks/verifierid_test.go | 4 +- pkg/registry/didweb/verifierid_test.go | 6 +- pkg/registry/oidfed/verifierid_test.go | 6 +- pkg/registry/policy_verifierids_test.go | 4 +- pkg/registry/verifierid_manager_test.go | 10 ++-- pkg/registry/verifierid_test.go | 80 ++++++++++++------------- pkg/testserver/verifierids_test.go | 6 +- 19 files changed, 94 insertions(+), 94 deletions(-) diff --git a/README.md b/README.md index fd5ce83c..106d569d 100644 --- a/README.md +++ b/README.md @@ -610,7 +610,7 @@ policies: | `etsi` | Service types, statuses, countries, credential types (audit) | ETSI TSL | | `lote` | Entity types, statuses, territories, credential types (audit) | ETSI LoTE | | `oidfed` | Entity types, trust marks, credential type mapping | OpenID Federation | -| `did` | Allowed domains, verifiable history | DID Web, DID Web VH | +| `decentralized_identifier` | Allowed domains, verifiable history | DID Web, DID Web VH | | `mdociaca` | Issuer allowlist, IACA endpoint | mDOC IACA | | `fidomds3` | AAGUID allowlist/blocklist | FIDO MDS3 | | `constraints` (`require_key_binding`, `allowed_key_types`) | Generic; used by `emrtd-document-signer` | eMRTD | @@ -830,7 +830,7 @@ policies: policies: credential-verifier: constraints: - return_verifier_ids: [semantic, dns] # unknown type names are rejected at startup + return_verifier_ids: [semantic, x509_san_dns] # unknown type names are rejected at startup ``` ```json @@ -839,29 +839,29 @@ policies: "context": { "verifier_ids": [ {"verifier_id_type": "semantic", "verifier_id": "semantic_id:VATSE-556000000101", "basis": "x5c-chain"}, - {"verifier_id_type": "dns", "verifier_id": "rp.example.com", "basis": "client-id-binding"} + {"verifier_id_type": "x509_san_dns", "verifier_id": "rp.example.com", "basis": "client-id-binding"} ] } } ``` -The response lists everything the policy enables and go-trust can derive; a type it cannot establish is omitted. A request may narrow the response to one type with the optional request `context` member `verifier_id_type` (for example `"context": {"verifier_id_type": "dns"}`). The hint only narrows: it cannot enable a type the policy did not. Clients treat `verifier_id_type` as opaque; the order of entries carries no meaning. +The response lists everything the policy enables and go-trust can derive; a type it cannot establish is omitted. A request may narrow the response to one type with the optional request `context` member `verifier_id_type` (for example `"context": {"verifier_id_type": "x509_san_dns"}`). The hint only narrows: it cannot enable a type the policy did not. Clients treat `verifier_id_type` as opaque; the order of entries carries no meaning. -Types go-trust can derive (the exact value form is fixed per type): +Types go-trust can derive (the exact value form is fixed per type). Type names follow the OpenID4VP `client_id` scheme names (`x509_san_dns`, `x509_san_uri`, `decentralized_identifier`, `openid_federation`); `semantic` is the only exception: | Type | Value | Basis | Registries | |---|---|---|---| | `semantic` | `semantic_id:` + the ETSI semantic identifier from the **access certificate** leaf (ETSI TS 119 475 5.1.3 legal persons: `organizationIdentifier`; 5.1.5 natural persons: `serialNumber`). Must match `<3 letters><2 letters>-`; the value is kept verbatim. | `x5c-chain` | x5c registries (`etsi`, `lote`) | -| `dns` | the `client_id` of an `x509_san_dns` request, lower-case, no trailing dot | `client-id-binding` | x5c registries | -| `uri` | the `client_id` of an `x509_san_uri` request, byte-for-byte (no case folding, no slash normalisation, not re-serialised through `net/url`) | `client-id-binding` | x5c registries | -| `did` | the full DID of the resolved DID document, without fragment | `did-resolution` | `didweb`, `didwebvh`, `didjwks`, `did` | -| `oidf_entity` | the Entity ID as in the validated trust chain, byte-for-byte | `oidf-trust-chain` | `oidfed` | +| `x509_san_dns` | the `client_id` of an `x509_san_dns` request, lower-case, no trailing dot | `client-id-binding` | x5c registries | +| `x509_san_uri` | the `client_id` of an `x509_san_uri` request, byte-for-byte (no case folding, no slash normalisation, not re-serialised through `net/url`) | `client-id-binding` | x5c registries | +| `decentralized_identifier` | the full DID of the resolved DID document, without fragment | `did-resolution` | `didweb`, `didwebvh`, `didjwks`, `did` | +| `openid_federation` | the Entity ID as in the validated trust chain, byte-for-byte | `oidf-trust-chain` | `oidfed` | Rules: -- **Fail closed.** Nothing is emitted for a deny decision, when the matched profile's validation failed (normally only a warning), or when the identifier was not verified: `dns`/`uri` need `VerifyLeafBinding` to pass, `did` needs a successful resolution and a matching key, `oidf_entity` needs a validated trust chain and a matching key. An unverified `client_id` is never echoed. Resolution-only responses carry no `verifier_ids`. +- **Fail closed.** Nothing is emitted for a deny decision, when the matched profile's validation failed (normally only a warning), or when the identifier was not verified: `x509_san_dns`/`x509_san_uri` need `VerifyLeafBinding` to pass, `decentralized_identifier` needs a successful resolution and a matching key, `openid_federation` needs a validated trust chain and a matching key. An unverified `client_id` is never echoed. Resolution-only responses carry no `verifier_ids`. - **`semantic` comes from the access certificate only.** The registration certificate is not an input today, so wallets must not assume registration-certificate precedence. A `serialNumber` that is not a semantic identifier (for example a plain certificate serial) is never emitted as `semantic`. For a certificate classified by its WRPAC policy OIDs, the type must fit the subject: natural-person types (`PNO`, `IDC`, `PAS`, `TAX`, `TIN`) are read from `serialNumber` of natural-person certificates only, and refused for legal-person ones. -- **Intermediary requests** (`allow_intermediaries`): the identifier is the end relying party's. `semantic` is taken from the RP leaf; `dns` and `uri` are omitted because the `client_id` of a brokered request cannot be shown to name the end relying party. The intermediary's own, unvalidated chain is never a source. +- **Intermediary requests** (`allow_intermediaries`): the identifier is the end relying party's. `semantic` is taken from the RP leaf; `x509_san_dns` and `x509_san_uri` are omitted because the `client_id` of a brokered request cannot be shown to name the end relying party. The intermediary's own, unvalidated chain is never a source. - The member is carried through by the strategies that return the matching registry's own response (`first_match`, `sequential`); the aggregating `all` and `best_match` strategies and composite registries build a new response and do not carry it. - Not offered: `did_scid`, `service` and `spki_sha256` are not implemented yet and are rejected in `return_verifier_ids`. diff --git a/cmd/gt/policy_config_test.go b/cmd/gt/policy_config_test.go index 2a36dcbf..289af59d 100644 --- a/cmd/gt/policy_config_test.go +++ b/cmd/gt/policy_config_test.go @@ -92,7 +92,7 @@ func TestConfigurePoliciesFromConfigCopiesEveryField(t *testing.T) { Constraints: &config.PolicyConstraintsConfig{ AllowedKeyTypes: []string{"x5c"}, RequireKeyBinding: true, - ReturnVerifierIDs: []string{"semantic", "dns"}, + ReturnVerifierIDs: []string{"semantic", "x509_san_dns"}, }, ETSI: &config.ETSIPolicyConfig{ ServiceTypes: []string{"http://uri.etsi.org/TrstSvc/Svctype/CA/QC"}, diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 4c405d14..229c3cfb 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -73,7 +73,7 @@ A few `server` settings can also be set via CLI flag (`gt -host`, `-port`, `-ext | `policies.policies..registries` | — | string list | Registries limits evaluation to specific registry names. If empty, all registries are considered. | | `policies.policies..constraints.require_key_binding` | — | boolean | RequireKeyBinding requires that a key be provided and validated. | | `policies.policies..constraints.allowed_key_types` | — | string list | AllowedKeyTypes restricts accepted key types (e.g., ["x5c", "jwk"]) | -| `policies.policies..constraints.return_verifier_ids` | — | string list | ReturnVerifierIDs lists the verifier identifier types to return in the evaluation response context member verifier_ids. Supported types: semantic, dns, uri, did, oidf_entity. Empty (default) returns none. Unknown names are rejected at startup. | +| `policies.policies..constraints.return_verifier_ids` | — | string list | ReturnVerifierIDs lists the verifier identifier types to return in the evaluation response context member verifier_ids. Supported types: semantic, x509_san_dns, x509_san_uri, decentralized_identifier, openid_federation. Empty (default) returns none. Unknown names are rejected at startup. | | `policies.policies..oidfed.required_trust_marks` | — | string list | RequiredTrustMarks specifies trust mark types that MUST be present | | `policies.policies..oidfed.entity_types` | — | string list | EntityTypes filters by OpenID Federation entity types | | `policies.policies..oidfed.max_chain_depth` | — | integer | MaxChainDepth limits trust chain resolution depth | diff --git a/docs/swagger/docs.go b/docs/swagger/docs.go index e6afe9dc..630c0660 100644 --- a/docs/swagger/docs.go +++ b/docs/swagger/docs.go @@ -444,7 +444,7 @@ const docTemplate = `{ "example": "semantic_id:VATSE-556000000101" }, "verifier_id_type": { - "description": "Type is an opaque type string, for example \"semantic\" or \"dns\".", + "description": "Type is an opaque type string, for example \"semantic\" or \"x509_san_dns\".", "type": "string", "example": "semantic" } diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index 2584023e..2da89c05 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -464,7 +464,7 @@ "example": "semantic_id:VATSE-556000000101" }, "verifier_id_type": { - "description": "Type is an opaque type string, for example \"semantic\" or \"dns\".", + "description": "Type is an opaque type string, for example \"semantic\" or \"x509_san_dns\".", "type": "string", "example": "semantic" } diff --git a/docs/swagger/swagger.yaml b/docs/swagger/swagger.yaml index a977a126..c18e52f6 100644 --- a/docs/swagger/swagger.yaml +++ b/docs/swagger/swagger.yaml @@ -189,7 +189,7 @@ definitions: example: semantic_id:VATSE-556000000101 type: string verifier_id_type: - description: Type is an opaque type string, for example "semantic" or "dns". + description: Type is an opaque type string, for example "semantic" or "x509_san_dns". example: semantic type: string type: object diff --git a/example/config.yaml b/example/config.yaml index 96be7aac..df9f657f 100644 --- a/example/config.yaml +++ b/example/config.yaml @@ -542,10 +542,10 @@ policies: # # Verifier identifiers returned in the response context # # (verifier_ids), for wallets that need a stable identifier for the # # relying party. Off by default. Types: semantic (access certificate - # # only), dns, uri, did, oidf_entity. Unknown names are rejected at - # # startup. A request can narrow the response to one type with + # # only), x509_san_dns, x509_san_uri, decentralized_identifier, + # # openid_federation. Unknown names are rejected at startup. A request can narrow the response to one type with # # context.verifier_id_type. - # return_verifier_ids: [semantic, dns] + # return_verifier_ids: [semantic, x509_san_dns] etsi: service_types: diff --git a/pkg/authzen/types.go b/pkg/authzen/types.go index 48f73b75..c630a587 100644 --- a/pkg/authzen/types.go +++ b/pkg/authzen/types.go @@ -93,18 +93,18 @@ const ( // VerifierIDTypeSemantic is "semantic_id:" followed by the ETSI semantic // identifier (EN 319 412-1 5.1.3) from the access certificate. VerifierIDTypeSemantic = "semantic" - // VerifierIDTypeDNS is the dNSName SAN value bound by x509_san_dns, + // VerifierIDTypeDNS (x509_san_dns) is the dNSName SAN value, // lower-cased and without a trailing dot. - VerifierIDTypeDNS = "dns" - // VerifierIDTypeURI is the uniformResourceIdentifier SAN value bound by - // x509_san_uri, byte-for-byte as bound. - VerifierIDTypeURI = "uri" + VerifierIDTypeDNS = "x509_san_dns" + // VerifierIDTypeURI (x509_san_uri) is the uniformResourceIdentifier SAN + // value, byte-for-byte as bound. + VerifierIDTypeURI = "x509_san_uri" // VerifierIDTypeDID is the full DID (without fragment) of a resolved DID // document whose verification method matched the presented key. - VerifierIDTypeDID = "did" + VerifierIDTypeDID = "decentralized_identifier" // VerifierIDTypeOIDFEntity is the OpenID Federation Entity ID (https URL), // byte-for-byte as in the validated trust chain. - VerifierIDTypeOIDFEntity = "oidf_entity" + VerifierIDTypeOIDFEntity = "openid_federation" ) // Basis values describe how a verifier identifier was verified. @@ -153,7 +153,7 @@ func IsSupportedVerifierIDType(t string) bool { // VerifierID is one verifier identifier in the evaluation response context. // @Description A verifier identifier derived by the PDP from authenticated material type VerifierID struct { - // Type is an opaque type string, for example "semantic" or "dns". + // Type is an opaque type string, for example "semantic" or "x509_san_dns". Type string `json:"verifier_id_type" example:"semantic"` // ID is the identifier in the canonical form defined for the type. ID string `json:"verifier_id" example:"semantic_id:VATSE-556000000101"` diff --git a/pkg/authzen/verifierids_test.go b/pkg/authzen/verifierids_test.go index b486db3e..45725eb2 100644 --- a/pkg/authzen/verifierids_test.go +++ b/pkg/authzen/verifierids_test.go @@ -19,7 +19,7 @@ func TestVerifierIDsJSONShape(t *testing.T) { require.NoError(t, err) assert.JSONEq(t, `{"decision":true,"context":{"verifier_ids":[ {"verifier_id_type":"semantic","verifier_id":"semantic_id:VATSE-556000000101","basis":"x5c-chain"}, - {"verifier_id_type":"dns","verifier_id":"rp.example.com"}]}}`, string(b), "basis is optional") + {"verifier_id_type":"x509_san_dns","verifier_id":"rp.example.com"}]}}`, string(b), "basis is optional") // Omitted entirely when empty, so existing responses are byte-identical. b, err = json.Marshal(EvaluationResponse{Decision: true, Context: &EvaluationResponseContext{ID: "x"}}) @@ -27,8 +27,8 @@ func TestVerifierIDsJSONShape(t *testing.T) { assert.NotContains(t, string(b), "verifier_ids") var back EvaluationResponse - require.NoError(t, json.Unmarshal([]byte(`{"decision":true,"context":{"verifier_ids":[{"verifier_id_type":"did","verifier_id":"did:web:x"}]}}`), &back)) - assert.Equal(t, []VerifierID{{Type: "did", ID: "did:web:x"}}, back.Context.VerifierIDs) + require.NoError(t, json.Unmarshal([]byte(`{"decision":true,"context":{"verifier_ids":[{"verifier_id_type":"decentralized_identifier","verifier_id":"did:web:x"}]}}`), &back)) + assert.Equal(t, []VerifierID{{Type: "decentralized_identifier", ID: "did:web:x"}}, back.Context.VerifierIDs) } func TestSupportedVerifierIDTypes(t *testing.T) { diff --git a/pkg/config/config.go b/pkg/config/config.go index 621ef6b6..85417d6a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -464,7 +464,7 @@ type PolicyConstraintsConfig struct { // ReturnVerifierIDs lists the verifier identifier types to return in the // evaluation response context member verifier_ids. Supported types: - // semantic, dns, uri, did, oidf_entity. Empty (default) returns none. + // semantic, x509_san_dns, x509_san_uri, decentralized_identifier, openid_federation. Empty (default) returns none. // Unknown names are rejected at startup. ReturnVerifierIDs []string `yaml:"return_verifier_ids,omitempty"` } diff --git a/pkg/config/verifierids_test.go b/pkg/config/verifierids_test.go index 9d09cd7a..7f4304d7 100644 --- a/pkg/config/verifierids_test.go +++ b/pkg/config/verifierids_test.go @@ -15,7 +15,7 @@ func TestValidateReturnVerifierIDs(t *testing.T) { }{ {"absent", nil, ""}, {"default off", &PolicyConstraintsConfig{}, ""}, - {"all supported", &PolicyConstraintsConfig{ReturnVerifierIDs: []string{"semantic", "dns", "uri", "did", "oidf_entity"}}, ""}, + {"all supported", &PolicyConstraintsConfig{ReturnVerifierIDs: []string{"semantic", "x509_san_dns", "x509_san_uri", "decentralized_identifier", "openid_federation"}}, ""}, {"unknown", &PolicyConstraintsConfig{ReturnVerifierIDs: []string{"semantic", "sematic"}}, `"sematic"`}, {"deferred type is not accepted yet", &PolicyConstraintsConfig{ReturnVerifierIDs: []string{"did_scid"}}, `"did_scid"`}, {"wrong case", &PolicyConstraintsConfig{ReturnVerifierIDs: []string{"DNS"}}, `"DNS"`}, @@ -45,7 +45,7 @@ policies: policies: rp: constraints: - return_verifier_ids: [semantic, dns] + return_verifier_ids: [semantic, x509_san_dns] ` if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatal(err) @@ -55,7 +55,7 @@ policies: t.Fatal(err) } got := cfg.Policies.Policies["rp"].Constraints.ReturnVerifierIDs - if len(got) != 2 || got[0] != "semantic" || got[1] != "dns" { + if len(got) != 2 || got[0] != "semantic" || got[1] != "x509_san_dns" { t.Fatalf("return_verifier_ids = %v", got) } if err := cfg.Validate(); err != nil { diff --git a/pkg/registry/did/verifierid_test.go b/pkg/registry/did/verifierid_test.go index ad711460..a7ff7fe9 100644 --- a/pkg/registry/did/verifierid_test.go +++ b/pkg/registry/did/verifierid_test.go @@ -32,12 +32,12 @@ func TestGenericDIDRegistry_VerifierIDs(t *testing.T) { } return rq } - on := map[string]interface{}{authzen.VerifierIDsPolicyContextKey: []string{"did"}} + on := map[string]interface{}{authzen.VerifierIDsPolicyContextKey: []string{"decentralized_identifier"}} resp, err := reg.Evaluate(ctx, req(true, on)) require.NoError(t, err) require.True(t, resp.Decision, "%v", resp.Context.Reason) - assert.Equal(t, []authzen.VerifierID{{Type: "did", ID: did, Basis: "did-resolution"}}, resp.Context.VerifierIDs) + assert.Equal(t, []authzen.VerifierID{{Type: "decentralized_identifier", ID: did, Basis: "did-resolution"}}, resp.Context.VerifierIDs) resp, err = reg.Evaluate(ctx, req(true, nil)) require.NoError(t, err) diff --git a/pkg/registry/didjwks/verifierid_test.go b/pkg/registry/didjwks/verifierid_test.go index 322a3d37..b102bb61 100644 --- a/pkg/registry/didjwks/verifierid_test.go +++ b/pkg/registry/didjwks/verifierid_test.go @@ -27,7 +27,7 @@ func TestEvaluateVerifierIDs(t *testing.T) { } return rq } - enabled := map[string]interface{}{authzen.VerifierIDsPolicyContextKey: []string{"did"}} + enabled := map[string]interface{}{authzen.VerifierIDsPolicyContextKey: []string{"decentralized_identifier"}} // The fragment names a verification method that is not in the document, so // use the bare DID for the matching case. @@ -40,7 +40,7 @@ func TestEvaluateVerifierIDs(t *testing.T) { if !resp.Decision { t.Fatalf("expected allow: %v", resp.Context.Reason) } - want := []authzen.VerifierID{{Type: "did", ID: did, Basis: "did-resolution"}} + want := []authzen.VerifierID{{Type: "decentralized_identifier", ID: did, Basis: "did-resolution"}} if len(resp.Context.VerifierIDs) != 1 || resp.Context.VerifierIDs[0] != want[0] { t.Fatalf("verifier_ids = %v, want %v", resp.Context.VerifierIDs, want) } diff --git a/pkg/registry/didweb/verifierid_test.go b/pkg/registry/didweb/verifierid_test.go index 87748444..da1d727b 100644 --- a/pkg/registry/didweb/verifierid_test.go +++ b/pkg/registry/didweb/verifierid_test.go @@ -47,13 +47,13 @@ func TestEvaluate_VerifierIDs(t *testing.T) { } return rq } - enabled := map[string]interface{}{authzen.VerifierIDsPolicyContextKey: []string{"did"}} + enabled := map[string]interface{}{authzen.VerifierIDsPolicyContextKey: []string{"decentralized_identifier"}} // Key-bound allow with the policy on: the DID, without the fragment. resp, err := r.Evaluate(context.Background(), req(did+"#key-1", jwk, enabled)) require.NoError(t, err) require.True(t, resp.Decision, "%v", resp.Context.Reason) - assert.Equal(t, []authzen.VerifierID{{Type: "did", ID: did, Basis: "did-resolution"}}, resp.Context.VerifierIDs) + assert.Equal(t, []authzen.VerifierID{{Type: "decentralized_identifier", ID: did, Basis: "did-resolution"}}, resp.Context.VerifierIDs) // Policy off: unchanged. resp, err = r.Evaluate(context.Background(), req(did, jwk, nil)) @@ -62,7 +62,7 @@ func TestEvaluate_VerifierIDs(t *testing.T) { assert.Empty(t, resp.Context.VerifierIDs) // Hint for another type narrows to nothing. - hint := map[string]interface{}{authzen.VerifierIDsPolicyContextKey: []string{"did"}, authzen.VerifierIDHintContextKey: "dns"} + hint := map[string]interface{}{authzen.VerifierIDsPolicyContextKey: []string{"decentralized_identifier"}, authzen.VerifierIDHintContextKey: "x509_san_dns"} resp, err = r.Evaluate(context.Background(), req(did, jwk, hint)) require.NoError(t, err) assert.Empty(t, resp.Context.VerifierIDs) diff --git a/pkg/registry/oidfed/verifierid_test.go b/pkg/registry/oidfed/verifierid_test.go index 1e5574e3..627a0ad1 100644 --- a/pkg/registry/oidfed/verifierid_test.go +++ b/pkg/registry/oidfed/verifierid_test.go @@ -61,14 +61,14 @@ func TestEvaluate_VerifierIDs_OIDFEntity(t *testing.T) { } return rq } - enabled := map[string]interface{}{authzen.VerifierIDsPolicyContextKey: []string{"oidf_entity"}} + enabled := map[string]interface{}{authzen.VerifierIDsPolicyContextKey: []string{"openid_federation"}} reg.cache.Set(entityID, nil, nil, []oidfed.TrustChain{chainFor(entityID)}, "https://ta.example.com") resp, err := reg.Evaluate(context.Background(), request(enabled, true)) require.NoError(t, err) require.True(t, resp.Decision, "%v", resp.Context.Reason) - assert.Equal(t, []authzen.VerifierID{{Type: "oidf_entity", ID: entityID, Basis: "oidf-trust-chain"}}, resp.Context.VerifierIDs) + assert.Equal(t, []authzen.VerifierID{{Type: "openid_federation", ID: entityID, Basis: "oidf-trust-chain"}}, resp.Context.VerifierIDs) // Policy off: unchanged. resp, err = reg.Evaluate(context.Background(), request(nil, true)) @@ -77,7 +77,7 @@ func TestEvaluate_VerifierIDs_OIDFEntity(t *testing.T) { assert.Empty(t, resp.Context.VerifierIDs) // Hint narrowing to another type: nothing. - hint := map[string]interface{}{authzen.VerifierIDsPolicyContextKey: []string{"oidf_entity"}, authzen.VerifierIDHintContextKey: "did"} + hint := map[string]interface{}{authzen.VerifierIDsPolicyContextKey: []string{"openid_federation"}, authzen.VerifierIDHintContextKey: "decentralized_identifier"} resp, err = reg.Evaluate(context.Background(), request(hint, true)) require.NoError(t, err) assert.Empty(t, resp.Context.VerifierIDs) diff --git a/pkg/registry/policy_verifierids_test.go b/pkg/registry/policy_verifierids_test.go index e506e444..17ac20e1 100644 --- a/pkg/registry/policy_verifierids_test.go +++ b/pkg/registry/policy_verifierids_test.go @@ -20,7 +20,7 @@ func TestPolicyManager_RegisterPolicyRejectsUnknownVerifierIDType(t *testing.T) assert.Contains(t, err.Error(), `"bad"`) assert.Nil(t, pm.GetPolicy("bad"), "a rejected policy is not installed") - require.NoError(t, pm.RegisterPolicy(&Policy{Name: "ok", Constraints: PolicyConstraints{ReturnVerifierIDs: []string{"dns"}}})) + require.NoError(t, pm.RegisterPolicy(&Policy{Name: "ok", Constraints: PolicyConstraints{ReturnVerifierIDs: []string{"x509_san_dns"}}})) assert.NotNil(t, pm.GetPolicy("ok")) // SetDefaultPolicy goes through the same check and leaves the default alone on error. @@ -34,7 +34,7 @@ func TestPolicyManager_RegisterPolicyRejectsUnknownVerifierIDType(t *testing.T) func TestPolicyConstraintsValidate_VerifierIDs(t *testing.T) { assert.NoError(t, PolicyConstraints{}.Validate()) assert.NoError(t, PolicyConstraints{ReturnVerifierIDs: authzen.SupportedVerifierIDTypes()}.Validate()) - err := PolicyConstraints{ReturnVerifierIDs: []string{"dns", "bogus"}}.Validate() + err := PolicyConstraints{ReturnVerifierIDs: []string{"x509_san_dns", "bogus"}}.Validate() require.Error(t, err) assert.Contains(t, err.Error(), `"bogus"`) } diff --git a/pkg/registry/verifierid_manager_test.go b/pkg/registry/verifierid_manager_test.go index ca0611df..5ea5dcca 100644 --- a/pkg/registry/verifierid_manager_test.go +++ b/pkg/registry/verifierid_manager_test.go @@ -50,7 +50,7 @@ func vidManagerRequest(ctx map[string]interface{}) *authzen.EvaluationRequest { func TestManager_VerifierIDs_PolicyEnablesAndHintNarrows(t *testing.T) { leaf := makeVIDCert(t, vidCertOpts{orgID: "VATSE-556000000101", policy: rpcert.OIDNCPLegalPerson, dns: []string{"rp.example.com"}}) - mgr := newEnrichingManager(t, leaf, PolicyConstraints{ReturnVerifierIDs: []string{"semantic", "dns"}}) + mgr := newEnrichingManager(t, leaf, PolicyConstraints{ReturnVerifierIDs: []string{"semantic", "x509_san_dns"}}) // Subject.ID is rewritten by Evaluate ("x509_san_dns:host" -> "https://host"); // the dns identifier is still derived, from the original claim. @@ -60,7 +60,7 @@ func TestManager_VerifierIDs_PolicyEnablesAndHintNarrows(t *testing.T) { require.NotNil(t, resp.Context) got := idsOf(resp.Context.VerifierIDs) assert.Equal(t, "semantic_id:VATSE-556000000101", got["semantic"].ID) - assert.Equal(t, "rp.example.com", got["dns"].ID) + assert.Equal(t, "rp.example.com", got["x509_san_dns"].ID) assert.Len(t, resp.Context.VerifierIDs, 2) // The client's optional hint narrows to one entry. @@ -93,7 +93,7 @@ func TestManager_VerifierIDs_ClientCannotEnableOrWiden(t *testing.T) { off := newEnrichingManager(t, leaf, PolicyConstraints{}) resp, err := off.Evaluate(context.Background(), vidManagerRequest(map[string]interface{}{ - authzen.VerifierIDsPolicyContextKey: []string{"semantic", "dns"}, + authzen.VerifierIDsPolicyContextKey: []string{"semantic", "x509_san_dns"}, authzen.VerifierIDHintContextKey: "semantic", })) require.NoError(t, err) @@ -101,8 +101,8 @@ func TestManager_VerifierIDs_ClientCannotEnableOrWiden(t *testing.T) { narrow := newEnrichingManager(t, leaf, PolicyConstraints{ReturnVerifierIDs: []string{"semantic"}}) resp, err = narrow.Evaluate(context.Background(), vidManagerRequest(map[string]interface{}{ - authzen.VerifierIDsPolicyContextKey: []string{"semantic", "dns"}, - authzen.VerifierIDHintContextKey: "dns", + authzen.VerifierIDsPolicyContextKey: []string{"semantic", "x509_san_dns"}, + authzen.VerifierIDHintContextKey: "x509_san_dns", })) require.NoError(t, err) assert.Empty(t, resp.Context.VerifierIDs, "hint for a type the policy did not enable returns nothing") diff --git a/pkg/registry/verifierid_test.go b/pkg/registry/verifierid_test.go index 40dca2d1..8cca99f4 100644 --- a/pkg/registry/verifierid_test.go +++ b/pkg/registry/verifierid_test.go @@ -98,56 +98,56 @@ func TestVerifierIDTypesEnabled(t *testing.T) { assert.Nil(t, VerifierIDTypesEnabled(&authzen.EvaluationRequest{})) assert.Nil(t, VerifierIDTypesEnabled(vidReq("x", nil, nil)), "policy off") - assert.Equal(t, []string{"semantic", "dns"}, VerifierIDTypesEnabled(vidReq("x", []string{"semantic", "dns"}, nil))) + assert.Equal(t, []string{"semantic", "x509_san_dns"}, VerifierIDTypesEnabled(vidReq("x", []string{"semantic", "x509_san_dns"}, nil))) // From JSON the policy list is []interface{}. - req := vidReq("x", nil, map[string]interface{}{authzen.VerifierIDsPolicyContextKey: []interface{}{"uri"}}) - assert.Equal(t, []string{"uri"}, VerifierIDTypesEnabled(req)) + req := vidReq("x", nil, map[string]interface{}{authzen.VerifierIDsPolicyContextKey: []interface{}{"x509_san_uri"}}) + assert.Equal(t, []string{"x509_san_uri"}, VerifierIDTypesEnabled(req)) hint := func(h string) map[string]interface{} { return map[string]interface{}{authzen.VerifierIDHintContextKey: h} } - assert.Equal(t, []string{"dns"}, VerifierIDTypesEnabled(vidReq("x", []string{"semantic", "dns"}, hint("dns")))) + assert.Equal(t, []string{"x509_san_dns"}, VerifierIDTypesEnabled(vidReq("x", []string{"semantic", "x509_san_dns"}, hint("x509_san_dns")))) // The hint only narrows: it cannot enable a type the policy did not. - assert.Empty(t, VerifierIDTypesEnabled(vidReq("x", []string{"semantic"}, hint("dns")))) - assert.NotNil(t, VerifierIDTypesEnabled(vidReq("x", []string{"semantic"}, hint("dns")))) + assert.Empty(t, VerifierIDTypesEnabled(vidReq("x", []string{"semantic"}, hint("x509_san_dns")))) + assert.NotNil(t, VerifierIDTypesEnabled(vidReq("x", []string{"semantic"}, hint("x509_san_dns")))) // ...and with the policy off, a hint enables nothing. - assert.Nil(t, VerifierIDTypesEnabled(vidReq("x", nil, hint("dns")))) + assert.Nil(t, VerifierIDTypesEnabled(vidReq("x", nil, hint("x509_san_dns")))) // Empty / non-string hint is ignored. - assert.Equal(t, []string{"dns"}, VerifierIDTypesEnabled(vidReq("x", []string{"dns"}, hint("")))) + assert.Equal(t, []string{"x509_san_dns"}, VerifierIDTypesEnabled(vidReq("x", []string{"x509_san_dns"}, hint("")))) } func TestAddVerifierID_FailsClosed(t *testing.T) { - req := vidReq("x", []string{"dns"}, nil) + req := vidReq("x", []string{"x509_san_dns"}, nil) allow := &authzen.EvaluationResponse{Decision: true} - AddVerifierID(allow, req, "dns", "rp.example", "client-id-binding") + AddVerifierID(allow, req, "x509_san_dns", "rp.example", "client-id-binding") require.NotNil(t, allow.Context) - assert.Equal(t, []authzen.VerifierID{{Type: "dns", ID: "rp.example", Basis: "client-id-binding"}}, allow.Context.VerifierIDs) + assert.Equal(t, []authzen.VerifierID{{Type: "x509_san_dns", ID: "rp.example", Basis: "client-id-binding"}}, allow.Context.VerifierIDs) - AddVerifierID(allow, req, "dns", "rp.example", "client-id-binding") + AddVerifierID(allow, req, "x509_san_dns", "rp.example", "client-id-binding") assert.Len(t, allow.Context.VerifierIDs, 1, "duplicates are not added twice") deny := &authzen.EvaluationResponse{Decision: false} - AddVerifierID(deny, req, "dns", "rp.example", "client-id-binding") + AddVerifierID(deny, req, "x509_san_dns", "rp.example", "client-id-binding") assert.Nil(t, deny.Context, "nothing on a deny") notEnabled := &authzen.EvaluationResponse{Decision: true} - AddVerifierID(notEnabled, req, "uri", "https://rp.example", "client-id-binding") + AddVerifierID(notEnabled, req, "x509_san_uri", "https://rp.example", "client-id-binding") assert.Nil(t, notEnabled.Context, "type the policy did not enable") empty := &authzen.EvaluationResponse{Decision: true} - AddVerifierID(empty, req, "dns", "", "client-id-binding") + AddVerifierID(empty, req, "x509_san_dns", "", "client-id-binding") assert.Nil(t, empty.Context) - AddVerifierID(nil, req, "dns", "x", "b") // must not panic + AddVerifierID(nil, req, "x509_san_dns", "x", "b") // must not panic } func TestAddDIDVerifierID_RequiresSameDID(t *testing.T) { - req := vidReq("did:web:rp.example#key-1", []string{"did"}, nil) + req := vidReq("did:web:rp.example#key-1", []string{"decentralized_identifier"}, nil) ok := &authzen.EvaluationResponse{Decision: true} AddDIDVerifierID(ok, req, "did:web:rp.example") require.NotNil(t, ok.Context) - assert.Equal(t, []authzen.VerifierID{{Type: "did", ID: "did:web:rp.example", Basis: "did-resolution"}}, ok.Context.VerifierIDs, + assert.Equal(t, []authzen.VerifierID{{Type: "decentralized_identifier", ID: "did:web:rp.example", Basis: "did-resolution"}}, ok.Context.VerifierIDs, "the fragment is dropped: the identifier is the DID, not a verification method") other := &authzen.EvaluationResponse{Decision: true} @@ -230,29 +230,29 @@ func TestEnrichVerifierIDs_DNS_CanonicalFormAndBasis(t *testing.T) { // VerifyLeafBinding is an exact match, so the cert carries the spelling used. certMixed := makeVIDCert(t, vidCertOpts{dns: []string{"RP.Example.com."}}) - res := EnrichX5CResponse(vidReq("x509_san_dns:rp.example.com", []string{"dns"}, nil), cert) - assert.Equal(t, []authzen.VerifierID{{Type: "dns", ID: "rp.example.com", Basis: "client-id-binding"}}, res.VerifierIDs) + res := EnrichX5CResponse(vidReq("x509_san_dns:rp.example.com", []string{"x509_san_dns"}, nil), cert) + assert.Equal(t, []authzen.VerifierID{{Type: "x509_san_dns", ID: "rp.example.com", Basis: "client-id-binding"}}, res.VerifierIDs) - res = EnrichX5CResponse(vidReq("x509_san_dns:RP.Example.com.", []string{"dns"}, nil), certMixed) - assert.Equal(t, []authzen.VerifierID{{Type: "dns", ID: "rp.example.com", Basis: "client-id-binding"}}, res.VerifierIDs) + res = EnrichX5CResponse(vidReq("x509_san_dns:RP.Example.com.", []string{"x509_san_dns"}, nil), certMixed) + assert.Equal(t, []authzen.VerifierID{{Type: "x509_san_dns", ID: "rp.example.com", Basis: "client-id-binding"}}, res.VerifierIDs) } func TestEnrichVerifierIDs_URI_ByteForByte(t *testing.T) { cert := makeVIDCert(t, vidCertOpts{uris: []string{"https://rp.example.com/Path/"}}) - res := EnrichX5CResponse(vidReq("x509_san_uri:https://rp.example.com/Path/", []string{"uri"}, nil), cert) - assert.Equal(t, []authzen.VerifierID{{Type: "uri", ID: "https://rp.example.com/Path/", Basis: "client-id-binding"}}, res.VerifierIDs, + res := EnrichX5CResponse(vidReq("x509_san_uri:https://rp.example.com/Path/", []string{"x509_san_uri"}, nil), cert) + assert.Equal(t, []authzen.VerifierID{{Type: "x509_san_uri", ID: "https://rp.example.com/Path/", Basis: "client-id-binding"}}, res.VerifierIDs, "case and trailing slash are kept: not re-serialised") // A "//" in the value (the credential-offer regression) survives. cert = makeVIDCert(t, vidCertOpts{uris: []string{"https://rp.example.com//a"}}) - res = EnrichX5CResponse(vidReq("x509_san_uri:https://rp.example.com//a", []string{"uri"}, nil), cert) + res = EnrichX5CResponse(vidReq("x509_san_uri:https://rp.example.com//a", []string{"x509_san_uri"}, nil), cert) require.Len(t, res.VerifierIDs, 1) assert.Equal(t, "https://rp.example.com//a", res.VerifierIDs[0].ID) } func TestEnrichVerifierIDs_UnverifiedClientIDNeverEchoed(t *testing.T) { cert := makeVIDCert(t, vidCertOpts{dns: []string{"rp.example.com"}, uris: []string{"https://rp.example.com"}, orgID: "VATSE-1"}) - types := []string{"dns", "uri"} + types := []string{"x509_san_dns", "x509_san_uri"} // client_id names a host that is not a SAN of the leaf. res := EnrichX5CResponse(vidReq("x509_san_dns:evil.example.net", types, nil), cert) @@ -268,11 +268,11 @@ func TestEnrichVerifierIDs_UnverifiedClientIDNeverEchoed(t *testing.T) { // Wildcard client_id is not an identifier of one RP, even if the SAN is literally the same. wild := makeVIDCert(t, vidCertOpts{dns: []string{"*.example.com"}}) - res = EnrichX5CResponse(vidReq("x509_san_dns:*.example.com", []string{"dns"}, nil), wild) + res = EnrichX5CResponse(vidReq("x509_san_dns:*.example.com", []string{"x509_san_dns"}, nil), wild) assert.Empty(t, res.VerifierIDs) // A dns binding does not satisfy the uri type and vice versa. - res = EnrichX5CResponse(vidReq("x509_san_dns:rp.example.com", []string{"uri"}, nil), cert) + res = EnrichX5CResponse(vidReq("x509_san_dns:rp.example.com", []string{"x509_san_uri"}, nil), cert) assert.Empty(t, res.VerifierIDs) } @@ -280,7 +280,7 @@ func TestEnrichVerifierIDs_UnverifiedClientIDNeverEchoed(t *testing.T) { // warning for the decision. func TestEnrichVerifierIDs_ProfileValidationFailureEmitsNothing(t *testing.T) { cert := makeVIDCert(t, vidCertOpts{orgID: "VATSE-556000000101", policy: rpcert.OIDNCPLegalPerson, dns: []string{"rp.example.com"}, noEmails: true}) - req := vidReq("x509_san_dns:rp.example.com", []string{"semantic", "dns"}, nil) + req := vidReq("x509_san_dns:rp.example.com", []string{"semantic", "x509_san_dns"}, nil) res := EnrichX5CResponseWithProfiles(req, cert, rpcert.DefaultProfileRegistry()) require.True(t, res.Decision, "profile failure is a warning, not a deny") @@ -330,50 +330,50 @@ func TestEnrichVerifierIDs_Intermediary(t *testing.T) { "intermediary_x5c": []string{"MIIBintermediary"}, } res := EnrichX5CResponseWithProfiles( - vidReq("x509_san_dns:rp.example.com", []string{"semantic", "dns"}, extra), cert, rpcert.DefaultProfileRegistry()) + vidReq("x509_san_dns:rp.example.com", []string{"semantic", "x509_san_dns"}, extra), cert, rpcert.DefaultProfileRegistry()) require.True(t, res.Decision) require.True(t, res.IsIntermediaryRequest) got := idsOf(res.VerifierIDs) require.Contains(t, got, "semantic") assert.Equal(t, "semantic_id:VATSE-556000000101", got["semantic"].ID) - assert.NotContains(t, got, "dns", "dns cannot be established as the end RP's for an intermediary") + assert.NotContains(t, got, "x509_san_dns", "dns cannot be established as the end RP's for an intermediary") // Same request without the intermediary: dns is there, so the omission is // the intermediary's doing. res = EnrichX5CResponseWithProfiles( - vidReq("x509_san_dns:rp.example.com", []string{"semantic", "dns"}, nil), cert, rpcert.DefaultProfileRegistry()) - assert.Contains(t, idsOf(res.VerifierIDs), "dns") + vidReq("x509_san_dns:rp.example.com", []string{"semantic", "x509_san_dns"}, nil), cert, rpcert.DefaultProfileRegistry()) + assert.Contains(t, idsOf(res.VerifierIDs), "x509_san_dns") // Intermediary with a leaf that has no semantic id: nothing at all. bare := makeVIDCert(t, vidCertOpts{serial: "123456", dns: []string{"rp.example.com"}}) res = EnrichX5CResponseWithProfiles( - vidReq("x509_san_dns:rp.example.com", []string{"semantic", "dns"}, extra), bare, rpcert.DefaultProfileRegistry()) + vidReq("x509_san_dns:rp.example.com", []string{"semantic", "x509_san_dns"}, extra), bare, rpcert.DefaultProfileRegistry()) assert.Empty(t, res.VerifierIDs) } func TestEnrichVerifierIDs_HintNarrowsToOneEntry(t *testing.T) { cert := makeVIDCert(t, vidCertOpts{orgID: "VATSE-556000000101", policy: rpcert.OIDNCPLegalPerson, dns: []string{"rp.example.com"}}) - types := []string{"semantic", "dns"} + types := []string{"semantic", "x509_san_dns"} profiles := rpcert.DefaultProfileRegistry() all := EnrichX5CResponseWithProfiles(vidReq("x509_san_dns:rp.example.com", types, nil), cert, profiles) assert.Len(t, all.VerifierIDs, 2, "everything the policy enables") - hinted := map[string]interface{}{authzen.VerifierIDHintContextKey: "dns"} + hinted := map[string]interface{}{authzen.VerifierIDHintContextKey: "x509_san_dns"} one := EnrichX5CResponseWithProfiles(vidReq("x509_san_dns:rp.example.com", types, hinted), cert, profiles) require.Len(t, one.VerifierIDs, 1) - assert.Equal(t, "dns", one.VerifierIDs[0].Type) + assert.Equal(t, "x509_san_dns", one.VerifierIDs[0].Type) // A hint for a type the policy does not enable returns nothing. - hinted = map[string]interface{}{authzen.VerifierIDHintContextKey: "uri"} + hinted = map[string]interface{}{authzen.VerifierIDHintContextKey: "x509_san_uri"} none := EnrichX5CResponseWithProfiles(vidReq("x509_san_dns:rp.example.com", types, hinted), cert, profiles) assert.Empty(t, none.VerifierIDs) } func TestApplyEnrichmentToResponse_VerifierIDsJSONShape(t *testing.T) { cert := makeVIDCert(t, vidCertOpts{orgID: "VATSE-556000000101", policy: rpcert.OIDNCPLegalPerson, dns: []string{"rp.example.com"}}) - res := EnrichX5CResponseWithProfiles(vidReq("x509_san_dns:rp.example.com", []string{"semantic", "dns"}, nil), cert, rpcert.DefaultProfileRegistry()) + res := EnrichX5CResponseWithProfiles(vidReq("x509_san_dns:rp.example.com", []string{"semantic", "x509_san_dns"}, nil), cert, rpcert.DefaultProfileRegistry()) resp := &authzen.EvaluationResponse{Decision: true} ApplyEnrichmentToResponse(resp, res) @@ -381,5 +381,5 @@ func TestApplyEnrichmentToResponse_VerifierIDsJSONShape(t *testing.T) { require.NoError(t, err) assert.JSONEq(t, `{"decision":true,"context":{"reason":{"matched_profile":"wrpac"},"trust_metadata":{"rp_profile":"wrpac"},"verifier_ids":[ {"verifier_id_type":"semantic","verifier_id":"semantic_id:VATSE-556000000101","basis":"x5c-chain"}, - {"verifier_id_type":"dns","verifier_id":"rp.example.com","basis":"client-id-binding"}]}}`, string(b)) + {"verifier_id_type":"x509_san_dns","verifier_id":"rp.example.com","basis":"client-id-binding"}]}}`, string(b)) } diff --git a/pkg/testserver/verifierids_test.go b/pkg/testserver/verifierids_test.go index e714d971..05649acf 100644 --- a/pkg/testserver/verifierids_test.go +++ b/pkg/testserver/verifierids_test.go @@ -35,14 +35,14 @@ func TestDecisionFunc_VerifierIDsRoundTrip(t *testing.T) { resp, err := client.Evaluate(context.Background(), &authzen.EvaluationRequest{ Subject: authzen.Subject{Type: "key", ID: "https://rp.example.com"}, Resource: authzen.Resource{Type: "x5c", ID: "https://rp.example.com", Key: []interface{}{"MIIC"}}, - Context: map[string]interface{}{authzen.VerifierIDHintContextKey: "dns"}, + Context: map[string]interface{}{authzen.VerifierIDHintContextKey: "x509_san_dns"}, }) require.NoError(t, err) require.True(t, resp.Decision) require.NotNil(t, resp.Context) assert.Equal(t, []authzen.VerifierID{ {Type: "semantic", ID: "semantic_id:VATSE-556000000101", Basis: "x5c-chain"}, - {Type: "dns", ID: "rp.example.com", Basis: "client-id-binding"}, + {Type: "x509_san_dns", ID: "rp.example.com", Basis: "client-id-binding"}, }, resp.Context.VerifierIDs) - assert.Equal(t, "dns", sawHint, "the request hint reaches the PDP") + assert.Equal(t, "x509_san_dns", sawHint, "the request hint reaches the PDP") } From e8a0b9c22e3f0a2f2b9919b6d3ba7c509fe62681 Mon Sep 17 00:00:00 2001 From: Leif Johansson Date: Thu, 15 Oct 2026 12:47:03 +0200 Subject: [PATCH 08/10] rpcert: no semantic identifier for unclassified certificates With neither a natural- nor a legal-person WRPAC policy OID, an organizationIdentifier could be a natural person's affiliation rather than the verifier, so SemanticIdentifier now returns none (fail closed). Every WRPAC carries one of the four -n/-l policy OIDs, so real access certificates are unaffected. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 +- pkg/registry/rpcert/semanticid.go | 34 ++++++++++++++------------ pkg/registry/rpcert/semanticid_test.go | 14 ++++++----- pkg/registry/verifierid_test.go | 9 ++++++- 4 files changed, 35 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 106d569d..58438d02 100644 --- a/README.md +++ b/README.md @@ -860,7 +860,7 @@ Types go-trust can derive (the exact value form is fixed per type). Type names f Rules: - **Fail closed.** Nothing is emitted for a deny decision, when the matched profile's validation failed (normally only a warning), or when the identifier was not verified: `x509_san_dns`/`x509_san_uri` need `VerifyLeafBinding` to pass, `decentralized_identifier` needs a successful resolution and a matching key, `openid_federation` needs a validated trust chain and a matching key. An unverified `client_id` is never echoed. Resolution-only responses carry no `verifier_ids`. -- **`semantic` comes from the access certificate only.** The registration certificate is not an input today, so wallets must not assume registration-certificate precedence. A `serialNumber` that is not a semantic identifier (for example a plain certificate serial) is never emitted as `semantic`. For a certificate classified by its WRPAC policy OIDs, the type must fit the subject: natural-person types (`PNO`, `IDC`, `PAS`, `TAX`, `TIN`) are read from `serialNumber` of natural-person certificates only, and refused for legal-person ones. +- **`semantic` comes from the access certificate only.** The registration certificate is not an input today, so wallets must not assume registration-certificate precedence. A `serialNumber` that is not a semantic identifier (for example a plain certificate serial) is never emitted as `semantic`. The subject kind comes from the WRPAC policy OIDs alone: a certificate with neither a natural- nor a legal-person policy (or with both) yields no `semantic`, because its `organizationIdentifier` could be a natural person's affiliation rather than the verifier. The type must fit the subject: natural-person types (`PNO`, `IDC`, `PAS`, `TAX`, `TIN`) are read from `serialNumber` of natural-person certificates only, and refused for legal-person ones. - **Intermediary requests** (`allow_intermediaries`): the identifier is the end relying party's. `semantic` is taken from the RP leaf; `x509_san_dns` and `x509_san_uri` are omitted because the `client_id` of a brokered request cannot be shown to name the end relying party. The intermediary's own, unvalidated chain is never a source. - The member is carried through by the strategies that return the matching registry's own response (`first_match`, `sequential`); the aggregating `all` and `best_match` strategies and composite registries build a new response and do not carry it. - Not offered: `did_scid`, `service` and `spki_sha256` are not implemented yet and are rejected in `return_verifier_ids`. diff --git a/pkg/registry/rpcert/semanticid.go b/pkg/registry/rpcert/semanticid.go index a063ec95..88cd7337 100644 --- a/pkg/registry/rpcert/semanticid.go +++ b/pkg/registry/rpcert/semanticid.go @@ -105,8 +105,11 @@ func realOrganizationIdentifier(cert *x509.Certificate) string { // // The value must match the semantic identifier syntax, and its type must fit // the subject: a natural-person type (PNO, IDC, PAS, TAX, TIN) is refused for a -// legal-person certificate and any other type for a natural-person one, when the -// policy OIDs tell the two apart. A serialNumber that is merely a certificate +// legal-person certificate and any other type for a natural-person one. The +// subject kind comes from the WRPAC policy OIDs alone; a certificate with +// neither kind has no semantic identifier, because an organizationIdentifier +// there could be the affiliation of a natural person rather than the verifier. +// A serialNumber that is merely a certificate // serial (or anything else non-semantic) is therefore never returned. An // organizationIdentifier that is present but not valid is not papered over by // falling back to serialNumber. A certificate carrying both natural- and @@ -122,32 +125,31 @@ func SemanticIdentifier(cert *x509.Certificate) (string, bool) { return "", false } + // Which attribute names the subject depends on whether it is a natural or a + // legal person, and only the WRPAC policy OIDs say so. Without one the + // organizationIdentifier cannot be told from an affiliation of a natural + // person, so no identifier is returned (fail closed). var candidate string - if kind == SubjectKindNaturalPerson { + switch kind { + case SubjectKindNaturalPerson: candidate = cert.Subject.SerialNumber - } else { + case SubjectKindLegalPerson: var present bool candidate, present = organizationIdentifierAttr(cert) if !present { - // Legacy certificates and fixtures: serialNumber, but only when - // 2.5.4.97 is absent. A malformed one is not papered over. + // Older certificates put it in serialNumber, but only when 2.5.4.97 + // is absent. A malformed one is not papered over. candidate = cert.Subject.SerialNumber } + default: + return "", false } if !IsSemanticIdentifier(candidate) { return "", false } - isNaturalType := naturalPersonSemanticTypes[candidate[:3]] - switch kind { - case SubjectKindNaturalPerson: - if !isNaturalType { - return "", false - } - case SubjectKindLegalPerson: - if isNaturalType { - return "", false - } + if naturalPersonSemanticTypes[candidate[:3]] != (kind == SubjectKindNaturalPerson) { + return "", false } return candidate, true } diff --git a/pkg/registry/rpcert/semanticid_test.go b/pkg/registry/rpcert/semanticid_test.go index ce648c21..8d83194e 100644 --- a/pkg/registry/rpcert/semanticid_test.go +++ b/pkg/registry/rpcert/semanticid_test.go @@ -30,7 +30,10 @@ func TestSemanticIdentifier(t *testing.T) { }{ {"legal person, organizationIdentifier (5.1.3)", orgIDName("VATSE-556000000101", ""), legal, "VATSE-556000000101", true}, {"legal person, legacy serialNumber fallback", orgIDName("", "NTRSE-5560000001"), legal, "NTRSE-5560000001", true}, - {"no policy OIDs, organizationIdentifier", orgIDName("LEIXG-529900T8BM49AURSDO55", ""), nil, "LEIXG-529900T8BM49AURSDO55", true}, + // Unclassified: organizationIdentifier could be a natural person's affiliation. + {"no policy OIDs, organizationIdentifier", orgIDName("LEIXG-529900T8BM49AURSDO55", ""), nil, "", false}, + {"no policy OIDs, serialNumber", orgIDName("", "PNOSE-196001010101"), nil, "", false}, + {"no policy OIDs, natural person with affiliation", orgIDName("VATSE-556000000101", "PNOSE-196001010101"), nil, "", false}, {"value with an internal space", orgIDName("NTRDE-HRB 12345", ""), legal, "NTRDE-HRB 12345", true}, // ETSI TS 119 475 5.1.5: a natural person's identifier is in serialNumber. {"natural person, serialNumber (5.1.5)", orgIDName("", "PNOSE-196001010101"), natural, "PNOSE-196001010101", true}, @@ -70,8 +73,8 @@ func TestSemanticIdentifier(t *testing.T) { } } -// Contradictory policies are not "unclassified": a policy-less certificate may -// yield a semantic identifier, one claiming to be both natural and legal may not. +// Neither a contradictory nor a missing classification yields a semantic +// identifier; only a certificate that is clearly one kind does. func TestSemanticIdentifier_ContradictoryPoliciesFailClosed(t *testing.T) { both := []asn1.ObjectIdentifier{parseOID(OIDNCPNaturalPerson), parseOID(OIDNCPLegalPerson)} for _, subject := range []pkix.Name{ @@ -83,9 +86,8 @@ func TestSemanticIdentifier_ContradictoryPoliciesFailClosed(t *testing.T) { if got, ok := SemanticIdentifier(cert); ok || got != "" { t.Fatalf("contradictory policies must not yield a semantic id, got (%q, %v)", got, ok) } - // Same subject without any policy OID is accepted: the refusal is the contradiction's. - if _, ok := SemanticIdentifier(makeCert(t, certOpts{subject: subject})); !ok { - t.Fatalf("policy-less certificate with %v should still yield one", subject) + if got, ok := SemanticIdentifier(makeCert(t, certOpts{subject: subject})); ok || got != "" { + t.Fatalf("policy-less certificate with %v must not yield one, got (%q, %v)", subject, got, ok) } } } diff --git a/pkg/registry/verifierid_test.go b/pkg/registry/verifierid_test.go index 8cca99f4..71fbbed0 100644 --- a/pkg/registry/verifierid_test.go +++ b/pkg/registry/verifierid_test.go @@ -218,12 +218,19 @@ func TestEnrichVerifierIDs_Semantic_NonSemanticSerialNumberNotEmitted(t *testing } func TestEnrichVerifierIDs_Semantic_WithoutProfileRegistry(t *testing.T) { - cert := makeVIDCert(t, vidCertOpts{orgID: "VATSE-556000000101"}) + cert := makeVIDCert(t, vidCertOpts{orgID: "VATSE-556000000101", policy: rpcert.OIDNCPLegalPerson}) res := EnrichX5CResponse(vidReq("https://rp.example.com", []string{"semantic"}, nil), cert) require.Len(t, res.VerifierIDs, 1) assert.Equal(t, "semantic_id:VATSE-556000000101", res.VerifierIDs[0].ID) } +// Without a natural/legal-person policy OID the organizationIdentifier could be a +// natural person's affiliation rather than the verifier, so nothing is emitted. +func TestEnrichVerifierIDs_Semantic_UnclassifiedCertificateEmitsNothing(t *testing.T) { + cert := makeVIDCert(t, vidCertOpts{orgID: "VATSE-556000000101"}) + assert.Empty(t, EnrichX5CResponse(vidReq("https://rp.example.com", []string{"semantic"}, nil), cert).VerifierIDs) +} + func TestEnrichVerifierIDs_DNS_CanonicalFormAndBasis(t *testing.T) { cert := makeVIDCert(t, vidCertOpts{dns: []string{"rp.example.com"}}) // Mixed case and a trailing dot are one name: it must be one identifier. From dc85b238082324e6266aae9385d594b9e1011f89 Mon Sep 17 00:00:00 2001 From: Leif Johansson Date: Thu, 15 Oct 2026 13:00:28 +0200 Subject: [PATCH 09/10] verifier_ids: strict x509_san_dns canonical form (#203) CanonicalDNSName now returns (value, ok): ASCII A-label form, ASCII lower case, one trailing dot removed, and refuses non-ASCII (no U-label conversion), empty labels, over-long labels/names, whitespace and wildcards, so nothing is emitted for them. The check is explicit rather than left to the SAN binding. A client_id that binds only through a wildcard SAN no longer yields an identifier. Shared vectors in pkg/registry/testdata for the SDKs. The binding stays exact, since DNSSANMatches is also the trust-evaluation path. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 3 +- pkg/authzen/types.go | 3 +- .../x509_san_dns_verifier_id_vectors.json | 113 ++++++++++++++++++ pkg/registry/verifierid.go | 51 +++++++- pkg/registry/verifierid_test.go | 53 +++++++- pkg/registry/x5c_enrichment.go | 15 ++- 6 files changed, 225 insertions(+), 13 deletions(-) create mode 100644 pkg/registry/testdata/x509_san_dns_verifier_id_vectors.json diff --git a/README.md b/README.md index 58438d02..1d166d0e 100644 --- a/README.md +++ b/README.md @@ -852,7 +852,7 @@ Types go-trust can derive (the exact value form is fixed per type). Type names f | Type | Value | Basis | Registries | |---|---|---|---| | `semantic` | `semantic_id:` + the ETSI semantic identifier from the **access certificate** leaf (ETSI TS 119 475 5.1.3 legal persons: `organizationIdentifier`; 5.1.5 natural persons: `serialNumber`). Must match `<3 letters><2 letters>-`; the value is kept verbatim. | `x5c-chain` | x5c registries (`etsi`, `lote`) | -| `x509_san_dns` | the `client_id` of an `x509_san_dns` request, lower-case, no trailing dot | `client-id-binding` | x5c registries | +| `x509_san_dns` | the `client_id` of an `x509_san_dns` request, which must equal a dNSName SAN of the leaf exactly: ASCII (A-label) form, ASCII lower case, one trailing dot removed. No U-label conversion; see the rules below | `client-id-binding` | x5c registries | | `x509_san_uri` | the `client_id` of an `x509_san_uri` request, byte-for-byte (no case folding, no slash normalisation, not re-serialised through `net/url`) | `client-id-binding` | x5c registries | | `decentralized_identifier` | the full DID of the resolved DID document, without fragment | `did-resolution` | `didweb`, `didwebvh`, `didjwks`, `did` | | `openid_federation` | the Entity ID as in the validated trust chain, byte-for-byte | `oidf-trust-chain` | `oidfed` | @@ -860,6 +860,7 @@ Types go-trust can derive (the exact value form is fixed per type). Type names f Rules: - **Fail closed.** Nothing is emitted for a deny decision, when the matched profile's validation failed (normally only a warning), or when the identifier was not verified: `x509_san_dns`/`x509_san_uri` need `VerifyLeafBinding` to pass, `decentralized_identifier` needs a successful resolution and a matching key, `openid_federation` needs a validated trust chain and a matching key. An unverified `client_id` is never echoed. Resolution-only responses carry no `verifier_ids`. +- **`x509_san_dns` is strict.** The entry is omitted, never repaired, when the `client_id` is not an identifier of one relying party: a non-ASCII (U-label) name (IDNA conversions differ between IDNA2003, IDNA2008 and UTS #46, and a difference would be a different pseudonym, so nothing is converted), an empty label (`a..b`, a leading dot, more than one trailing dot), whitespace or control characters, a label over 63 or a name over 253 octets, or a wildcard. A `client_id` that binds only through a wildcard SAN (`*.example.com`) is omitted too, since it names one of many hosts under one certificate. The SAN binding itself stays an exact, case-sensitive comparison (it is shared with the trust evaluation), so a `client_id` that differs from the SAN only in case yields no entry. Shared test vectors: `pkg/registry/testdata/x509_san_dns_verifier_id_vectors.json`. - **`semantic` comes from the access certificate only.** The registration certificate is not an input today, so wallets must not assume registration-certificate precedence. A `serialNumber` that is not a semantic identifier (for example a plain certificate serial) is never emitted as `semantic`. The subject kind comes from the WRPAC policy OIDs alone: a certificate with neither a natural- nor a legal-person policy (or with both) yields no `semantic`, because its `organizationIdentifier` could be a natural person's affiliation rather than the verifier. The type must fit the subject: natural-person types (`PNO`, `IDC`, `PAS`, `TAX`, `TIN`) are read from `serialNumber` of natural-person certificates only, and refused for legal-person ones. - **Intermediary requests** (`allow_intermediaries`): the identifier is the end relying party's. `semantic` is taken from the RP leaf; `x509_san_dns` and `x509_san_uri` are omitted because the `client_id` of a brokered request cannot be shown to name the end relying party. The intermediary's own, unvalidated chain is never a source. - The member is carried through by the strategies that return the matching registry's own response (`first_match`, `sequential`); the aggregating `all` and `best_match` strategies and composite registries build a new response and do not carry it. diff --git a/pkg/authzen/types.go b/pkg/authzen/types.go index c630a587..413d6962 100644 --- a/pkg/authzen/types.go +++ b/pkg/authzen/types.go @@ -94,7 +94,8 @@ const ( // identifier (EN 319 412-1 5.1.3) from the access certificate. VerifierIDTypeSemantic = "semantic" // VerifierIDTypeDNS (x509_san_dns) is the dNSName SAN value, - // lower-cased and without a trailing dot. + // in its ASCII (A-label) form, ASCII lower case, without a trailing dot. + // Names that are not ASCII are not identifiers. VerifierIDTypeDNS = "x509_san_dns" // VerifierIDTypeURI (x509_san_uri) is the uniformResourceIdentifier SAN // value, byte-for-byte as bound. diff --git a/pkg/registry/testdata/x509_san_dns_verifier_id_vectors.json b/pkg/registry/testdata/x509_san_dns_verifier_id_vectors.json new file mode 100644 index 00000000..d1121e09 --- /dev/null +++ b/pkg/registry/testdata/x509_san_dns_verifier_id_vectors.json @@ -0,0 +1,113 @@ +{ + "description": "Canonical x509_san_dns verifier_id: dNSName SAN, ASCII (A-label) lower case, one trailing dot removed, no U-label conversion. verifier_id null means no entry is emitted. Clients hash the value byte for byte.", + "vectors": [ + { + "name": "plain", + "san": "rp.example.com", + "client_id": "x509_san_dns:rp.example.com", + "verifier_id": "rp.example.com" + }, + { + "name": "mixed case SAN and client_id", + "san": "RP.Example.COM", + "client_id": "x509_san_dns:RP.Example.COM", + "verifier_id": "rp.example.com" + }, + { + "name": "A-label passes through, lower-cased", + "san": "XN--Bcher-Kva.example", + "client_id": "x509_san_dns:XN--Bcher-Kva.example", + "verifier_id": "xn--bcher-kva.example" + }, + { + "name": "A-label lower case", + "san": "xn--bcher-kva.example", + "client_id": "x509_san_dns:xn--bcher-kva.example", + "verifier_id": "xn--bcher-kva.example" + }, + { + "name": "trailing dot on SAN and client_id", + "san": "rp.example.com.", + "client_id": "x509_san_dns:rp.example.com.", + "verifier_id": "rp.example.com" + }, + { + "name": "client_id differs from SAN in case: binding is exact, no entry", + "san": "rp.example.com", + "client_id": "x509_san_dns:RP.example.com", + "verifier_id": null + }, + { + "name": "client_id has trailing dot, SAN does not: no entry", + "san": "rp.example.com", + "client_id": "x509_san_dns:rp.example.com.", + "verifier_id": null + }, + { + "name": "U-label client_id: not an identifier, no conversion", + "san": "rp.example.com", + "client_id": "x509_san_dns:bücher.example", + "verifier_id": null + }, + { + "name": "empty label", + "san": "rp.example.com", + "client_id": "x509_san_dns:a..example.com", + "verifier_id": null + }, + { + "name": "leading dot", + "san": "rp.example.com", + "client_id": "x509_san_dns:.example.com", + "verifier_id": null + }, + { + "name": "two trailing dots", + "san": "rp.example.com", + "client_id": "x509_san_dns:rp.example.com..", + "verifier_id": null + }, + { + "name": "whitespace", + "san": "rp.example.com", + "client_id": "x509_san_dns: rp.example.com", + "verifier_id": null + }, + { + "name": "wildcard client_id", + "san": "*.example.com", + "client_id": "x509_san_dns:*.example.com", + "verifier_id": null + }, + { + "name": "client_id bound only via wildcard SAN: no entry", + "san": "*.example.com", + "client_id": "x509_san_dns:a.example.com", + "verifier_id": null + }, + { + "name": "label of 63 octets", + "san": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.example.com", + "client_id": "x509_san_dns:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.example.com", + "verifier_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.example.com" + }, + { + "name": "label of 64 octets", + "san": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.example.com", + "client_id": "x509_san_dns:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.example.com", + "verifier_id": null + }, + { + "name": "name of 253 octets", + "san": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "client_id": "x509_san_dns:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "verifier_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + { + "name": "name of 254 octets", + "san": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "client_id": "x509_san_dns:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "verifier_id": null + } + ] +} \ No newline at end of file diff --git a/pkg/registry/verifierid.go b/pkg/registry/verifierid.go index 73c27a9e..5360a0ad 100644 --- a/pkg/registry/verifierid.go +++ b/pkg/registry/verifierid.go @@ -64,11 +64,52 @@ func AddVerifierID(resp *authzen.EvaluationResponse, req *authzen.EvaluationRequ resp.Context.VerifierIDs = append(resp.Context.VerifierIDs, authzen.VerifierID{Type: idType, ID: id, Basis: basis}) } -// CanonicalDNSName returns the canonical verifier form of a DNS name: ASCII -// lower case, no trailing dot. DNS names are case-insensitive, so two spellings -// of one name must not become two pseudonyms. -func CanonicalDNSName(name string) string { - return strings.TrimSuffix(strings.ToLower(name), ".") +// CanonicalDNSName returns the canonical verifier form of a DNS name for the +// x509_san_dns identifier: the ASCII (A-label) spelling, ASCII lower case, one +// trailing dot removed. ok is false when name is not an identifier, in which +// case no entry is emitted (fail closed). +// +// The value is hashed into a pseudonym, so two spellings of one host must give +// one string and nothing may be rewritten that could be rewritten differently +// elsewhere. In particular U-labels are NOT converted to A-labels: IDNA2003, +// IDNA2008 and UTS #46 disagree for some names, and a conversion difference is a +// different pseudonym. A name containing any non-ASCII byte is therefore not an +// identifier, and an A-label ("xn--...") is passed through verbatim apart from +// case. Also refused: empty labels (including a leading dot or more than one +// trailing dot), labels over 63 and names over 253 octets, whitespace and +// control characters, and a wildcard. +func CanonicalDNSName(name string) (string, bool) { + name = strings.TrimSuffix(name, ".") + if name == "" || len(name) > 253 { + return "", false + } + for _, label := range strings.Split(name, ".") { + if label == "" || len(label) > 63 { + return "", false + } + } + b := []byte(name) + for i, c := range b { + switch { + case c < 0x21 || c > 0x7e || c == '*': + return "", false + case c >= 'A' && c <= 'Z': + b[i] = c + ('a' - 'A') + } + } + return string(b), true +} + +// sanExactlyContains reports whether dnsNames holds clientID byte for byte. A +// client_id bound only through a wildcard SAN (*.example.com) is one of many +// hosts under one certificate, so it does not identify a single verifier. +func sanExactlyContains(dnsNames []string, clientID string) bool { + for _, n := range dnsNames { + if n == clientID { + return true + } + } + return false } // CanonicalDID returns the DID without any fragment, i.e. the DID itself rather diff --git a/pkg/registry/verifierid_test.go b/pkg/registry/verifierid_test.go index 71fbbed0..93b8ff64 100644 --- a/pkg/registry/verifierid_test.go +++ b/pkg/registry/verifierid_test.go @@ -10,6 +10,8 @@ import ( "encoding/json" "math/big" "net/url" + "os" + "strings" "testing" "time" @@ -158,8 +160,12 @@ func TestAddDIDVerifierID_RequiresSameDID(t *testing.T) { } func TestCanonicalForms(t *testing.T) { - assert.Equal(t, "rp.example.com", CanonicalDNSName("RP.Example.COM.")) - assert.Equal(t, "rp.example.com", CanonicalDNSName("rp.example.com")) + got, ok := CanonicalDNSName("RP.Example.COM.") + assert.True(t, ok) + assert.Equal(t, "rp.example.com", got) + got, ok = CanonicalDNSName("rp.example.com") + assert.True(t, ok) + assert.Equal(t, "rp.example.com", got) assert.Equal(t, "did:web:Example.com:Path", CanonicalDID("did:web:Example.com:Path#key-1"), "no case folding in a DID") assert.Equal(t, "did:web:example.com", CanonicalDID("did:web:example.com")) } @@ -390,3 +396,46 @@ func TestApplyEnrichmentToResponse_VerifierIDsJSONShape(t *testing.T) { {"verifier_id_type":"semantic","verifier_id":"semantic_id:VATSE-556000000101","basis":"x5c-chain"}, {"verifier_id_type":"x509_san_dns","verifier_id":"rp.example.com","basis":"client-id-binding"}]}}`, string(b)) } + +// The vectors are shared with the SDKs, which hash the value byte for byte and +// must produce the same strings. +func TestEnrichVerifierIDs_DNS_SharedVectors(t *testing.T) { + raw, err := os.ReadFile("testdata/x509_san_dns_verifier_id_vectors.json") + require.NoError(t, err) + var file struct { + Vectors []struct { + Name string `json:"name"` + SAN string `json:"san"` + ClientID string `json:"client_id"` + VerifierID *string `json:"verifier_id"` + } `json:"vectors"` + } + require.NoError(t, json.Unmarshal(raw, &file)) + require.NotEmpty(t, file.Vectors) + + for _, v := range file.Vectors { + t.Run(v.Name, func(t *testing.T) { + // A SAN that Go's x509 builder refuses to encode can only be exercised + // through the canonicaliser, which is where non-ASCII is refused. + cert := makeVIDCert(t, vidCertOpts{dns: []string{v.SAN}}) + res := EnrichX5CResponse(vidReq(v.ClientID, []string{"x509_san_dns"}, nil), cert) + if v.VerifierID == nil { + assert.Empty(t, res.VerifierIDs) + return + } + require.Len(t, res.VerifierIDs, 1) + assert.Equal(t, authzen.VerifierID{Type: "x509_san_dns", ID: *v.VerifierID, Basis: "client-id-binding"}, res.VerifierIDs[0]) + }) + } +} + +// Non-ASCII is refused by the canonicaliser itself, not left to the SAN binding +// (a non-ASCII dNSName does not survive certificate parsing, but the rule must +// not depend on that). +func TestCanonicalDNSName_RefusesWhatIsNotAnIdentifier(t *testing.T) { + for _, name := range []string{"", ".", "bücher.example", "a..b", ".a", "a.b..", " a.b", "a.b ", "*.example.com", "a\tb.example", strings.Repeat("a", 64) + ".example"} { + got, ok := CanonicalDNSName(name) + assert.False(t, ok, "%q", name) + assert.Empty(t, got, "%q", name) + } +} diff --git a/pkg/registry/x5c_enrichment.go b/pkg/registry/x5c_enrichment.go index 03461f36..32d25a35 100644 --- a/pkg/registry/x5c_enrichment.go +++ b/pkg/registry/x5c_enrichment.go @@ -2,7 +2,6 @@ package registry import ( "crypto/x509" - "strings" "github.com/sirosfoundation/go-trust/pkg/authzen" "github.com/sirosfoundation/go-trust/pkg/registry/rpcert" @@ -521,11 +520,19 @@ func deriveSANVerifierID(req *authzen.EvaluationRequest, leaf *x509.Certificate, var idType, id string switch scheme { case ClientIDSchemeX509SANDNS: - // A wildcard is not an identifier of one RP. - if strings.Contains(value, "*") { + // Not an identifier of one RP: a wildcard, or anything that has no + // canonical form (non-ASCII, empty labels, over-long, ...). Checked + // here explicitly rather than left to the SAN binding. + canonical, ok := CanonicalDNSName(value) + if !ok { return authzen.VerifierID{}, false } - idType, id = authzen.VerifierIDTypeDNS, CanonicalDNSName(value) + // Likewise a client_id that binds only through a wildcard SAN names + // one of many hosts, not the certificate holder. + if !sanExactlyContains(leaf.DNSNames, value) { + return authzen.VerifierID{}, false + } + idType, id = authzen.VerifierIDTypeDNS, canonical case ClientIDSchemeX509SANURI: // Byte-for-byte: URISANMatches is an exact comparison, so the bound // value already is the SAN. Not re-serialised through net/url. From 262037ac1411a98ba03a941dc1c7f3b1b4e4d259 Mon Sep 17 00:00:00 2001 From: Leif Johansson Date: Thu, 15 Oct 2026 13:02:49 +0200 Subject: [PATCH 10/10] verifier_ids: ASCII case-insensitive x509_san_dns binding, A-label docs (#203) DNSSANMatches compares ASCII case-insensitively (DNS names are), so a client_id differing from the SAN only in case binds and yields the same lower-case verifier_id; the emitted value matches the match. Only ASCII letters are folded: a U-label never matches and nothing is converted. Internationalised names work via their A-label. The wildcard branch also no longer matches an empty label (.example.com against *.example.com). DNSSANMatches is also the ETSI trust-evaluation binding, so this widens it only to hostnames that differ in case. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 4 +-- pkg/authzen/types.go | 2 +- pkg/registry/clientid.go | 36 +++++++++++++++---- pkg/registry/clientid_test.go | 7 ++++ .../x509_san_dns_verifier_id_vectors.json | 12 +++++-- pkg/registry/verifierid.go | 9 ++--- 6 files changed, 54 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 1d166d0e..8e9cdc91 100644 --- a/README.md +++ b/README.md @@ -852,7 +852,7 @@ Types go-trust can derive (the exact value form is fixed per type). Type names f | Type | Value | Basis | Registries | |---|---|---|---| | `semantic` | `semantic_id:` + the ETSI semantic identifier from the **access certificate** leaf (ETSI TS 119 475 5.1.3 legal persons: `organizationIdentifier`; 5.1.5 natural persons: `serialNumber`). Must match `<3 letters><2 letters>-`; the value is kept verbatim. | `x5c-chain` | x5c registries (`etsi`, `lote`) | -| `x509_san_dns` | the `client_id` of an `x509_san_dns` request, which must equal a dNSName SAN of the leaf exactly: ASCII (A-label) form, ASCII lower case, one trailing dot removed. No U-label conversion; see the rules below | `client-id-binding` | x5c registries | +| `x509_san_dns` | the `client_id` of an `x509_san_dns` request, which must equal a dNSName SAN of the leaf (ASCII case-insensitively): the A-label form, lower case, one trailing dot removed; see the rules below | `client-id-binding` | x5c registries | | `x509_san_uri` | the `client_id` of an `x509_san_uri` request, byte-for-byte (no case folding, no slash normalisation, not re-serialised through `net/url`) | `client-id-binding` | x5c registries | | `decentralized_identifier` | the full DID of the resolved DID document, without fragment | `did-resolution` | `didweb`, `didwebvh`, `didjwks`, `did` | | `openid_federation` | the Entity ID as in the validated trust chain, byte-for-byte | `oidf-trust-chain` | `oidfed` | @@ -860,7 +860,7 @@ Types go-trust can derive (the exact value form is fixed per type). Type names f Rules: - **Fail closed.** Nothing is emitted for a deny decision, when the matched profile's validation failed (normally only a warning), or when the identifier was not verified: `x509_san_dns`/`x509_san_uri` need `VerifyLeafBinding` to pass, `decentralized_identifier` needs a successful resolution and a matching key, `openid_federation` needs a validated trust chain and a matching key. An unverified `client_id` is never echoed. Resolution-only responses carry no `verifier_ids`. -- **`x509_san_dns` is strict.** The entry is omitted, never repaired, when the `client_id` is not an identifier of one relying party: a non-ASCII (U-label) name (IDNA conversions differ between IDNA2003, IDNA2008 and UTS #46, and a difference would be a different pseudonym, so nothing is converted), an empty label (`a..b`, a leading dot, more than one trailing dot), whitespace or control characters, a label over 63 or a name over 253 octets, or a wildcard. A `client_id` that binds only through a wildcard SAN (`*.example.com`) is omitted too, since it names one of many hosts under one certificate. The SAN binding itself stays an exact, case-sensitive comparison (it is shared with the trust evaluation), so a `client_id` that differs from the SAN only in case yields no entry. Shared test vectors: `pkg/registry/testdata/x509_san_dns_verifier_id_vectors.json`. +- **`x509_san_dns` is the A-label, lower case.** Internationalised names are supported: a certificate carries them as A-labels (`xn--...`, RFC 5280), and a `client_id` that carries the A-label binds to the SAN and yields the A-label as the value. The `client_id` is compared with the SAN ASCII case-insensitively (the value emitted is always lower case, one trailing dot removed). A U-label `client_id` (`bücher.example`) is not converted: the PDP does no IDNA processing, because IDNA2003, IDNA2008 and UTS #46 differ for some names and a difference would be a different pseudonym, so no entry is emitted and the verifier must send the A-label. Also omitted, never repaired: an empty label (`a..b`, a leading dot, more than one trailing dot), whitespace or control characters, a label over 63 or a name over 253 octets, a wildcard `client_id`, and a `client_id` that binds only through a wildcard SAN (`*.example.com`), which names one of many hosts under one certificate. Shared test vectors: `pkg/registry/testdata/x509_san_dns_verifier_id_vectors.json`. - **`semantic` comes from the access certificate only.** The registration certificate is not an input today, so wallets must not assume registration-certificate precedence. A `serialNumber` that is not a semantic identifier (for example a plain certificate serial) is never emitted as `semantic`. The subject kind comes from the WRPAC policy OIDs alone: a certificate with neither a natural- nor a legal-person policy (or with both) yields no `semantic`, because its `organizationIdentifier` could be a natural person's affiliation rather than the verifier. The type must fit the subject: natural-person types (`PNO`, `IDC`, `PAS`, `TAX`, `TIN`) are read from `serialNumber` of natural-person certificates only, and refused for legal-person ones. - **Intermediary requests** (`allow_intermediaries`): the identifier is the end relying party's. `semantic` is taken from the RP leaf; `x509_san_dns` and `x509_san_uri` are omitted because the `client_id` of a brokered request cannot be shown to name the end relying party. The intermediary's own, unvalidated chain is never a source. - The member is carried through by the strategies that return the matching registry's own response (`first_match`, `sequential`); the aggregating `all` and `best_match` strategies and composite registries build a new response and do not carry it. diff --git a/pkg/authzen/types.go b/pkg/authzen/types.go index 413d6962..6cbdf5f4 100644 --- a/pkg/authzen/types.go +++ b/pkg/authzen/types.go @@ -95,7 +95,7 @@ const ( VerifierIDTypeSemantic = "semantic" // VerifierIDTypeDNS (x509_san_dns) is the dNSName SAN value, // in its ASCII (A-label) form, ASCII lower case, without a trailing dot. - // Names that are not ASCII are not identifiers. + // An internationalised name is its A-label (xn--...); a U-label is not converted. VerifierIDTypeDNS = "x509_san_dns" // VerifierIDTypeURI (x509_san_uri) is the uniformResourceIdentifier SAN // value, byte-for-byte as bound. diff --git a/pkg/registry/clientid.go b/pkg/registry/clientid.go index 6619f729..31a642c3 100644 --- a/pkg/registry/clientid.go +++ b/pkg/registry/clientid.go @@ -109,20 +109,22 @@ func VerifyLeafBinding(scheme ClientIDScheme, value string, leaf *x509.Certifica } // DNSSANMatches reports whether clientID matches one of dnsNames, per -// OpenID4VP's x509_san_dns semantics (exact match) with RFC 6125 single-label -// wildcard support: "*.example.com" matches "sub.example.com" but not -// "example.com" or "deep.sub.example.com". +// OpenID4VP's x509_san_dns semantics with RFC 6125 single-label wildcard +// support: "*.example.com" matches "sub.example.com" but not "example.com" or +// "deep.sub.example.com". Comparison is ASCII case-insensitive, as DNS names +// are; nothing else is folded, so a non-ASCII clientID (a U-label) never +// matches: an internationalised name must be presented as its A-label. func DNSSANMatches(clientID string, dnsNames []string) bool { for _, dnsName := range dnsNames { - if dnsName == clientID { + if equalASCIIFold(dnsName, clientID) { return true } if strings.HasPrefix(dnsName, "*.") { suffix := dnsName[1:] // *.example.com -> .example.com baseDomain := dnsName[2:] // *.example.com -> example.com - if strings.HasSuffix(clientID, suffix) && clientID != baseDomain { + if len(clientID) > len(suffix) && equalASCIIFold(clientID[len(clientID)-len(suffix):], suffix) && !equalASCIIFold(clientID, baseDomain) { // Ensure only a single label before the suffix (no nested subdomains). - prefix := strings.TrimSuffix(clientID, suffix) + prefix := clientID[:len(clientID)-len(suffix)] if !strings.Contains(prefix, ".") { return true } @@ -132,6 +134,28 @@ func DNSSANMatches(clientID string, dnsNames []string) bool { return false } +// equalASCIIFold reports whether a and b are equal when only ASCII letters are +// case-folded. Unlike strings.EqualFold it never folds non-ASCII characters +// (U+212A KELVIN SIGN would otherwise equal "k"). +func equalASCIIFold(a, b string) bool { + if len(a) != len(b) { + return false + } + for i := 0; i < len(a); i++ { + x, y := a[i], b[i] + if x >= 'A' && x <= 'Z' { + x += 'a' - 'A' + } + if y >= 'A' && y <= 'Z' { + y += 'a' - 'A' + } + if x != y { + return false + } + } + return true +} + // URISANMatches reports whether clientID matches one of the certificate's // URI SANs, per OpenID4VP's x509_san_uri semantics (exact match). func URISANMatches(clientID string, uris []*url.URL) bool { diff --git a/pkg/registry/clientid_test.go b/pkg/registry/clientid_test.go index 723ada5d..e9133a6c 100644 --- a/pkg/registry/clientid_test.go +++ b/pkg/registry/clientid_test.go @@ -56,6 +56,13 @@ func TestDNSSANMatches(t *testing.T) { {"wildcard does not match base domain", "example.com", []string{"*.example.com"}, false}, {"wildcard does not match nested subdomain", "deep.sub.example.com", []string{"*.example.com"}, false}, {"empty SAN list", "example.com", nil, false}, + {"ASCII case-insensitive", "RP.Example.COM", []string{"rp.example.com"}, true}, + {"ASCII case-insensitive, SAN upper case", "rp.example.com", []string{"RP.EXAMPLE.COM"}, true}, + {"wildcard, ASCII case-insensitive", "Sub.EXAMPLE.com", []string{"*.example.com"}, true}, + {"wildcard does not match an empty label", ".example.com", []string{"*.example.com"}, false}, + {"U-label never matches the A-label SAN", "bücher.example", []string{"xn--bcher-kva.example"}, false}, + {"non-ASCII is not case-folded (KELVIN SIGN is not k)", "\u212Aey.example", []string{"key.example"}, false}, + {"trailing dot is not folded away", "rp.example.com.", []string{"rp.example.com"}, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/registry/testdata/x509_san_dns_verifier_id_vectors.json b/pkg/registry/testdata/x509_san_dns_verifier_id_vectors.json index d1121e09..3444e177 100644 --- a/pkg/registry/testdata/x509_san_dns_verifier_id_vectors.json +++ b/pkg/registry/testdata/x509_san_dns_verifier_id_vectors.json @@ -1,5 +1,5 @@ { - "description": "Canonical x509_san_dns verifier_id: dNSName SAN, ASCII (A-label) lower case, one trailing dot removed, no U-label conversion. verifier_id null means no entry is emitted. Clients hash the value byte for byte.", + "description": "Canonical x509_san_dns verifier_id: dNSName SAN in A-label form (internationalised names are supported when the client_id carries the A-label), ASCII lower case; the client_id binds to the SAN ASCII case-insensitively, one trailing dot removed, no U-label conversion. verifier_id null means no entry is emitted. Clients hash the value byte for byte.", "vectors": [ { "name": "plain", @@ -32,10 +32,10 @@ "verifier_id": "rp.example.com" }, { - "name": "client_id differs from SAN in case: binding is exact, no entry", + "name": "client_id differs from SAN in ASCII case: binds, canonical form is lower case", "san": "rp.example.com", "client_id": "x509_san_dns:RP.example.com", - "verifier_id": null + "verifier_id": "rp.example.com" }, { "name": "client_id has trailing dot, SAN does not: no entry", @@ -108,6 +108,12 @@ "san": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "client_id": "x509_san_dns:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "verifier_id": null + }, + { + "name": "U-label client_id against an A-label SAN: no entry, no conversion", + "san": "xn--bcher-kva.example", + "client_id": "x509_san_dns:bücher.example", + "verifier_id": null } ] } \ No newline at end of file diff --git a/pkg/registry/verifierid.go b/pkg/registry/verifierid.go index 5360a0ad..d8c9ae0a 100644 --- a/pkg/registry/verifierid.go +++ b/pkg/registry/verifierid.go @@ -100,12 +100,13 @@ func CanonicalDNSName(name string) (string, bool) { return string(b), true } -// sanExactlyContains reports whether dnsNames holds clientID byte for byte. A -// client_id bound only through a wildcard SAN (*.example.com) is one of many -// hosts under one certificate, so it does not identify a single verifier. +// sanExactlyContains reports whether dnsNames holds clientID, ASCII +// case-insensitively like the binding. A client_id bound only through a +// wildcard SAN (*.example.com) is one of many hosts under one certificate, so it +// does not identify a single verifier. func sanExactlyContains(dnsNames []string, clientID string) bool { for _, n := range dnsNames { - if n == clientID { + if equalASCIIFold(n, clientID) { return true } }