feat(identity): add the ans scheme to claim verification - #2152
Draft
kperry-godaddy wants to merge 12 commits into
Draft
kperry-godaddy wants to merge 12 commits into
kperry-godaddy wants to merge 12 commits into
Conversation
…c helpers InferIdentityType returns "ans" for ans:// subjects instead of falling through to "dns". An AnsSigner signs claims with an Agent Name Service identity key and carries the identity certificate in the JWS protected header as x5c (RFC 7515 section 4.1.6), the standard place for the signing certificate; the claim's own certificate field stays SPIFFE-only. CertificateFromJWS reads the leaf back for a resolver, bounded before any decoding and documented as untrusted until the caller anchors it, since the log this scheme relies on attests certificate fingerprints rather than keys. CheckSigningKey exposes the one key allowlist signers and resolvers share, and NewCertificateSignerFromFile selects the SPIFFE or ANS signer from the certificate's URI SAN scheme so callers never switch on schemes. The key, size and key-type checks every certificate-backed signer needs move into a shared loader that the SPIFFE signer now uses too, and both certificate signers report their expiry so a CLI can tell the publisher when a claim stops verifying. Lint debt in the edited files is cleared: import grouping and the nested verification branch in verify.go, named returns and an unwrapped signer error in sign.go, and the string-pointer helper that new(expr) replaces. Signed-off-by: kperry <kperry@godaddy.com>
The configuration block the ans resolver reads: the transparency-log allow-list, pinned root keys with an explicit opt-in to run without them, the verification time budget, an optional DNS server and CA file. Validate normalizes hosts and keys in place and reports nothing while disabled. Keys lists the block's keys from the struct tags so the environment binders of the server, the reconciler and the daemon read one list. Signed-off-by: kperry <kperry@godaddy.com>
…he environment The ans scheme is configured under identity.ans in the server and in the reconciler's identity task, mirroring how the SPIFFE trust domains are carried in both. Neither loader bound any identity.* key before, so environment overrides for the identity task were silently ignored and the task could not be enabled from the environment at all; the reconciler now binds identity.enabled, identity.interval and the ans keys, the server the ans keys, and the daemon both prefixes. Every binder reads the ans key list from the configuration package, so a new key is bound everywhere or nowhere. The daemon resolves a relative ca_file against its data directory like its other paths and warns at startup when the server and the reconciler disagree about ans:// claims, since the last writer of a claim row wins. Signed-off-by: kperry <kperry@godaddy.com>
…m the certificate The claim command marked --key and --cert as required together, so a claim signed with a plain key, the command's own first example, failed flag validation. --key is now required on its own and --cert stays optional. With a certificate the signer is chosen from the certificate's URI SAN scheme (SPIFFE or ANS) by the api package, so the command carries no scheme logic, and after a certificate-backed claim it prints when the claim stops verifying and that it must be pushed again after a renewal. Signed-off-by: kperry <kperry@godaddy.com>
… samples The reconciler Deployment gains reconciler.extraVolumes and reconciler.extraVolumeMounts, mirroring the API server's hooks, so the CA of a private ANS transparency log can be mounted for identity.ans.ca_file. Commented identity.ans examples go into the daemon configuration, the Helm values and both compose environment files, in the reconciler's case together with identity.enabled and interval since revocation pickup needs the task. The environment samples note that list and duration keys are left unset rather than empty, because an empty value replaces the list from the file or fails to parse, and the compose samples omit ca_file since compose mounts no volume for it. Signed-off-by: kperry <kperry@godaddy.com>
The scheme table gains its ans:// row and a section covering what a publisher needs, why the scheme exists next to dns: and https://, the configuration in both binaries and the requirement to run the reconciler identity task for revocation pickup, pinned root keys and the unpinned downgrade, the RFC 7515 chain-validation departure, rotation and rollback steps, and the supported key types. The Kubernetes deployment guide names the reconciler volume hooks, the verification reference lists the scheme, and codespell learns the word ans. Signed-off-by: kperry <kperry@godaddy.com>
…osition roots server.New and Service.registerTasks both failed the repository lint at this base (maintainability index 19, cognitive complexity 32 and a nested block of 6). The identity block of each moves into a helper, newIngestor in the server and newIdentityTask in the reconciler, which brings both functions back under their limits and gives the ans resolver one place per binary to be registered. Signed-off-by: kperry <kperry@godaddy.com>
…cheme in the registry docs registerTasks had no test; the extracted identity constructor now has one for the three outcomes (skipped without referrer support, failing on a trust bundle that cannot be loaded, registered). The registry's unwrapped resolver error is wrapped without changing its text, since claim rows store that text verbatim, and the registry docs list the ans scheme. Signed-off-by: kperry <kperry@godaddy.com>
The pieces of the ans:// trust path that stand without the resolver: the structural agent name with its canonical-form check, the local certificate checks (URI SAN, validity window, key type), the badge URL gate between a DNS record anyone can publish and the HTTPS requests the resolver makes, the per-host circuit breaker, the log client wrapper that reports every fetch to it, the stage-prefixed errors with the sanitizers that keep SDK transport text out of stored errors, and the event envelope reader. Ported from the naming.v1 verifier. Dropped with it: the transient versus terminal classification, the pending-agent cause, and every import of server/naming, since identity.v1 stores one verdict per run and has no transient state. The host validation of the subject moves from the DNS stage to the name stage, so everything about the subject is decided before the certificate is read, and a badge URL's scheme is no longer echoed. The breaker cooldown is two minutes, below the identity task's default interval, so a short outage costs one run of failed verdicts rather than two, and an expired circuit is logged once at INFO when it closes. A failed fetch logs at WARN only when it says the log is down (a connection-level strike) or broken (HTTP 5xx); an answer that states a verdict about the agent, such as a 404 or a 410, logs at DEBUG. Adds github.com/agentnameservice/ans-sdk-go v0.1.17 to the server module; fxamacker/cbor becomes a direct requirement through the test that mints COSE_Sign1 artifacts. Signed-off-by: kperry <kperry@godaddy.com>
The resolver for ans:// subjects behind server/identity.Resolver. Verify runs the trust path for one claim within the configured time budget. Without a network call: the subject must be a canonical ANS name, the JWS must carry the signer's identity certificate as x5c, the certificate must name the agent, be valid now and hold a key claims are verified with, and the signature must verify against that key, otherwise the verdict is (false, nil) and no DNS or log traffic is spent on it. Only then the certificate is anchored: the agent's badge record in DNS names an allow-listed transparency log whose status token, verified against the trusted root keys, attests the certificate's fingerprint for the agent, and whose receipt proves it logged the agent. Every other failure is an error whose text starts with the stage that failed and stays stable, since the claim row stores it verbatim; when the caller's context is done, that context error is returned as is so a task can recognize it. Log clients and, in unpinned mode, fetched root keys are cached per log origin, the mutex held only around map access. A token or receipt signed by a key the cached set does not hold drops the entry and verifies once more against fresh keys, so a log key rotation heals within one verification; in pinned mode it is reported at WARN, since only the operator can pin the new key. The startup INFO line states the effective configuration without key material; per-claim verdicts log at DEBUG, because the identity task re-verifies every claim each interval. The tests drive Verify with a JWS minted per case, so the cases the production signer refuses by construction (no x5c, the JSON serialization, a chain, a certificate for another key or another agent) are covered, and two cases sign through the production AnsSigner. The golden fixtures captured from the ANS reference implementation drive the attestation alone, since the identity certificate's private key was never captured. Signed-off-by: kperry <kperry@godaddy.com>
server/identity.NewDefaultRegistry assembles the https, dns and did resolvers and adds the ans resolver when identity.ans is enabled. The API server's ingestor and the reconciler's identity task both call it, so the two processes cannot register different resolver sets for the same configuration, and a configuration the ans resolver rejects stops either binary at startup, as an unreadable SPIFFE bundle does today. The registry gets its first tests: dispatch by inferred scheme, the (false, nil) verdict, a resolver error's text kept intact for the claim row, and the unrouted scheme. The reconciler service tests drive New with the ans block off, valid and invalid. Signed-off-by: kperry <kperry@godaddy.com>
The circuit breaker cleared a log host's strike count on every answered request. A log whose status-token endpoint answers while its receipt endpoint hangs therefore never reached the threshold: each verification earned one strike at the receipt and lost it at the next status token, and every claim anchored at that log kept spending the fetch budget on the hang, run after run. An answered request is now neutral. Strikes accumulate across verifications and only a verification that completed every fetch against the host clears them, so a log with one dead endpoint trips the breaker as a dead log does. The tests drive the exact scenario: three verifications against a hanging receipt endpoint open the circuit and the fourth makes no request. Signed-off-by: kperry <kperry@godaddy.com>
2 tasks done
This was referenced Sep 22, 2026
Member
|
@kperry-godaddy I created a breakdown of this work to be split in different parts. Can you review that and tell me what you think about it? |
muscariello
self-requested a review
September 22, 2026 11:56
Author
|
@muscariello - At first glance, it looks great. I will dive into the details later today and this week. Thanks for taking the time to do this. |
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #2138. Builds on #2125 (identity.v1) and is based on its branch,
feat/identity-add, so the diff shows only this change; once #2125 merges I rebase ontomain.What this adds
A fifth identity scheme,
ans, next todns,https,did, andspiffe. A record whoseagntcy.dir/identity(oragntcy.dir/owner) isans://v{MAJOR}.{MINOR}.{PATCH}.{agentHost}carries anIdentityClaimsigned with the agent's Agent Name Service (ANS) identity key. The resolver verifies the claim through ANS rather than through anything the publisher self-hosts:x5c(RFC 7515 §4.1.6). The resolver takes the leaf, requires a URI SAN naming the same agent and version, the current time within its validity, and a key type claims support.(false, nil); a bad claim never costs DNS or log traffic._ans-badge.<agentHost>TXT is resolved; the badge URL must behttps, on the configured allow-list of transparency logs, and shaped/v1/agents/{agentId}.ans://name structurally, a status of ACTIVE, WARNING, or DEPRECATED, and the certificate's fingerprint among the agent's attested identity certificates.The subject must be in canonical form (lowercase host, numeric version without leading zeros), so one attested identity cannot verify under several spellings.
Hooks in identity.v1
InferIdentityTypereturnsansforans://; until now the prefix fell through todns.api/identity/v1:AnsSigner(certificate inx5c; the claim'scertificatefield stays SPIFFE-only),CertificateFromJWS(bounded before decoding, documented as untrusted until the caller anchors it),CheckSigningKey(the one key allowlist signers and resolvers share), andNewCertificateSignerFromFile, which selects the SPIFFE or ANS signer from the certificate's URI SAN scheme so the CLI never switches on schemes. The key, size, and key-type checks every certificate-backed signer needs moved into a shared loader that the SPIFFE signer now uses too; both certificate signers report their expiry.server/identity.NewDefaultRegistrybuilds the resolver set for both binaries and appends the ANS resolver when its block is enabled.Resolver.Verify,KeyResolver,verifyClaim, the protos, and theclaimstable are unchanged.The RFC 7515 chain-validation requirement is deliberately not applied to
x5c: the log's fingerprint attestation is the trust decision, and the certificate is untrusted until it passes. If you prefer the claim'scertificatefield as the transport, the change isverifyClaim, the three resolver signatures, and the removal ofCertificateFromJWS.Publisher side
The command prints when the certificate, and with it the claim, stops verifying; after the registration authority renews the certificate the claim is pushed again. Two fixes to the command as it stood in #2125:
--keyand--certwere marked required together, so a plain--keyclaim failed flag validation;--keyis now required on its own. With a certificate the signer follows the certificate's scheme.Operator side
identity.ansin both the server and the reconciler's identity task (enabled,trusted_log_hosts,root_keys,allow_unpinned_root_keys,timeout,dns_server,ca_file), off by default. While off, anans://claim fails withno resolver registered for subject scheme "ans"(before this change the dns resolver failed it with a TXT lookup error; no persisted column carries the scheme, so nothing is re-indexed). Re-verification, and with it revocation pickup, needsreconciler.identity.enabled: true, which no shipped sample set; the reconciler samples now show it. The daemon warns at startup when the two blocks disagree or when the server has ANS on while the task is off.Environment binding: no
identity.*key was bound in either binary or the daemon, soRECONCILER_IDENTITY_*andDIRECTORY_SERVER_IDENTITY_*were inert unless the key appeared in the file. The reconciler now bindsidentity.enabled,identity.interval, and the ans keys, the server the ans keys, the daemon both prefixes; every binder reads the ans key list from the configuration package. The Helm reconciler Deployment gainsreconciler.extraVolumes/extraVolumeMountsso a private log's CA can be mounted forca_file; the daemon resolves a relativeca_fileagainst its data directory.Resolver behaviour under failure: a per-log circuit breaker (3 connection-level strikes, 2-minute cooldown, under the task's 5-minute default interval) keeps a dead log from stalling ingest or the task; a short outage costs one run of
failedverdicts, the identity task's model for every scheme. Strikes accumulate across verifications and only a verification that completed every fetch against the log clears them, so a log with one hanging endpoint trips the breaker as a dead log does. Log clients and, in unpinned mode, root keys are cached per log origin; a key rotation is recovered within the same verification, and an unknown pinned key logs a WARN with the kid. Dependency failures log at WARN, per-claim verdicts at DEBUG, since the task re-verifies every claim every interval.Lint at the base
server.NewandregisterTasksfailed the repository lint at #2125's head (maintainability index 19, cognitive complexity 32, nested block 6); the identity wiring of each moved into a helper, which clears both. Every other lint finding in a file this PR edits is fixed here (api/identity/v1/{claim.go,sign.go,verify.go,sign_verify_test.go},server/identity/registry.go). Findings in files this PR does not edit are left as they are, so that the diff stays about one thing; with the repository's pinned linter at #2125's head they are: one inapi(identity/v1/referrer_test.go), two inreconciler(the duplicate pair intasks/identity/task.go), and 22 inserver(controller/{identity.go,identity_test.go,naming.go},database/gorm/{identity_claims.go,identity_claims_test.go,record_exclude.go,record_remove_test.go},types/database.go,ingest/{ingest.go,ingest_test.go}, and thedid,dns,spiffe, andwellknownresolver packages).task linttherefore stops at the server module until those are fixed; every package this PR edits lints clean on its own.Follow-ups
expires_atfrom the certificate for certificate-backed claims.api/core/v1edit; registry injection into the reconciler service so one composition root serves both binaries.ClaimStatus.agentnameservice/ans-sdk-go; the SDK'sAnsVerifierverifies a connecting peer that presents its own SCITT headers) that shrinks this package to the allow-list, configuration, and result mapping.#record-identity--ownershipanchors indir-component-records-validation.md, and the stalename:sample blocks that refactor!: remove naming.v1, dirctl mcp serve, and dirctl import #2126 removes.Testing
The pinned linter on every edited package (0 issues),
task test:unit(83 packages),task license, codespell and markdown lint on the docs,helm lint --with-subchartson both charts. Coverage:server/identity/ans99.8%,server/identity/ans/config100%,server/identity100%. New tests: the signer and header helpers inapi/identity/v1(table-driven, every key type, every rejection), the resolver package (per-stage tables throughVerifywith synthetic ES256 fixtures for every negative case and golden fixtures captured from the ANS reference implementation; the golden certificate's private key was never captured, so the golden test drives the attestation step and a signature-mismatch case), the configuration binders in all three loaders, the daemon drift warning and path resolution, the CLI loader and flag validation, the registry (dispatch by scheme, the(false, nil)verdict, error text kept intact, the unrouted scheme, the default set with the ans block off, on, and invalid), and the reconciler service with the identity task and its ans block. Manual end to end against the ANS demo stack is described in the trust-model doc.Dependency:
github.com/agentnameservice/ans-sdk-go v0.1.17(MIT) in the server module; the reconciler, cli, and tests modules carry it as an indirect requirement.fxamacker/cbor/v2becomes a direct server requirement through the fixture-minting test.task deps:tidyat #2125's head also promotessigstore/sigstoreto a direct requirement of the reconciler module and drops three stale indirect requirements from the tests module; both are pre-existing and included so the modules are tidy.