Skip to content

feat(identity): add the ans scheme to claim verification - #2152

Draft
kperry-godaddy wants to merge 12 commits into
agntcy:feat/identity-addfrom
kperry-godaddy:feat/ans-identity-resolver
Draft

kperry-godaddy wants to merge 12 commits into
agntcy:feat/identity-addfrom
kperry-godaddy:feat/ans-identity-resolver

Conversation

@kperry-godaddy

@kperry-godaddy kperry-godaddy commented Sep 14, 2026 •

Copy link
Copy Markdown

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 onto main.

What this adds

A fifth identity scheme, ans, next to dns, https, did, and spiffe. A record whose agntcy.dir/identity (or agntcy.dir/owner) is ans://v{MAJOR}.{MINOR}.{PATCH}.{agentHost} carries an IdentityClaim signed 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:

  1. The identity certificate travels in the claim's JWS protected header as 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.
  2. The JWS is verified against that certificate's key before any network call. A mismatch is the registry's (false, nil); a bad claim never costs DNS or log traffic.
  3. _ans-badge.<agentHost> TXT is resolved; the badge URL must be https, on the configured allow-list of transparency logs, and shaped /v1/agents/{agentId}.
  4. The log's status token is verified against pinned root keys (or, with an explicit opt-in, keys fetched from the log over TLS): same agent id, same ans:// name structurally, a status of ACTIVE, WARNING, or DEPRECATED, and the certificate's fingerprint among the agent's attested identity certificates.
  5. The log's SCITT receipt is verified and bound to the same agent and name.

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

  • InferIdentityType returns ans for ans://; until now the prefix fell through to dns.
  • api/identity/v1: AnsSigner (certificate in x5c; the claim's certificate field stays SPIFFE-only), CertificateFromJWS (bounded before decoding, documented as untrusted until the caller anchors it), CheckSigningKey (the one key allowlist signers and resolvers share), and NewCertificateSignerFromFile, 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.NewDefaultRegistry builds the resolver set for both binaries and appends the ANS resolver when its block is enabled.
  • Resolver.Verify, KeyResolver, verifyClaim, the protos, and the claims table 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's certificate field as the transport, the change is verifyClaim, the three resolver signatures, and the removal of CertificateFromJWS.

Publisher side

dirctl identity claim --record <cid> --role identity \
  --subject ans://v1.0.0.agent.example.com --key identity.key --cert identity.crt

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: --key and --cert were marked required together, so a plain --key claim failed flag validation; --key is now required on its own. With a certificate the signer follows the certificate's scheme.

Operator side

identity.ans in 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, an ans:// claim fails with no 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, needs reconciler.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, so RECONCILER_IDENTITY_* and DIRECTORY_SERVER_IDENTITY_* were inert unless the key appeared in the file. The reconciler now binds identity.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 gains reconciler.extraVolumes/extraVolumeMounts so a private log's CA can be mounted for ca_file; the daemon resolves a relative ca_file against 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 failed verdicts, 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.New and registerTasks failed 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 in api (identity/v1/referrer_test.go), two in reconciler (the duplicate pair in tasks/identity/task.go), and 22 in server (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 the did, dns, spiffe, and wellknown resolver packages). task lint therefore stops at the server module until those are fixed; every package this PR edits lints clean on its own.

Follow-ups

  • In the identity task: keep the previous verdict on dependency failures and skip the upsert on context errors, a per-record deadline, a failure count and duration in the run summary, metrics.
  • expires_at from the certificate for certificate-backed claims.
  • Scheme constants instead of string literals; registry-owned scheme inference so a new resolver needs no api/core/v1 edit; registry injection into the reconciler service so one composition root serves both binaries.
  • ANS details (log, agent status) in ClaimStatus.
  • A Kind e2e with a local ANS stack.
  • A pull-mode relying-party verifier in the ANS SDK (agentnameservice/ans-sdk-go; the SDK's AnsVerifier verifies a connecting peer that presents its own SCITT headers) that shrinks this package to the allow-list, configuration, and result mapping.
  • The dangling #record-identity--ownership anchors in dir-component-records-validation.md, and the stale name: 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-subcharts on both charts. Coverage: server/identity/ans 99.8%, server/identity/ans/config 100%, server/identity 100%. New tests: the signer and header helpers in api/identity/v1 (table-driven, every key type, every rejection), the resolver package (per-stage tables through Verify with 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/v2 becomes a direct server requirement through the fixture-minting test. task deps:tidy at #2125's head also promotes sigstore/sigstore to 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.

…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>
@kperry-godaddy
kperry-godaddy requested a review from a team as a code owner September 14, 2026 15:13
@github-actions github-actions Bot added the size/XL Denotes a PR that changes 2000+ lines label Sep 14, 2026
@kperry-godaddy
kperry-godaddy marked this pull request as draft September 14, 2026 15:21
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>
@muscariello

Copy link
Copy Markdown
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
muscariello self-requested a review September 22, 2026 11:56
@kperry-godaddy

kperry-godaddy commented Sep 23, 2026 •

Copy link
Copy Markdown
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

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/XL Denotes a PR that changes 2000+ lines

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

[Feature]: ans identity scheme: verify ans:// identity claims through DNS and the ANS transparency log

3 participants