Skip to content

feat: verified identities — the who behind agents (did:web + did:key)#41

Open
csnitker-godaddy wants to merge 8 commits into
mainfrom
feat/verified-identities
Open

feat: verified identities — the who behind agents (did:web + did:key)#41
csnitker-godaddy wants to merge 8 commits into
mainfrom
feat/verified-identities

Conversation

@csnitker-godaddy

Copy link
Copy Markdown
Collaborator

Summary

Implements Verified Identities — the "who" behind an agent (the "what") — per the team's DESIGN-multi-identity-anchors.md rev 4 design of record (who/what pivot, 2026-06-10), including the two post-design corrections: multi-algorithm key support and verbatim verification-method sealing.

An identity is a first-class object owned by the providerId, proven through a challenge-bound control proof, sealed onto its own Transparency Log stream, and linked to any number of that owner's agents. Prove once, link the fleet; rotation/revocation is one sealed event regardless of linked-agent count — propagation is the TL's read-time join. The agent registration path is byte-for-byte untouched.

What's in here (4 commits, reviewable independently)

  1. feat(tl) — the IDENTITY_* event family (closed 5-token enum, same JCS + RFC 6962 leaf rules), the POST /v1/internal/identities/event ingest lane on the existing producer-signature discipline, the identity read surface (/v1/identities/{id}{,/audit,/receipt,/agents}), and the agent badge's computed identities[] join. One Merkle tree; "streams" are read indexes (tl_events.identity_id + a link fan-out table).
  2. feat(ra) — the /v2/ans/identities surface: register → verify-control → rotate/revoke + the owner-gated links. Per-kind logic lives behind the controlVerifier registry (internal/ra/service/identitykinds.go) — the extension seam for did:plc, did:ion, did:ethr, and lei (vLEI). did:web + did:key ship enabled.
  3. feat(demo)scripts/demo/identity-lifecycle.sh exercises every operation live (multi-key ES256+EdDSA proof, Ed25519 did:key side-by-side with the did:web on one agent, two-agent batch link sealing one event, rotation 2→1 keys, per-pair unlink, read-time revocation propagation); run-lifecycle.sh gains the identity steps; signproof is the registrant-side key/JWS tool.
  4. docs — the implementation plan + as-built notes.

Security invariant (stated once)

The RA seals an identity attestation only after control is proven — never on resolution alone. Every submitted compact JWS must carry the served JCS IdentityProofInput ({identifier, identityId, nonce, purpose:"ans:identity-proof:v1", raId, scheme}) verbatim as its payload, and verify against the identifier's authoritative key (the re-fetched did.json for did:web; the identifier itself for did:key). One bad proof fails the call closed. The nonce is single-use, consumed only inside the success transaction (conditional-UPDATE TOCTOU guard); failed attempts never consume it.

Pre-implementation shape diff (per CLAUDE.md)

The canonical contracts under spec/ are authored in this PR (both validate as OpenAPI 3.0.3; docsui copies synced via make docs-sync):

  • spec/api-spec-v2.yaml — 8 identity routes (/ans/identities{,/{id}}, verify-control, revoke (a POST — identities are never deleted), links{,/{agentId}}) + AgentDetails.identities[] (additive, computed).
  • spec/api-spec-tl-v2.yaml — the identity ingest + read surface, IdentityProducerEvent, ProvenKey = {verificationMethod (verbatim), signedProof}, LinkedIdentityView.provenKeyIds.

Deliberate deltas from the rev-4 design text, all discussed with Connor/team:

Design said This PR does Why
widen the V2 Type.IsValid() enum parallel internal/tl/event/identity package, own closed enum agent codec stays byte-frozen; cross-lane 422 falls out of each codec's Validate()
separate tl_identity_events table nullable identity_id column on tl_events + link fan-out table the design's own maxim — streams are read indexes over one log
seal keyThumbprint + normalized publicKeyJwk seal the document's verificationMethod verbatim; thumbprints compute-at-read post-design correction (no derived values in seals); lei keeps its AID+thumbprint exception
(implicit single alg) EdDSA + ES256 + RS256; precise rejections for X25519/secp256k1/P-384+ support exactly what the JWS layer verifies

Extensibility (the vlei/did:ion/did:plc path)

Adding a kind = one controlVerifier implementation + one registry entry + a lexical arm in domain.InferIdentifierKind. JWS-scheme kinds reuse the shared proof machinery wholesale with a different fetcher port (noop/real adapter split, same as the DNS verifier). Per-kind wire shapes are additive members (signedProofs today; cesrSignature/ethSignature later — exactly one family per kind). Unregistered kinds → IDENTIFIER_KIND_UNSUPPORTED; unknown did methods name the method in the error. No kind CHECK in storage — the domain dispatcher + registry are the single source of truth.

Verification

  • make check green: fmt, vet, golangci-lint (0 issues), coverage 90.1% ≥ 90% gate (internal/domain additions at 100%)
  • make test-race clean (incl. concurrent nonce-consumption)
  • Both demos run end-to-end against the live stack (RA + TL + outbox + Tessera): every IDENTITY_* token sealed, COSE receipts verify, ans-verify passes on the agent path
  • Cross-lane guards tested in both directions; V1 lane frozen

Notes for reviewers

  • Sealed shapes are append-only-forever — the event vocabulary in commit 1 is the thing to scrutinize hardest (design §5.5 sequencing note; ANS-spec amendment rows A–F still need ratification upstream before a shared TL seals these).
  • The noop resolver is quickstart-only (real signature verification, waived live-document binding) — config-gated exactly like dns.type: noop.
  • Commits are DCO-signed-off; GPG signing isn't configured in the authoring environment, so signing needs to happen at merge.

🤖 Generated with Claude Code

@csnitker-godaddy csnitker-godaddy marked this pull request as ready for review June 13, 2026 13:37
Copilot AI review requested due to automatic review settings June 13, 2026 13:37
@csnitker-godaddy csnitker-godaddy force-pushed the feat/verified-identities branch from 964ca27 to b07a1ce Compare June 13, 2026 13:41

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds Verified Identities (“who”) as a first-class, provider-owned object that can be control-proved (did:web + did:key), sealed as an IDENTITY_* event stream in the existing Transparency Log (single Merkle tree), and linked to agents with read-time joins (no agent re-sealing for identity lifecycle changes).

Changes:

  • Introduces TL support for IDENTITY_* producer events (new ingest lane + identity read/join endpoints) and agent badge identity joins.
  • Adds RA /v2/ans/identities lifecycle (register → verify-control → rotate/revoke + link/unlink), including JWS proof machinery, did:web resolution (noop + hardened web), and did:key verification.
  • Updates demos/tooling (signproof, lifecycle scripts) and adds an Ethereum identity PoC CLI module (scripts/poc/ethid).

Reviewed changes

Copilot reviewed 81 out of 82 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
scripts/poc/ethid/README.md Documents the Ethereum identity PoC CLI goals/recipes and observed ENSIP drift.
scripts/poc/ethid/out.go Adds terminal-safe output helpers with control-character stripping.
scripts/poc/ethid/main.go Adds ethid CLI entrypoint + subcommand dispatch.
scripts/poc/ethid/keys.go Implements did:ethr keygen/sign/verify and DID parsing helpers.
scripts/poc/ethid/go.mod Declares the standalone Go module/deps for the ethid PoC.
scripts/poc/ethid/ensip26.go Implements ENSIP-26 discovery, optional DNS checks, and well-known probing.
scripts/poc/ethid/dnscheck.go Adds real DNS querying logic via miekg/dns with DNSSEC AD reporting.
scripts/poc/ethid/chains.go Adds chain presets + shared RPC flag plumbing and chain-id sanity checks.
scripts/poc/ethid/ccip.go Implements EIP-3668 CCIP-Read gateway loop for ENS offchain resolution.
scripts/poc/ethid/.gitignore Ignores the built ethid binary.
scripts/demo/start.sh Adds RA demo config block for identity resolver + challenge TTL.
scripts/demo/signproof/main.go Adds demo-side keygen/sign tooling for identity control proofs (EdDSA/ES256).
scripts/demo/run-lifecycle.sh Extends lifecycle demo to register/verify/link an identity and validate TL join.
scripts/demo/common.sh Adds assert_tl_identity_audit to enforce seal-before-success invariants in demos.
Makefile Excludes scripts/* from coverage instrumentation.
internal/tl/service/receipt.go Adds identity receipt support and switches receipt cache lookup to (leafIndex, treeSize).
internal/tl/service/log.go Adds identity ingest + identity stream/index query helpers and link join helpers.
internal/tl/service/codec.go Adds the identity ingest codec (identityCodec) for IDENTITY_* events.
internal/tl/service/codec_identity_test.go Adds unit tests for identity codec + cross-lane rejection.
internal/tl/service/badge.go Extends badge model with computed identity join fields and identity-key view fields.
internal/tl/handler/paginate_internal_test.go Tests computed-view pagination helper edge cases.
internal/tl/handler/handler.go Mounts identity routes, adds agent identity computed view routes, and adjusts JSON encoding.
internal/tl/handler/handler_test.go Wires identity badge service into TL handler testbed.
internal/tl/handler/empty_param_test.go Updates handler constructor usage for new dependency.
internal/tl/handler/empty_param_identity_test.go Adds empty-param guard tests for identity/agent-identity routes.
internal/tl/event/identity/event_test.go Adds tests for identity event/envelope validation + signing lifecycle.
internal/ra/service/identityratelimit.go Adds per-owner fixed-window rate limiter for identity operations.
internal/ra/service/identitykinds.go Adds control-verifier registry and did:web + did:key proof verification + shared JWS parsing/sealing.
internal/ra/handler/lifecycle.go Optionally decorates agent detail responses with computed identities[] view.
internal/ra/handler/identity.go Implements /v2/ans/identities handlers (register/rotate/verify/revoke/link/unlink/list/detail).
internal/ra/handler/errors.go Maps transient ErrUnavailable to HTTP 503.
internal/ra/handler/errors_internal_test.go Adds tests pinning the error→HTTP mapping including 503 behavior.
internal/ra/handler/dto.go Adds DTOs/mapping for agent detail identities[] computed view.
internal/port/store.go Adds IdentityStore + IdentityLinkStore interfaces for persistence and link caching.
internal/port/didresolver.go Adds DIDResolver port + DID document/verification method shapes for did:web control proof.
internal/domain/identity_test.go Adds identity domain tests (kind inference, challenge lifecycle, rotation/revoke invariants).
internal/domain/errors.go Introduces ErrUnavailable + constructor for retryable upstream failures.
internal/crypto/proofinput.go Adds canonical identity proof input + signing-input encoding utilities.
internal/crypto/proofinput_test.go Tests canonicalization determinism and signing-input round trip.
internal/crypto/jws.go Adds EdDSA support, crit header rejection, and helper to decode standard JWS without verifying.
internal/crypto/jws_crit_test.go Tests rejection of JWS crit headers.
internal/crypto/jwk.go Adds JWK parsing/allowlist + multibase key encoding/decoding for did:key.
internal/crypto/jwk_test.go Tests JWK/multibase round-trips and rejection cases, including EdDSA verification.
internal/config/defaults.go Adds default RA identity config (resolver, TTL, rate limits, seal timeout).
internal/config/config.go Adds RA identity config types and validation hooks.
internal/adapter/tlclient/client.go Adds IDENTITY ingest lane support and synchronous seal helper with domain error mapping.
internal/adapter/tlclient/client_test.go Adds tests for seal-before-success mapping behavior.
internal/adapter/store/sqlitetl/receipts.go Switches receipt cache lookup to leafIndex-based query (supports identity leaves).
internal/adapter/store/sqlitetl/migrations/004_identity_events.sql Adds tl_events.identity_id + identity↔agent fan-out index table for link joins.
internal/adapter/store/sqlitetl/identityevents_test.go Adds tests for identity indexing, link fan-out, proof selection, pagination, and receipt lookup.
internal/adapter/store/sqlite/outbox.go Allows outbox schema_version to include IDENTITY (lane support).
internal/adapter/store/sqlite/migrations/008_identity_seal_claim.sql Adds challenge-claim column for seal-before-success concurrency control.
internal/adapter/store/sqlite/migrations/007_outbox_identity_lane.sql Widens outbox schema_version CHECK to include IDENTITY via table rebuild.
internal/adapter/store/sqlite/migrations/006_identities.sql Adds identities + identity_links tables and uniqueness constraints.
internal/adapter/didresolver/web.go Adds hardened did:web resolver with SSRF guards, redirect constraints, and size/time bounds.
internal/adapter/didresolver/noop.go Adds noop did:web resolver that synthesizes documents from JWK hints (dev-only).
go.mod Promotes golang.org/x/net to a direct dependency.
config/ra-local.yaml Adds local RA identity config block documentation + defaults.
cmd/ans-tl/main.go Wires identity badge service into TL server.
cmd/ans-ra/main.go Wires identity service, DID resolver selection, and mounts /v2/ans/identities routes.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread internal/config/config.go
Comment on lines +374 to +384
switch c.Identity.Resolver.Type {
case "noop", "web":
default:
return fmt.Errorf("identity.resolver.type %q not supported (expected 'noop' or 'web')", c.Identity.Resolver.Type)
}
if c.Identity.ChallengeTTL < 0 {
return errors.New("identity.challenge-ttl must not be negative")
}
if c.Identity.RegisterRateLimit < 0 {
return errors.New("identity.register-rate-limit must not be negative")
}
Comment thread scripts/poc/ethid/keys.go Outdated
Comment on lines +192 to +199
type didEthr struct {
DID string
Network string // "" = mainnet per the method spec
ChainID uint64
// Exactly one of Address / PublicKey is the subject form.
Address common.Address
PublicKey []byte // 33-byte compressed, nil for address form
}
@csnitker-godaddy csnitker-godaddy force-pushed the feat/verified-identities branch from b07a1ce to 9ecfd38 Compare June 13, 2026 13:42
Add the IDENTITY_* event family to the Transparency Log — the "who"
behind agents, sealed on its own read stream over the same single
Merkle tree as agent events.

Event family (internal/tl/event/identity): IDENTITY_VERIFIED,
IDENTITY_UPDATED, IDENTITY_REVOKED, IDENTITY_LINKED,
IDENTITY_UNLINKED — a closed enum mirroring the agent envelope's
JCS + RFC 6962 leaf rules. Sealed proof events quote the DID
document's verification method VERBATIM ({verificationMethod,
signedProof}); nothing derived, re-encoded, or normalized enters a
seal. Thumbprints are compute-at-read conveniences.

Ingest: POST /v1/internal/identities/event — a third codec on the
same producer-signature lane. The closed enums are the cross-lane
guard: agent bodies fail the identity codec and vice versa, and the
V1 lane stays frozen.

Storage: tl_events gains a nullable identity_id read index (streams
are read indexes over one log, never separate trees) plus a
tl_identity_event_agents fan-out table so link batches — ONE sealed
event carrying ansIds[] — join back to agents at read time.

Reads: /v1/identities/{id}{,/audit,/receipt,/agents}, agent badges
gain a computed identities[] join (link live ∧ identity stream
state; provenKeyIds name the current proven set), and
/v1/agents/{id}/identities{,/history} serve the agent-side views in
the standard audit envelope. Identity operations never write to an
agent's stream; all propagation is read-time.

Receipt cache lookups move from (agent, treeSize) to the table's
natural (leafIndex, treeSize) key so identity leaves reuse the same
COSE_Sign1 machinery.

Signed-off-by: Connor Snitker <csnitker@godaddy.com>
…, links

Add the /v2/ans/identities surface: owner-level Verified Identities
(the "who") proven through challenge-bound key proofs, sealed onto
their own TL stream, and linked to any number of the owner's agents.
The agent registration path is byte-for-byte untouched — agents
carry no identity fields; the association is the links sub-resource.

Proof gate (the single security invariant): the RA seals only after
control is PROVEN — every submitted compact JWS must carry the
served JCS IdentityProofInput verbatim as its payload (clients never
canonicalize) and verify against the identifier's AUTHORITATIVE key.
The nonce is single-use, consumed only inside the success
transaction via a conditional UPDATE (TOCTOU guard); failed attempts
never consume it, and recovery is the idempotent re-add.

Kinds are pluggable behind the controlVerifier registry
(identitykinds.go) — THE extension seam for did:plc, did:ion,
did:ethr, and lei (vLEI). did:web and did:key ship enabled:

- did:web — multi-key possession of the document's assertionMethod
  keys, any host. The did.json fetch is a port with two adapters
  (identity.resolver.type): "noop" synthesizes the document from
  the proofs' embedded jwk headers (quickstart — real signature
  verification, waived live-document binding) and "web" is the
  hardened fetcher: WebPKI, SSRF egress denylist with per-call IP
  pinning, 5s timeout, size cap, same-registrable-domain redirects.
- did:key — the key IS the identifier; zero I/O. Ed25519 (z6Mk…)
  and P-256 (zDn…) forms.

Key support matches exactly what the JWS layer verifies: EdDSA
(Ed25519, raw-signing-input per RFC 8037), ES256 (P-256), RS256
(RSA ≥ 2048) — with precise rejections for X25519 (key agreement,
cannot sign) and curves without a verifier.

Links are a single owner-gated call with no challenge and no
signature: the caller must own the identity AND every named agent.
A batch seals as ONE IDENTITY_LINKED event (fleet link = O(1)
events); rotation and revocation are likewise one event each, with
propagation to linked badges left to the TL's read-time join.
Agent detail responses gain the additive computed identities[] view.

Outbox grows a third IDENTITY lane (migration 007 rebuilds the
CHECK; tlclient maps it to /v1/internal/identities/event) under the
same sign-once/replay-verbatim invariant. Per-owner register/rotate
rate limiting bounds the outbound fetches.

Signed-off-by: Connor Snitker <csnitker@godaddy.com>
scripts/demo/identity-lifecycle.sh exercises EVERY identity
operation against the live stack: register → idempotent re-add →
multi-key verify-control (one ES256 + one EdDSA proof over the same
nonce, both verification methods sealed verbatim) → an Ed25519
did:key registered, proven, and linked alongside the did:web (one
agent carrying two identities) → a two-agent batch link sealing ONE
IDENTITY_LINKED with ansIds[2] → RA + TL computed views in both
directions → rotation (proven set 2→1, visible on every linked
badge from one sealed event) → identity SCITT receipt → per-pair
unlink (agent keeps its other identity) → revocation propagating to
the still-linked badge at read time while agents stay ACTIVE.

run-lifecycle.sh gains steps 16-19: register the who, prove
control, link the agent, and read the badge's identities[] join —
one hop answers "who is behind this agent".

scripts/demo/signproof is the registrant-side tool (keys minted and
proofs signed locally; private keys never touch the RA): keygen
emits did:key identifiers for P-256 or Ed25519 keypairs, sign emits
the compact JWS with kid + embedded jwk headers, alg following the
key type.

The coverage gate now excludes scripts/* alongside cmd/* — demo
tooling is exercised by the end-to-end lifecycle runs, not unit
tests.

Signed-off-by: Connor Snitker <csnitker@godaddy.com>
The repo-adapted implementation plan derived from the
DESIGN-multi-identity-anchors rev-4 design of record, including the
as-built deviations: the single-table TL read index, the
multi-algorithm key allowlist, verbatim verification-method sealing,
and the noop/web resolver split.

Signed-off-by: Connor Snitker <csnitker@godaddy.com>
Signed-off-by: Connor Snitker <csnitker@godaddy.com>
Implements the 2026-06-11 design-review deltas on the RA side
(design §5.6.1 item 6, §4.3, §3.2, §3.6):

- Identity operations (verify-control, revoke, link, unlink) now seal
  SYNCHRONOUSLY and report success only after the TL acknowledges the
  seal; the row transition commits with the acknowledgment, so the RA
  row can never be ahead of the log. The IDENTITY lane leaves the
  outbox entirely: sign once, submit once — a failed delivery IS a
  failed operation (503 TL_UNAVAILABLE, retryable, nothing consumed).
  A nil/disabled TL client fails identity sealing closed.
- A short-TTL provisional nonce claim (migration 008) serializes
  concurrent verify attempts across the seal round trip
  (VERIFICATION_IN_FLIGHT to the loser); failed attempts release it,
  preserving the failed-attempts-don't-consume rule.
- Conditional commits replace every blind save a racing operation
  could clobber: MarkRevoked flips VERIFIED->REVOKED only while still
  VERIFIED; Link re-checks the identity in its commit tx (a revoke
  landing during the seal gains no live link rows); StageChallenge
  persists a re-add/rotate challenge conditional on the load-time
  status+nonce with no live claim, and never writes status.
- Link liveness gate: every agent in the batch must be ACTIVE or
  DEPRECATED — terminal or pre-activation agents fail the whole batch
  with 422 AGENT_NOT_LINKABLE. New per-owner link/unlink rate limit
  (identity.link-rate-limit, default 60/min).
- RA reads follow the visibility predicate: AgentDetails.identities[]
  is empty for a terminal agent; the identity detail's linked list
  drops links to terminal agents and surfaces (not swallows) agent
  lookup failures.
- GET /v2/ans/identities adopts the v2 limit + opaque-cursor
  envelope; JWS proof decoding rejects crit headers (RFC 7515
  $4.1.11); did:web path segments additionally reject '.', '..', and
  control bytes.

Race-pinning tests simulate a concurrent revoke committing inside the
seal round trip via a sealer hook; spec/api-spec-v2.yaml documents the
new codes and seal-before-success semantics.

Signed-off-by: Connor Snitker <csnitker@godaddy.com>
…ation

The TL half of the 2026-06-11 review-pass deltas (design §5.6.3):

- Computed views carry the keys: the badge identities[] join and the
  identity badge quote the CURRENT proven key set verbatim from the
  latest sealed proof event (json.RawMessage end to end), with
  keysLogId pointing at that seal — a verifier checks operator
  signatures from the badge alone. provenKeyIds/identityLogId are
  replaced. writeJSON disables HTML escaping so the quoted sealed
  bytes survive byte-verbatim.
- One visibility predicate on every current view: an entry appears
  while the link is LINKED and the agent is live (ACTIVE/DEPRECATED/
  WARNING). REVOKED identities STAY VISIBLE with identityStatus
  REVOKED and keys withheld — a verifier must see the who behind a
  still-linked agent was revoked. Terminal agents drop out of the
  badge join and the reverse join; history routes are untouched.
- Revocation is terminal at read time: status derives REVOKED from
  ANY IDENTITY_REVOKED leaf on the stream, never tail-only — a racing
  operation's leaf landing after the revocation can never resurrect
  the identity on the public surface.
- Join failure is explicit, never silent: the badge serves the agent
  material with identitiesUnavailable: true when the join cannot be
  computed; the reverse join propagates non-not-found failures
  instead of shrinking the answer.
- Badge identities[] carries a small safety cap with identitiesTotal;
  the standalone per-agent route and the reverse join are paginated
  (TL limit/offset convention) as the overflow targets.

Signed-off-by: Connor Snitker <csnitker@godaddy.com>
…views

- TL reads after identity operations no longer poll for records: the
  200 already guarantees the seal (assert_tl_identity_audit fails
  immediately on a missing record as a seal-before-success
  regression; only Merkle-proof coverage may briefly wait on the
  checkpoint cadence).
- New negative step: linking a REVOKED agent fails 422
  AGENT_NOT_LINKABLE all-or-nothing and seals nothing.
- Badge asserts move to the new shape: verbatim keys[] lengths +
  keysLogId presence, rotation visible as the quoted set flipping
  2 -> 1, and the revoked did:web staying VISIBLE on the still-linked
  agent with identityStatus REVOKED and no quoted keys.
- Agents' TL presence is polled right after activation: the AGENT
  lane still seals via the async outbox (flagged in the design as a
  bug to fix separately), and the identity joins need the
  AGENT_REGISTERED leaves present.

Signed-off-by: Connor Snitker <csnitker@godaddy.com>
@csnitker-godaddy csnitker-godaddy force-pushed the feat/verified-identities branch from 9ecfd38 to a599ea8 Compare June 13, 2026 13:49
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.

2 participants