Skip to content

authzen: return verifier identifiers in the evaluation response context (verifier_ids) - #204

Merged
leifj merged 10 commits into
mainfrom
feat/verifier-ids-203
Oct 15, 2026
Merged

leifj merged 10 commits into
mainfrom
feat/verifier-ids-203

Conversation

@leifj

@leifj leifj commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Refs #203. Implements the first-implementation scope of #203: an optional verifier_ids member in the AuthZEN evaluation response context, so a wallet that answers a circuit_zk request with the pseudonym function gets a verified identifier string for the relying party without parsing certificates, resolving DIDs or walking federation chains.

"verifier_ids": [
  {"verifier_id_type": "semantic", "verifier_id": "semantic_id:VATSE-556000000101", "basis": "x5c-chain"},
  {"verifier_id_type": "dns", "verifier_id": "rp.example.com", "basis": "client-id-binding"}
]
  • Off by default. Enabled per policy: constraints.return_verifier_ids: [semantic, dns]. Existing responses and tests are unchanged. Unknown (and deferred) type names are rejected at config load (Config.Validate) and again when the policy is installed.
  • Request hint. The optional request context.verifier_id_type narrows the response to one type. It only narrows: return_verifier_ids is a server-side policy control and is stripped from client context like the other controls; the hint is added to the client-suppliable allowlist.
  • Types: semantic (access certificate only), dns / uri (after VerifyLeafBinding), did (didweb, didwebvh, didjwks, generic did; resolved + key matched), oidf_entity (validated chain + key matched).
  • authzenclient exposes it through authzen.EvaluationResponseContext.VerifierIDs; a test-server DecisionFunc can set it (round-trip test over HTTP).
  • Docs: swagger, docs/CONFIGURATION.md (regenerated), README section, example/config.yaml.

Rules, as implemented

  1. Fail closed. Nothing is emitted for a deny, when the matched profile's validation failed (normally only a warning, so it is checked explicitly), or when the identifier is not verified. dns/uri re-run VerifyLeafBinding inside the enrichment (so correctness does not depend on the calling registry having done it); an unverified client_id is never echoed; wildcard client_ids are not identifiers. DID/OIDF entries are added only on the key-bound allow path (not resolution-only), and only when the resolved document id / chain leaf subject equals the requested identifier.
  2. Canonicalised once, in the PDP. dns: ASCII lower case, no trailing dot. uri: byte-for-byte (the match is already exact; nothing goes through net/url). did: fragment dropped, nothing else (path part is case-sensitive). oidf_entity: byte-for-byte as in the chain. semantic: semantic_id: + value verbatim.
  3. semantic is validated against ^[A-Z]{3}[A-Z]{2}-<value>$ (EN 319 412-1 5.1.3) and its type must fit the subject.

Finding: natural-person classification in wrpac.go (narrow fix)

WRPACProfile.ExtractIdentity read organizationIdentifier with a serialNumber fallback and called the certificate a legal person whenever Organization was set. For a natural-person certificate (NCP-n / QCP-n, ETSI TS 119 475 5.1.5) the semantic identifier legitimately lives in serialNumber (e.g. PNOSE-...), so it was relabelled organization_identifier, and a natural person with an affiliated organisation was classified legal_person. Fixed narrowly: when the WRPAC policy OIDs say natural person, serialNumber is not treated as an organisation identifier and the type is natural_person; behaviour is unchanged for legal-person and policy-less certificates. New rpcert.SemanticIdentifier encodes where the value lives per subject kind: legal person reads 2.5.4.97 (serialNumber fallback for legacy certs and fixtures, and an invalid 2.5.4.97 is not papered over by falling back), natural person reads serialNumber only. Natural-person types (PNO IDC PAS TAX TIN) are refused for legal-person certificates and vice versa. A serialNumber that is not a semantic identifier is never emitted.

Decisions where the issue was ambiguous

  • Policy option lives under constraints (registry-agnostic: it covers x5c, DID and OIDF), not etsi.
  • Intermediary requests: semantic is taken from the RP leaf (the request's x5c is the end RP's; the intermediary chain is unvalidated and never read). dns/uri are omitted because the client_id of a brokered request cannot be shown to name the end RP.
  • A type with no entry for a given response is simply absent; did_scid, service, spki_sha256 are rejected in config until implemented, so a typo cannot be mistaken for a deferred type that silently returns nothing.
  • Aggregating strategies (all) and composite registries build a new response and do not carry the member; first_match and sequential return the registry's own response and do. Documented.
  • oidf_entity: extractEntityID trims a trailing / for resolution, so the chain leaf subject is compared with the raw requested identifier instead; a request that names the entity with a trailing slash still resolves but gets no identifier.

Not done / not verified

  • Deferred per scope: did_scid, service, spki_sha256. Hence Refs, not Closes.
  • Registration certificate is not an input (as per the issue); documented.
  • The real round trip through go-wallet-backend (open question in the issue) is not verified here; the Go client/test-server round trip is.
  • No didwebvh end-to-end success test (a valid DID log fixture is heavy); the one-line call site is the same helper as the other three DID registries, which are tested.
  • Swagger was patched by hand to the new definitions: regenerating with swag pulled in unrelated drift in the committed docs, so only the verifier_ids additions are in the diff.

Tests

New tests for each acceptance item: member present/absent/off; canonical form and basis per type; intermediary; no emission on deny and on failed profile validation; natural-person form and non-semantic serialNumber; hint narrowing (incl. through RegistryManager, and that a client cannot set return_verifier_ids); config rejection; client/test-server round trip. Each was checked to be non-vacuous by breaking the behaviour and confirming the test fails (28 mutations; one, re-serialising a verified URI through net/url, is an equivalent mutant because URISANMatches already compares u.String()). golangci-lint clean; full go test ./... passes with GOEXPERIMENT=jsonv2.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VS8tGFQncGyEhFwkQecjPB

Copilot AI balanced review requested due to automatic review settings October 14, 2026 09:36

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Contradictory certificate policies can bypass fail-closed classification, natural-person identity extraction can discard the personal serial, and programmatic policy installation remains unvalidated.

3 open findings
What changed in this PR

Adds policy-controlled, verified verifier identifiers to AuthZEN evaluation responses.

Changes:

  • Adds verifier_ids models, policy configuration, validation, and request narrowing.
  • Derives identifiers from verified X.509, DID, and OID Federation material.
  • Updates WRPAC semantic-ID handling, tests, examples, and documentation.
File Description
README.md Documents verifier identifiers.
pkg/​testserver/​verifierids_test.go Tests HTTP round-trip behavior.
pkg/​registry/​x5c_enrichment.go Derives X.509 identifiers.
pkg/​registry/​verifierid.go Adds shared identifier helpers.
pkg/​registry/​verifierid_test.go Tests derivation and fail-closed behavior.
pkg/​registry/​verifierid_manager_test.go Tests policy and hint handling.
pkg/​registry/​rpcert/​wrpac.go Adjusts natural-person classification.
pkg/​registry/​rpcert/​semanticid.go Adds semantic-ID parsing and classification.
pkg/​registry/​rpcert/​semanticid_test.go Tests semantic-ID behavior.
pkg/​registry/​policy.go Adds verifier-ID policy constraints.
pkg/​registry/​policy_verifierids_test.go Tests runtime constraint validation.
pkg/​registry/​oidfed/​verifierid_test.go Tests OIDF identifiers.
pkg/​registry/​oidfed/​oidfed_registry.go Emits verified OIDF entity IDs.
pkg/​registry/​manager.go Injects policy and permits request hints.
pkg/​registry/​didwebvh/​didwebvh_registry.go Emits did:webvh identifiers.
pkg/​registry/​didweb/​verifierid_test.go Tests did:web identifiers.
pkg/​registry/​didweb/​didweb_registry.go Emits did:web identifiers.
pkg/​registry/​didjwks/​verifierid_test.go Tests did:jwks identifiers.
pkg/​registry/​didjwks/​didjwks_registry.go Emits did:jwks identifiers.
pkg/​registry/​did/​verifierid_test.go Tests generic DID identifiers.
pkg/​registry/​did/​resolver.go Emits generic DID identifiers.
pkg/​config/​verifierids_test.go Tests configuration validation.
pkg/​config/​config.go Adds verifier-ID configuration.
pkg/​authzen/​verifierids_test.go Tests response JSON shape.
pkg/​authzen/​types.go Defines verifier-ID API types.
example/​config.yaml Adds configuration example.
docs/​swagger/​swagger.yaml Updates YAML API schema.
docs/​swagger/​swagger.json Updates JSON API schema.
docs/​swagger/​docs.go Updates generated Swagger definitions.
docs/​CONFIGURATION.md Documents the policy setting.
cmd/​gt/​policy_config_test.go Tests configuration translation.
cmd/​gt/​main.go Installs verifier-ID policy settings.
Files not reviewed (1)
  • docs/swagger/docs.go: Generated file

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread pkg/registry/rpcert/semanticid.go Resolved
Comment thread pkg/registry/policy.go Resolved
Comment thread pkg/registry/rpcert/wrpac.go Resolved

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

OIDF normalization can emit an entity identifier that does not exactly match the identifier supplied by the request.

0 open findings

3 resolved since last review
Files not reviewed (1)
  • docs/swagger/docs.go: Generated file
Previously missed (1)

In code that hasn't changed since last review

Medium severity Compare chain subject with the raw requested identifier

pkg/​registry/​oidfed/​oidfed_registry.go:875

This compares the chain subject with entityID after extractEntityID has stripped trailing slashes, not with the identifier actually named by the request. For example, a request for https://rp.example/ can resolve a chain whose leaf is https://rp.example and emit that identifier even though the documented fail-closed rule requires byte-for-byte equality with the requested identifier. Preserve the raw subject/resource ID for this final equality check; normalization may still be used for resolution.

🧠 Review effort: Balanced

@leifj

leifj commented Oct 14, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the finding from the second Copilot review (it was only in the review body, not an inline thread): in dcf6106 the oidf_entity identifier is now compared with the raw requested identifier rather than the slash-stripped one, with a test (TestEvaluate_VerifierIDs_OIDFEntity, trailing-slash case) shown to fail when the old comparison is restored. The same commit splits two functions that Sonar flagged for cognitive complexity.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Malformed organization identifiers can still incorrectly fall back to serialNumber, violating fail-closed semantics.

0 open findings

Files not reviewed (1)
  • docs/swagger/docs.go: Generated file
Previously missed (1)

In code that hasn't changed since last review

Medium severity Prevent serialNumber fallback for malformed organization identifiers

pkg/​registry/​rpcert/​semanticid.go:122

This still falls back to serialNumber when 2.5.4.97 is present but empty or has a non-string ASN.1 value, because realOrganizationIdentifier returns "" for both malformed and absent attributes. Such values survive x509.ParseCertificate, so a malformed organization identifier can be papered over by a valid semantic ID in serialNumber, contrary to the fail-closed rule. Track attribute presence separately and use the legacy fallback only when 2.5.4.97 is absent.

🧠 Review effort: Balanced

@leifj

leifj commented Oct 14, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the finding from the third Copilot review (again review-body only) in the follow-up commit: SemanticIdentifier now tracks whether 2.5.4.97 is present, so an empty or non-string organizationIdentifier is treated as malformed and not papered over by serialNumber (test TestSemanticIdentifier_MalformedOrganizationIdentifierDoesNotFallBack, shown to fail when the old fallback is restored).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Security-sensitive identity derivation now spans several trust mechanisms and warrants final human validation.

0 open findings

Files not reviewed (1)
  • docs/swagger/docs.go: Generated file
Previously missed (1)

In code that hasn't changed since last review

Low severity Document best_match VerifierIDs loss in compatibility note

README.md:850

best_match also delegates to evaluateAllWithPolicy, whose newly built response drops VerifierIDs, but this compatibility note names only all. Operators using best_match may therefore expect the member to survive; list it with the non-carrying strategies.

🧠 Review effort: Balanced

@leifj

leifj commented Oct 14, 2026

Copy link
Copy Markdown
Collaborator Author

Fourth Copilot review (body only): README now lists best_match with the strategies that do not carry verifier_ids (it delegates to the aggregating strategy). Also removed the duplicated http/https literals Sonar flagged (S1192).

leifj and others added 6 commits October 15, 2026 10:00
Add an optional, informational context member verifier_ids: a list of
{verifier_id_type, verifier_id, basis} entries the PDP derives from
authenticated material, so a wallet answering a circuit_zk request with a
pseudonym function does not have to parse certificates, resolve DIDs or walk
federation chains itself.

Off by default (policy constraints.return_verifier_ids; unknown type names
are rejected at config load). A request context member verifier_id_type
narrows the response to one type and can never enable one.

Types: semantic (access certificate only), dns and uri (x509_san_dns /
x509_san_uri, only after VerifyLeafBinding), did (resolved document + key
match), oidf_entity (validated trust chain + key match). did_scid, service
and spki_sha256 are deferred.

Fail closed: nothing is emitted for a deny, when profile validation failed,
or when the identifier was not verified. An unverified client_id is never
echoed. semantic is validated against the semantic-id syntax and its type
must fit the subject; a serialNumber that is not a semantic identifier is not
emitted. For intermediary requests semantic comes from the RP leaf and dns/uri
are omitted.

Narrow fix in WRPACProfile.ExtractIdentity: a natural-person certificate
(NCP-n/QCP-n) keeps its serialNumber as the person's identifier (ETSI TS 119
475 5.1.5) instead of relabelling it organization_identifier and classifying
the certificate legal_person when it names an affiliated organisation.

Refs #203

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VS8tGFQncGyEhFwkQecjPB
- Contradictory WRPAC policy OIDs (natural and legal) are now their own
  SubjectKind and SemanticIdentifier refuses them, instead of treating them
  like a policy-less certificate.
- WRPACProfile.ExtractIdentity keeps a natural person's serialNumber when the
  certificate also carries an affiliated organisation's 2.5.4.97, and exposes
  both.
- PolicyManager.RegisterPolicy and SetDefaultPolicy validate the constraints
  and return an error, so the programmatic installation path rejects unknown
  return_verifier_ids types as the config path does.

Refs #203

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VS8tGFQncGyEhFwkQecjPB
extractEntityID strips trailing slashes for resolution, so comparing the
chain's leaf subject with it could emit an identifier that differs from what
the request named. Compare against the raw requested identifier instead.

Also split deriveX5CVerifierIDs and ExtractIdentity's subject_type decision
into helpers to bring their cognitive complexity under the Sonar threshold.

Refs #203

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VS8tGFQncGyEhFwkQecjPB
…nIdentifier

SemanticIdentifier used the serialNumber fallback whenever the 2.5.4.97 value
was empty, which also covered an attribute that is present but empty or not a
string. Track presence separately so only an absent attribute falls back.

Refs #203

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VS8tGFQncGyEhFwkQecjPB
… URL prefix check

best_match delegates to the aggregating strategy and builds a new response, so
it does not carry verifier_ids; say so in the README. Replace the repeated
http/https prefix literals in the oidfed registry with a helper (Sonar S1192).

Refs #203

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VS8tGFQncGyEhFwkQecjPB
#210 removed the keyUsage/contentCommitment check, so noKU no longer
fails validation. Use the contact-information check instead.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Per the trust-framework alignment on #203, type names follow the
OpenID4VP client_id scheme names; semantic is the only exception:

  dns -> x509_san_dns, uri -> x509_san_uri,
  did -> decentralized_identifier, oidf_entity -> openid_federation

No alias for the old names: #204 is unreleased, and unknown names are
still rejected in return_verifier_ids. Value forms are unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Comment thread pkg/registry/rpcert/semanticid.go Resolved
With neither a natural- nor a legal-person WRPAC policy OID, an
organizationIdentifier could be a natural person's affiliation rather
than the verifier, so SemanticIdentifier now returns none (fail closed).
Every WRPAC carries one of the four -n/-l policy OIDs, so real access
certificates are unaffected.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

@smncd smncd left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to my eyes now. I've only reviewed:

  • The return format, which I think makes sense.
  • The not-strict-enough profile handling, which is now fixed.

I've not looked too closely at the code otherwise.

leifj and others added 2 commits October 15, 2026 13:00
CanonicalDNSName now returns (value, ok): ASCII A-label form, ASCII
lower case, one trailing dot removed, and refuses non-ASCII (no U-label
conversion), empty labels, over-long labels/names, whitespace and
wildcards, so nothing is emitted for them. The check is explicit rather
than left to the SAN binding. A client_id that binds only through a
wildcard SAN no longer yields an identifier.

Shared vectors in pkg/registry/testdata for the SDKs. The binding stays
exact, since DNSSANMatches is also the trust-evaluation path.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…cs (#203)

DNSSANMatches compares ASCII case-insensitively (DNS names are), so a
client_id differing from the SAN only in case binds and yields the same
lower-case verifier_id; the emitted value matches the match. Only ASCII
letters are folded: a U-label never matches and nothing is converted.
Internationalised names work via their A-label. The wildcard branch also
no longer matches an empty label (.example.com against *.example.com).

DNSSANMatches is also the ETSI trust-evaluation binding, so this widens
it only to hostnames that differ in case.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@leifj
leifj merged commit 402da30 into main Oct 15, 2026
15 checks passed
@leifj
leifj deleted the feat/verifier-ids-203 branch October 15, 2026 12:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants