diff --git a/README.md b/README.md index d1e9028d..8e9cdc91 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 | @@ -821,6 +821,51 @@ 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, x509_san_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": "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": "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). 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`) | +| `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` | + +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 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. +- 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..3b783e35 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,7 +1374,12 @@ func configurePoliciesFromConfig(cfg *config.Config, registryMgr *registry.Regis } } - policyMgr.RegisterPolicy(policy) + // 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())) + } policyCount++ logger.Debug("Registered policy from config", @@ -1387,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/cmd/gt/policy_config_test.go b/cmd/gt/policy_config_test.go index fa6a50e6..289af59d 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", "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 759bb660..229c3cfb 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, 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 5935d77d..630c0660 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 \"x509_san_dns\".", + "type": "string", + "example": "semantic" + } + } } }, "tags": [ diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index bbf22d3d..2da89c05 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 \"x509_san_dns\".", + "type": "string", + "example": "semantic" + } + } } }, "tags": [ diff --git a/docs/swagger/swagger.yaml b/docs/swagger/swagger.yaml index 21a81f61..c18e52f6 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 "x509_san_dns". + example: semantic + type: string + type: object host: localhost:6001 info: contact: diff --git a/example/config.yaml b/example/config.yaml index a25ace95..df9f657f 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), 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, x509_san_dns] etsi: service_types: diff --git a/pkg/authzen/types.go b/pkg/authzen/types.go index 1b4856a5..6cbdf5f4 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,89 @@ 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 (x509_san_dns) is the dNSName SAN value, + // in its ASCII (A-label) form, ASCII lower case, without a trailing dot. + // 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. + VerifierIDTypeURI = "x509_san_uri" + // VerifierIDTypeDID is the full DID (without fragment) of a resolved DID + // document whose verification method matched the presented key. + VerifierIDTypeDID = "decentralized_identifier" + // VerifierIDTypeOIDFEntity is the OpenID Federation Entity ID (https URL), + // byte-for-byte as in the validated trust chain. + VerifierIDTypeOIDFEntity = "openid_federation" +) + +// 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 "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"` + // 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..45725eb2 --- /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":"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"}}) + 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":"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) { + 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..85417d6a 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, 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"` } // 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..7f4304d7 --- /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", "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"`}, + } + 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, x509_san_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] != "x509_san_dns" { + t.Fatalf("return_verifier_ids = %v", got) + } + if err := cfg.Validate(); err != nil { + t.Fatal(err) + } +} 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/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..a7ff7fe9 --- /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{"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: "decentralized_identifier", 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..b102bb61 --- /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{"decentralized_identifier"}} + + // 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: "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) + } + + // 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..da1d727b --- /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{"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: "decentralized_identifier", 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{"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) + + // 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..cef1d22b 100644 --- a/pkg/registry/oidfed/oidfed_registry.go +++ b/pkg/registry/oidfed/oidfed_registry.go @@ -859,13 +859,23 @@ 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 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 } // verifyKeyBinding checks if the key in the request matches a key in the entity's JWKS. @@ -1413,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 } } @@ -1428,6 +1438,24 @@ 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" && isHTTPURL(req.Subject.ID) { + return req.Subject.ID + } + if isHTTPURL(req.Resource.ID) { + 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 new file mode 100644 index 00000000..627a0ad1 --- /dev/null +++ b/pkg/registry/oidfed/verifierid_test.go @@ -0,0 +1,109 @@ +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{"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: "openid_federation", 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{"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) + + // 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 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") + 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..fbcf707b 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. @@ -290,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 new file mode 100644 index 00000000..17ac20e1 --- /dev/null +++ b/pkg/registry/policy_verifierids_test.go @@ -0,0 +1,40 @@ +package registry + +import ( + "testing" + + "github.com/sirosfoundation/go-trust/pkg/authzen" + "github.com/stretchr/testify/assert" + "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{"x509_san_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()) + err := PolicyConstraints{ReturnVerifierIDs: []string{"x509_san_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..88cd7337 --- /dev/null +++ b/pkg/registry/rpcert/semanticid.go @@ -0,0 +1,155 @@ +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 + // 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. + 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 neither kind is +// SubjectKindUnknown; one carrying both is SubjectKindContradictory. +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 SubjectKindContradictory + case natural: + return SubjectKindNaturalPerson + case legal: + 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) +} + +// 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, true + } + present = true + } + } + 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 +// 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. 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 +// 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 + } + + // 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 + switch kind { + case SubjectKindNaturalPerson: + candidate = cert.Subject.SerialNumber + case SubjectKindLegalPerson: + var present bool + candidate, present = organizationIdentifierAttr(cert) + if !present { + // 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 + } + 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 new file mode 100644 index 00000000..8d83194e --- /dev/null +++ b/pkg/registry/rpcert/semanticid_test.go @@ -0,0 +1,195 @@ +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}, + // 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}, + {"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) + } +} + +// 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{ + 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) + } + 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) + } + } +} + +// 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 + 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}, SubjectKindContradictory}, + } + 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_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{ + 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..7b0f8570 100644 --- a/pkg/registry/rpcert/wrpac.go +++ b/pkg/registry/rpcert/wrpac.go @@ -191,10 +191,26 @@ 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") + // 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 @@ -202,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 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) @@ -336,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/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..3444e177 --- /dev/null +++ b/pkg/registry/testdata/x509_san_dns_verifier_id_vectors.json @@ -0,0 +1,119 @@ +{ + "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", + "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 ASCII case: binds, canonical form is lower case", + "san": "rp.example.com", + "client_id": "x509_san_dns:RP.example.com", + "verifier_id": "rp.example.com" + }, + { + "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 + }, + { + "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 new file mode 100644 index 00000000..d8c9ae0a --- /dev/null +++ b/pkg/registry/verifierid.go @@ -0,0 +1,136 @@ +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 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, 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 equalASCIIFold(n, clientID) { + return true + } + } + return false +} + +// 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..5ea5dcca --- /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", "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. + 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["x509_san_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", "x509_san_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", "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") +} + +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..93b8ff64 --- /dev/null +++ b/pkg/registry/verifierid_test.go @@ -0,0 +1,441 @@ +package registry + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/json" + "math/big" + "net/url" + "os" + "strings" + "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 + noEmails bool // omit the contact SAN so WRPAC profile validation fails +} + +// 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, + } + tmpl.KeyUsage = x509.KeyUsageDigitalSignature + 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", "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{}{"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{"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("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("x509_san_dns")))) + // Empty / non-string hint is ignored. + 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{"x509_san_dns"}, nil) + + allow := &authzen.EvaluationResponse{Decision: true} + AddVerifierID(allow, req, "x509_san_dns", "rp.example", "client-id-binding") + require.NotNil(t, allow.Context) + assert.Equal(t, []authzen.VerifierID{{Type: "x509_san_dns", ID: "rp.example", Basis: "client-id-binding"}}, allow.Context.VerifierIDs) + + 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, "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, "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, "x509_san_dns", "", "client-id-binding") + assert.Nil(t, empty.Context) + + 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{"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: "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} + 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) { + 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")) +} + +// --------------------------------------------------------------------------- +// 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", 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. + // 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{"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{"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{"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{"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{"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) + 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{"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{"x509_san_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"}, noEmails: true}) + 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") + 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", "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, "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", "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", "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", "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: "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, "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: "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", "x509_san_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":"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 662c7e72..32d25a35 100644 --- a/pkg/registry/x5c_enrichment.go +++ b/pkg/registry/x5c_enrichment.go @@ -54,6 +54,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 +200,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 +262,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 +462,86 @@ 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, + }) + } + } + + // 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 authzen.VerifierID{}, false + } + var idType, id string + switch scheme { + case ClientIDSchemeX509SANDNS: + // 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 + } + // 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. + idType, id = authzen.VerifierIDTypeURI, value + default: + return authzen.VerifierID{}, false + } + if !want[idType] || VerifyLeafBinding(scheme, value, leaf) != nil { + return authzen.VerifierID{}, false + } + return authzen.VerifierID{Type: idType, ID: id, Basis: authzen.VerifierIDBasisClientIDBinding}, true +} diff --git a/pkg/testserver/verifierids_test.go b/pkg/testserver/verifierids_test.go new file mode 100644 index 00000000..05649acf --- /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: "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: "x509_san_dns", ID: "rp.example.com", Basis: "client-id-binding"}, + }, resp.Context.VerifierIDs) + assert.Equal(t, "x509_san_dns", sawHint, "the request hint reaches the PDP") +}