From bde648460f4fc47965144b832d3779e9eccd042e Mon Sep 17 00:00:00 2001 From: Connor Snitker Date: Wed, 10 Jun 2026 18:09:16 -0500 Subject: [PATCH 01/13] feat(tl): seal and serve verified-identity events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd/ans-tl/main.go | 3 +- internal/adapter/docsui/openapi/tl.yaml | 401 ++++++++++++++- internal/adapter/store/sqlitetl/events.go | 286 +++++++++-- .../store/sqlitetl/identityevents_test.go | 314 ++++++++++++ .../migrations/004_identity_events.sql | 47 ++ internal/adapter/store/sqlitetl/receipts.go | 20 +- internal/tl/event/identity/event.go | 474 ++++++++++++++++++ internal/tl/event/identity/event_test.go | 294 +++++++++++ .../tl/handler/empty_param_identity_test.go | 42 ++ internal/tl/handler/empty_param_test.go | 14 +- internal/tl/handler/handler.go | 199 +++++++- internal/tl/handler/handler_identity_test.go | 407 +++++++++++++++ internal/tl/handler/handler_test.go | 3 +- internal/tl/service/badge.go | 8 + internal/tl/service/codec.go | 42 ++ internal/tl/service/codec_identity_test.go | 117 +++++ internal/tl/service/identitybadge.go | 306 +++++++++++ internal/tl/service/log.go | 51 ++ internal/tl/service/receipt.go | 33 +- spec/api-spec-tl-v2.yaml | 401 ++++++++++++++- 20 files changed, 3382 insertions(+), 80 deletions(-) create mode 100644 internal/adapter/store/sqlitetl/identityevents_test.go create mode 100644 internal/adapter/store/sqlitetl/migrations/004_identity_events.sql create mode 100644 internal/tl/event/identity/event.go create mode 100644 internal/tl/event/identity/event_test.go create mode 100644 internal/tl/handler/empty_param_identity_test.go create mode 100644 internal/tl/handler/handler_identity_test.go create mode 100644 internal/tl/service/codec_identity_test.go create mode 100644 internal/tl/service/identitybadge.go diff --git a/cmd/ans-tl/main.go b/cmd/ans-tl/main.go index 611b5fe..3a202bc 100644 --- a/cmd/ans-tl/main.go +++ b/cmd/ans-tl/main.go @@ -177,6 +177,7 @@ func run(cfgPath string) error { // underlying Tessera reader gets torn down. defer logSvc.Close() badgeSvc := service.NewBadgeService(logSvc) + identityBadgeSvc := service.NewIdentityBadgeService(logSvc, badgeSvc) // Receipt + status-token generators reuse the single signing // key. Matches the reference TL's deployed topology: one KMS key @@ -253,7 +254,7 @@ func run(cfgPath string) error { } h := handler.NewHandlers( - logSvc, badgeSvc, receiptSvc, statusTokenSvc, + logSvc, badgeSvc, identityBadgeSvc, receiptSvc, statusTokenSvc, checkpointSvc, schemaSvc, rootKeysBody, ) h.Mount(r, lg.DataDir()) diff --git a/internal/adapter/docsui/openapi/tl.yaml b/internal/adapter/docsui/openapi/tl.yaml index 6dfbd9a..fa98ebc 100644 --- a/internal/adapter/docsui/openapi/tl.yaml +++ b/internal/adapter/docsui/openapi/tl.yaml @@ -259,6 +259,255 @@ paths: schema: $ref: '#/components/schemas/Problem' + # ────────────────────────────────────────────────────────────── + # Verified identities — the "who" behind agents + # + # There is ONE transparency log — a single Merkle tree. Identity + # events append to the same tree as agent events; the "identity + # stream" is a read index over that single log keyed by + # identityId, exactly as the agent surface is a read index keyed + # by ansId. Tiles/checkpoints/receipts/witnesses are unchanged. + # + # Placement rule: every IDENTITY_* event — proofs, rotations, + # revocations, AND links — is indexed under the identity stream. + # An identity operation never modifies an agent's event or audit + # history; the agent-side views below are read-time joins through + # the link events' agent index. + # ────────────────────────────────────────────────────────────── + /v1/internal/identities/event: + post: + tags: [Transparency Log] + summary: Append a signed identity event to the log + description: | + The identity-family ingest lane. Identical wire contract to + `/v1/internal/agents/event` — JCS-canonicalized inner event + body + detached-JWS `X-Signature`, same producer-key trust + store, same dedup — with the identity payload schema (keyed + by `identityId`, closed `IDENTITY_*` eventType enum). The + closed enums are the cross-lane guard: an `AGENT_*` body on + this route (or an `IDENTITY_*` body on an agent route) fails + validation with 422 `INVALID_EVENT`. + operationId: appendIdentityEvent + parameters: + - name: X-Signature + in: header + required: true + description: | + Detached JWS compact serialization over the raw request + body — same discipline as the agent lanes. + schema: + type: string + requestBody: + required: true + description: JCS-canonicalized inner identity event (≤ 256 KiB). + content: + application/json: + schema: + $ref: '#/components/schemas/IdentityProducerEvent' + responses: + '200': + description: Event logged (or duplicate echoed — idempotent retries). + content: + application/json: + schema: + $ref: '#/components/schemas/AppendResponse' + '413': + description: Request body exceeds 256 KiB. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + '422': + description: | + Body fails validation — same codes as the agent lanes, + including `INVALID_EVENT` for cross-lane posts. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + + /v1/identities/{identityId}: + get: + tags: [Transparency Log] + summary: Identity badge + description: | + The latest sealed identity event + inclusion proof + computed + status (`VERIFIED` | `REVOKED`). The proof events seal every + proven key self-verifyingly: any third party reads the key + out of the sealed verbatim `verificationMethod`, verifies the + sealed `signedProof` against it, and confirms the payload + binds this identityId — offline, without trusting the RA. + operationId: getIdentityBadge + parameters: + - $ref: '#/components/parameters/IdentityIdPath' + responses: + '200': + description: Identity badge retrieved. + content: + application/json: + schema: + $ref: '#/components/schemas/TransparencyLog' + '404': + description: No events for this identity. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + + /v1/identities/{identityId}/audit: + get: + tags: [Transparency Log] + summary: Paginated audit history for an identity + description: | + The identity's full event chain — IDENTITY_VERIFIED, links, + rotations, revocation — in the exact same audit envelope as + the agent audit. This unbroken chain is the continuity + thread: when an agent's domain is lost, the identity stream + survives and links to the successor. + operationId: getIdentityAudit + parameters: + - $ref: '#/components/parameters/IdentityIdPath' + - name: limit + in: query + required: false + schema: { type: integer, default: 50, minimum: 1, maximum: 200 } + - name: offset + in: query + required: false + schema: { type: integer, default: 0, minimum: 0 } + responses: + '200': + description: Audit records, newest-first. + content: + application/json: + schema: + $ref: '#/components/schemas/TransparencyLogAudit' + + /v1/identities/{identityId}/receipt: + get: + tags: [Transparency Log] + summary: SCITT receipt for the identity's latest event + description: | + COSE_Sign1 receipt (CBOR tag 18) for the identity's latest + sealed leaf — same machinery, media type, and 503 retry + semantics as the agent receipt. + operationId: getIdentityReceipt + parameters: + - $ref: '#/components/parameters/IdentityIdPath' + responses: + '200': + description: Binary COSE_Sign1 receipt. + content: + application/scitt-receipt+cose: + schema: + type: string + format: binary + '404': + description: No events for this identity. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + '503': + description: | + Leaf committed but no signed checkpoint covers it yet; + retry after the `Retry-After` delay. + headers: + Retry-After: + schema: { type: integer } + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + + /v1/identities/{identityId}/agents: + get: + tags: [Transparency Log] + summary: Agents currently linked to the identity (reverse join) + description: | + Computed at query time from the link events' agent index: + every agent whose latest link/unlink fact naming it is + LINKED, each decorated with its own computed badge status so + a reader checks both ends of the link in one response. + operationId: getIdentityAgents + parameters: + - $ref: '#/components/parameters/IdentityIdPath' + responses: + '200': + description: Currently-linked agents. + content: + application/json: + schema: + type: object + required: [agents] + properties: + agents: + type: array + items: + $ref: '#/components/schemas/LinkedAgentView' + '404': + description: No events for this identity. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + + /v1/agents/{agentId}/identities: + get: + tags: [Transparency Log] + summary: Identities currently linked to the agent (computed) + description: | + The same entries as the agent badge's `identities[]` field, + served alone. Computed at read time — link live ∧ identity + stream state — never stored on, or sealed into, the agent. + operationId: getAgentIdentities + parameters: + - $ref: '#/components/parameters/AgentIdPath' + responses: + '200': + description: Currently-linked identities. + content: + application/json: + schema: + type: object + required: [identities] + properties: + identities: + type: array + items: + $ref: '#/components/schemas/LinkedIdentityView' + + /v1/agents/{agentId}/identities/history: + get: + tags: [Transparency Log] + summary: Link/unlink events that ever named this agent + description: | + Past and present associations, in the STANDARD audit envelope + (each record a TransparencyLog) — no bespoke format. Filtered + through the link events' agent index; the records themselves + live on the identity streams. The agent's own `/audit` stays + purely AGENT_*. This endpoint is a droppable convenience: the + same view derives from the identity audits or the raw entry + tiles. + operationId: getAgentIdentityHistory + parameters: + - $ref: '#/components/parameters/AgentIdPath' + - name: limit + in: query + required: false + schema: { type: integer, default: 50, minimum: 1, maximum: 200 } + - name: offset + in: query + required: false + schema: { type: integer, default: 0, minimum: 0 } + responses: + '200': + description: Link events naming this agent, newest-first. + content: + application/json: + schema: + $ref: '#/components/schemas/TransparencyLogAudit' + # ────────────────────────────────────────────────────────────── # Log static artefacts + verifier keys (public under public-read) # ────────────────────────────────────────────────────────────── @@ -689,6 +938,14 @@ components: type: string format: uuid + IdentityIdPath: + name: identityId + in: path + required: true + description: Immutable identity UUIDv7 — the identity stream key. + schema: + type: string + schemas: # ── Ingest ───────────────────────────────────────────────── ProducerEvent: @@ -763,7 +1020,22 @@ components: signature: { type: string, description: "TL attestation (detached JWS)" } status: type: string - enum: [ACTIVE, REVOKED, DEPRECATED] + description: | + Read-time computed status. Agent badges derive ACTIVE / + REVOKED / DEPRECATED / EXPIRED / WARNING from the latest + event + attested cert expiry; identity badges derive + VERIFIED / REVOKED from the identity stream. + enum: [ACTIVE, REVOKED, DEPRECATED, EXPIRED, WARNING, VERIFIED] + identities: + type: array + description: | + Agent badges only — the COMPUTED read-time join of the + agent's currently-linked verified identities. Covered by + the TL's response signature, never by any seal; identity + rotation/revocation is visible here immediately with + zero agent-stream writes. + items: + $ref: '#/components/schemas/LinkedIdentityView' TransparencyLogAudit: type: object @@ -789,6 +1061,133 @@ components: description: "standard base64 of each sibling hash in order" treeVersion: { type: integer } + # ── Verified identities ──────────────────────────────────── + IdentityProducerEvent: + type: object + description: | + The canonical inner identity event the RA POSTs to the + identity ingest lane. Sealed shapes are append-only-forever; + the five eventType tokens and these fields are the contract. + JCS-canonicalized when signed, same as the agent lanes. + + Per-type required fields: proofs (IDENTITY_VERIFIED / + IDENTITY_UPDATED) carry non-empty `keys[]`, `verifiedAt`, and + `providerId`; IDENTITY_REVOKED carries `revokedAt`; the link + events carry non-empty `ansIds[]` (the whole batch in ONE + event) and are the only types allowed to name agents. + required: [identityId, kind, value, eventType, timestamp] + properties: + identityId: { type: string, description: "UUIDv7 — the identity stream key" } + kind: + type: string + enum: ['did:web', 'did:key', 'lei'] + value: { type: string, example: "did:web:identity.acme-corp.com" } + providerId: { type: string, description: "The owning principal — the WHO's owner" } + proofMethod: + type: string + enum: [did-web-sig, did-key-sig, lei-vlei-acdc] + eventType: + type: string + enum: [IDENTITY_VERIFIED, IDENTITY_UPDATED, IDENTITY_REVOKED, IDENTITY_LINKED, IDENTITY_UNLINKED] + keys: + type: array + description: | + The proven key set — sealed self-verifyingly: each entry + quotes the DID document's verification method VERBATIM + alongside the registrant's signed proof. + items: + $ref: '#/components/schemas/ProvenKey' + ansIds: + type: array + description: Linked agents' ids (IDENTITY_LINKED / IDENTITY_UNLINKED only) + items: { type: string } + previousValue: { type: string, description: "Pre-rotation identifier (IDENTITY_UPDATED)" } + verifiedAt: { type: string, format: date-time } + revokedAt: { type: string, format: date-time } + raId: { type: string, example: "ans-ra-local" } + timestamp: { type: string, format: date-time } + + ProvenKey: + type: object + description: | + One key the registrant proved possession of, sealed + self-verifyingly: any third party reads the key material out + of `verificationMethod`, verifies `signedProof` against it, + then confirms the payload decodes to an IdentityProofInput + binding this identityId + identifier + purpose — offline, + without trusting the RA. + + `verificationMethod` is quoted EXACTLY as the DID document + served it — `id`, `type`, `controller`, and the key material + in whichever representation the document used + (`publicKeyJwk`, or `publicKeyMultibase` for Multikey + documents) — member-for-member, values untouched. Nothing + derived, re-encoded, or normalized enters a seal; the event + envelope is JCS-canonicalized for signing like every event, + and JCS preserves member values exactly, so the quoted + material survives intact. Thumbprints are compute-at-read + conveniences (anyone can derive RFC 7638 from the sealed + source) and are never part of the sealed contract. + + The postponed `lei` kind is the one deliberate exception: + it will seal the subject AID + a key thumbprint only — there + is no document to quote, the ACDC is PII, and KERI's KEL is + already the authoritative key history. Seal verbatim what + has no other tamper-evident home; commit minimally where one + exists. + required: [verificationMethod, signedProof] + properties: + verificationMethod: + type: object + description: The DID document's verification-method object, verbatim + example: + id: did:web:identity.acme-corp.com#key-1 + type: JsonWebKey2020 + controller: did:web:identity.acme-corp.com + publicKeyJwk: { kty: OKP, crv: Ed25519, x: "0-e2i2_..." } + signedProof: + type: string + description: The compact JWS over the served IdentityProofInput + + LinkedIdentityView: + type: object + description: One computed identities[] entry on the agent badge. + required: [identityId, kind, value, identityStatus] + properties: + identityId: { type: string } + kind: { type: string, enum: ['did:web', 'did:key', 'lei'] } + value: { type: string } + identityStatus: + type: string + enum: [VERIFIED, REVOKED] + description: Reflects the identity stream NOW + provenKeyIds: + type: array + description: | + Verification-method ids of the current proven key set + (post-rotation). The full verbatim methods live in the + sealed proof event. + items: { type: string } + linkedAt: { type: string, format: date-time } + linkLogId: + type: string + description: The sealed IDENTITY_LINKED entry on the identity stream — fetch for link evidence + identityLogId: + type: string + description: Latest identity-stream entry — fetch for the identity evidence/history + + LinkedAgentView: + type: object + description: One reverse-join entry (identity → agents). + required: [ansId] + properties: + ansId: { type: string } + linkedAt: { type: string, format: date-time } + agentStatus: + type: string + enum: [ACTIVE, REVOKED, DEPRECATED, EXPIRED, WARNING] + description: The linked agent's own computed badge status — a link is effective only while both ends are live + # ── Admin: producer keys ─────────────────────────────────── ProducerKeyRequest: type: object diff --git a/internal/adapter/store/sqlitetl/events.go b/internal/adapter/store/sqlitetl/events.go index 2b25f5b..84aa033 100644 --- a/internal/adapter/store/sqlitetl/events.go +++ b/internal/adapter/store/sqlitetl/events.go @@ -23,6 +23,11 @@ import ( // - EventHashHex — SHA-256 of the JCS-canonical inner-producer-event // bytes. UNIQUE — the table rejects retries with // the same inner event content. +// +// AgentID and IdentityID are the two read-index keys over the single +// log: agent events carry AgentID (IdentityID empty), identity events +// carry IdentityID (AgentID empty). "Stream" means nothing more than +// filtering this table by one of those keys. type EventRecord struct { ID int64 `db:"id"` LeafIndex uint64 `db:"leaf_index"` @@ -32,12 +37,21 @@ type EventRecord struct { AgentID string `db:"agent_id"` AnsName string `db:"ans_name"` AgentFQDN string `db:"agent_fqdn"` + IdentityID string `db:"identity_id"` EventType string `db:"event_type"` SchemaVersion string `db:"schema_version"` RawEvent string `db:"raw_event"` CreatedAtMs int64 `db:"created_at_ms"` } +// eventCols is the shared SELECT column list. identity_id is nullable +// on disk (NULL for agent events); COALESCE keeps the Go-side scan a +// plain string. +const eventCols = `id, leaf_index, leaf_hash, event_hash, log_id, + agent_id, ans_name, agent_fqdn, event_type, + schema_version, raw_event, created_at_ms, + COALESCE(identity_id, '') AS identity_id` + // CreatedAt returns the event creation time in UTC. func (r *EventRecord) CreatedAt() time.Time { return time.UnixMilli(r.CreatedAtMs).UTC() } @@ -61,9 +75,39 @@ func (r *EventRecord) LeafHashBytes() ([32]byte, error) { return out, nil } +// LinkState is the latest link/unlink fact for one (identity, agent) +// pair, computed from the tl_identity_event_agents read-join index. A +// link is live when EventType == IDENTITY_LINKED; whether it is +// *effective* additionally depends on the identity's and agent's +// current stream state, which the service layer joins in. +type LinkState struct { + IdentityID string `db:"identity_id"` + AnsID string `db:"ans_id"` + LeafIndex uint64 `db:"leaf_index"` + EventType string `db:"event_type"` + CreatedAtMs int64 `db:"created_at_ms"` +} + +// eventTypeIdentityLinked mirrors identityevent.TypeIdentityLinked +// without importing the event package for one comparison string. +const eventTypeIdentityLinked = "IDENTITY_LINKED" + +// Linked reports whether the latest event for the pair is a link. +func (l *LinkState) Linked() bool { return l.EventType == eventTypeIdentityLinked } + +// identityIndexed is the optional capability an envelope exposes when +// it should be indexed on the identity stream. The identity envelope +// implements it; agent envelopes (V1/V2) don't, and simply land with +// a NULL identity_id. Discovering the capability by type assertion +// keeps event.View frozen for existing implementers. +type identityIndexed interface { + IdentityID() string + LinkedAgentIDs() []string +} + // EventStore persists event records. Mirrors the reference // EventStorage.StoreEvent / GetLatestRecordByAgentID / GetRecordsByAgentID / -// GetEventByLeafIndex surface. +// GetEventByLeafIndex surface, extended with the identity read index. type EventStore struct{ db *DB } // NewEventStore returns a new SQLite-backed event store. @@ -81,10 +125,15 @@ func ComputeEventHash(innerCanonical []byte) string { // StoreEvent persists a freshly-appended event. Returns a domain // conflict error if event_hash already exists (idempotent retry). -// Takes an event.View so both V1 and V2 envelopes land through the -// same persistence path; the `schema_version` column holds -// `env.Version()` for downstream read handlers to echo back in the -// TransparencyLog response. +// Takes an event.View so every envelope shape lands through the same +// persistence path; the `schema_version` column holds `env.Version()` +// for downstream read handlers to echo back. +// +// Identity envelopes (discovered via the identityIndexed capability) +// additionally fan their linked-agent ids into the +// tl_identity_event_agents read-join index, atomically with the event +// row — a link event whose index rows were lost would silently hide +// the association from every agent-side read. func (s *EventStore) StoreEvent( ctx context.Context, leafIndex uint64, @@ -93,13 +142,27 @@ func (s *EventStore) StoreEvent( env event.View, canonicalEnvelope []byte, ) (*EventRecord, error) { + identityID := "" + var linkedAgents []string + if iv, ok := env.(identityIndexed); ok { + identityID = iv.IdentityID() + linkedAgents = iv.LinkedAgentIDs() + } + + tx, err := s.db.db.BeginTxx(ctx, nil) + if err != nil { + return nil, mapSQLErr(err) + } + defer func() { _ = tx.Rollback() }() + + now := nowMs() const q = ` INSERT INTO tl_events( leaf_index, leaf_hash, event_hash, log_id, agent_id, ans_name, agent_fqdn, event_type, - schema_version, raw_event, created_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` - _, err := s.db.db.ExecContext(ctx, q, + schema_version, raw_event, created_at_ms, identity_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULLIF(?, ''))` + if _, err := tx.ExecContext(ctx, q, leafIndex, hex.EncodeToString(leafHash[:]), innerEventHash, @@ -110,9 +173,25 @@ func (s *EventStore) StoreEvent( env.EventType(), env.Version(), string(canonicalEnvelope), - nowMs(), - ) - if err != nil { + now, + identityID, + ); err != nil { + return nil, mapSQLErr(err) + } + + if identityID != "" && len(linkedAgents) > 0 { + const fq = ` + INSERT INTO tl_identity_event_agents( + leaf_index, identity_id, ans_id, event_type, created_at_ms + ) VALUES (?, ?, ?, ?, ?)` + for _, ansID := range linkedAgents { + if _, err := tx.ExecContext(ctx, fq, leafIndex, identityID, ansID, env.EventType(), now); err != nil { + return nil, mapSQLErr(err) + } + } + } + + if err := tx.Commit(); err != nil { return nil, mapSQLErr(err) } return s.GetEventByLeafIndex(ctx, leafIndex) @@ -122,10 +201,7 @@ func (s *EventStore) StoreEvent( func (s *EventStore) GetEventByLeafIndex(ctx context.Context, index uint64) (*EventRecord, error) { var r EventRecord err := s.db.db.GetContext(ctx, &r, - `SELECT id, leaf_index, leaf_hash, event_hash, log_id, - agent_id, ans_name, agent_fqdn, event_type, - schema_version, raw_event, created_at_ms - FROM tl_events WHERE leaf_index = ?`, index) + `SELECT `+eventCols+` FROM tl_events WHERE leaf_index = ?`, index) if err != nil { return nil, mapSQLErr(err) } @@ -139,18 +215,14 @@ func (s *EventStore) GetLatestByAgentID(ctx context.Context, agentID string, max var r EventRecord var err error if maxLeafIndex > 0 { - err = s.db.db.GetContext(ctx, &r, ` - SELECT id, leaf_index, leaf_hash, event_hash, log_id, - agent_id, ans_name, agent_fqdn, event_type, - schema_version, raw_event, created_at_ms + err = s.db.db.GetContext(ctx, &r, + `SELECT `+eventCols+` FROM tl_events WHERE agent_id = ? AND leaf_index < ? ORDER BY leaf_index DESC LIMIT 1`, agentID, maxLeafIndex) } else { - err = s.db.db.GetContext(ctx, &r, ` - SELECT id, leaf_index, leaf_hash, event_hash, log_id, - agent_id, ans_name, agent_fqdn, event_type, - schema_version, raw_event, created_at_ms + err = s.db.db.GetContext(ctx, &r, + `SELECT `+eventCols+` FROM tl_events WHERE agent_id = ? ORDER BY leaf_index DESC LIMIT 1`, agentID) @@ -168,28 +240,19 @@ func (s *EventStore) GetByAgentID( limit, offset int, maxLeafIndex uint64, ) ([]*EventRecord, error) { - if limit <= 0 || limit > 200 { - limit = 50 - } - if offset < 0 { - offset = 0 - } + limit, offset = clampPage(limit, offset) var rows []*EventRecord var err error if maxLeafIndex > 0 { - err = s.db.db.SelectContext(ctx, &rows, ` - SELECT id, leaf_index, leaf_hash, event_hash, log_id, - agent_id, ans_name, agent_fqdn, event_type, - schema_version, raw_event, created_at_ms + err = s.db.db.SelectContext(ctx, &rows, + `SELECT `+eventCols+` FROM tl_events WHERE agent_id = ? AND leaf_index < ? ORDER BY leaf_index DESC LIMIT ? OFFSET ?`, agentID, maxLeafIndex, limit, offset) } else { - err = s.db.db.SelectContext(ctx, &rows, ` - SELECT id, leaf_index, leaf_hash, event_hash, log_id, - agent_id, ans_name, agent_fqdn, event_type, - schema_version, raw_event, created_at_ms + err = s.db.db.SelectContext(ctx, &rows, + `SELECT `+eventCols+` FROM tl_events WHERE agent_id = ? ORDER BY leaf_index DESC LIMIT ? OFFSET ?`, @@ -204,6 +267,155 @@ func (s *EventStore) GetByAgentID( return rows, nil } +// GetLatestByIdentityID returns the newest event on an identity's +// stream — the read index over the single log keyed by identity_id. +func (s *EventStore) GetLatestByIdentityID(ctx context.Context, identityID string) (*EventRecord, error) { + var r EventRecord + err := s.db.db.GetContext(ctx, &r, + `SELECT `+eventCols+` + FROM tl_events + WHERE identity_id = ? + ORDER BY leaf_index DESC LIMIT 1`, identityID) + if err != nil { + return nil, mapSQLErr(err) + } + return &r, nil +} + +// GetByIdentityID returns paginated events for an identity, newest first. +func (s *EventStore) GetByIdentityID( + ctx context.Context, + identityID string, + limit, offset int, +) ([]*EventRecord, error) { + limit, offset = clampPage(limit, offset) + var rows []*EventRecord + err := s.db.db.SelectContext(ctx, &rows, + `SELECT `+eventCols+` + FROM tl_events + WHERE identity_id = ? + ORDER BY leaf_index DESC LIMIT ? OFFSET ?`, + identityID, limit, offset) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + return rows, nil +} + +// GetLatestProofByIdentityID returns the newest *proof* event +// (IDENTITY_VERIFIED / IDENTITY_UPDATED) on an identity's stream — +// the event carrying the current proven key set. Link events sit on +// the same stream but carry no keys; badge joins need the proof. +func (s *EventStore) GetLatestProofByIdentityID(ctx context.Context, identityID string) (*EventRecord, error) { + var r EventRecord + err := s.db.db.GetContext(ctx, &r, + `SELECT `+eventCols+` + FROM tl_events + WHERE identity_id = ? + AND event_type IN ('IDENTITY_VERIFIED', 'IDENTITY_UPDATED') + ORDER BY leaf_index DESC LIMIT 1`, identityID) + if err != nil { + return nil, mapSQLErr(err) + } + return &r, nil +} + +// LinkStatesByAgent returns, for one agent, the latest link/unlink +// fact per identity that ever named it — the badge-join input. Rows +// where Linked() is true are the agent's live links. +func (s *EventStore) LinkStatesByAgent(ctx context.Context, ansID string) ([]*LinkState, error) { + var rows []*LinkState + err := s.db.db.SelectContext(ctx, &rows, ` + SELECT a.identity_id, a.ans_id, a.leaf_index, a.event_type, a.created_at_ms + FROM tl_identity_event_agents a + JOIN ( + SELECT identity_id, MAX(leaf_index) AS max_leaf + FROM tl_identity_event_agents + WHERE ans_id = ? + GROUP BY identity_id + ) m ON a.identity_id = m.identity_id AND a.leaf_index = m.max_leaf + WHERE a.ans_id = ? + ORDER BY a.leaf_index DESC`, ansID, ansID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + return rows, nil +} + +// LinkStatesByIdentity returns, for one identity, the latest +// link/unlink fact per agent it ever named — the reverse join. +func (s *EventStore) LinkStatesByIdentity(ctx context.Context, identityID string) ([]*LinkState, error) { + var rows []*LinkState + err := s.db.db.SelectContext(ctx, &rows, ` + SELECT a.identity_id, a.ans_id, a.leaf_index, a.event_type, a.created_at_ms + FROM tl_identity_event_agents a + JOIN ( + SELECT ans_id, MAX(leaf_index) AS max_leaf + FROM tl_identity_event_agents + WHERE identity_id = ? + GROUP BY ans_id + ) m ON a.ans_id = m.ans_id AND a.leaf_index = m.max_leaf + WHERE a.identity_id = ? + ORDER BY a.leaf_index DESC`, identityID, identityID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + return rows, nil +} + +// LinkEventsByAgent returns the link/unlink events that ever named +// this agent, newest first — the per-agent association history, +// served in the standard audit envelope by the handler. +func (s *EventStore) LinkEventsByAgent( + ctx context.Context, + ansID string, + limit, offset int, +) ([]*EventRecord, error) { + limit, offset = clampPage(limit, offset) + var rows []*EventRecord + err := s.db.db.SelectContext(ctx, &rows, + `SELECT `+eventColsPrefixed+` + FROM tl_events e + JOIN tl_identity_event_agents a ON a.leaf_index = e.leaf_index + WHERE a.ans_id = ? + ORDER BY e.leaf_index DESC LIMIT ? OFFSET ?`, + ansID, limit, offset) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + return rows, nil +} + +// eventColsPrefixed is eventCols with an `e.` table alias for joined +// queries. +const eventColsPrefixed = `e.id, e.leaf_index, e.leaf_hash, e.event_hash, e.log_id, + e.agent_id, e.ans_name, e.agent_fqdn, e.event_type, + e.schema_version, e.raw_event, e.created_at_ms, + COALESCE(e.identity_id, '') AS identity_id` + +// clampPage normalizes pagination inputs. +func clampPage(limit, offset int) (int, int) { + if limit <= 0 || limit > 200 { + limit = 50 + } + if offset < 0 { + offset = 0 + } + return limit, offset +} + // ExistsByEventHash returns (true, leafIndex) if an event with the // given content hash has already been stored, enabling idempotent // retries at the service layer. The UNIQUE constraint on event_hash diff --git a/internal/adapter/store/sqlitetl/identityevents_test.go b/internal/adapter/store/sqlitetl/identityevents_test.go new file mode 100644 index 0000000..04b364f --- /dev/null +++ b/internal/adapter/store/sqlitetl/identityevents_test.go @@ -0,0 +1,314 @@ +package sqlitetl + +// Tests for the identity read index over tl_events: identity_id +// column population (via the identityIndexed capability), the +// tl_identity_event_agents fan-out, and the join queries the +// badge/audit/reverse views run on. + +import ( + "context" + "encoding/json" + "testing" + + identityevent "github.com/godaddy/ans/internal/tl/event/identity" +) + +// buildIdentityEnvelope assembles a signed-shaped identity envelope. +func buildIdentityEnvelope(t *testing.T, typ identityevent.Type, identityID string, ansIDs []string) *identityevent.Envelope { + t.Helper() + ev := &identityevent.Event{ + EventType: typ, + IdentityID: identityID, + Kind: "did:web", + Value: "did:web:identity.acme-corp.com", + Timestamp: "2026-06-10T15:04:05Z", + } + switch typ { + case identityevent.TypeIdentityVerified, identityevent.TypeIdentityUpdated: + ev.ProviderID = "PID-1" + ev.VerifiedAt = "2026-06-10T15:04:05Z" + ev.Keys = []identityevent.ProvenKey{{ + VerificationMethod: json.RawMessage(`{"id":"did:web:identity.acme-corp.com#key-1","type":"JsonWebKey2020","controller":"did:web:identity.acme-corp.com","publicKeyJwk":{"kty":"OKP","crv":"Ed25519","x":"abc"}}`), + SignedProof: "jws", + }} + case identityevent.TypeIdentityRevoked: + ev.RevokedAt = "2026-06-10T16:00:00Z" + case identityevent.TypeIdentityLinked, identityevent.TypeIdentityUnlinked: + ev.AnsIDs = ansIDs + } + env := identityevent.BuildEnvelope("log-"+identityID, ev, "key-1", "psig") + env.Signature = "tl-attestation" + return env +} + +// storeIdentityEvent appends one identity envelope at the given leaf. +func storeIdentityEvent(t *testing.T, store *EventStore, leaf uint64, env *identityevent.Envelope) { + t.Helper() + canonical, err := env.LeafBytes() + if err != nil { + t.Fatalf("leaf bytes: %v", err) + } + leafHash, err := env.LeafHash() + if err != nil { + t.Fatalf("leaf hash: %v", err) + } + if _, err := store.StoreEvent( + context.Background(), leaf, leafHash, + "event-hash-"+itoa(int(leaf)), env, canonical, + ); err != nil { + t.Fatalf("store event: %v", err) + } +} + +func TestStoreEvent_IdentityEnvelope_IndexesIdentityID(t *testing.T) { + db := newDB(t) + store := NewEventStore(db) + + env := buildIdentityEnvelope(t, identityevent.TypeIdentityVerified, "id-A", nil) + storeIdentityEvent(t, store, 0, env) + + rec, err := store.GetLatestByIdentityID(context.Background(), "id-A") + if err != nil { + t.Fatalf("GetLatestByIdentityID: %v", err) + } + if rec.IdentityID != "id-A" { + t.Errorf("IdentityID = %q", rec.IdentityID) + } + if rec.AgentID != "" { + t.Errorf("AgentID should be empty for identity events, got %q", rec.AgentID) + } + if rec.EventType != "IDENTITY_VERIFIED" { + t.Errorf("EventType = %q", rec.EventType) + } +} + +func TestStoreEvent_AgentRowsHaveEmptyIdentityID(t *testing.T) { + db := newDB(t) + store := NewEventStore(db) + + insertRawEvent(t, db, 0, "agent-1") + rec, err := store.GetEventByLeafIndex(context.Background(), 0) + if err != nil { + t.Fatalf("GetEventByLeafIndex: %v", err) + } + if rec.IdentityID != "" { + t.Errorf("agent row IdentityID = %q, want empty", rec.IdentityID) + } +} + +func TestStoreEvent_LinkFanOut(t *testing.T) { + db := newDB(t) + store := NewEventStore(db) + ctx := context.Background() + + // Verify, then link two agents in ONE event. + storeIdentityEvent(t, store, 0, + buildIdentityEnvelope(t, identityevent.TypeIdentityVerified, "id-A", nil)) + storeIdentityEvent(t, store, 1, + buildIdentityEnvelope(t, identityevent.TypeIdentityLinked, "id-A", []string{"agent-1", "agent-2"})) + + // Both agents see the link. + for _, agent := range []string{"agent-1", "agent-2"} { + states, err := store.LinkStatesByAgent(ctx, agent) + if err != nil { + t.Fatalf("LinkStatesByAgent(%s): %v", agent, err) + } + if len(states) != 1 || !states[0].Linked() || states[0].IdentityID != "id-A" { + t.Fatalf("LinkStatesByAgent(%s) = %+v", agent, states) + } + } + + // Reverse join sees both agents. + states, err := store.LinkStatesByIdentity(ctx, "id-A") + if err != nil { + t.Fatalf("LinkStatesByIdentity: %v", err) + } + if len(states) != 2 { + t.Fatalf("expected 2 link states, got %d", len(states)) + } +} + +func TestLinkStates_LatestWins(t *testing.T) { + db := newDB(t) + store := NewEventStore(db) + ctx := context.Background() + + storeIdentityEvent(t, store, 0, + buildIdentityEnvelope(t, identityevent.TypeIdentityLinked, "id-A", []string{"agent-1"})) + storeIdentityEvent(t, store, 1, + buildIdentityEnvelope(t, identityevent.TypeIdentityUnlinked, "id-A", []string{"agent-1"})) + + states, err := store.LinkStatesByAgent(ctx, "agent-1") + if err != nil { + t.Fatalf("LinkStatesByAgent: %v", err) + } + if len(states) != 1 { + t.Fatalf("expected 1 state, got %d", len(states)) + } + if states[0].Linked() { + t.Fatal("latest event is UNLINKED — Linked() must be false") + } + + // Re-link → live again (UNLINKED rows never block re-linking). + storeIdentityEvent(t, store, 2, + buildIdentityEnvelope(t, identityevent.TypeIdentityLinked, "id-A", []string{"agent-1"})) + states, err = store.LinkStatesByAgent(ctx, "agent-1") + if err != nil { + t.Fatalf("LinkStatesByAgent: %v", err) + } + if len(states) != 1 || !states[0].Linked() { + t.Fatalf("expected live link after re-link, got %+v", states) + } +} + +func TestLinkEventsByAgent_History(t *testing.T) { + db := newDB(t) + store := NewEventStore(db) + ctx := context.Background() + + storeIdentityEvent(t, store, 0, + buildIdentityEnvelope(t, identityevent.TypeIdentityLinked, "id-A", []string{"agent-1"})) + storeIdentityEvent(t, store, 1, + buildIdentityEnvelope(t, identityevent.TypeIdentityUnlinked, "id-A", []string{"agent-1"})) + // A link event for a DIFFERENT agent must not appear. + storeIdentityEvent(t, store, 2, + buildIdentityEnvelope(t, identityevent.TypeIdentityLinked, "id-A", []string{"agent-2"})) + + recs, err := store.LinkEventsByAgent(ctx, "agent-1", 50, 0) + if err != nil { + t.Fatalf("LinkEventsByAgent: %v", err) + } + if len(recs) != 2 { + t.Fatalf("expected 2 history records, got %d", len(recs)) + } + // Newest first. + if recs[0].EventType != "IDENTITY_UNLINKED" || recs[1].EventType != "IDENTITY_LINKED" { + t.Fatalf("history order wrong: %s, %s", recs[0].EventType, recs[1].EventType) + } +} + +func TestGetLatestProofByIdentityID_SkipsLinkEvents(t *testing.T) { + db := newDB(t) + store := NewEventStore(db) + ctx := context.Background() + + storeIdentityEvent(t, store, 0, + buildIdentityEnvelope(t, identityevent.TypeIdentityVerified, "id-A", nil)) + storeIdentityEvent(t, store, 1, + buildIdentityEnvelope(t, identityevent.TypeIdentityLinked, "id-A", []string{"agent-1"})) + + proof, err := store.GetLatestProofByIdentityID(ctx, "id-A") + if err != nil { + t.Fatalf("GetLatestProofByIdentityID: %v", err) + } + if proof.EventType != "IDENTITY_VERIFIED" { + t.Fatalf("latest proof = %s, want IDENTITY_VERIFIED", proof.EventType) + } + + // A rotation supersedes the original proof. + upd := buildIdentityEnvelope(t, identityevent.TypeIdentityUpdated, "id-A", nil) + upd.Payload.Producer.Event.Keys[0].VerificationMethod = json.RawMessage( + `{"id":"did:web:identity.acme-corp.com#key-2","type":"JsonWebKey2020","controller":"did:web:identity.acme-corp.com","publicKeyJwk":{"kty":"OKP","crv":"Ed25519","x":"def"}}`) + storeIdentityEvent(t, store, 2, upd) + + proof, err = store.GetLatestProofByIdentityID(ctx, "id-A") + if err != nil { + t.Fatalf("GetLatestProofByIdentityID after rotation: %v", err) + } + if proof.EventType != "IDENTITY_UPDATED" { + t.Fatalf("latest proof = %s, want IDENTITY_UPDATED", proof.EventType) + } +} + +func TestGetByIdentityID_Pagination(t *testing.T) { + db := newDB(t) + store := NewEventStore(db) + ctx := context.Background() + + storeIdentityEvent(t, store, 0, + buildIdentityEnvelope(t, identityevent.TypeIdentityVerified, "id-A", nil)) + storeIdentityEvent(t, store, 1, + buildIdentityEnvelope(t, identityevent.TypeIdentityLinked, "id-A", []string{"agent-1"})) + storeIdentityEvent(t, store, 2, + buildIdentityEnvelope(t, identityevent.TypeIdentityRevoked, "id-A", nil)) + + all, err := store.GetByIdentityID(ctx, "id-A", 50, 0) + if err != nil { + t.Fatalf("GetByIdentityID: %v", err) + } + if len(all) != 3 { + t.Fatalf("expected 3 events, got %d", len(all)) + } + if all[0].EventType != "IDENTITY_REVOKED" { + t.Fatalf("newest first expected, got %s", all[0].EventType) + } + + page, err := store.GetByIdentityID(ctx, "id-A", 1, 1) + if err != nil { + t.Fatalf("GetByIdentityID paged: %v", err) + } + if len(page) != 1 || page[0].EventType != "IDENTITY_LINKED" { + t.Fatalf("page = %+v", page) + } + + // Unknown identity → empty, no error. + none, err := store.GetByIdentityID(ctx, "id-unknown", 50, 0) + if err != nil { + t.Fatalf("GetByIdentityID unknown: %v", err) + } + if len(none) != 0 { + t.Fatalf("expected no events, got %d", len(none)) + } +} + +func TestGetLatestByIdentityID_NotFound(t *testing.T) { + db := newDB(t) + store := NewEventStore(db) + if _, err := store.GetLatestByIdentityID(context.Background(), "missing"); err == nil { + t.Fatal("expected not-found error") + } +} + +func TestStoreEvent_IdentityDedupAcrossFamilies(t *testing.T) { + db := newDB(t) + store := NewEventStore(db) + ctx := context.Background() + + env := buildIdentityEnvelope(t, identityevent.TypeIdentityVerified, "id-A", nil) + storeIdentityEvent(t, store, 0, env) + + dup, leaf, err := store.ExistsByEventHash(ctx, "event-hash-0") + if err != nil { + t.Fatalf("ExistsByEventHash: %v", err) + } + if !dup || leaf != 0 { + t.Fatalf("dedup miss: dup=%v leaf=%d", dup, leaf) + } +} + +func TestReceiptStore_FindByLeafIndex(t *testing.T) { + db := newDB(t) + receipts := NewReceiptStore(db) + ctx := context.Background() + + if err := receipts.Store(ctx, 7, "id-A", 10, []byte{0xd2, 0x84}); err != nil { + t.Fatalf("store receipt: %v", err) + } + + rec, err := receipts.FindByLeafIndex(ctx, 7, 10) + if err != nil { + t.Fatalf("FindByLeafIndex: %v", err) + } + if rec == nil || rec.AgentID != "id-A" || len(rec.ReceiptBlob) != 2 { + t.Fatalf("unexpected record: %+v", rec) + } + + // Different tree size → miss (nil, nil). + miss, err := receipts.FindByLeafIndex(ctx, 7, 11) + if err != nil { + t.Fatalf("FindByLeafIndex miss: %v", err) + } + if miss != nil { + t.Fatalf("expected cache miss, got %+v", miss) + } +} diff --git a/internal/adapter/store/sqlitetl/migrations/004_identity_events.sql b/internal/adapter/store/sqlitetl/migrations/004_identity_events.sql new file mode 100644 index 0000000..f673117 --- /dev/null +++ b/internal/adapter/store/sqlitetl/migrations/004_identity_events.sql @@ -0,0 +1,47 @@ +-- Identity events: the Verified Identities (the "who") event family. +-- +-- There is ONE transparency log — a single Merkle tree. Identity +-- events append to the same tree as agent events and mirror into the +-- same tl_events table; the "identity stream" is nothing more than a +-- read index over that single log, keyed by identity_id. This is the +-- normative model: streams are read indexes, never separate trees. +-- +-- identity_id is NULL for agent events and set for IDENTITY_* events +-- (whose agent_id / ans_name columns are '' — an identity event names +-- no single agent). The partial index keeps the identity read path +-- O(log n) without taxing the agent-event hot path. +ALTER TABLE tl_events ADD COLUMN identity_id TEXT; + +CREATE INDEX IF NOT EXISTS idx_tl_events_identity_leaf + ON tl_events(identity_id, leaf_index DESC) + WHERE identity_id IS NOT NULL; + +-- tl_identity_event_agents is the read-join index for link events. +-- +-- IDENTITY_LINKED / IDENTITY_UNLINKED seal ONE event on the identity +-- stream carrying the whole batch in ansIds[]; this table fans those +-- ids out so reads can join in both directions: +-- +-- - badge join: "which identities are currently linked to agent X?" +-- → latest row per identity_id for ans_id = X, keep LINKED ones. +-- - association history: "which link events ever named agent X?" +-- → all rows for ans_id = X, resolved to tl_events by leaf_index. +-- +-- The rows are derivable from the log (they mirror sealed events) — +-- this is an index, not a source of truth. Agent streams are never +-- written by identity operations; this table is how reads cross over. +CREATE TABLE IF NOT EXISTS tl_identity_event_agents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + leaf_index INTEGER NOT NULL, + identity_id TEXT NOT NULL, + ans_id TEXT NOT NULL, + event_type TEXT NOT NULL, -- IDENTITY_LINKED | IDENTITY_UNLINKED + created_at_ms INTEGER NOT NULL, + UNIQUE(leaf_index, ans_id) +); + +CREATE INDEX IF NOT EXISTS idx_tl_identity_event_agents_ans + ON tl_identity_event_agents(ans_id, leaf_index DESC); + +CREATE INDEX IF NOT EXISTS idx_tl_identity_event_agents_identity + ON tl_identity_event_agents(identity_id, leaf_index DESC); diff --git a/internal/adapter/store/sqlitetl/receipts.go b/internal/adapter/store/sqlitetl/receipts.go index 2c01b1c..c6e46eb 100644 --- a/internal/adapter/store/sqlitetl/receipts.go +++ b/internal/adapter/store/sqlitetl/receipts.go @@ -41,24 +41,22 @@ func (s *ReceiptStore) Store( return mapSQLErr(err) } -// FindByAgentID returns the cached receipt for an agent's latest -// event at the given tree size, or (nil, nil) if no cached receipt -// exists. Unlike GetLatestByAgentID this scopes to a specific tree -// size so the ReceiptService can key its cache on (agent, -// treeSize) rather than (leafIndex, treeSize) — agents can have -// multiple events in the log but the receipt we want is always the -// latest one covered by the current checkpoint. -func (s *ReceiptStore) FindByAgentID( +// FindByLeafIndex returns the cached receipt for a specific +// (leaf_index, tree_size) pair — the table's natural UNIQUE key — or +// (nil, nil) if no cached receipt exists. The pair fully determines +// the receipt's payload (same event bytes + same inclusion proof), +// and unlike an agent-keyed lookup it works identically for agent +// and identity subjects (identity events carry no agent id). +func (s *ReceiptStore) FindByLeafIndex( ctx context.Context, - agentID string, + leafIndex uint64, treeSize uint64, ) (*ReceiptRecord, error) { var r ReceiptRecord err := s.db.db.GetContext(ctx, &r, `SELECT id, leaf_index, agent_id, tree_size, receipt_blob, created_at_ms FROM tl_receipts - WHERE agent_id = ? AND tree_size = ? - ORDER BY leaf_index DESC LIMIT 1`, agentID, treeSize) + WHERE leaf_index = ? AND tree_size = ?`, leafIndex, treeSize) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil //nolint:nilnil // (nil, nil) signals "no cached receipt for this tree size" diff --git a/internal/tl/event/identity/event.go b/internal/tl/event/identity/event.go new file mode 100644 index 0000000..00f2ffe --- /dev/null +++ b/internal/tl/event/identity/event.go @@ -0,0 +1,474 @@ +// Package identity defines the Transparency Log event family for +// Verified Identities — the "who" behind an agent. Identity events +// ride the same producer lane, the same envelope wrapper shape, and +// the same Merkle tree as agent events; what differs is the inner +// event payload (keyed by identityId instead of ansId) and the +// closed eventType vocabulary. +// +// Shape is a contract: sealed events are append-only-forever, so the +// five tokens and the payload fields below must not change once a +// real TL has sealed them. The canonicalization and leaf-hash rules +// are identical to the agent envelope (JCS + RFC 6962 §2.1) — one +// log, one set of verifier rules. +// +// Cross-lane guard: an identity event posted to an agent ingest lane +// fails the agent codec's closed enum (and lacks ansId); an agent +// event posted to the identity lane fails this package's closed enum +// (and lacks identityId). Both reject with 422 INVALID_EVENT, so the +// V1 lane stays frozen and the two V2 families cannot cross. +package identity + +import ( + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "time" + + anscrypto "github.com/godaddy/ans/internal/crypto" +) + +// SchemaVersion pins the envelope version. Identity events are part +// of the V2 surface (the V2 event-set amendment adds the five +// IDENTITY_* tokens); the V1 lane never carries them. +const SchemaVersion = "V2" + +// Type classifies an identity lifecycle event. The set is closed — +// adding a token is a spec amendment, not a code change. +type Type string + +// The identity event family. Every IDENTITY_* event — proofs, +// rotations, revocations, and links — seals on the identity stream +// (read-indexed by identityId). An identity operation never writes +// to an agent's stream; propagation to linked agents is a read-time +// join. +const ( + // TypeIdentityVerified — first successful control proof. Seals + // every proven key self-verifyingly (public key + signed proof). + TypeIdentityVerified Type = "IDENTITY_VERIFIED" + + // TypeIdentityUpdated — a rotation (PUT + verify-control) + // completed. One event total, regardless of linked-agent count. + TypeIdentityUpdated Type = "IDENTITY_UPDATED" + + // TypeIdentityRevoked — the identity was revoked by its owner. + // Terminal. Linked agents reflect it at the next read. + TypeIdentityRevoked Type = "IDENTITY_REVOKED" + + // TypeIdentityLinked — a batch of the owner's agents was linked. + // One event carries the whole batch in ansIds[]. + TypeIdentityLinked Type = "IDENTITY_LINKED" + + // TypeIdentityUnlinked — an association ended. The event is the + // history; the link rows elsewhere are merely caches. + TypeIdentityUnlinked Type = "IDENTITY_UNLINKED" +) + +// IsValid reports whether t is a recognized identity event type. +func (t Type) IsValid() bool { + switch t { + case TypeIdentityVerified, + TypeIdentityUpdated, + TypeIdentityRevoked, + TypeIdentityLinked, + TypeIdentityUnlinked: + return true + default: + return false + } +} + +// isLink reports whether t is one of the two association events. +func (t Type) isLink() bool { + return t == TypeIdentityLinked || t == TypeIdentityUnlinked +} + +// isProof reports whether t seals a control proof (and therefore +// must carry the proven key set). +func (t Type) isProof() bool { + return t == TypeIdentityVerified || t == TypeIdentityUpdated +} + +// Envelope is the top-level structure appended to the Merkle tree — +// the same wrapper shape as the agent envelope (payload / +// schemaVersion / signature / status) with an identity inner event. +type Envelope struct { + Payload *Payload `json:"payload,omitempty"` + SchemaVersion string `json:"schemaVersion,omitempty"` + Signature string `json:"signature,omitempty"` // TL attestation (detached JWS) + Status string `json:"status,omitempty"` +} + +// Payload wraps the producer's signed event with a TL-assigned logId. +type Payload struct { + LogID string `json:"logId"` + Producer *Producer `json:"producer"` +} + +// Producer records the event together with the producer's identity +// and its detached-JWS attestation over the inner Event. +type Producer struct { + Event *Event `json:"event"` + KeyID string `json:"keyId"` + Signature string `json:"signature"` +} + +// Event is the producer-authored identity event payload. The RA +// JCS-canonicalizes this and signs it; the resulting detached JWS +// lands in Producer.Signature. +type Event struct { + // AnsIDs carries the linked agents' ids on IDENTITY_LINKED / + // IDENTITY_UNLINKED — the whole batch in one event. Forbidden on + // the proof/revocation types: those events never name agents. + AnsIDs []string `json:"ansIds,omitempty"` + + EventType Type `json:"eventType"` + + // IdentityID is the stream key — the RA-assigned UUIDv7 of the + // VerifiedIdentity aggregate. Required on every identity event. + IdentityID string `json:"identityId"` + + // Kind is the identifier kind ("did:web" | "did:key" | "lei"). + Kind string `json:"kind"` + + // Keys is the proven key set — one entry per key the registrant + // proved possession of. Required (non-empty) on the proof events; + // self-verifying: each entry quotes the DID document's + // verification method VERBATIM alongside the registrant's proof. + Keys []ProvenKey `json:"keys,omitempty"` + + // PreviousValue records the pre-rotation identifier value on + // IDENTITY_UPDATED. + PreviousValue string `json:"previousValue,omitempty"` + + // ProofMethod names the control-proof mechanism + // ("did-web-sig" | "did-key-sig" | "lei-vlei-acdc"). + ProofMethod string `json:"proofMethod,omitempty"` + + // ProviderID is the owning principal — the WHO's owner, parallel + // to the agent event's agent.providerId. + ProviderID string `json:"providerId,omitempty"` + + RaID string `json:"raId,omitempty"` + RevokedAt string `json:"revokedAt,omitempty"` + Timestamp string `json:"timestamp"` // RFC3339, required + + // Value is the canonical identifier + // (e.g. "did:web:identity.acme-corp.com"). + Value string `json:"value"` + + VerifiedAt string `json:"verifiedAt,omitempty"` +} + +// ProvenKey is one key the registrant proved possession of, sealed +// self-verifyingly: any third party reads the key material out of +// VerificationMethod, verifies SignedProof against it, then confirms +// the payload decodes to an IdentityProofInput binding this +// identityId + identifier + purpose — offline, without trusting the +// RA. +// +// VerificationMethod is quoted EXACTLY as the DID document served it +// — id, type, controller, and the key material in whichever +// representation the document used (publicKeyJwk, or +// publicKeyMultibase for Multikey documents) — member-for-member, +// values untouched. Nothing derived, re-encoded, or normalized +// enters a seal: the envelope is JCS-canonicalized for signing like +// every event, and JCS preserves member values exactly, so the +// quoted material survives intact. Thumbprints are compute-at-read +// conveniences (anyone can derive RFC 7638 from the sealed source); +// they are never part of the sealed contract. +// +// The postponed lei kind is the one deliberate exception: it will +// seal the subject AID + a key thumbprint only — there is no +// document to quote, the ACDC is PII, and KERI's KEL is already the +// authoritative key history. Seal verbatim what has no other +// tamper-evident home; commit minimally where one exists. +type ProvenKey struct { + // VerificationMethod is the DID document's verification-method + // object, verbatim. + VerificationMethod json.RawMessage `json:"verificationMethod"` + + // SignedProof is the compact JWS the registrant submitted over + // the served IdentityProofInput. + SignedProof string `json:"signedProof"` +} + +// ID extracts the verification-method id from the sealed verbatim +// object — the read-side accessor index/joins use. +func (k ProvenKey) ID() string { + var vm struct { + ID string `json:"id"` + } + if err := json.Unmarshal(k.VerificationMethod, &vm); err != nil { + return "" + } + return vm.ID +} + +// Validate returns nil if the envelope has the minimum fields the TL +// requires to index, de-dupe, and sign the event. It does NOT verify +// the producer signature — that's handled upstream. +func (e *Envelope) Validate() error { + if e == nil { + return errors.New("identityevent: nil envelope") + } + if e.SchemaVersion != SchemaVersion { + return fmt.Errorf("identityevent: schemaVersion must be %q, got %q", SchemaVersion, e.SchemaVersion) + } + if e.Payload == nil { + return errors.New("identityevent: payload required") + } + if e.Payload.LogID == "" { + return errors.New("identityevent: payload.logId required") + } + if e.Payload.Producer == nil { + return errors.New("identityevent: payload.producer required") + } + p := e.Payload.Producer + if p.KeyID == "" { + return errors.New("identityevent: payload.producer.keyId required") + } + if p.Signature == "" { + return errors.New("identityevent: payload.producer.signature required") + } + return p.Event.Validate() +} + +// Validate enforces the per-type required-field matrix: +// +// all types — identityId, kind, value, RFC3339 timestamp +// VERIFIED/UPDATED — non-empty keys[] (each with thumbprint + +// verificationMethodId), verifiedAt, providerId +// REVOKED — revokedAt +// LINKED/UNLINKED — non-empty ansIds[] +// non-link types — ansIds[] forbidden (identity facts never name agents) +func (ev *Event) Validate() error { + if ev == nil { + return errors.New("identityevent: producer.event required") + } + if !ev.EventType.IsValid() { + return fmt.Errorf("identityevent: invalid eventType %q", ev.EventType) + } + if ev.IdentityID == "" { + return errors.New("identityevent: identityId required") + } + if ev.Kind == "" { + return errors.New("identityevent: kind required") + } + if ev.Value == "" { + return errors.New("identityevent: value required") + } + if ev.Timestamp == "" { + return errors.New("identityevent: timestamp required") + } + if _, err := time.Parse(time.RFC3339, ev.Timestamp); err != nil { + return fmt.Errorf("identityevent: timestamp must be RFC3339: %w", err) + } + if ev.EventType.isLink() { + if len(ev.AnsIDs) == 0 { + return fmt.Errorf("identityevent: %s requires non-empty ansIds", ev.EventType) + } + for i, id := range ev.AnsIDs { + if id == "" { + return fmt.Errorf("identityevent: ansIds[%d] is empty", i) + } + } + } else if len(ev.AnsIDs) > 0 { + return fmt.Errorf("identityevent: ansIds forbidden on %s", ev.EventType) + } + if ev.EventType.isProof() { + if err := ev.validateProofFields(); err != nil { + return err + } + } + if ev.EventType == TypeIdentityRevoked && ev.RevokedAt == "" { + return errors.New("identityevent: IDENTITY_REVOKED requires revokedAt") + } + return nil +} + +// validateProofFields enforces the proof-event requirements: a +// non-empty sealed key set (each entry a verbatim verification +// method carrying an id, plus the registrant's proof), verifiedAt, +// and the owning providerId. +func (ev *Event) validateProofFields() error { + if len(ev.Keys) == 0 { + return fmt.Errorf("identityevent: %s requires non-empty keys", ev.EventType) + } + for i, k := range ev.Keys { + if len(k.VerificationMethod) == 0 { + return fmt.Errorf("identityevent: keys[%d].verificationMethod required", i) + } + if k.ID() == "" { + return fmt.Errorf("identityevent: keys[%d].verificationMethod must be an object with an id", i) + } + if k.SignedProof == "" { + return fmt.Errorf("identityevent: keys[%d].signedProof required", i) + } + } + if ev.VerifiedAt == "" { + return fmt.Errorf("identityevent: %s requires verifiedAt", ev.EventType) + } + if ev.ProviderID == "" { + return fmt.Errorf("identityevent: %s requires providerId", ev.EventType) + } + return nil +} + +// SigningInput returns the JCS-canonical bytes of the envelope while +// its outer Signature is still empty — the bytes the TL's attestation +// signer signs. Errors if already signed (signing twice would +// invalidate the prior signature's domain). +func (e *Envelope) SigningInput() ([]byte, error) { + if e.Signature != "" { + return nil, errors.New("identityevent: SigningInput called on already-signed envelope") + } + return canonicalize(e) +} + +// LeafBytes returns the JCS-canonical bytes of the fully signed +// envelope — the bytes Tessera appends to the Merkle tree. +func (e *Envelope) LeafBytes() ([]byte, error) { + if e.Signature == "" { + return nil, errors.New("identityevent: LeafBytes called on unsigned envelope") + } + return canonicalize(e) +} + +// LeafHash returns the RFC 6962 §2.1 leaf hash of the envelope: +// SHA-256(0x00 || LeafBytes()). Identical rule to the agent envelope +// — one tree, one hash discipline. +func (e *Envelope) LeafHash() ([32]byte, error) { + leaf, err := e.LeafBytes() + if err != nil { + return [32]byte{}, err + } + h := sha256.New() + h.Write([]byte{0x00}) + h.Write(leaf) + var out [32]byte + copy(out[:], h.Sum(nil)) + return out, nil +} + +// canonicalize is the shared json.Marshal + JCS wrapper. +func canonicalize(v any) ([]byte, error) { + body, err := json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("identityevent: marshal: %w", err) + } + return anscrypto.Canonicalize(body) +} + +// BuildEnvelope is the single chokepoint for identity-envelope +// construction on the TL side: the TL receives an Event + producer +// signature + producer keyId; it assigns logId and wraps. +func BuildEnvelope(logID string, inner *Event, producerKeyID, producerSignature string) *Envelope { + return &Envelope{ + SchemaVersion: SchemaVersion, + Payload: &Payload{ + LogID: logID, + Producer: &Producer{ + Event: inner, + KeyID: producerKeyID, + Signature: producerSignature, + }, + }, + } +} + +// CanonicalizeEvent JCS-canonicalizes a producer Event — the byte +// sequence the RA signs and the TL re-canonicalizes to verify that +// signature. Exposed so both sides of the RA ↔ TL boundary share the +// exact same canonicalization. +func CanonicalizeEvent(inner *Event) ([]byte, error) { + return canonicalize(inner) +} + +// ----- event.View / event.Signable conformance ----- +// +// The shared ingest pipeline and the SQLite mirror operate on the +// version-agnostic event.View surface. Identity envelopes implement +// it with empty agent-side accessors (an identity event names no +// single agent) and expose the identity-side fields through the +// optional capability methods IdentityID / LinkedAgentIDs, which the +// store discovers by type assertion. + +// Version returns the on-wire schemaVersion tag ("V2"). +func (e *Envelope) Version() string { return e.SchemaVersion } + +// LogID returns the TL-assigned logId, or "". +func (e *Envelope) LogID() string { + if e.Payload != nil { + return e.Payload.LogID + } + return "" +} + +// AgentID returns "" — identity events are not keyed by a single +// agent; linked agents are exposed via LinkedAgentIDs. +func (e *Envelope) AgentID() string { return "" } + +// AnsName returns "" — identity events carry no ANS name. +func (e *Envelope) AnsName() string { return "" } + +// AgentFQDN returns "" — identity events carry no agent host. +func (e *Envelope) AgentFQDN() string { return "" } + +// EventType returns the inner event's type as a string. +func (e *Envelope) EventType() string { + if ev := e.innerEvent(); ev != nil { + return string(ev.EventType) + } + return "" +} + +// Timestamp returns the producer's RFC3339 timestamp. +func (e *Envelope) Timestamp() string { + if ev := e.innerEvent(); ev != nil { + return ev.Timestamp + } + return "" +} + +// ProducerKeyID returns the kid used by the producer to sign. +func (e *Envelope) ProducerKeyID() string { + if e.Payload != nil && e.Payload.Producer != nil { + return e.Payload.Producer.KeyID + } + return "" +} + +// ProducerSignature returns the detached JWS the producer signed. +func (e *Envelope) ProducerSignature() string { + if e.Payload != nil && e.Payload.Producer != nil { + return e.Payload.Producer.Signature + } + return "" +} + +// IdentityID returns the inner event's identityId — the identity +// stream key the SQLite mirror indexes by. +func (e *Envelope) IdentityID() string { + if ev := e.innerEvent(); ev != nil { + return ev.IdentityID + } + return "" +} + +// LinkedAgentIDs returns the ansIds named by a link event (nil for +// the proof/revocation types). The mirror fans these into the +// agent-side read-join index. +func (e *Envelope) LinkedAgentIDs() []string { + if ev := e.innerEvent(); ev != nil { + return ev.AnsIDs + } + return nil +} + +func (e *Envelope) innerEvent() *Event { + if e == nil || e.Payload == nil || e.Payload.Producer == nil { + return nil + } + return e.Payload.Producer.Event +} diff --git a/internal/tl/event/identity/event_test.go b/internal/tl/event/identity/event_test.go new file mode 100644 index 0000000..e46db8a --- /dev/null +++ b/internal/tl/event/identity/event_test.go @@ -0,0 +1,294 @@ +package identity + +import ( + "crypto/sha256" + "encoding/json" + "strings" + "testing" +) + +// validEvent returns a minimal valid event of the given type. +func validEvent(t Type) *Event { + ev := &Event{ + EventType: t, + IdentityID: "01HXKQ00000000000000000000", + Kind: "did:web", + Value: "did:web:identity.acme-corp.com", + Timestamp: "2026-06-10T15:04:05Z", + } + switch { + case t.isProof(): + ev.ProviderID = "PID-8294" + ev.ProofMethod = "did-web-sig" + ev.VerifiedAt = "2026-06-10T15:04:05Z" + ev.Keys = []ProvenKey{{ + VerificationMethod: json.RawMessage(`{"id":"did:web:identity.acme-corp.com#key-1","type":"JsonWebKey2020","controller":"did:web:identity.acme-corp.com","publicKeyJwk":{"kty":"OKP","crv":"Ed25519","x":"abc"}}`), + SignedProof: "eyJhbGciOiJFZERTQSJ9.payload.sig", + }} + case t.isLink(): + ev.AnsIDs = []string{"550e8400-e29b-41d4-a716-446655440000"} + case t == TypeIdentityRevoked: + ev.RevokedAt = "2026-06-10T16:00:00Z" + } + return ev +} + +func TestTypeIsValid(t *testing.T) { + for _, tt := range []Type{ + TypeIdentityVerified, TypeIdentityUpdated, TypeIdentityRevoked, + TypeIdentityLinked, TypeIdentityUnlinked, + } { + if !tt.IsValid() { + t.Errorf("%s should be valid", tt) + } + } + for _, tt := range []Type{"", "AGENT_REGISTERED", "IDENTITY_ADDED", "identity_verified"} { + if tt.IsValid() { + t.Errorf("%s should be invalid", tt) + } + } +} + +func TestEventValidate_AllTypesHappyPath(t *testing.T) { + for _, tt := range []Type{ + TypeIdentityVerified, TypeIdentityUpdated, TypeIdentityRevoked, + TypeIdentityLinked, TypeIdentityUnlinked, + } { + if err := validEvent(tt).Validate(); err != nil { + t.Errorf("%s: unexpected validation error: %v", tt, err) + } + } +} + +func TestEventValidate_RequiredFieldMatrix(t *testing.T) { + cases := []struct { + name string + mutate func(*Event) + base Type + wantSub string + }{ + {"nil event", nil, TypeIdentityVerified, "producer.event required"}, + {"bad type", func(e *Event) { e.EventType = "AGENT_REGISTERED" }, TypeIdentityVerified, "invalid eventType"}, + {"missing identityId", func(e *Event) { e.IdentityID = "" }, TypeIdentityVerified, "identityId required"}, + {"missing kind", func(e *Event) { e.Kind = "" }, TypeIdentityVerified, "kind required"}, + {"missing value", func(e *Event) { e.Value = "" }, TypeIdentityVerified, "value required"}, + {"missing timestamp", func(e *Event) { e.Timestamp = "" }, TypeIdentityVerified, "timestamp required"}, + {"bad timestamp", func(e *Event) { e.Timestamp = "yesterday" }, TypeIdentityVerified, "RFC3339"}, + {"verified without keys", func(e *Event) { e.Keys = nil }, TypeIdentityVerified, "requires non-empty keys"}, + {"updated without keys", func(e *Event) { e.Keys = nil }, TypeIdentityUpdated, "requires non-empty keys"}, + {"key missing verificationMethod", func(e *Event) { e.Keys[0].VerificationMethod = nil }, TypeIdentityVerified, "verificationMethod required"}, + {"verificationMethod without id", func(e *Event) { e.Keys[0].VerificationMethod = json.RawMessage(`{"type":"JsonWebKey2020"}`) }, TypeIdentityVerified, "must be an object with an id"}, + {"key missing signedProof", func(e *Event) { e.Keys[0].SignedProof = "" }, TypeIdentityVerified, "signedProof required"}, + {"verified without verifiedAt", func(e *Event) { e.VerifiedAt = "" }, TypeIdentityVerified, "requires verifiedAt"}, + {"verified without providerId", func(e *Event) { e.ProviderID = "" }, TypeIdentityVerified, "requires providerId"}, + {"revoked without revokedAt", func(e *Event) { e.RevokedAt = "" }, TypeIdentityRevoked, "requires revokedAt"}, + {"linked without ansIds", func(e *Event) { e.AnsIDs = nil }, TypeIdentityLinked, "requires non-empty ansIds"}, + {"unlinked without ansIds", func(e *Event) { e.AnsIDs = nil }, TypeIdentityUnlinked, "requires non-empty ansIds"}, + {"linked with empty ansId entry", func(e *Event) { e.AnsIDs = []string{"a", ""} }, TypeIdentityLinked, "ansIds[1] is empty"}, + {"proof with ansIds", func(e *Event) { e.AnsIDs = []string{"a"} }, TypeIdentityVerified, "ansIds forbidden"}, + {"revoked with ansIds", func(e *Event) { e.AnsIDs = []string{"a"} }, TypeIdentityRevoked, "ansIds forbidden"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var ev *Event + if tc.mutate != nil { + ev = validEvent(tc.base) + tc.mutate(ev) + } + err := ev.Validate() + if err == nil { + t.Fatalf("expected error containing %q, got nil", tc.wantSub) + } + if !strings.Contains(err.Error(), tc.wantSub) { + t.Fatalf("expected error containing %q, got %v", tc.wantSub, err) + } + }) + } +} + +func TestEnvelopeValidate(t *testing.T) { + mk := func() *Envelope { + return BuildEnvelope("log-1", validEvent(TypeIdentityVerified), "key-1", "sig-1") + } + + if err := mk().Validate(); err != nil { + t.Fatalf("valid envelope rejected: %v", err) + } + + cases := []struct { + name string + mutate func(*Envelope) + env *Envelope + wantSub string + }{ + {name: "nil envelope", env: nil, wantSub: "nil envelope"}, + {name: "wrong schema", mutate: func(e *Envelope) { e.SchemaVersion = "V1" }, wantSub: "schemaVersion"}, + {name: "missing payload", mutate: func(e *Envelope) { e.Payload = nil }, wantSub: "payload required"}, + {name: "missing logId", mutate: func(e *Envelope) { e.Payload.LogID = "" }, wantSub: "logId required"}, + {name: "missing producer", mutate: func(e *Envelope) { e.Payload.Producer = nil }, wantSub: "producer required"}, + {name: "missing keyId", mutate: func(e *Envelope) { e.Payload.Producer.KeyID = "" }, wantSub: "keyId required"}, + {name: "missing signature", mutate: func(e *Envelope) { e.Payload.Producer.Signature = "" }, wantSub: "signature required"}, + {name: "invalid inner", mutate: func(e *Envelope) { e.Payload.Producer.Event.IdentityID = "" }, wantSub: "identityId required"}, + {name: "nil inner", mutate: func(e *Envelope) { e.Payload.Producer.Event = nil }, wantSub: "producer.event required"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + env := tc.env + if tc.mutate != nil { + env = mk() + tc.mutate(env) + } + err := env.Validate() + if err == nil || !strings.Contains(err.Error(), tc.wantSub) { + t.Fatalf("expected error containing %q, got %v", tc.wantSub, err) + } + }) + } +} + +func TestSigningLifecycle(t *testing.T) { + env := BuildEnvelope("log-1", validEvent(TypeIdentityVerified), "key-1", "sig-1") + + // LeafBytes before signing is a misuse. + if _, err := env.LeafBytes(); err == nil { + t.Fatal("LeafBytes on unsigned envelope should error") + } + + signingInput, err := env.SigningInput() + if err != nil { + t.Fatalf("SigningInput: %v", err) + } + // The omitempty on the outer Signature is load-bearing: the + // signing input must have no TOP-LEVEL "signature" key (the + // producer's signature under payload.producer is sealed content + // and legitimately present). + var top map[string]json.RawMessage + if err := json.Unmarshal(signingInput, &top); err != nil { + t.Fatalf("signing input not JSON: %v", err) + } + if _, ok := top["signature"]; ok { + t.Fatal("signing input must not contain the top-level signature key") + } + + env.Signature = "tl-attestation-jws" + + // SigningInput after signing is a misuse. + if _, err := env.SigningInput(); err == nil { + t.Fatal("SigningInput on signed envelope should error") + } + + leaf, err := env.LeafBytes() + if err != nil { + t.Fatalf("LeafBytes: %v", err) + } + if !strings.Contains(string(leaf), `"signature":"tl-attestation-jws"`) { + t.Fatal("leaf bytes must contain the outer signature") + } + + // LeafHash = SHA-256(0x00 || leaf) per RFC 6962 §2.1. + h := sha256.New() + h.Write([]byte{0x00}) + h.Write(leaf) + var want [32]byte + copy(want[:], h.Sum(nil)) + got, err := env.LeafHash() + if err != nil { + t.Fatalf("LeafHash: %v", err) + } + if got != want { + t.Fatal("leaf hash mismatch with independent RFC 6962 computation") + } +} + +func TestLeafHashOnUnsignedEnvelope(t *testing.T) { + env := BuildEnvelope("log-1", validEvent(TypeIdentityVerified), "key-1", "sig-1") + if _, err := env.LeafHash(); err == nil { + t.Fatal("LeafHash on unsigned envelope should error") + } +} + +func TestCanonicalizeEventDeterministic(t *testing.T) { + ev := validEvent(TypeIdentityLinked) + a, err := CanonicalizeEvent(ev) + if err != nil { + t.Fatalf("canonicalize: %v", err) + } + b, err := CanonicalizeEvent(ev) + if err != nil { + t.Fatalf("canonicalize: %v", err) + } + if string(a) != string(b) { + t.Fatal("canonicalization must be deterministic") + } + // JCS sorts keys: ansIds before eventType before identityId. + s := string(a) + if strings.Index(s, `"ansIds"`) >= strings.Index(s, `"eventType"`) || + strings.Index(s, `"eventType"`) >= strings.Index(s, `"identityId"`) { + t.Fatalf("canonical bytes not JCS-sorted: %s", s) + } +} + +func TestViewAccessors(t *testing.T) { + ev := validEvent(TypeIdentityLinked) + env := BuildEnvelope("log-9", ev, "key-9", "psig-9") + + if env.Version() != "V2" { + t.Errorf("Version = %q", env.Version()) + } + if env.LogID() != "log-9" { + t.Errorf("LogID = %q", env.LogID()) + } + if env.AgentID() != "" || env.AnsName() != "" || env.AgentFQDN() != "" { + t.Error("agent-side accessors must be empty on identity envelopes") + } + if env.EventType() != "IDENTITY_LINKED" { + t.Errorf("EventType = %q", env.EventType()) + } + if env.Timestamp() != ev.Timestamp { + t.Errorf("Timestamp = %q", env.Timestamp()) + } + if env.ProducerKeyID() != "key-9" || env.ProducerSignature() != "psig-9" { + t.Error("producer accessors mismatch") + } + if env.IdentityID() != ev.IdentityID { + t.Errorf("IdentityID = %q", env.IdentityID()) + } + if got := env.LinkedAgentIDs(); len(got) != 1 || got[0] != ev.AnsIDs[0] { + t.Errorf("LinkedAgentIDs = %v", got) + } +} + +func TestViewAccessorsOnEmptyEnvelope(t *testing.T) { + var nilEnv *Envelope + if nilEnv.innerEvent() != nil { + t.Fatal("nil envelope must have nil inner event") + } + empty := &Envelope{} + if empty.LogID() != "" || empty.EventType() != "" || empty.Timestamp() != "" || + empty.ProducerKeyID() != "" || empty.ProducerSignature() != "" || + empty.IdentityID() != "" || empty.LinkedAgentIDs() != nil { + t.Fatal("accessors on empty envelope must return zero values") + } +} + +func TestJSONRoundTrip(t *testing.T) { + env := BuildEnvelope("log-1", validEvent(TypeIdentityVerified), "key-1", "sig-1") + env.Signature = "outer" + raw, err := json.Marshal(env) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var back Envelope + if err := json.Unmarshal(raw, &back); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if err := back.Validate(); err != nil { + t.Fatalf("round-tripped envelope invalid: %v", err) + } + if back.IdentityID() != env.IdentityID() { + t.Fatal("identityId lost in round trip") + } + if len(back.Payload.Producer.Event.Keys) != 1 { + t.Fatal("keys lost in round trip") + } +} diff --git a/internal/tl/handler/empty_param_identity_test.go b/internal/tl/handler/empty_param_identity_test.go new file mode 100644 index 0000000..8964172 --- /dev/null +++ b/internal/tl/handler/empty_param_identity_test.go @@ -0,0 +1,42 @@ +package handler_test + +// Empty-path-param guards for the identity read surface — siblings of +// the agent-route cases in empty_param_test.go. Each guard fires +// before any service is touched, so a fully-nil Handlers suffices. + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/godaddy/ans/internal/tl/handler" +) + +func TestIdentityRoutes_EmptyParams(t *testing.T) { + t.Parallel() + h := handler.NewHandlers(nil, nil, nil, nil, nil, nil, nil, nil) + + cases := []struct { + name string + param string + path string + handler func(http.ResponseWriter, *http.Request) + }{ + {"identity badge", "identityId", "/v1/identities/", h.GetIdentityBadge}, + {"identity audit", "identityId", "/v1/identities//audit", h.GetIdentityAudit}, + {"identity receipt", "identityId", "/v1/identities//receipt", h.GetIdentityReceipt}, + {"identity agents", "identityId", "/v1/identities//agents", h.GetIdentityAgents}, + {"agent identities", "agentId", "/v1/agents//identities", h.GetAgentIdentities}, + {"agent identity history", "agentId", "/v1/agents//identities/history", h.GetAgentIdentityHistory}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + rec := httptest.NewRecorder() + tc.handler(rec, emptyParamReq(http.MethodGet, tc.path, tc.param)) + if rec.Code != http.StatusUnprocessableEntity { + t.Errorf("status: got %d want 422", rec.Code) + } + }) + } +} diff --git a/internal/tl/handler/empty_param_test.go b/internal/tl/handler/empty_param_test.go index 6110409..6eff3ab 100644 --- a/internal/tl/handler/empty_param_test.go +++ b/internal/tl/handler/empty_param_test.go @@ -35,7 +35,7 @@ func TestGetBadge_EmptyAgentID(t *testing.T) { t.Parallel() // Build a Handlers with all services nil; the empty-agentID guard // fires before any service is touched. - h := handler.NewHandlers(nil, nil, nil, nil, nil, nil, nil) + h := handler.NewHandlers(nil, nil, nil, nil, nil, nil, nil, nil) rec := httptest.NewRecorder() h.GetBadge(rec, emptyParamReq(http.MethodGet, "/v1/agents/", "agentId")) if rec.Code != http.StatusUnprocessableEntity { @@ -45,7 +45,7 @@ func TestGetBadge_EmptyAgentID(t *testing.T) { func TestGetAudit_EmptyAgentID(t *testing.T) { t.Parallel() - h := handler.NewHandlers(nil, nil, nil, nil, nil, nil, nil) + h := handler.NewHandlers(nil, nil, nil, nil, nil, nil, nil, nil) rec := httptest.NewRecorder() h.GetAudit(rec, emptyParamReq(http.MethodGet, "/v1/agents//audit", "agentId")) if rec.Code != http.StatusUnprocessableEntity { @@ -55,7 +55,7 @@ func TestGetAudit_EmptyAgentID(t *testing.T) { func TestGetReceipt_EmptyAgentID(t *testing.T) { t.Parallel() - h := handler.NewHandlers(nil, nil, nil, nil, nil, nil, nil) + h := handler.NewHandlers(nil, nil, nil, nil, nil, nil, nil, nil) rec := httptest.NewRecorder() h.GetReceipt(rec, emptyParamReq(http.MethodGet, "/v1/agents//receipt", "agentId")) if rec.Code != http.StatusUnprocessableEntity { @@ -69,7 +69,7 @@ func TestGetReceipt_EmptyAgentID(t *testing.T) { // unwired and clients see 501 rather than a misleading 500. func TestGetStatusToken_DisabledReturns501(t *testing.T) { t.Parallel() - h := handler.NewHandlers(nil, nil, nil, nil, nil, nil, nil) + h := handler.NewHandlers(nil, nil, nil, nil, nil, nil, nil, nil) rec := httptest.NewRecorder() h.GetStatusToken(rec, emptyParamReq(http.MethodGet, "/v1/agents/x/status-token", "agentId")) if rec.Code != http.StatusNotImplemented { @@ -84,7 +84,7 @@ func TestGetStatusToken_DisabledReturns501(t *testing.T) { // "h.checkpoint == nil" branch. func TestGetCheckpointJSON_DisabledReturnsInternal(t *testing.T) { t.Parallel() - h := handler.NewHandlers(nil, nil, nil, nil, nil, nil, nil) + h := handler.NewHandlers(nil, nil, nil, nil, nil, nil, nil, nil) rec := httptest.NewRecorder() h.GetCheckpointJSON(rec, httptest.NewRequest(http.MethodGet, "/v1/log/checkpoint", nil)) if rec.Code != http.StatusInternalServerError { @@ -96,7 +96,7 @@ func TestGetCheckpointJSON_DisabledReturnsInternal(t *testing.T) { // same guard on the history route. func TestGetCheckpointHistory_DisabledReturnsInternal(t *testing.T) { t.Parallel() - h := handler.NewHandlers(nil, nil, nil, nil, nil, nil, nil) + h := handler.NewHandlers(nil, nil, nil, nil, nil, nil, nil, nil) rec := httptest.NewRecorder() h.GetCheckpointHistory(rec, httptest.NewRequest(http.MethodGet, "/v1/log/checkpoint/history", nil)) if rec.Code != http.StatusInternalServerError { @@ -108,7 +108,7 @@ func TestGetCheckpointHistory_DisabledReturnsInternal(t *testing.T) { // "h.schema == nil" branch. func TestGetSchema_DisabledReturnsInternal(t *testing.T) { t.Parallel() - h := handler.NewHandlers(nil, nil, nil, nil, nil, nil, nil) + h := handler.NewHandlers(nil, nil, nil, nil, nil, nil, nil, nil) rec := httptest.NewRecorder() h.GetSchema(rec, emptyParamReq(http.MethodGet, "/v1/log/schema/V2", "version")) if rec.Code != http.StatusInternalServerError { diff --git a/internal/tl/handler/handler.go b/internal/tl/handler/handler.go index 910f74b..88b4566 100644 --- a/internal/tl/handler/handler.go +++ b/internal/tl/handler/handler.go @@ -46,12 +46,13 @@ import ( // Handlers groups the TL HTTP handlers. type Handlers struct { - log *service.LogService - badge *service.BadgeService - receipt *service.ReceiptService - statusToken *service.StatusTokenService // nil if status tokens are disabled - checkpoint *service.CheckpointService - schema *service.SchemaService + log *service.LogService + badge *service.BadgeService + identityBadge *service.IdentityBadgeService + receipt *service.ReceiptService + statusToken *service.StatusTokenService // nil if status tokens are disabled + checkpoint *service.CheckpointService + schema *service.SchemaService // rootKeysBody is the pre-rendered text/plain body for the // /root-keys endpoint — one verification-line per // active TL verifier, in the order the operator wired them. @@ -72,6 +73,7 @@ type Handlers struct { func NewHandlers( log *service.LogService, badge *service.BadgeService, + identityBadge *service.IdentityBadgeService, receipt *service.ReceiptService, statusToken *service.StatusTokenService, checkpoint *service.CheckpointService, @@ -79,13 +81,14 @@ func NewHandlers( rootKeysBody []byte, ) *Handlers { return &Handlers{ - log: log, - badge: badge, - receipt: receipt, - statusToken: statusToken, - checkpoint: checkpoint, - schema: schema, - rootKeysBody: rootKeysBody, + log: log, + badge: badge, + identityBadge: identityBadge, + receipt: receipt, + statusToken: statusToken, + checkpoint: checkpoint, + schema: schema, + rootKeysBody: rootKeysBody, } } @@ -105,12 +108,33 @@ func (h *Handlers) Mount(r chi.Router, tileRoot string) { r.Post("/v1/internal/agents/event", h.AppendEventV1) r.Post("/v2/internal/agents/event", h.AppendEventV2) + // Identity ingest — the IDENTITY_* event family rides the same + // producer-signature lane into the same Merkle tree; the + // dedicated route exists because the payload schema differs + // (keyed by identityId) and the closed enums are the cross-lane + // guard. + r.Post("/v1/internal/identities/event", h.AppendEventIdentity) + // Agent-scoped reads. Reference swagger §78-308. r.Get("/v1/agents/{agentId}", h.GetBadge) r.Get("/v1/agents/{agentId}/audit", h.GetAudit) r.Get("/v1/agents/{agentId}/receipt", h.GetReceipt) r.Get("/v1/agents/{agentId}/status-token", h.GetStatusToken) + // Agent-side computed identity views — read-time joins through + // the link events' agent index. Never stored on the agent; the + // agent's own audit stays purely AGENT_*. + r.Get("/v1/agents/{agentId}/identities", h.GetAgentIdentities) + r.Get("/v1/agents/{agentId}/identities/history", h.GetAgentIdentityHistory) + + // Identity-stream reads — same response shapes as the agent + // reads (badge / audit envelope / COSE receipt), keyed by + // identityId, plus the reverse join to currently-linked agents. + r.Get("/v1/identities/{identityId}", h.GetIdentityBadge) + r.Get("/v1/identities/{identityId}/audit", h.GetIdentityAudit) + r.Get("/v1/identities/{identityId}/receipt", h.GetIdentityReceipt) + r.Get("/v1/identities/{identityId}/agents", h.GetIdentityAgents) + // Log metadata (JSON variants). Reference swagger §310-461. r.Get("/v1/log/checkpoint", h.GetCheckpointJSON) r.Get("/v1/log/checkpoint/history", h.GetCheckpointHistory) @@ -164,6 +188,15 @@ func (h *Handlers) AppendEventV2(w http.ResponseWriter, r *http.Request) { h.appendEvent(w, r, h.log.AppendV2) } +// AppendEventIdentity is the identity-family ingest entrypoint — +// POST /v1/internal/identities/event. Same contract as the agent +// lanes (raw inner-event body + X-Signature detached JWS, 256 KiB +// cap); the identity codec enforces the IDENTITY_* enum and the +// identityId keyspace. +func (h *Handlers) AppendEventIdentity(w http.ResponseWriter, r *http.Request) { + h.appendEvent(w, r, h.log.AppendIdentity) +} + // appendEvent is the shared body-read / response-shape glue; the // passed-in `appendFn` picks which LogService path runs. func (h *Handlers) appendEvent( @@ -212,7 +245,11 @@ func (h *Handlers) appendEvent( // GetBadge handles GET /v1/agents/{agentId}. Returns the // reference-shaped TransparencyLog JSON: merkleProof, payload (the // V1 envelope's payload piece), schemaVersion, signature (TL -// attestation), status. +// attestation), status — plus the computed identities[] join (the +// agent's currently-linked verified identities, decorated with each +// identity's current stream state). The join is read-time: rotation +// and revocation on the identity stream are visible here immediately +// with zero agent-stream writes. func (h *Handlers) GetBadge(w http.ResponseWriter, r *http.Request) { agentID := chi.URLParam(r, "agentId") if agentID == "" { @@ -224,9 +261,143 @@ func (h *Handlers) GetBadge(w http.ResponseWriter, r *http.Request) { writeError(w, err) return } + if h.identityBadge != nil { + identities, jerr := h.identityBadge.LinkedIdentitiesForAgent(r.Context(), agentID) + if jerr != nil { + writeError(w, jerr) + return + } + tl.Identities = identities + } writeJSON(w, http.StatusOK, tl) } +// GetAgentIdentities handles GET /v1/agents/{agentId}/identities — +// the computed list of identities currently linked to the agent. +// Identical entries to the badge's identities[] field, served alone +// for callers who don't need the full badge. +func (h *Handlers) GetAgentIdentities(w http.ResponseWriter, r *http.Request) { + agentID := chi.URLParam(r, "agentId") + if agentID == "" { + writeError(w, domain.NewValidationError("MISSING_AGENT_ID", "agentId is required")) + return + } + identities, err := h.identityBadge.LinkedIdentitiesForAgent(r.Context(), agentID) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"identities": identities}) +} + +// GetAgentIdentityHistory handles +// GET /v1/agents/{agentId}/identities/history — the link/unlink +// events that ever named this agent, in the standard audit envelope +// (each record a TransparencyLog), filtered through the agent index. +// Past and present associations fall out of reading those events; +// current state is the computed identities[] join. +func (h *Handlers) GetAgentIdentityHistory(w http.ResponseWriter, r *http.Request) { + agentID := chi.URLParam(r, "agentId") + if agentID == "" { + writeError(w, domain.NewValidationError("MISSING_AGENT_ID", "agentId is required")) + return + } + limit, offset := parsePagination(r) + records, err := h.identityBadge.LinkHistoryForAgent(r.Context(), agentID, limit, offset) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"records": records}) +} + +// GetIdentityBadge handles GET /v1/identities/{identityId} — the +// identity badge: latest sealed identity event + inclusion proof + +// computed status (VERIFIED | REVOKED). +func (h *Handlers) GetIdentityBadge(w http.ResponseWriter, r *http.Request) { + identityID := chi.URLParam(r, "identityId") + if identityID == "" { + writeError(w, domain.NewValidationError("MISSING_IDENTITY_ID", "identityId is required")) + return + } + tl, err := h.identityBadge.Get(r.Context(), identityID) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, tl) +} + +// GetIdentityAudit handles GET /v1/identities/{identityId}/audit — +// the identity's full event chain in the same audit envelope as the +// agent audit ({ records: [TransparencyLog, ...] }). +func (h *Handlers) GetIdentityAudit(w http.ResponseWriter, r *http.Request) { + identityID := chi.URLParam(r, "identityId") + if identityID == "" { + writeError(w, domain.NewValidationError("MISSING_IDENTITY_ID", "identityId is required")) + return + } + limit, offset := parsePagination(r) + records, err := h.identityBadge.Audit(r.Context(), identityID, limit, offset) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"records": records}) +} + +// GetIdentityReceipt handles GET /v1/identities/{identityId}/receipt +// — a SCITT COSE_Sign1 receipt for the identity's latest sealed +// event. Same machinery, content type, and 503 retry semantics as +// the agent receipt. +func (h *Handlers) GetIdentityReceipt(w http.ResponseWriter, r *http.Request) { + identityID := chi.URLParam(r, "identityId") + if identityID == "" { + writeError(w, domain.NewValidationError("MISSING_IDENTITY_ID", "identityId is required")) + return + } + rec, err := h.receipt.ForIdentity(r.Context(), identityID) + if err != nil { + if errors.Is(err, service.ErrLeafNotYetCovered) { + w.Header().Set("Retry-After", "2") + writeJSON(w, http.StatusServiceUnavailable, problem{ + Type: "about:blank", + Title: "Receipt Not Yet Available", + Status: http.StatusServiceUnavailable, + Code: "TL_LEAF_UNCOMMITTED", + Detail: "leaf committed but no signed checkpoint yet covers it; retry after the Retry-After delay", + }) + return + } + writeError(w, err) + return + } + w.Header().Set("Content-Type", rec.ContentType) + w.Header().Set("Content-Length", strconv.Itoa(len(rec.Bytes))) + w.WriteHeader(http.StatusOK) + // nosemgrep: go.lang.security.audit.xss.no-direct-write-to-responsewriter.no-direct-write-to-responsewriter + // Binary COSE_Sign1 receipt (application/scitt-receipt+cose); not HTML, no user-controlled input. + _, _ = w.Write(rec.Bytes) //nolint:gosec // G705: binary receipt body, no XSS surface +} + +// GetIdentityAgents handles GET /v1/identities/{identityId}/agents — +// the reverse join: the agents this identity currently links to, +// each decorated with its own computed badge status so a reader +// checks both ends of the link in one response. +func (h *Handlers) GetIdentityAgents(w http.ResponseWriter, r *http.Request) { + identityID := chi.URLParam(r, "identityId") + if identityID == "" { + writeError(w, domain.NewValidationError("MISSING_IDENTITY_ID", "identityId is required")) + return + } + agents, err := h.identityBadge.LinkedAgentsForIdentity(r.Context(), identityID) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"agents": agents}) +} + // GetAudit handles GET /v1/agents/{agentId}/audit. Matches the // reference TransparencyLogAudit shape — { records: [TransparencyLog, ...] }. func (h *Handlers) GetAudit(w http.ResponseWriter, r *http.Request) { diff --git a/internal/tl/handler/handler_identity_test.go b/internal/tl/handler/handler_identity_test.go new file mode 100644 index 0000000..18b79ce --- /dev/null +++ b/internal/tl/handler/handler_identity_test.go @@ -0,0 +1,407 @@ +package handler_test + +// Integration tests for the identity event family through the full +// TL HTTP surface: the /v1/internal/identities/event ingest lane, +// the identity badge/audit/receipt/agents reads, the agent badge's +// computed identities[] join, and the cross-lane guards. + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + identityevent "github.com/godaddy/ans/internal/tl/event/identity" + receiptpkg "github.com/godaddy/ans/internal/tl/receipt" +) + +const testIdentityID = "01HXKQTESTIDENTITY0000000A" + +// identityInner returns a valid identity event of the given type, +// keyed to the testbed's identity fixture. timestamps vary per call +// site so dedup never collides across events in one test; proof +// events name their verification method by the given kid so the +// read-join's provenKeyIds visibly flips on rotation. +func identityInner(typ identityevent.Type, ts string, ansIDs []string, kid string) identityevent.Event { + ev := identityevent.Event{ + EventType: typ, + IdentityID: testIdentityID, + Kind: "did:web", + Value: "did:web:identity.acme-corp.com", + RaID: "ra-test-1", + Timestamp: ts, + } + switch typ { + case identityevent.TypeIdentityVerified, identityevent.TypeIdentityUpdated: + ev.ProviderID = "PID-1" + ev.ProofMethod = "did-web-sig" + ev.VerifiedAt = ts + ev.Keys = []identityevent.ProvenKey{{ + VerificationMethod: json.RawMessage(`{"id":"` + kid + `","type":"JsonWebKey2020","controller":"did:web:identity.acme-corp.com","publicKeyJwk":{"crv":"Ed25519","kty":"OKP","x":"abc"}}`), + SignedProof: "eyJhbGciOiJFZERTQSJ9.p.s", + }} + case identityevent.TypeIdentityRevoked: + ev.RevokedAt = ts + case identityevent.TypeIdentityLinked, identityevent.TypeIdentityUnlinked: + ev.AnsIDs = ansIDs + } + return ev +} + +// postIdentityEvent signs and posts an identity event body to the +// identity ingest lane, asserting 200. +func postIdentityEvent(t *testing.T, tb *tlTestbed, ev identityevent.Event) { + t.Helper() + body := []byte(mustJSON(t, ev)) + rec := tb.postTo(t, "/v1/internal/identities/event", body, tb.signWithProducer(t, body)) + if rec.Code != http.StatusOK { + t.Fatalf("identity ingest: got %d, body=%s", rec.Code, rec.Body) + } +} + +func getJSON(t *testing.T, tb *tlTestbed, path string, out any) int { + t.Helper() + req := httptest.NewRequest(http.MethodGet, path, nil) + rec := httptest.NewRecorder() + tb.router.ServeHTTP(rec, req) + if rec.Code == http.StatusOK && out != nil { + if err := json.Unmarshal(rec.Body.Bytes(), out); err != nil { + t.Fatalf("decode %s: %v (body=%s)", path, err, rec.Body) + } + } + return rec.Code +} + +// badgeView decodes the subset of the badge/audit responses the +// tests assert on. +type badgeView struct { + SchemaVersion string `json:"schemaVersion"` + Status string `json:"status"` + Signature string `json:"signature"` + Identities []struct { + IdentityID string `json:"identityId"` + Kind string `json:"kind"` + Value string `json:"value"` + IdentityStatus string `json:"identityStatus"` + ProvenKeyIDs []string `json:"provenKeyIds"` + LinkedAt string `json:"linkedAt"` + LinkLogID string `json:"linkLogId"` + IdentityLogID string `json:"identityLogId"` + } `json:"identities"` +} + +// auditView decodes the subset of audit responses the stages assert +// on. +type auditView struct { + Records []struct { + Payload struct { + Producer struct { + Event struct { + EventType string `json:"eventType"` + } `json:"event"` + } `json:"producer"` + } `json:"payload"` + } `json:"records"` +} + +// TestIdentityLifecycle_EndToEnd drives the whole identity read +// surface through real ingests: verify → link → rotate → revoke → +// unlink, asserting the computed joins after each stage. +func TestIdentityLifecycle_EndToEnd(t *testing.T) { + tb := newTLTestbed(t) + + // Seed the agent the identity will link to (the testbed's agent + // fixture) so the agent badge exists. + agentBody := []byte(mustJSON(t, tb.inner)) + tb.postEvent(t, agentBody, tb.signWithProducer(t, agentBody)) + agentID := tb.inner.AnsID + + stageVerify(t, tb, agentID) + stageLink(t, tb, agentID) + stageRotate(t, tb, agentID) + stageRevoke(t, tb, agentID) + stageUnlink(t, tb, agentID) +} + +// stageVerify seals IDENTITY_VERIFIED and checks the identity badge +// plus the (still identity-free) agent badge. +func stageVerify(t *testing.T, tb *tlTestbed, agentID string) { + t.Helper() + postIdentityEvent(t, tb, + identityInner(identityevent.TypeIdentityVerified, "2026-06-10T10:00:00Z", nil, "did:web:identity.acme-corp.com#key-1")) + + var idBadge badgeView + if code := getJSON(t, tb, "/v1/identities/"+testIdentityID, &idBadge); code != http.StatusOK { + t.Fatalf("identity badge: %d", code) + } + if idBadge.Status != "VERIFIED" { + t.Fatalf("identity status = %q, want VERIFIED", idBadge.Status) + } + if idBadge.SchemaVersion != "V2" || idBadge.Signature == "" { + t.Fatalf("identity badge missing schema/attestation: %+v", idBadge) + } + + var agentBadge badgeView + if code := getJSON(t, tb, "/v1/agents/"+agentID, &agentBadge); code != http.StatusOK { + t.Fatalf("agent badge: %d", code) + } + if len(agentBadge.Identities) != 0 { + t.Fatalf("agent badge identities before link: %+v", agentBadge.Identities) + } +} + +// stageLink seals IDENTITY_LINKED and checks the join in both +// directions. +func stageLink(t *testing.T, tb *tlTestbed, agentID string) { + t.Helper() + postIdentityEvent(t, tb, + identityInner(identityevent.TypeIdentityLinked, "2026-06-10T11:00:00Z", []string{agentID}, "")) + + var agentBadge badgeView + if code := getJSON(t, tb, "/v1/agents/"+agentID, &agentBadge); code != http.StatusOK { + t.Fatalf("agent badge after link: %d", code) + } + if len(agentBadge.Identities) != 1 { + t.Fatalf("agent badge identities after link: %+v", agentBadge.Identities) + } + got := agentBadge.Identities[0] + if got.IdentityID != testIdentityID || got.IdentityStatus != "VERIFIED" || + got.Kind != "did:web" || got.Value != "did:web:identity.acme-corp.com" { + t.Fatalf("join entry wrong: %+v", got) + } + if len(got.ProvenKeyIDs) != 1 || got.ProvenKeyIDs[0] != "did:web:identity.acme-corp.com#key-1" { + t.Fatalf("provenKeyIds = %v", got.ProvenKeyIDs) + } + if got.LinkedAt != "2026-06-10T11:00:00Z" || got.LinkLogID == "" || got.IdentityLogID == "" { + t.Fatalf("link evidence fields missing: %+v", got) + } + + // The standalone computed view matches the badge join. + var identitiesResp struct { + Identities []json.RawMessage `json:"identities"` + } + if code := getJSON(t, tb, "/v1/agents/"+agentID+"/identities", &identitiesResp); code != http.StatusOK { + t.Fatalf("agent identities view: %d", code) + } + if len(identitiesResp.Identities) != 1 { + t.Fatalf("agent identities view count = %d", len(identitiesResp.Identities)) + } + + // Reverse join: identity → agents, with the agent's own status. + var agentsResp struct { + Agents []struct { + AnsID string `json:"ansId"` + AgentStatus string `json:"agentStatus"` + LinkedAt string `json:"linkedAt"` + } `json:"agents"` + } + if code := getJSON(t, tb, "/v1/identities/"+testIdentityID+"/agents", &agentsResp); code != http.StatusOK { + t.Fatalf("identity agents view: %d", code) + } + if len(agentsResp.Agents) != 1 || agentsResp.Agents[0].AnsID != agentID || + agentsResp.Agents[0].AgentStatus != "ACTIVE" { + t.Fatalf("reverse join: %+v", agentsResp.Agents) + } +} + +// stageRotate seals IDENTITY_UPDATED and checks the proven-key flip +// plus the stream-purity invariants on both audits. +func stageRotate(t *testing.T, tb *tlTestbed, agentID string) { + t.Helper() + postIdentityEvent(t, tb, + identityInner(identityevent.TypeIdentityUpdated, "2026-06-10T12:00:00Z", nil, "did:web:identity.acme-corp.com#key-2")) + + var agentBadge badgeView + if code := getJSON(t, tb, "/v1/agents/"+agentID, &agentBadge); code != http.StatusOK { + t.Fatalf("agent badge after rotation: %d", code) + } + if ids := agentBadge.Identities[0].ProvenKeyIDs; len(ids) != 1 || ids[0] != "did:web:identity.acme-corp.com#key-2" { + t.Fatalf("rotation not visible on linked badge: %v", ids) + } + + // The agent's own audit history stays purely AGENT_* — identity + // operations never write to the agent stream. + var audit auditView + if code := getJSON(t, tb, "/v1/agents/"+agentID+"/audit", &audit); code != http.StatusOK { + t.Fatalf("agent audit: %d", code) + } + if len(audit.Records) != 1 || audit.Records[0].Payload.Producer.Event.EventType != "AGENT_REGISTERED" { + t.Fatalf("agent audit polluted by identity ops: %+v", audit.Records) + } + + // Identity audit carries the full chain, newest first. + if code := getJSON(t, tb, "/v1/identities/"+testIdentityID+"/audit", &audit); code != http.StatusOK { + t.Fatalf("identity audit: %d", code) + } + if len(audit.Records) != 3 { + t.Fatalf("identity audit count = %d, want 3", len(audit.Records)) + } + if audit.Records[0].Payload.Producer.Event.EventType != "IDENTITY_UPDATED" { + t.Fatalf("identity audit order: %+v", audit.Records[0]) + } + + // Association history for the agent: the standard audit envelope + // filtered to link events naming it. + if code := getJSON(t, tb, "/v1/agents/"+agentID+"/identities/history", &audit); code != http.StatusOK { + t.Fatalf("identity history: %d", code) + } + if len(audit.Records) != 1 || audit.Records[0].Payload.Producer.Event.EventType != "IDENTITY_LINKED" { + t.Fatalf("identity history: %+v", audit.Records) + } +} + +// stageRevoke seals IDENTITY_REVOKED: one event, every linked badge +// reflects it; the agent itself is untouched. +func stageRevoke(t *testing.T, tb *tlTestbed, agentID string) { + t.Helper() + postIdentityEvent(t, tb, + identityInner(identityevent.TypeIdentityRevoked, "2026-06-10T13:00:00Z", nil, "")) + + var idBadge badgeView + if code := getJSON(t, tb, "/v1/identities/"+testIdentityID, &idBadge); code != http.StatusOK { + t.Fatalf("identity badge after revoke: %d", code) + } + if idBadge.Status != "REVOKED" { + t.Fatalf("identity status after revoke = %q", idBadge.Status) + } + var agentBadge badgeView + if code := getJSON(t, tb, "/v1/agents/"+agentID, &agentBadge); code != http.StatusOK { + t.Fatalf("agent badge after revoke: %d", code) + } + if len(agentBadge.Identities) != 1 || agentBadge.Identities[0].IdentityStatus != "REVOKED" { + t.Fatalf("revocation not visible on linked badge: %+v", agentBadge.Identities) + } + // The agent itself is untouched (the what survives the who's + // revocation, and vice versa). + if agentBadge.Status != "ACTIVE" { + t.Fatalf("agent status changed by identity revocation: %s", agentBadge.Status) + } +} + +// stageUnlink seals IDENTITY_UNLINKED: the computed views empty out, +// the history retains both link events. +func stageUnlink(t *testing.T, tb *tlTestbed, agentID string) { + t.Helper() + postIdentityEvent(t, tb, + identityInner(identityevent.TypeIdentityUnlinked, "2026-06-10T14:00:00Z", []string{agentID}, "")) + + var unlinkedBadge badgeView + if code := getJSON(t, tb, "/v1/agents/"+agentID, &unlinkedBadge); code != http.StatusOK { + t.Fatalf("agent badge after unlink: %d", code) + } + if len(unlinkedBadge.Identities) != 0 { + t.Fatalf("identities after unlink: %+v", unlinkedBadge.Identities) + } + var audit auditView + if code := getJSON(t, tb, "/v1/agents/"+agentID+"/identities/history", &audit); code != http.StatusOK { + t.Fatalf("identity history after unlink: %d", code) + } + if len(audit.Records) != 2 { + t.Fatalf("identity history count after unlink = %d", len(audit.Records)) + } +} + +// TestIdentityIngest_CrossLaneGuards pins the 422s in both +// directions: identity bodies on the agent lanes, agent bodies on +// the identity lane. +func TestIdentityIngest_CrossLaneGuards(t *testing.T) { + tb := newTLTestbed(t) + + idBody := []byte(mustJSON(t, identityInner( + identityevent.TypeIdentityVerified, "2026-06-10T10:00:00Z", nil, "did:web:identity.acme-corp.com#key-1"))) + + // Identity body on the V2 agent lane → 422. + if rec := tb.postTo(t, "/v2/internal/agents/event", idBody, tb.signWithProducer(t, idBody)); rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("identity body on V2 agent lane: got %d", rec.Code) + } + // Identity body on the frozen V1 lane → 422. + if rec := tb.postTo(t, "/v1/internal/agents/event", idBody, tb.signWithProducer(t, idBody)); rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("identity body on V1 agent lane: got %d", rec.Code) + } + // Agent body on the identity lane → 422. + agentBody := []byte(mustJSON(t, tb.inner)) + if rec := tb.postTo(t, "/v1/internal/identities/event", agentBody, tb.signWithProducer(t, agentBody)); rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("agent body on identity lane: got %d", rec.Code) + } + // Missing producer signature → 422. + if rec := tb.postTo(t, "/v1/internal/identities/event", idBody, ""); rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("missing signature on identity lane: got %d", rec.Code) + } +} + +// TestIdentityIngest_Duplicate pins idempotent retries on the +// identity lane: same canonical bytes → 200 + duplicate flag. +func TestIdentityIngest_Duplicate(t *testing.T) { + tb := newTLTestbed(t) + body := []byte(mustJSON(t, identityInner( + identityevent.TypeIdentityVerified, "2026-06-10T10:00:00Z", nil, "did:web:identity.acme-corp.com#key-1"))) + jws := tb.signWithProducer(t, body) + + first := tb.postTo(t, "/v1/internal/identities/event", body, jws) + if first.Code != http.StatusOK { + t.Fatalf("first append: %d body=%s", first.Code, first.Body) + } + second := tb.postTo(t, "/v1/internal/identities/event", body, jws) + if second.Code != http.StatusOK { + t.Fatalf("retry: %d", second.Code) + } + var resp struct { + Duplicate bool `json:"duplicate"` + Message string `json:"message"` + } + _ = json.Unmarshal(second.Body.Bytes(), &resp) + if !resp.Duplicate || resp.Message != "Event already logged" { + t.Fatalf("retry not flagged duplicate: %+v", resp) + } +} + +// TestIdentityReceipt verifies the identity receipt is a real +// COSE_Sign1 that offline-verifies against the TL's public key. +func TestIdentityReceipt(t *testing.T) { + tb := newTLTestbed(t) + postIdentityEvent(t, tb, + identityInner(identityevent.TypeIdentityVerified, "2026-06-10T10:00:00Z", nil, "did:web:identity.acme-corp.com#key-1")) + + var rec *httptest.ResponseRecorder + for range 50 { + rec = httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/v1/identities/"+testIdentityID+"/receipt", nil) + tb.router.ServeHTTP(rec, req) + if rec.Code == http.StatusOK { + break + } + time.Sleep(50 * time.Millisecond) + } + if rec.Code != http.StatusOK { + t.Fatalf("never got 200; last status=%d body=%s", rec.Code, rec.Body) + } + if ct := rec.Header().Get("Content-Type"); ct != receiptpkg.MediaType { + t.Errorf("content-type: got %q want %q", ct, receiptpkg.MediaType) + } + if err := receiptpkg.VerifyWithPEM(rec.Body.Bytes(), string(tb.signPubPEM)); err != nil { + t.Errorf("offline verify: %v", err) + } +} + +// TestIdentityReads_NotFound pins 404s for unknown identities across +// the read surface. +func TestIdentityReads_NotFound(t *testing.T) { + tb := newTLTestbed(t) + for _, path := range []string{ + "/v1/identities/unknown-id", + "/v1/identities/unknown-id/receipt", + "/v1/identities/unknown-id/agents", + } { + if code := getJSON(t, tb, path, nil); code != http.StatusNotFound { + t.Errorf("%s: got %d, want 404", path, code) + } + } + // Audit and the agent-side views are list-shaped: empty lists. + var audit struct { + Records []json.RawMessage `json:"records"` + } + if code := getJSON(t, tb, "/v1/identities/unknown-id/audit", &audit); code != http.StatusOK || len(audit.Records) != 0 { + t.Errorf("unknown identity audit: code=%d records=%d", code, len(audit.Records)) + } +} diff --git a/internal/tl/handler/handler_test.go b/internal/tl/handler/handler_test.go index 66cd8ff..901a97f 100644 --- a/internal/tl/handler/handler_test.go +++ b/internal/tl/handler/handler_test.go @@ -887,6 +887,7 @@ func newTLTestbed(t *testing.T) *tlTestbed { _ = lg.Close(cctx) }) badgeSvc := service.NewBadgeService(logSvc) + identityBadgeSvc := service.NewIdentityBadgeService(logSvc, badgeSvc) receiptGen, err := receiptpkg.NewKeyManagerGenerator( context.Background(), tlKM, "tl-sign", "ans-test", ) @@ -915,7 +916,7 @@ func newTLTestbed(t *testing.T) *tlTestbed { // 6. Router. r := chi.NewRouter() h := handler.NewHandlers( - logSvc, badgeSvc, receiptSvc, statusSvc, + logSvc, badgeSvc, identityBadgeSvc, receiptSvc, statusSvc, checkpointSvc, schemaSvc, rootKeysBody, ) h.Mount(r, lg.DataDir()) diff --git a/internal/tl/service/badge.go b/internal/tl/service/badge.go index f9751b4..6d6e331 100644 --- a/internal/tl/service/badge.go +++ b/internal/tl/service/badge.go @@ -43,6 +43,14 @@ type TransparencyLog struct { SchemaVersion string `json:"schemaVersion,omitempty"` Signature string `json:"signature,omitempty"` Status BadgeStatus `json:"status,omitempty"` + + // Identities is the computed read-time join of the agent's + // currently-linked verified identities — populated on the agent + // badge only (never on audit entries, never sealed). The handler + // composes it from the IdentityBadgeService so this service stays + // single-purpose. Covered by the TL's response signature, not by + // any seal: link facts live on the identity stream. + Identities []*LinkedIdentityView `json:"identities,omitempty"` } // BadgeService computes the badge from the latest mirrored event diff --git a/internal/tl/service/codec.go b/internal/tl/service/codec.go index 392609a..478ec38 100644 --- a/internal/tl/service/codec.go +++ b/internal/tl/service/codec.go @@ -6,6 +6,7 @@ import ( "github.com/godaddy/ans/internal/domain" "github.com/godaddy/ans/internal/tl/event" + identityevent "github.com/godaddy/ans/internal/tl/event/identity" eventv1 "github.com/godaddy/ans/internal/tl/event/v1" ) @@ -103,3 +104,44 @@ func (v1Codec) ParseAndBuild( env := eventv1.BuildEnvelope(logID, &inner, keyID, sig) return env, canonical, nil } + +// identityCodec implements envelopeCodec for the identity event +// family — the shape the RA's `/v2/ans/identities/*` routes emit to +// the `/v1/internal/identities/event` ingest lane. Same producer +// lane, same tree; the inner event is keyed by identityId. +// +// The cross-lane guard lives in the closed enums: an AGENT_* body +// fails this codec's `inner.Validate()` (unknown eventType, missing +// identityId) with 422 INVALID_EVENT, exactly as an IDENTITY_* body +// fails the agent codecs. +type identityCodec struct{} + +// ParseAndBuild unmarshals raw into `identityevent.Event`, validates +// it, stamps the verified raID, canonicalizes, and wraps in an +// identity envelope. +func (identityCodec) ParseAndBuild( + raw []byte, + raID, keyID, sig, logID string, +) (event.Signable, []byte, error) { + var inner identityevent.Event + if err := json.Unmarshal(raw, &inner); err != nil { + return nil, nil, domain.NewValidationError("INVALID_EVENT_BODY", err.Error()) + } + if err := inner.Validate(); err != nil { + return nil, nil, domain.NewValidationError("INVALID_EVENT", err.Error()) + } + if inner.RaID == "" { + inner.RaID = raID + } else if inner.RaID != raID { + return nil, nil, domain.NewValidationError( + "RAID_MISMATCH", + fmt.Sprintf("inner event raId %q does not match signed raId %q", inner.RaID, raID), + ) + } + canonical, err := identityevent.CanonicalizeEvent(&inner) + if err != nil { + return nil, nil, fmt.Errorf("service: canonicalize identity inner: %w", err) + } + env := identityevent.BuildEnvelope(logID, &inner, keyID, sig) + return env, canonical, nil +} diff --git a/internal/tl/service/codec_identity_test.go b/internal/tl/service/codec_identity_test.go new file mode 100644 index 0000000..10ec6ba --- /dev/null +++ b/internal/tl/service/codec_identity_test.go @@ -0,0 +1,117 @@ +package service + +// White-box unit tests for the identity envelope codec, including the +// cross-lane guards: agent-shaped bodies fail this codec's closed +// enum, and identity-shaped bodies fail the V1/V2 agent codecs — both +// directions reject with INVALID_EVENT before anything touches the +// tree. + +import ( + "strings" + "testing" +) + +const validIdentityBody = `{ + "identityId":"01HXKQ00000000000000000000", + "kind":"did:web", + "value":"did:web:identity.acme-corp.com", + "providerId":"PID-8294", + "proofMethod":"did-web-sig", + "eventType":"IDENTITY_VERIFIED", + "keys":[{ + "verificationMethod":{ + "id":"did:web:identity.acme-corp.com#key-1", + "type":"JsonWebKey2020", + "controller":"did:web:identity.acme-corp.com", + "publicKeyJwk":{"kty":"OKP","crv":"Ed25519","x":"abc"} + }, + "signedProof":"eyJhbGciOiJFZERTQSJ9.p.s" + }], + "verifiedAt":"2026-06-10T15:04:05Z", + "raId":"ra-test", + "timestamp":"2026-06-10T15:04:05Z" +}` + +func TestIdentityCodec_ParseAndBuild_HappyPath(t *testing.T) { + t.Parallel() + env, canonical, err := identityCodec{}.ParseAndBuild( + []byte(validIdentityBody), "ra-test", "kid-1", "sig", "log-id") + if err != nil { + t.Fatalf("ParseAndBuild: %v", err) + } + if env == nil { + t.Fatal("nil envelope") + } + if len(canonical) == 0 { + t.Fatal("empty canonical bytes") + } + if env.EventType() != "IDENTITY_VERIFIED" { + t.Fatalf("EventType = %q", env.EventType()) + } +} + +func TestIdentityCodec_ParseAndBuild_BadJSON(t *testing.T) { + t.Parallel() + _, _, err := identityCodec{}.ParseAndBuild( + []byte("{not json"), "ra-test", "k", "s", "l") + if err == nil { + t.Error("expected error for malformed JSON") + } +} + +func TestIdentityCodec_ParseAndBuild_RaidMismatch(t *testing.T) { + t.Parallel() + _, _, err := identityCodec{}.ParseAndBuild( + []byte(validIdentityBody), "ra-other", "k", "s", "l") + if err == nil || !strings.Contains(err.Error(), "raId") { + t.Fatalf("expected RAID_MISMATCH error, got %v", err) + } +} + +func TestIdentityCodec_ParseAndBuild_StampsBlankRAID(t *testing.T) { + t.Parallel() + body := strings.Replace(validIdentityBody, `"raId":"ra-test",`, "", 1) + env, _, err := identityCodec{}.ParseAndBuild( + []byte(body), "ra-verified", "k", "s", "l") + if err != nil { + t.Fatalf("ParseAndBuild: %v", err) + } + if env == nil { + t.Fatal("nil envelope") + } +} + +// TestIdentityCodec_RejectsAgentBody is one half of the cross-lane +// guard: a V2 agent event posted to the identity ingest lane fails +// the identity codec's closed enum + required identityId. +func TestIdentityCodec_RejectsAgentBody(t *testing.T) { + t.Parallel() + agentBody := []byte(`{ + "ansId":"10000000-0000-4000-8000-000000000001", + "ansName":"ans://v1.0.0.agent.example.com", + "eventType":"AGENT_REGISTERED", + "timestamp":"2026-04-17T00:00:00Z" + }`) + _, _, err := identityCodec{}.ParseAndBuild(agentBody, "ra-test", "k", "s", "l") + if err == nil { + t.Fatal("agent body must fail the identity codec") + } + if !strings.Contains(err.Error(), "INVALID_EVENT") && !strings.Contains(err.Error(), "eventType") { + t.Fatalf("expected INVALID_EVENT for agent body, got %v", err) + } +} + +// TestAgentCodecs_RejectIdentityBody is the other half: an identity +// event posted to either agent lane fails the agent codecs (unknown +// eventType for the closed agent enums + missing ansId). +func TestAgentCodecs_RejectIdentityBody(t *testing.T) { + t.Parallel() + if _, _, err := (v2Codec{}).ParseAndBuild( + []byte(validIdentityBody), "ra-test", "k", "s", "l"); err == nil { + t.Fatal("identity body must fail the V2 agent codec") + } + if _, _, err := (v1Codec{}).ParseAndBuild( + []byte(validIdentityBody), "ra-test", "k", "s", "l"); err == nil { + t.Fatal("identity body must fail the V1 agent codec — the V1 lane is frozen") + } +} diff --git a/internal/tl/service/identitybadge.go b/internal/tl/service/identitybadge.go new file mode 100644 index 0000000..f1816c0 --- /dev/null +++ b/internal/tl/service/identitybadge.go @@ -0,0 +1,306 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + + sqlitetl "github.com/godaddy/ans/internal/adapter/store/sqlitetl" +) + +// Identity badge statuses. Identities have a two-state read-time +// status (unlike agents, which derive WARNING/EXPIRED from attested +// cert expiry): the stream either ends in a revocation or it doesn't. +const ( + // BadgeVerified — the identity's control proof stands. + BadgeVerified BadgeStatus = "VERIFIED" + // BadgeIdentityRevoked — the identity stream ends in + // IDENTITY_REVOKED. (BadgeRevoked already names the agent label; + // identities share the wire value.) + BadgeIdentityRevoked BadgeStatus = "REVOKED" +) + +// LinkedIdentityView is one entry of the computed identities[] join +// on the agent badge (and the /v1/agents/{agentId}/identities view). +// Everything here is computed at query time from the identity stream +// — never stored on, or sealed into, the agent. The view is covered +// by the TL's response signature, not by any seal. +type LinkedIdentityView struct { + IdentityID string `json:"identityId"` + Kind string `json:"kind"` + Value string `json:"value"` + IdentityStatus string `json:"identityStatus"` // VERIFIED | REVOKED — reflects the identity stream NOW + + // ProvenKeyIDs names the identity's current proven key set — the + // verification-method ids from the latest proof event + // (post-rotation). The full verbatim verification methods live in + // the sealed event; thumbprints are compute-at-read conveniences + // derivable from that sealed source. + ProvenKeyIDs []string `json:"provenKeyIds,omitempty"` + + // LinkedAt is the producer timestamp of the sealed + // IDENTITY_LINKED event that bound this agent. + LinkedAt string `json:"linkedAt,omitempty"` + + // LinkLogID points at the sealed IDENTITY_LINKED entry on the + // identity stream — fetch it for link evidence. + LinkLogID string `json:"linkLogId,omitempty"` + + // IdentityLogID points at the latest identity-stream entry — + // fetch it (or the audit) for the identity evidence/history. + IdentityLogID string `json:"identityLogId,omitempty"` +} + +// LinkedAgentView is one entry of the reverse join — the agents an +// identity currently links to (GET /v1/identities/{id}/agents). +type LinkedAgentView struct { + AnsID string `json:"ansId"` + LinkedAt string `json:"linkedAt,omitempty"` + // AgentStatus is the linked agent's own computed badge status — + // a link is *effective* only while both ends are live, and this + // field is how a reader checks the agent end in the same hop. + AgentStatus BadgeStatus `json:"agentStatus,omitempty"` +} + +// IdentityBadgeService serves the identity read surface: the identity +// badge, audit chain, and the computed joins in both directions. It +// reuses the agent badge's TransparencyLog response shape — audits +// stay audits, one format. +type IdentityBadgeService struct { + log *LogService + badge *BadgeService +} + +// NewIdentityBadgeService constructs an IdentityBadgeService. The +// BadgeService is used for the reverse join's per-agent status (a +// link is effective only while both ends are live). +func NewIdentityBadgeService(log *LogService, badge *BadgeService) *IdentityBadgeService { + return &IdentityBadgeService{log: log, badge: badge} +} + +// Get returns the TransparencyLog view of an identity's most recent +// event — the identity badge. +func (s *IdentityBadgeService) Get(ctx context.Context, identityID string) (*TransparencyLog, error) { + rec, err := s.log.LatestEventByIdentity(ctx, identityID) + if err != nil { + return nil, err + } + return s.buildTransparencyLog(ctx, rec) +} + +// Audit returns the identity's full event chain, paginated, in the +// exact same audit envelope as the agent audit — no bespoke format. +func (s *IdentityBadgeService) Audit(ctx context.Context, identityID string, limit, offset int) ([]*TransparencyLog, error) { + recs, err := s.log.EventsByIdentity(ctx, identityID, limit, offset) + if err != nil { + return nil, err + } + out := make([]*TransparencyLog, 0, len(recs)) + for _, rec := range recs { + tl, err := s.buildTransparencyLog(ctx, rec) + if err != nil { + return nil, err + } + out = append(out, tl) + } + return out, nil +} + +// buildTransparencyLog assembles the response for one identity-stream +// record: parsed envelope wrapper + Merkle proof + computed status. +func (s *IdentityBadgeService) buildTransparencyLog(ctx context.Context, rec *sqlitetl.EventRecord) (*TransparencyLog, error) { + wrapper, err := parseEnvelopeWrapper(rec.RawEvent) + if err != nil { + return nil, err + } + proof, perr := BuildMerkleProof(ctx, s.log.log, rec) + if perr != nil && !errors.Is(perr, ErrProofLeafNotCovered) { + proof = nil + } + schema := rec.SchemaVersion + if schema == "" { + schema = wrapper.SchemaVersion + } + return &TransparencyLog{ + MerkleProof: proof, + Payload: wrapper.Payload, + SchemaVersion: schema, + Signature: wrapper.Signature, + Status: identityStatusFromEventType(rec.EventType), + }, nil +} + +// identityStatusFromEventType derives the identity's read-time +// status from its latest event. REVOKED is terminal — the RA seals +// no identity events after a revocation — so "latest event is +// IDENTITY_REVOKED" is exactly "the identity is revoked", the same +// latest-event discipline the agent badge uses for AGENT_REVOKED. +func identityStatusFromEventType(eventType string) BadgeStatus { + if eventType == string(sqlitetlIdentityRevoked) { + return BadgeIdentityRevoked + } + return BadgeVerified +} + +// sqlitetlIdentityRevoked mirrors identityevent.TypeIdentityRevoked +// without importing the event package for one string. +const sqlitetlIdentityRevoked = "IDENTITY_REVOKED" + +// LinkedIdentitiesForAgent computes the identities[] join for an +// agent badge: every identity whose latest link/unlink fact naming +// this agent is LINKED, decorated with that identity's current +// stream state. Revoked identities stay in the list with +// identityStatus REVOKED — the rotation/revocation visibility on +// every linked badge is the point of the read-time join. +func (s *IdentityBadgeService) LinkedIdentitiesForAgent(ctx context.Context, ansID string) ([]*LinkedIdentityView, error) { + states, err := s.log.LinkStatesByAgent(ctx, ansID) + if err != nil { + return nil, err + } + out := make([]*LinkedIdentityView, 0, len(states)) + for _, st := range states { + if !st.Linked() { + continue + } + view, err := s.linkedIdentityView(ctx, st) + if err != nil { + return nil, err + } + out = append(out, view) + } + return out, nil +} + +// linkedIdentityView decorates one live link with the identity's +// current state: latest event (status + identityLogId), latest proof +// (kind/value/thumbprints), and the sealed link event (linkedAt + +// linkLogId). +func (s *IdentityBadgeService) linkedIdentityView(ctx context.Context, st *sqlitetl.LinkState) (*LinkedIdentityView, error) { + view := &LinkedIdentityView{IdentityID: st.IdentityID} + + linkRec, err := s.log.EventByLeafIndex(ctx, st.LeafIndex) + if err != nil { + return nil, err + } + view.LinkLogID = linkRec.LogID + view.LinkedAt = innerTimestamp(linkRec.RawEvent) + + latest, err := s.log.LatestEventByIdentity(ctx, st.IdentityID) + if err != nil { + return nil, err + } + view.IdentityLogID = latest.LogID + view.IdentityStatus = string(identityStatusFromEventType(latest.EventType)) + + proof, err := s.log.LatestProofByIdentity(ctx, st.IdentityID) + if err != nil { + return nil, err + } + kind, value, keyIDs := proofSummary(proof.RawEvent) + view.Kind = kind + view.Value = value + view.ProvenKeyIDs = keyIDs + return view, nil +} + +// LinkedAgentsForIdentity computes the reverse join: the agents this +// identity currently links to, each with its own computed badge +// status so a reader checks both ends of the link in one response. +func (s *IdentityBadgeService) LinkedAgentsForIdentity(ctx context.Context, identityID string) ([]*LinkedAgentView, error) { + // 404 on an unknown identity (parity with the badge route) — + // otherwise every random id would return an empty 200 list. + if _, err := s.log.LatestEventByIdentity(ctx, identityID); err != nil { + return nil, err + } + states, err := s.log.LinkStatesByIdentity(ctx, identityID) + if err != nil { + return nil, err + } + out := make([]*LinkedAgentView, 0, len(states)) + for _, st := range states { + if !st.Linked() { + continue + } + view := &LinkedAgentView{AnsID: st.AnsID} + if linkRec, err := s.log.EventByLeafIndex(ctx, st.LeafIndex); err == nil { + view.LinkedAt = innerTimestamp(linkRec.RawEvent) + } + if agentTL, err := s.badge.Get(ctx, st.AnsID); err == nil { + view.AgentStatus = agentTL.Status + } + out = append(out, view) + } + return out, nil +} + +// LinkHistoryForAgent returns the link/unlink events that ever named +// this agent, in the standard audit envelope — the +// /v1/agents/{agentId}/identities/history view. No bespoke format: +// each record is the same TransparencyLog shape as every audit entry, +// filtered through the agent index. +func (s *IdentityBadgeService) LinkHistoryForAgent(ctx context.Context, ansID string, limit, offset int) ([]*TransparencyLog, error) { + recs, err := s.log.LinkEventsByAgent(ctx, ansID, limit, offset) + if err != nil { + return nil, err + } + out := make([]*TransparencyLog, 0, len(recs)) + for _, rec := range recs { + tl, err := s.buildTransparencyLog(ctx, rec) + if err != nil { + return nil, err + } + out = append(out, tl) + } + return out, nil +} + +// innerTimestamp drills the producer timestamp out of a stored +// envelope without binding to a concrete inner-event type. +func innerTimestamp(rawEvent string) string { + var w struct { + Payload struct { + Producer struct { + Event struct { + Timestamp string `json:"timestamp"` + } `json:"event"` + } `json:"producer"` + } `json:"payload"` + } + if err := json.Unmarshal([]byte(rawEvent), &w); err != nil { + return "" + } + return w.Payload.Producer.Event.Timestamp +} + +// proofSummary drills kind, value, and the proven verification-method +// ids out of a stored proof event (IDENTITY_VERIFIED / +// IDENTITY_UPDATED). The ids come from the sealed verbatim +// verification methods. +func proofSummary(rawEvent string) (string, string, []string) { + var w struct { + Payload struct { + Producer struct { + Event struct { + Kind string `json:"kind"` + Value string `json:"value"` + Keys []struct { + VerificationMethod struct { + ID string `json:"id"` + } `json:"verificationMethod"` + } `json:"keys"` + } `json:"event"` + } `json:"producer"` + } `json:"payload"` + } + if err := json.Unmarshal([]byte(rawEvent), &w); err != nil { + return "", "", nil + } + ev := w.Payload.Producer.Event + ids := make([]string, 0, len(ev.Keys)) + for _, k := range ev.Keys { + if k.VerificationMethod.ID != "" { + ids = append(ids, k.VerificationMethod.ID) + } + } + return ev.Kind, ev.Value, ids +} diff --git a/internal/tl/service/log.go b/internal/tl/service/log.go index 7b709a2..3b95e55 100644 --- a/internal/tl/service/log.go +++ b/internal/tl/service/log.go @@ -21,6 +21,7 @@ import ( anscrypto "github.com/godaddy/ans/internal/crypto" "github.com/godaddy/ans/internal/port" "github.com/godaddy/ans/internal/tl/event" + identityevent "github.com/godaddy/ans/internal/tl/event/identity" eventv1 "github.com/godaddy/ans/internal/tl/event/v1" "github.com/godaddy/ans/internal/tl/logstore" ) @@ -138,6 +139,16 @@ func (s *LogService) AppendV1(ctx context.Context, in AppendInput) (*AppendResul return s.append(ctx, in, v1Codec{}) } +// AppendIdentity ingests an identity-family producer event +// (IDENTITY_*). Wired to the `POST /v1/internal/identities/event` +// route. Identity events ride the same producer-signature lane and +// land in the same Merkle tree as agent events; the dedicated route +// exists because the payload schema differs (keyed by identityId), +// and the codec's closed enum is the cross-lane guard. +func (s *LogService) AppendIdentity(ctx context.Context, in AppendInput) (*AppendResult, error) { + return s.append(ctx, in, identityCodec{}) +} + // append is the schema-agnostic ingest pipeline. The only per-version // steps (parse, canonicalize, wrap) are delegated to the codec; every // other step — producer-signature verify, dedup, TL attestation sign, @@ -258,6 +269,9 @@ func setOuterSignature(env event.Signable, sig string) error { case *eventv1.Envelope: e.Signature = sig return nil + case *identityevent.Envelope: + e.Signature = sig + return nil default: return fmt.Errorf("log: unknown envelope type %T", env) } @@ -278,6 +292,43 @@ func (s *LogService) EventByLeafIndex(ctx context.Context, idx uint64) (*sqlitet return s.events.GetEventByLeafIndex(ctx, idx) } +// LatestEventByIdentity returns the newest event on an identity's +// stream (the read index over the single log keyed by identityId). +func (s *LogService) LatestEventByIdentity(ctx context.Context, identityID string) (*sqlitetl.EventRecord, error) { + return s.events.GetLatestByIdentityID(ctx, identityID) +} + +// EventsByIdentity returns paginated events for an identity. +func (s *LogService) EventsByIdentity(ctx context.Context, identityID string, limit, offset int) ([]*sqlitetl.EventRecord, error) { + return s.events.GetByIdentityID(ctx, identityID, limit, offset) +} + +// LatestProofByIdentity returns the newest proof event +// (IDENTITY_VERIFIED / IDENTITY_UPDATED) for an identity — the event +// carrying the current proven key set, which the badge join surfaces +// as provenKeyThumbprints. +func (s *LogService) LatestProofByIdentity(ctx context.Context, identityID string) (*sqlitetl.EventRecord, error) { + return s.events.GetLatestProofByIdentityID(ctx, identityID) +} + +// LinkStatesByAgent returns the latest link/unlink fact per identity +// that ever named this agent. +func (s *LogService) LinkStatesByAgent(ctx context.Context, ansID string) ([]*sqlitetl.LinkState, error) { + return s.events.LinkStatesByAgent(ctx, ansID) +} + +// LinkStatesByIdentity returns the latest link/unlink fact per agent +// this identity ever named. +func (s *LogService) LinkStatesByIdentity(ctx context.Context, identityID string) ([]*sqlitetl.LinkState, error) { + return s.events.LinkStatesByIdentity(ctx, identityID) +} + +// LinkEventsByAgent returns the link/unlink events that ever named +// this agent — the per-agent association history. +func (s *LogService) LinkEventsByAgent(ctx context.Context, ansID string, limit, offset int) ([]*sqlitetl.EventRecord, error) { + return s.events.LinkEventsByAgent(ctx, ansID, limit, offset) +} + // LatestCheckpoint returns the most recent checkpoint Tessera has // written to disk, falling back to the DB cache on file errors. func (s *LogService) LatestCheckpoint(ctx context.Context) ([]byte, error) { diff --git a/internal/tl/service/receipt.go b/internal/tl/service/receipt.go index 67ff145..2b56458 100644 --- a/internal/tl/service/receipt.go +++ b/internal/tl/service/receipt.go @@ -74,6 +74,18 @@ func (s *ReceiptService) ForAgent(ctx context.Context, agentID string) (*Receipt return s.buildOrFetch(ctx, rec) } +// ForIdentity returns a receipt for the most recent event on an +// identity's stream — the same COSE_Sign1 machinery as agent +// receipts; receipts are leaf-scoped and don't care which read index +// found the leaf. +func (s *ReceiptService) ForIdentity(ctx context.Context, identityID string) (*Receipt, error) { + rec, err := s.log.LatestEventByIdentity(ctx, identityID) + if err != nil { + return nil, err + } + return s.buildOrFetch(ctx, rec) +} + // ForLeafIndex returns a receipt for a specific leaf. func (s *ReceiptService) ForLeafIndex(ctx context.Context, idx uint64) (*Receipt, error) { rec, err := s.log.EventByLeafIndex(ctx, idx) @@ -95,12 +107,13 @@ func (s *ReceiptService) buildOrFetch(ctx context.Context, rec *sqlitetl.EventRe return nil, err } - // Cache lookup by (leafIndex, treeSize). If a receipt was already - // minted for this exact pair it's byte-identical payload-wise - // (same event bytes + same proof); the CBOR signature isn't - // deterministic but the receipt is still cryptographically valid. + // Cache lookup by (leafIndex, treeSize) — the table's UNIQUE key. + // If a receipt was already minted for this exact pair it's + // byte-identical payload-wise (same event bytes + same proof); + // the CBOR signature isn't deterministic but the receipt is still + // cryptographically valid. treeSize := uint64(proof.TreeSize) //nolint:gosec // int64→uint64 always safe for tree sizes - if cached, cerr := s.receipts.FindByAgentID(ctx, rec.AgentID, treeSize); cerr == nil && cached != nil { + if cached, cerr := s.receipts.FindByLeafIndex(ctx, rec.LeafIndex, treeSize); cerr == nil && cached != nil { return &Receipt{ Bytes: cached.ReceiptBlob, ContentType: receipt.MediaType, @@ -143,8 +156,14 @@ func (s *ReceiptService) buildOrFetch(ctx context.Context, rec *sqlitetl.EventRe } // Cache best-effort — failure here doesn't prevent returning the - // receipt we just computed. The next request will recompute. - _ = s.receipts.Store(ctx, rec.LeafIndex, rec.AgentID, treeSize, coseBytes) + // receipt we just computed. The next request will recompute. The + // subject column is informational: the agent id for agent leaves, + // the identity id for identity leaves. + subjectID := rec.AgentID + if subjectID == "" { + subjectID = rec.IdentityID + } + _ = s.receipts.Store(ctx, rec.LeafIndex, subjectID, treeSize, coseBytes) return &Receipt{ Bytes: coseBytes, diff --git a/spec/api-spec-tl-v2.yaml b/spec/api-spec-tl-v2.yaml index 6dfbd9a..fa98ebc 100644 --- a/spec/api-spec-tl-v2.yaml +++ b/spec/api-spec-tl-v2.yaml @@ -259,6 +259,255 @@ paths: schema: $ref: '#/components/schemas/Problem' + # ────────────────────────────────────────────────────────────── + # Verified identities — the "who" behind agents + # + # There is ONE transparency log — a single Merkle tree. Identity + # events append to the same tree as agent events; the "identity + # stream" is a read index over that single log keyed by + # identityId, exactly as the agent surface is a read index keyed + # by ansId. Tiles/checkpoints/receipts/witnesses are unchanged. + # + # Placement rule: every IDENTITY_* event — proofs, rotations, + # revocations, AND links — is indexed under the identity stream. + # An identity operation never modifies an agent's event or audit + # history; the agent-side views below are read-time joins through + # the link events' agent index. + # ────────────────────────────────────────────────────────────── + /v1/internal/identities/event: + post: + tags: [Transparency Log] + summary: Append a signed identity event to the log + description: | + The identity-family ingest lane. Identical wire contract to + `/v1/internal/agents/event` — JCS-canonicalized inner event + body + detached-JWS `X-Signature`, same producer-key trust + store, same dedup — with the identity payload schema (keyed + by `identityId`, closed `IDENTITY_*` eventType enum). The + closed enums are the cross-lane guard: an `AGENT_*` body on + this route (or an `IDENTITY_*` body on an agent route) fails + validation with 422 `INVALID_EVENT`. + operationId: appendIdentityEvent + parameters: + - name: X-Signature + in: header + required: true + description: | + Detached JWS compact serialization over the raw request + body — same discipline as the agent lanes. + schema: + type: string + requestBody: + required: true + description: JCS-canonicalized inner identity event (≤ 256 KiB). + content: + application/json: + schema: + $ref: '#/components/schemas/IdentityProducerEvent' + responses: + '200': + description: Event logged (or duplicate echoed — idempotent retries). + content: + application/json: + schema: + $ref: '#/components/schemas/AppendResponse' + '413': + description: Request body exceeds 256 KiB. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + '422': + description: | + Body fails validation — same codes as the agent lanes, + including `INVALID_EVENT` for cross-lane posts. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + + /v1/identities/{identityId}: + get: + tags: [Transparency Log] + summary: Identity badge + description: | + The latest sealed identity event + inclusion proof + computed + status (`VERIFIED` | `REVOKED`). The proof events seal every + proven key self-verifyingly: any third party reads the key + out of the sealed verbatim `verificationMethod`, verifies the + sealed `signedProof` against it, and confirms the payload + binds this identityId — offline, without trusting the RA. + operationId: getIdentityBadge + parameters: + - $ref: '#/components/parameters/IdentityIdPath' + responses: + '200': + description: Identity badge retrieved. + content: + application/json: + schema: + $ref: '#/components/schemas/TransparencyLog' + '404': + description: No events for this identity. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + + /v1/identities/{identityId}/audit: + get: + tags: [Transparency Log] + summary: Paginated audit history for an identity + description: | + The identity's full event chain — IDENTITY_VERIFIED, links, + rotations, revocation — in the exact same audit envelope as + the agent audit. This unbroken chain is the continuity + thread: when an agent's domain is lost, the identity stream + survives and links to the successor. + operationId: getIdentityAudit + parameters: + - $ref: '#/components/parameters/IdentityIdPath' + - name: limit + in: query + required: false + schema: { type: integer, default: 50, minimum: 1, maximum: 200 } + - name: offset + in: query + required: false + schema: { type: integer, default: 0, minimum: 0 } + responses: + '200': + description: Audit records, newest-first. + content: + application/json: + schema: + $ref: '#/components/schemas/TransparencyLogAudit' + + /v1/identities/{identityId}/receipt: + get: + tags: [Transparency Log] + summary: SCITT receipt for the identity's latest event + description: | + COSE_Sign1 receipt (CBOR tag 18) for the identity's latest + sealed leaf — same machinery, media type, and 503 retry + semantics as the agent receipt. + operationId: getIdentityReceipt + parameters: + - $ref: '#/components/parameters/IdentityIdPath' + responses: + '200': + description: Binary COSE_Sign1 receipt. + content: + application/scitt-receipt+cose: + schema: + type: string + format: binary + '404': + description: No events for this identity. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + '503': + description: | + Leaf committed but no signed checkpoint covers it yet; + retry after the `Retry-After` delay. + headers: + Retry-After: + schema: { type: integer } + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + + /v1/identities/{identityId}/agents: + get: + tags: [Transparency Log] + summary: Agents currently linked to the identity (reverse join) + description: | + Computed at query time from the link events' agent index: + every agent whose latest link/unlink fact naming it is + LINKED, each decorated with its own computed badge status so + a reader checks both ends of the link in one response. + operationId: getIdentityAgents + parameters: + - $ref: '#/components/parameters/IdentityIdPath' + responses: + '200': + description: Currently-linked agents. + content: + application/json: + schema: + type: object + required: [agents] + properties: + agents: + type: array + items: + $ref: '#/components/schemas/LinkedAgentView' + '404': + description: No events for this identity. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + + /v1/agents/{agentId}/identities: + get: + tags: [Transparency Log] + summary: Identities currently linked to the agent (computed) + description: | + The same entries as the agent badge's `identities[]` field, + served alone. Computed at read time — link live ∧ identity + stream state — never stored on, or sealed into, the agent. + operationId: getAgentIdentities + parameters: + - $ref: '#/components/parameters/AgentIdPath' + responses: + '200': + description: Currently-linked identities. + content: + application/json: + schema: + type: object + required: [identities] + properties: + identities: + type: array + items: + $ref: '#/components/schemas/LinkedIdentityView' + + /v1/agents/{agentId}/identities/history: + get: + tags: [Transparency Log] + summary: Link/unlink events that ever named this agent + description: | + Past and present associations, in the STANDARD audit envelope + (each record a TransparencyLog) — no bespoke format. Filtered + through the link events' agent index; the records themselves + live on the identity streams. The agent's own `/audit` stays + purely AGENT_*. This endpoint is a droppable convenience: the + same view derives from the identity audits or the raw entry + tiles. + operationId: getAgentIdentityHistory + parameters: + - $ref: '#/components/parameters/AgentIdPath' + - name: limit + in: query + required: false + schema: { type: integer, default: 50, minimum: 1, maximum: 200 } + - name: offset + in: query + required: false + schema: { type: integer, default: 0, minimum: 0 } + responses: + '200': + description: Link events naming this agent, newest-first. + content: + application/json: + schema: + $ref: '#/components/schemas/TransparencyLogAudit' + # ────────────────────────────────────────────────────────────── # Log static artefacts + verifier keys (public under public-read) # ────────────────────────────────────────────────────────────── @@ -689,6 +938,14 @@ components: type: string format: uuid + IdentityIdPath: + name: identityId + in: path + required: true + description: Immutable identity UUIDv7 — the identity stream key. + schema: + type: string + schemas: # ── Ingest ───────────────────────────────────────────────── ProducerEvent: @@ -763,7 +1020,22 @@ components: signature: { type: string, description: "TL attestation (detached JWS)" } status: type: string - enum: [ACTIVE, REVOKED, DEPRECATED] + description: | + Read-time computed status. Agent badges derive ACTIVE / + REVOKED / DEPRECATED / EXPIRED / WARNING from the latest + event + attested cert expiry; identity badges derive + VERIFIED / REVOKED from the identity stream. + enum: [ACTIVE, REVOKED, DEPRECATED, EXPIRED, WARNING, VERIFIED] + identities: + type: array + description: | + Agent badges only — the COMPUTED read-time join of the + agent's currently-linked verified identities. Covered by + the TL's response signature, never by any seal; identity + rotation/revocation is visible here immediately with + zero agent-stream writes. + items: + $ref: '#/components/schemas/LinkedIdentityView' TransparencyLogAudit: type: object @@ -789,6 +1061,133 @@ components: description: "standard base64 of each sibling hash in order" treeVersion: { type: integer } + # ── Verified identities ──────────────────────────────────── + IdentityProducerEvent: + type: object + description: | + The canonical inner identity event the RA POSTs to the + identity ingest lane. Sealed shapes are append-only-forever; + the five eventType tokens and these fields are the contract. + JCS-canonicalized when signed, same as the agent lanes. + + Per-type required fields: proofs (IDENTITY_VERIFIED / + IDENTITY_UPDATED) carry non-empty `keys[]`, `verifiedAt`, and + `providerId`; IDENTITY_REVOKED carries `revokedAt`; the link + events carry non-empty `ansIds[]` (the whole batch in ONE + event) and are the only types allowed to name agents. + required: [identityId, kind, value, eventType, timestamp] + properties: + identityId: { type: string, description: "UUIDv7 — the identity stream key" } + kind: + type: string + enum: ['did:web', 'did:key', 'lei'] + value: { type: string, example: "did:web:identity.acme-corp.com" } + providerId: { type: string, description: "The owning principal — the WHO's owner" } + proofMethod: + type: string + enum: [did-web-sig, did-key-sig, lei-vlei-acdc] + eventType: + type: string + enum: [IDENTITY_VERIFIED, IDENTITY_UPDATED, IDENTITY_REVOKED, IDENTITY_LINKED, IDENTITY_UNLINKED] + keys: + type: array + description: | + The proven key set — sealed self-verifyingly: each entry + quotes the DID document's verification method VERBATIM + alongside the registrant's signed proof. + items: + $ref: '#/components/schemas/ProvenKey' + ansIds: + type: array + description: Linked agents' ids (IDENTITY_LINKED / IDENTITY_UNLINKED only) + items: { type: string } + previousValue: { type: string, description: "Pre-rotation identifier (IDENTITY_UPDATED)" } + verifiedAt: { type: string, format: date-time } + revokedAt: { type: string, format: date-time } + raId: { type: string, example: "ans-ra-local" } + timestamp: { type: string, format: date-time } + + ProvenKey: + type: object + description: | + One key the registrant proved possession of, sealed + self-verifyingly: any third party reads the key material out + of `verificationMethod`, verifies `signedProof` against it, + then confirms the payload decodes to an IdentityProofInput + binding this identityId + identifier + purpose — offline, + without trusting the RA. + + `verificationMethod` is quoted EXACTLY as the DID document + served it — `id`, `type`, `controller`, and the key material + in whichever representation the document used + (`publicKeyJwk`, or `publicKeyMultibase` for Multikey + documents) — member-for-member, values untouched. Nothing + derived, re-encoded, or normalized enters a seal; the event + envelope is JCS-canonicalized for signing like every event, + and JCS preserves member values exactly, so the quoted + material survives intact. Thumbprints are compute-at-read + conveniences (anyone can derive RFC 7638 from the sealed + source) and are never part of the sealed contract. + + The postponed `lei` kind is the one deliberate exception: + it will seal the subject AID + a key thumbprint only — there + is no document to quote, the ACDC is PII, and KERI's KEL is + already the authoritative key history. Seal verbatim what + has no other tamper-evident home; commit minimally where one + exists. + required: [verificationMethod, signedProof] + properties: + verificationMethod: + type: object + description: The DID document's verification-method object, verbatim + example: + id: did:web:identity.acme-corp.com#key-1 + type: JsonWebKey2020 + controller: did:web:identity.acme-corp.com + publicKeyJwk: { kty: OKP, crv: Ed25519, x: "0-e2i2_..." } + signedProof: + type: string + description: The compact JWS over the served IdentityProofInput + + LinkedIdentityView: + type: object + description: One computed identities[] entry on the agent badge. + required: [identityId, kind, value, identityStatus] + properties: + identityId: { type: string } + kind: { type: string, enum: ['did:web', 'did:key', 'lei'] } + value: { type: string } + identityStatus: + type: string + enum: [VERIFIED, REVOKED] + description: Reflects the identity stream NOW + provenKeyIds: + type: array + description: | + Verification-method ids of the current proven key set + (post-rotation). The full verbatim methods live in the + sealed proof event. + items: { type: string } + linkedAt: { type: string, format: date-time } + linkLogId: + type: string + description: The sealed IDENTITY_LINKED entry on the identity stream — fetch for link evidence + identityLogId: + type: string + description: Latest identity-stream entry — fetch for the identity evidence/history + + LinkedAgentView: + type: object + description: One reverse-join entry (identity → agents). + required: [ansId] + properties: + ansId: { type: string } + linkedAt: { type: string, format: date-time } + agentStatus: + type: string + enum: [ACTIVE, REVOKED, DEPRECATED, EXPIRED, WARNING] + description: The linked agent's own computed badge status — a link is effective only while both ends are live + # ── Admin: producer keys ─────────────────────────────────── ProducerKeyRequest: type: object From 97c84b0b09c25d09ea81fe1edb3eea069918d28d Mon Sep 17 00:00:00 2001 From: Connor Snitker Date: Wed, 10 Jun 2026 18:09:58 -0500 Subject: [PATCH 02/13] =?UTF-8?q?feat(ra):=20verified=20identities=20?= =?UTF-8?q?=E2=80=94=20proof-of-control=20gate,=20kinds=20registry,=20link?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd/ans-ra/main.go | 54 +- go.mod | 2 +- internal/adapter/didresolver/noop.go | 82 ++ internal/adapter/didresolver/resolver_test.go | 379 ++++++ internal/adapter/didresolver/web.go | 363 ++++++ internal/adapter/docsui/openapi/ra.yaml | 597 +++++++++- internal/adapter/store/sqlite/identity.go | 304 +++++ .../adapter/store/sqlite/identity_test.go | 299 +++++ .../sqlite/migrations/006_identities.sql | 74 ++ .../migrations/007_outbox_identity_lane.sql | 41 + internal/adapter/store/sqlite/outbox.go | 10 +- internal/adapter/tlclient/client.go | 8 +- internal/config/config.go | 36 + internal/config/defaults.go | 5 + internal/crypto/jwk.go | 342 ++++++ internal/crypto/jwk_test.go | 329 ++++++ internal/crypto/jws.go | 49 +- internal/crypto/proofinput.go | 60 + internal/crypto/proofinput_test.go | 69 ++ internal/domain/identity.go | 449 +++++++ internal/domain/identity_test.go | 282 +++++ internal/port/didresolver.go | 80 ++ internal/port/store.go | 60 + internal/ra/handler/dto.go | 32 + internal/ra/handler/identity.go | 297 +++++ internal/ra/handler/identity_handler_test.go | 414 +++++++ internal/ra/handler/lifecycle.go | 23 +- internal/ra/service/identity.go | 673 +++++++++++ internal/ra/service/identity_test.go | 1036 +++++++++++++++++ internal/ra/service/identitykinds.go | 317 +++++ internal/ra/service/identityratelimit.go | 80 ++ spec/api-spec-v2.yaml | 597 +++++++++- 32 files changed, 7432 insertions(+), 11 deletions(-) create mode 100644 internal/adapter/didresolver/noop.go create mode 100644 internal/adapter/didresolver/resolver_test.go create mode 100644 internal/adapter/didresolver/web.go create mode 100644 internal/adapter/store/sqlite/identity.go create mode 100644 internal/adapter/store/sqlite/identity_test.go create mode 100644 internal/adapter/store/sqlite/migrations/006_identities.sql create mode 100644 internal/adapter/store/sqlite/migrations/007_outbox_identity_lane.sql create mode 100644 internal/crypto/jwk.go create mode 100644 internal/crypto/jwk_test.go create mode 100644 internal/crypto/proofinput.go create mode 100644 internal/crypto/proofinput_test.go create mode 100644 internal/domain/identity.go create mode 100644 internal/domain/identity_test.go create mode 100644 internal/port/didresolver.go create mode 100644 internal/ra/handler/identity.go create mode 100644 internal/ra/handler/identity_handler_test.go create mode 100644 internal/ra/service/identity.go create mode 100644 internal/ra/service/identity_test.go create mode 100644 internal/ra/service/identitykinds.go create mode 100644 internal/ra/service/identityratelimit.go diff --git a/cmd/ans-ra/main.go b/cmd/ans-ra/main.go index b61a5b7..bc1c032 100644 --- a/cmd/ans-ra/main.go +++ b/cmd/ans-ra/main.go @@ -27,6 +27,7 @@ import ( "github.com/godaddy/ans/internal/adapter/auth" "github.com/godaddy/ans/internal/adapter/cert" + "github.com/godaddy/ans/internal/adapter/didresolver" "github.com/godaddy/ans/internal/adapter/dns" "github.com/godaddy/ans/internal/adapter/docsui" "github.com/godaddy/ans/internal/adapter/eventbus" @@ -95,6 +96,8 @@ func run(cfgPath string) error { byoc := sqlite.NewByocCertificateStore(db) renewals := sqlite.NewRenewalStore(db) outbox := sqlite.NewOutboxStore(db) + identityStore := sqlite.NewIdentityStore(db) + identityLinks := sqlite.NewIdentityLinkStore(db) // Crypto. km, err := keymanager.NewFileKeyManager(cfg.Keys.File.Path) @@ -149,6 +152,14 @@ func run(cfgPath string) error { // DNS verifier. var dnsVerifier = selectDNSVerifier(cfg) + // did:web resolver — noop (quickstart) or hardened web fetch, + // the identity surface's analog of the DNS verifier selection. + didResolver := selectDIDResolver(cfg) + logger.Info(). + Str("resolver", cfg.Identity.Resolver.Type). + Dur("challengeTTL", cfg.Identity.ChallengeTTL). + Msg("verified-identity surface configured") + logger.Info(). Str("tlPublicBaseURL", cfg.TLClient.PublicBaseURL). Str("tlBaseURL", cfg.TLClient.BaseURL). @@ -174,6 +185,18 @@ func run(cfgPath string) error { WithServerCertificateAuthority(serverCA). WithTLPublicBaseURL(cfg.TLClient.PublicBaseURL) + // Verified identities — the "who" behind the agents. Shares the + // producer signer with the registration service: one RA, one + // producer identity on every TL lane. + identitySvc := service.NewIdentityService( + identityStore, identityLinks, agents, didResolver, outbox, db, + ).WithSigner(service.EventSigner{ + KeyManager: km, + KeyID: signerKeyID, + RaID: cfg.Signer.RaID, + }).WithChallengeTTL(cfg.Identity.ChallengeTTL). + WithRegisterRateLimit(cfg.Identity.RegisterRateLimit) + // HTTP. r := chi.NewRouter() r.Use(middleware.Recoverer) @@ -203,7 +226,7 @@ func run(cfgPath string) error { // routes (GET) 404 on not-owned to hide existence; write routes // (POST) 403 so authenticated operators understand it's an // authorization failure (spec §26, §370). - lifeH := handler.NewLifecycleHandler(regSvc) + lifeH := handler.NewLifecycleHandler(regSvc).WithIdentityViews(identitySvc) r.Get("/v2/ans/agents", lifeH.List) readOwnership := ramiddleware.ReadOwnership(agents) @@ -226,6 +249,21 @@ func run(cfgPath string) error { r.With(writeOwnership).Delete("/v2/ans/agents/{agentId}/certificates/server/renewal", lifeH.CancelServerCertRenewal) r.With(writeOwnership).Post("/v2/ans/agents/{agentId}/certificates/server/renewal/verify-acme", lifeH.VerifyRenewalACME) + // Verified-identity routes (V2 only — the V1 lane is frozen). + // Ownership is enforced inside the IdentityService (reads hide + // existence with 404, writes split 404/403) because the owner + // gate is the link mechanism's security boundary and the service + // loads the aggregate anyway. + idH := handler.NewIdentityHandler(identitySvc) + r.Post("/v2/ans/identities", idH.Register) + r.Get("/v2/ans/identities", idH.List) + r.Get("/v2/ans/identities/{identityId}", idH.Detail) + r.Put("/v2/ans/identities/{identityId}", idH.Rotate) + r.Post("/v2/ans/identities/{identityId}/verify-control", idH.VerifyControl) + r.Post("/v2/ans/identities/{identityId}/revoke", idH.Revoke) + r.Post("/v2/ans/identities/{identityId}/links", idH.Link) + r.Delete("/v2/ans/identities/{identityId}/links/{agentId}", idH.Unlink) + // V1 RA surface — byte-for-byte parity with the reference V1 API // spec. Shares the same RegistrationService as the V2 routes; // only the DTO marshalling + TL-emit schema version differ. See @@ -419,3 +457,17 @@ func selectDNSVerifier(cfg *config.RAConfig) port.DNSVerifier { return dns.NewNoopVerifier() } } + +// selectDIDResolver returns the configured did:web resolver adapter — +// the identity surface's analog of selectDNSVerifier. "web" performs +// the hardened HTTPS fetch (WebPKI + SSRF dialer guards); the default +// "noop" synthesizes documents from the submitted proofs' embedded +// keys for self-contained local development. +func selectDIDResolver(cfg *config.RAConfig) port.DIDResolver { + switch cfg.Identity.Resolver.Type { + case "web": + return didresolver.NewWebResolver() + default: + return didresolver.NewNoopResolver() + } +} diff --git a/go.mod b/go.mod index 195ebc6..0769f20 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( github.com/stretchr/testify v1.11.1 github.com/transparency-dev/tessera v1.0.2 golang.org/x/mod v0.36.0 + golang.org/x/net v0.53.0 modernc.org/sqlite v1.51.0 ) @@ -51,7 +52,6 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.50.0 // indirect golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect - golang.org/x/net v0.53.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.43.0 // indirect diff --git a/internal/adapter/didresolver/noop.go b/internal/adapter/didresolver/noop.go new file mode 100644 index 0000000..6b91ce8 --- /dev/null +++ b/internal/adapter/didresolver/noop.go @@ -0,0 +1,82 @@ +// Package didresolver provides the two port.DIDResolver adapters: +// +// - Noop — zero-I/O quickstart resolver; synthesizes the DID +// document from the submitted proofs' embedded keys. +// - Web — the real thing: hardened HTTPS fetch of did.json with +// WebPKI validation and SSRF dialer guards. +// +// Selected by `identity.resolver.type` ("noop" | "web") in the RA +// config — the same pattern as the DNS verifier's `dns.type` +// ("noop" | "lookup"). +package didresolver + +import ( + "context" + "encoding/json" + + "github.com/godaddy/ans/internal/port" +) + +// Noop is the quickstart resolver. It never dials anywhere: the DID +// document is synthesized from the hints — the kid → public-JWK pairs +// the service extracted from the submitted proofs' `jwk` protected +// headers. +// +// What this preserves and what it waives, stated precisely (the noop +// DNS verifier precedent — real crypto, waived external-world +// binding): +// +// - PRESERVED: every JWS still genuinely verifies against the +// embedded key, the proof input still binds identityId / nonce / +// purpose / raId, and the sealed event remains self-verifying. +// - WAIVED: the binding "the live did.json at the DID's host really +// lists this key". Anyone can mint a keypair and claim any +// did:web value. +// +// Strictly for local development and the demo scripts. NOT for +// production. +type Noop struct{} + +// NewNoopResolver returns the quickstart resolver. +func NewNoopResolver() *Noop { return &Noop{} } + +// Resolve synthesizes a DID document listing exactly the hinted keys +// as assertionMethod entries of the requested DID. With no hints +// (the register-time advisory fetch) the document is valid and empty +// — the 202 challenge list then carries a single unkeyed entry, and +// the registrant names keys via the JWS `kid` + `jwk` headers at +// verify time. +// +// The synthesized Raw entry embeds the registrant's submitted jwk +// VERBATIM (json.RawMessage passes through marshalling untouched), so +// the sealed verification method quotes the registrant's exact key +// bytes — the same no-derived-values rule the web resolver satisfies +// by quoting the live document. +func (n *Noop) Resolve(_ context.Context, did string, hints []port.KeyHint) (*port.DIDDocument, error) { + doc := &port.DIDDocument{ID: did} + for _, h := range hints { + if h.Kid == "" || len(h.PublicKeyJWK) == 0 { + continue + } + raw, err := json.Marshal(map[string]any{ + "id": h.Kid, + "type": "JsonWebKey2020", + "controller": did, + "publicKeyJwk": h.PublicKeyJWK, + }) + if err != nil { + continue + } + doc.AssertionMethod = append(doc.AssertionMethod, port.VerificationMethod{ + ID: h.Kid, + Controller: did, + Type: "JsonWebKey2020", + PublicKeyJwk: h.PublicKeyJWK, + Raw: raw, + }) + } + return doc, nil +} + +// compile-time conformance. +var _ port.DIDResolver = (*Noop)(nil) diff --git a/internal/adapter/didresolver/resolver_test.go b/internal/adapter/didresolver/resolver_test.go new file mode 100644 index 0000000..221555e --- /dev/null +++ b/internal/adapter/didresolver/resolver_test.go @@ -0,0 +1,379 @@ +package didresolver + +import ( + "context" + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/godaddy/ans/internal/domain" + "github.com/godaddy/ans/internal/port" +) + +func TestNoopResolver_SynthesizesFromHints(t *testing.T) { + n := NewNoopResolver() + did := "did:web:identity.acme-corp.com" + + // No hints (register-time advisory fetch) → empty, valid doc. + doc, err := n.Resolve(context.Background(), did, nil) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if doc.ID != did || len(doc.AssertionMethod) != 0 { + t.Fatalf("empty-hint doc: %+v", doc) + } + + hints := []port.KeyHint{ + {Kid: did + "#key-1", PublicKeyJWK: json.RawMessage(`{"kty":"EC"}`)}, + {Kid: "", PublicKeyJWK: json.RawMessage(`{"kty":"EC"}`)}, // skipped + {Kid: did + "#key-2"}, // no key — skipped + } + doc, err = n.Resolve(context.Background(), did, hints) + if err != nil { + t.Fatalf("resolve with hints: %v", err) + } + if len(doc.AssertionMethod) != 1 { + t.Fatalf("want 1 synthesized method, got %d", len(doc.AssertionMethod)) + } + vm := doc.FindAssertionMethod(did + "#key-1") + if vm == nil || vm.Controller != did { + t.Fatalf("synthesized method wrong: %+v", doc.AssertionMethod) + } +} + +// testWebServer serves a did.json (or a custom handler) over TLS on +// 127.0.0.1 and returns a Web resolver wired to trust it. The +// resolver runs its REAL pipeline — pinning dialer included — with +// only the private-network guard waived (the test server is +// loopback). +// +// did:web resolution pins port 443, which a test can't bind; the +// test maps the DID host to the server's host:port via a dial +// rewrite inside the test transport... instead we keep it honest: +// the test overrides DNS by using the server's IP literal is not +// possible for TLS hostname verification, so we use the standard +// trick — a custom RootCAs pool plus the host being 127.0.0.1 won't +// carry a registrable domain. Therefore web-resolver behavior tests +// run against the parse/validation layers directly, and the fetch +// path is covered through a host-rewriting RoundTripper. +type rewriteTransport struct { + inner http.RoundTripper + to *url.URL +} + +func (rt rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req = req.Clone(req.Context()) + req.URL.Scheme = rt.to.Scheme + req.URL.Host = rt.to.Host + return rt.inner.RoundTrip(req) +} + +// resolveVia runs Web.Resolve with the HTTP layer rewired at the +// transport seam to hit the test server. Everything above the +// transport — URL derivation, status/size/parse/id checks — is the +// production code path. +func resolveVia(t *testing.T, srv *httptest.Server, did string, maxBody int64) (*port.DIDDocument, error) { + t.Helper() + w := NewWebResolver(WithMaxBodyBytes(maxBody), WithTimeout(2*time.Second)) + resolutionURL, err := domain.DIDWebResolutionURL(did) + if err != nil { + return nil, err + } + ctx, cancel := context.WithTimeout(context.Background(), w.timeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, resolutionURL, nil) + if err != nil { + return nil, err + } + target, _ := url.Parse(srv.URL) + client := &http.Client{Transport: rewriteTransport{inner: srv.Client().Transport, to: target}} + resp, err := client.Do(req) + if err != nil { + return nil, domain.NewValidationError("DID_RESOLUTION_FAILED", "could not fetch the DID document") + } + defer func() { _ = resp.Body.Close() }() + return w.parseResponse(did, resp) +} + +func TestWebResolver_HappyPath(t *testing.T) { + did := "did:web:identity.acme-corp.com" + doc := map[string]any{ + "id": did, + "verificationMethod": []map[string]any{ + { + "id": did + "#key-1", + "type": "JsonWebKey2020", + "controller": did, + "publicKeyJwk": map[string]any{"kty": "EC", "crv": "P-256", "x": "xx", "y": "yy"}, + }, + { + "id": did + "#key-2", + "type": "Multikey", + "controller": did, + "publicKeyMultibase": "zDnae", + }, + }, + // Mixed referencing styles: one string ref, one inline object. + "assertionMethod": []any{ + did + "#key-1", + map[string]any{ + "id": did + "#key-2", + "type": "Multikey", + "controller": did, + "publicKeyMultibase": "zDnae", + }, + did + "#unknown-ref", // dangling ref — skipped + }, + } + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/.well-known/did.json" { + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode(doc) + })) + defer srv.Close() + + got, err := resolveVia(t, srv, did, 1<<20) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if got.ID != did || len(got.AssertionMethod) != 2 { + t.Fatalf("doc: %+v", got) + } + if vm := got.FindAssertionMethod(did + "#key-1"); vm == nil || len(vm.PublicKeyJwk) == 0 { + t.Fatal("key-1 not materialized from string ref") + } + if vm := got.FindAssertionMethod(did + "#key-2"); vm == nil || vm.PublicKeyMultibase != "zDnae" { + t.Fatal("key-2 not materialized from inline object") + } +} + +func TestWebResolver_DocumentIDMismatch(t *testing.T) { + did := "did:web:identity.acme-corp.com" + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"id":"did:web:evil.example.com"}`)) + })) + defer srv.Close() + _, err := resolveVia(t, srv, did, 1<<20) + if err == nil || !strings.Contains(err.Error(), "DID_DOCUMENT_ID_MISMATCH") { + t.Fatalf("want DID_DOCUMENT_ID_MISMATCH, got %v", err) + } +} + +func TestWebResolver_Non200(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + _, err := resolveVia(t, srv, "did:web:identity.acme-corp.com", 1<<20) + if err == nil || !strings.Contains(err.Error(), "DID_RESOLUTION_FAILED") { + t.Fatalf("want DID_RESOLUTION_FAILED, got %v", err) + } +} + +func TestWebResolver_BodyTooLarge(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(make([]byte, 4096)) + })) + defer srv.Close() + _, err := resolveVia(t, srv, "did:web:identity.acme-corp.com", 1024) + if err == nil || !strings.Contains(err.Error(), "byte limit") { + t.Fatalf("want size-cap error, got %v", err) + } +} + +func TestWebResolver_BadJSON(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{not json`)) + })) + defer srv.Close() + _, err := resolveVia(t, srv, "did:web:identity.acme-corp.com", 1<<20) + if err == nil || !strings.Contains(err.Error(), "not valid JSON") { + t.Fatalf("want parse error, got %v", err) + } +} + +func TestWebResolver_BadDID(t *testing.T) { + w := NewWebResolver() + if _, err := w.Resolve(context.Background(), "did:key:z6Mk", nil); err == nil { + t.Fatal("non-did:web should fail before any I/O") + } +} + +func TestWebResolver_NoRegistrableDomain(t *testing.T) { + w := NewWebResolver() + // localhost has no eTLD+1 — rejected before any fetch. + _, err := w.Resolve(context.Background(), "did:web:localhost", nil) + if err == nil || !strings.Contains(err.Error(), "DID_BAD_FORMAT") { + t.Fatalf("want DID_BAD_FORMAT for localhost, got %v", err) + } +} + +func TestWebResolver_SSRFBlocksLoopback(t *testing.T) { + // A real end-to-end run against a loopback host: the pinning + // dialer must reject the resolved address class. The DID needs a + // registrable domain, so use a hosts-style resolver shim via the + // dialer directly. + d := &pinningDialer{} + _, err := d.DialContext(context.Background(), "tcp", "localhost:443") + if err == nil || !strings.Contains(err.Error(), "disallowed") { + t.Fatalf("loopback should be rejected: %v", err) + } +} + +func TestPinningDialer_RejectsNon443(t *testing.T) { + d := &pinningDialer{} + if _, err := d.DialContext(context.Background(), "tcp", "example.com:8443"); err == nil { + t.Fatal("non-443 should be rejected") + } + if _, err := d.DialContext(context.Background(), "tcp", "malformed"); err == nil { + t.Fatal("malformed address should be rejected") + } +} + +func TestPinningDialer_AllowPrivateReachesLoopback(t *testing.T) { + // With the test-only escape hatch the dialer connects to a local + // listener — proving the happy dial path works end to end. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer func() { _ = ln.Close() }() + go func() { + conn, aerr := ln.Accept() + if aerr == nil { + _ = conn.Close() + } + }() + + _, port, _ := net.SplitHostPort(ln.Addr().String()) + _ = port // the dialer pins 443; dial loopback via a name that resolves there + d := &pinningDialer{allowPrivate: true} + // localhost:443 likely has no listener; accept either a + // connection refusal (dial path reached) or success. + conn, err := d.DialContext(context.Background(), "tcp", "localhost:443") + if err == nil { + _ = conn.Close() + } else if strings.Contains(err.Error(), "disallowed") { + t.Fatalf("allowPrivate must bypass the class filter: %v", err) + } +} + +func TestIsPublicUnicast(t *testing.T) { + cases := []struct { + ip string + want bool + }{ + {"127.0.0.1", false}, + {"10.1.2.3", false}, + {"172.16.0.1", false}, + {"192.168.1.1", false}, + {"169.254.169.254", false}, // cloud metadata (link-local) + {"::1", false}, + {"fe80::1", false}, + {"fc00::1", false}, + {"ff02::1", false}, + {"0.0.0.0", false}, + {"93.184.216.34", true}, + {"2606:2800:220:1::1", true}, + } + for _, tc := range cases { + if got := isPublicUnicast(net.ParseIP(tc.ip)); got != tc.want { + t.Errorf("isPublicUnicast(%s) = %v, want %v", tc.ip, got, tc.want) + } + } +} + +func TestCheckRedirectPolicy(t *testing.T) { + w := NewWebResolver() + client, err := w.newClient("identity.acme-corp.com") + if err != nil { + t.Fatal(err) + } + mkReq := func(raw string) *http.Request { + u, _ := url.Parse(raw) + return &http.Request{URL: u} + } + via := make([]*http.Request, 0, maxRedirects) + + // Same registrable domain → allowed. + if err := client.CheckRedirect(mkReq("https://www.acme-corp.com/did.json"), via); err != nil { + t.Errorf("same-domain redirect rejected: %v", err) + } + // Cross-domain → rejected. + if err := client.CheckRedirect(mkReq("https://evil.example.com/did.json"), via); err == nil || + !strings.Contains(err.Error(), "DID_REDIRECT_DOMAIN_MISMATCH") { + t.Errorf("cross-domain redirect: %v", err) + } + // Scheme downgrade → rejected. + if err := client.CheckRedirect(mkReq("http://www.acme-corp.com/did.json"), via); err == nil { + t.Error("http downgrade should be rejected") + } + // Hop cap. + for range maxRedirects { + via = append(via, &http.Request{}) + } + if err := client.CheckRedirect(mkReq("https://www.acme-corp.com/d.json"), via); err == nil || + !strings.Contains(err.Error(), "too many redirects") { + t.Errorf("redirect cap: %v", err) + } +} + +func TestParseDIDDocument_Tolerance(t *testing.T) { + // Unknown assertionMethod entry shapes are skipped, not fatal. + body := []byte(`{"id":"did:web:a.com","assertionMethod":[42, {"type":"NoID"}]}`) + doc, err := parseDIDDocument(body) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(doc.AssertionMethod) != 0 { + t.Fatalf("malformed entries must be skipped: %+v", doc.AssertionMethod) + } +} + +// TestWebResolver_FullResolveFetchFailure drives the REAL Resolve +// path — option wiring, client construction, the pinning dialer — +// against a host that cannot resolve: the fetch-failure branch +// returns the coarse DID_RESOLUTION_FAILED (no SSRF oracle detail). +func TestWebResolver_FullResolveFetchFailure(t *testing.T) { + w := NewWebResolver( + WithTimeout(2*time.Second), + WithRootCAs(nil), + WithAllowPrivateNetworks(), + ) + _, err := w.Resolve(context.Background(), "did:web:no-such-host-ans-test.invalid", nil) + if err == nil || !strings.Contains(err.Error(), "DID_RESOLUTION_FAILED") { + t.Fatalf("want DID_RESOLUTION_FAILED, got %v", err) + } + if strings.Contains(err.Error(), "127.") { + t.Fatalf("error detail leaks addresses: %v", err) + } +} + +// resolveTimeoutGuard pins that the context deadline propagates: a +// server that never answers must not hang past the timeout. +func TestWebResolver_Timeout(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select { + case <-r.Context().Done(): + case <-time.After(5 * time.Second): + } + })) + defer srv.Close() + + did := "did:web:identity.acme-corp.com" + start := time.Now() + _, err := resolveVia(t, srv, did, 1<<20) + if err == nil { + t.Fatal("expected timeout error") + } + if elapsed := time.Since(start); elapsed > 4*time.Second { + t.Fatalf("timeout did not bound the fetch: %v", elapsed) + } +} diff --git a/internal/adapter/didresolver/web.go b/internal/adapter/didresolver/web.go new file mode 100644 index 0000000..d37a017 --- /dev/null +++ b/internal/adapter/didresolver/web.go @@ -0,0 +1,363 @@ +package didresolver + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "strings" + "sync" + "time" + + "golang.org/x/net/publicsuffix" + + "github.com/godaddy/ans/internal/domain" + "github.com/godaddy/ans/internal/port" +) + +// Web is the production did:web resolver: an HTTPS GET of the DID +// document at the DID's resolution URL, through one hardened fetcher. +// +// The fetch target is registrant-steered (any host the DID names), so +// SSRF is a first-class control (design §3.7): +// +// 1. Egress IP denylist enforced at connect time, post-DNS: RFC +// 1918, loopback, link-local (which covers cloud metadata +// addresses), ULA, and unspecified addresses are rejected at the +// dialer, never by hostname string inspection. +// 2. The resolved IP is pinned per host for the duration of one +// Resolve call — a DNS rebind between redirect hops cannot slip +// an internal target past check 1. +// 3. Full WebPKI validation (chain to a trusted root + hostname +// verification) on every fetch — Go's default TLS behavior, left +// fully enabled. +// 4. Bounded: hard timeout (default 5 s, parity with the DNS +// verifier), response-size cap (default 1 MiB), ≤5 redirects +// constrained to the original host's registrable domain. +// 5. Error details never echo resolved IPs, ports, or redirect +// chains (no SSRF oracle). +type Web struct { + timeout time.Duration + maxBodyBytes int64 + rootCAs *x509.CertPool + allowPrivate bool +} + +// WebOption customizes the Web resolver. +type WebOption func(*Web) + +// WithTimeout overrides the per-resolve hard timeout (default 5s). +func WithTimeout(d time.Duration) WebOption { + return func(w *Web) { + if d > 0 { + w.timeout = d + } + } +} + +// WithMaxBodyBytes overrides the response-size cap (default 1 MiB). +func WithMaxBodyBytes(n int64) WebOption { + return func(w *Web) { + if n > 0 { + w.maxBodyBytes = n + } + } +} + +// WithRootCAs overrides the trusted root pool (default: system +// roots). Deployments with private PKI inject their pool here; tests +// inject the httptest server's certificate. +func WithRootCAs(pool *x509.CertPool) WebOption { + return func(w *Web) { w.rootCAs = pool } +} + +// WithAllowPrivateNetworks disables the egress IP denylist. FOR +// TESTS ONLY — it exists so the full real fetch path (dialer +// included) is exercisable against a loopback TLS server. Never +// reachable from configuration. +func WithAllowPrivateNetworks() WebOption { + return func(w *Web) { w.allowPrivate = true } +} + +// NewWebResolver constructs the production resolver. +func NewWebResolver(opts ...WebOption) *Web { + w := &Web{ + timeout: 5 * time.Second, + maxBodyBytes: 1 << 20, // 1 MiB + } + for _, opt := range opts { + opt(w) + } + return w +} + +// maxRedirects bounds the redirect chain per design §3.6. +const maxRedirects = 5 + +// Resolve fetches and parses the DID document. Hints are ignored — +// the authoritatively resolved document is always the key source. +func (w *Web) Resolve(ctx context.Context, did string, _ []port.KeyHint) (*port.DIDDocument, error) { + resolutionURL, err := domain.DIDWebResolutionURL(did) + if err != nil { + return nil, err + } + originHost := strings.Split(strings.TrimPrefix(did, "did:web:"), ":")[0] + + client, err := w.newClient(originHost) + if err != nil { + return nil, err + } + + ctx, cancel := context.WithTimeout(ctx, w.timeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, resolutionURL, nil) + if err != nil { + return nil, domain.NewValidationError("DID_RESOLUTION_FAILED", "could not build resolution request") + } + req.Header.Set("Accept", "application/did+json, application/json") + + resp, err := client.Do(req) + if err != nil { + // Deliberately coarse: no resolved IPs, ports, or redirect + // targets in the detail (no SSRF oracle). + return nil, domain.NewValidationError("DID_RESOLUTION_FAILED", + fmt.Sprintf("could not fetch the DID document for %s", did)) + } + defer func() { _ = resp.Body.Close() }() + return w.parseResponse(did, resp) +} + +// parseResponse applies the status, size, JSON, and document-id +// checks to a fetched response. Split from Resolve so the validation +// pipeline is testable independent of the dial/TLS plumbing. +func (w *Web) parseResponse(did string, resp *http.Response) (*port.DIDDocument, error) { + if resp.StatusCode != http.StatusOK { + return nil, domain.NewValidationError("DID_RESOLUTION_FAILED", + fmt.Sprintf("DID document fetch for %s returned status %d", did, resp.StatusCode)) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, w.maxBodyBytes+1)) + if err != nil { + return nil, domain.NewValidationError("DID_RESOLUTION_FAILED", + fmt.Sprintf("could not read the DID document for %s", did)) + } + if int64(len(body)) > w.maxBodyBytes { + return nil, domain.NewValidationError("DID_RESOLUTION_FAILED", + fmt.Sprintf("DID document for %s exceeds the %d-byte limit", did, w.maxBodyBytes)) + } + + doc, err := parseDIDDocument(body) + if err != nil { + return nil, err + } + if doc.ID != did { + return nil, domain.NewValidationError("DID_DOCUMENT_ID_MISMATCH", + fmt.Sprintf("DID document id %q does not match the requested DID %q", doc.ID, did)) + } + return doc, nil +} + +// newClient builds the per-resolve HTTP client: pinning dialer + +// WebPKI transport + same-registrable-domain redirect policy. A fresh +// client per call keeps the DNS pin scoped to exactly one +// verify-control round. +func (w *Web) newClient(originHost string) (*http.Client, error) { + pinned := &pinningDialer{allowPrivate: w.allowPrivate} + transport := &http.Transport{ + DialContext: pinned.DialContext, + ForceAttemptHTTP2: true, + TLSClientConfig: &tls.Config{RootCAs: w.rootCAs, MinVersion: tls.VersionTLS12}, + } + originDomain, err := registrableDomain(originHost) + if err != nil { + return nil, domain.NewValidationError("DID_BAD_FORMAT", + fmt.Sprintf("did:web host %q has no registrable domain", originHost)) + } + return &http.Client{ + Transport: transport, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= maxRedirects { + return domain.NewValidationError("DID_RESOLUTION_FAILED", + "too many redirects resolving the DID document") + } + if req.URL.Scheme != "https" { + return domain.NewValidationError("DID_RESOLUTION_FAILED", + "DID document redirect left https") + } + redirDomain, derr := registrableDomain(req.URL.Hostname()) + if derr != nil || redirDomain != originDomain { + return domain.NewValidationError("DID_REDIRECT_DOMAIN_MISMATCH", + "DID document redirect left the DID's registrable domain") + } + return nil + }, + }, nil +} + +// registrableDomain returns the eTLD+1 for a host. Single-label hosts +// (localhost, bare TLDs) error — they have no registrable domain. +func registrableDomain(host string) (string, error) { + return publicsuffix.EffectiveTLDPlusOne(strings.ToLower(host)) +} + +// pinningDialer resolves, filters, and pins target IPs. +// +// The pin map lives for one Resolve call (one dialer per client per +// call): the first connection to a host fixes its IP, so a DNS rebind +// between the initial fetch and a redirect hop — or between TLS +// handshake retries — cannot redirect a later connection to a +// different (possibly internal) address. Every chosen IP passes the +// denylist *after* resolution; hostname-string checks are worthless +// against rebinding. +type pinningDialer struct { + allowPrivate bool + + mu sync.Mutex + pin map[string]string // host → ip +} + +func (d *pinningDialer) DialContext(ctx context.Context, network, addr string) (net.Conn, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, errors.New("didresolver: malformed dial address") + } + if port != "443" { + // did:web fetches are pinned to 443; nothing else should + // ever reach the dialer. + return nil, errors.New("didresolver: refusing non-443 connection") + } + + d.mu.Lock() + if d.pin == nil { + d.pin = make(map[string]string) + } + pinnedIP, ok := d.pin[host] + d.mu.Unlock() + + if !ok { + var err error + pinnedIP, err = d.resolveAndPin(ctx, host) + if err != nil { + return nil, err + } + } + + var dialer net.Dialer + return dialer.DialContext(ctx, network, net.JoinHostPort(pinnedIP, port)) +} + +// resolveAndPin resolves the host, applies the egress denylist to +// every candidate address, and pins the first allowed IP. First +// writer wins — concurrent dials for one host converge on one pin. +func (d *pinningDialer) resolveAndPin(ctx context.Context, host string) (string, error) { + ips, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil || len(ips) == 0 { + return "", errors.New("didresolver: host did not resolve") + } + chosen := "" + for _, ip := range ips { + if d.allowPrivate || isPublicUnicast(ip.IP) { + chosen = ip.IP.String() + break + } + } + if chosen == "" { + return "", errors.New("didresolver: host resolves only to disallowed addresses") + } + d.mu.Lock() + defer d.mu.Unlock() + if existing, dup := d.pin[host]; dup { + return existing, nil + } + d.pin[host] = chosen + return chosen, nil +} + +// isPublicUnicast rejects every address class the egress denylist +// names: loopback, RFC 1918 / ULA private ranges, link-local (which +// contains the cloud-metadata addresses), multicast, and unspecified. +func isPublicUnicast(ip net.IP) bool { + switch { + case ip.IsLoopback(), + ip.IsPrivate(), + ip.IsLinkLocalUnicast(), + ip.IsLinkLocalMulticast(), + ip.IsMulticast(), + ip.IsUnspecified(): + return false + default: + return true + } +} + +// didDocumentWire is the on-the-wire DID document subset we parse. +// Both arrays are kept as raw JSON: verificationMethod entries so +// the EXACT served bytes can be quoted verbatim into seals, +// assertionMethod entries because they are either string references +// or inline verification-method objects. +type didDocumentWire struct { + ID string `json:"id"` + VerificationMethod []json.RawMessage `json:"verificationMethod"` + AssertionMethod []json.RawMessage `json:"assertionMethod"` +} + +type vmWire struct { + ID string `json:"id"` + Type string `json:"type"` + Controller string `json:"controller"` + PublicKeyJwk json.RawMessage `json:"publicKeyJwk"` + PublicKeyMultibase string `json:"publicKeyMultibase"` +} + +func (v vmWire) toPort(raw json.RawMessage) port.VerificationMethod { + return port.VerificationMethod{ + ID: v.ID, + Controller: v.Controller, + Type: v.Type, + PublicKeyJwk: v.PublicKeyJwk, + PublicKeyMultibase: v.PublicKeyMultibase, + Raw: raw, + } +} + +// parseDIDDocument builds the port.DIDDocument from raw did.json +// bytes, materializing the assertionMethod set: string entries +// dereference into verificationMethod; object entries are used +// inline. Every entry carries its Raw bytes — the document's own +// JSON for that method, which is what sealing quotes. +func parseDIDDocument(body []byte) (*port.DIDDocument, error) { + var wire didDocumentWire + if err := json.Unmarshal(body, &wire); err != nil { + return nil, domain.NewValidationError("DID_RESOLUTION_FAILED", + "DID document is not valid JSON") + } + byID := make(map[string]port.VerificationMethod, len(wire.VerificationMethod)) + for _, raw := range wire.VerificationMethod { + var vm vmWire + if err := json.Unmarshal(raw, &vm); err == nil && vm.ID != "" { + byID[vm.ID] = vm.toPort(raw) + } + } + doc := &port.DIDDocument{ID: wire.ID} + for _, raw := range wire.AssertionMethod { + var ref string + if err := json.Unmarshal(raw, &ref); err == nil { + if vm, ok := byID[ref]; ok { + doc.AssertionMethod = append(doc.AssertionMethod, vm) + } + continue + } + var vm vmWire + if err := json.Unmarshal(raw, &vm); err == nil && vm.ID != "" { + doc.AssertionMethod = append(doc.AssertionMethod, vm.toPort(raw)) + } + } + return doc, nil +} + +// compile-time conformance. +var _ port.DIDResolver = (*Web)(nil) diff --git a/internal/adapter/docsui/openapi/ra.yaml b/internal/adapter/docsui/openapi/ra.yaml index 9ad2749..98073c1 100644 --- a/internal/adapter/docsui/openapi/ra.yaml +++ b/internal/adapter/docsui/openapi/ra.yaml @@ -59,6 +59,12 @@ tags: description: Agent certificate revocation operations - name: Certificate Management description: Certificate retrieval, CSR submission, and renewal operations + - name: Verified Identities + description: | + The "who" behind agents — owner-level identities (did:web, + did:key) proven through challenge-bound control proofs, sealed + on their own Transparency Log stream, and linked to the owner's + agents # Global security requirement. Every path inherits this and can # override with `security: []` to declare itself anonymous. The @@ -889,6 +895,372 @@ paths: schema: $ref: '#/components/schemas/ErrorResponse' + # ────────────────────────────────────────────────────────────────── + # Verified Identities — the "who" behind agents + # + # An identity is a first-class object owned by the authenticated + # principal (the same owner as the agents), proven through a + # per-kind control proof, sealed onto its own Transparency Log + # stream, and linked to any number of that owner's agents. The + # agent registration surface above is unchanged — agents carry no + # identity fields; the association is the links sub-resource. + # + # The single security invariant: the RA seals an identity + # attestation only after control is PROVEN — a challenge-bound + # signature verified against the identifier's authoritative keys — + # never on resolution alone. + # ────────────────────────────────────────────────────────────────── + /ans/identities: + post: + tags: + - Verified Identities + summary: Register a verified identity + description: | + Registers an identifier (the kind — `did:web`, `did:key` — is + inferred from the value's lexical form, never caller-asserted) + and returns the challenge round to sign: one entry per + eligible key, all over a single anti-replay nonce. The + identity is created in `PENDING_CONTROL`; nothing is sealed + until verify-control passes. + + Re-POSTing the same value while the row is `PENDING_CONTROL` + is the idempotent re-add: the same `identityId` returns with + a fresh nonce (the prior nonce is superseded). A value + already verified — by this owner or any other — returns 409 + `IDENTIFIER_DUPLICATE`. Kinds without an enabled control + verifier (`lei`, postponed) return 422 + `IDENTIFIER_KIND_UNSUPPORTED`. + operationId: registerIdentity + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/IdentityRegistrationRequest' + responses: + '202': + description: Identity registered — challenges to sign + content: + application/json: + schema: + $ref: '#/components/schemas/IdentityChallengeResponse' + '401': + description: Authentication failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: Identifier already verified (IDENTIFIER_DUPLICATE) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '422': + description: Invalid or unsupported identifier + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + get: + tags: + - Verified Identities + summary: List my identities + description: Returns every identity owned by the caller, newest first. + operationId: listIdentities + responses: + '200': + description: The caller's identities + content: + application/json: + schema: + type: object + properties: + identities: + type: array + items: + $ref: '#/components/schemas/IdentityDetails' + '401': + description: Authentication failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /ans/identities/{identityId}: + get: + tags: + - Verified Identities + summary: Get identity detail + description: | + Returns the identity plus its live links. Ownership-scoped: + an identity that doesn't exist or isn't the caller's returns + 404 (existence is hidden). + operationId: getIdentity + parameters: + - $ref: '#/components/parameters/IdentityIdPath' + responses: + '200': + description: Identity detail + content: + application/json: + schema: + $ref: '#/components/schemas/IdentityDetails' + '404': + description: Identity not found or not accessible + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + put: + tags: + - Verified Identities + summary: Rotate / replace the identifier + description: | + Stages a same-kind replacement and returns fresh challenges + over it. Until the new proof lands the previously sealed + state stands; a replacement that never verifies expires with + its nonce. On a clean verify-control the RA swaps the value + and seals ONE `IDENTITY_UPDATED` event — regardless of how + many agents are linked. Cross-kind replacement is rejected + (revoke and register a new identity instead). + operationId: rotateIdentity + parameters: + - $ref: '#/components/parameters/IdentityIdPath' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/IdentityRegistrationRequest' + responses: + '202': + description: Rotation staged — fresh challenges to sign + content: + application/json: + schema: + $ref: '#/components/schemas/IdentityChallengeResponse' + '403': + description: Caller does not own this identity + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Identity not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: Identity is not in a rotatable state + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '422': + description: Invalid replacement value (incl. IDENTIFIER_KIND_MISMATCH) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /ans/identities/{identityId}/verify-control: + post: + tags: + - Verified Identities + summary: Prove control of the identifier + description: | + Submits the control proofs: one compact JWS per proven key, + each payload equal — verbatim — to the served `signingInput` + (clients never canonicalize; the RA checks payload equality + before verifying any signature). Every proof must verify + against the identifier's AUTHORITATIVE key for its `kid` + (resolved from the DID document for `did:web`, decoded from + the identifier for `did:key`); one bad proof fails the call + closed. The nonce is consumed exactly once, inside the + success transaction — a failed attempt does not consume it. + + On success the identity flips to `VERIFIED` (or completes a + staged rotation) and the RA seals `IDENTITY_VERIFIED` / + `IDENTITY_UPDATED` on the identity's own Transparency Log + stream, with every proven key sealed self-verifyingly + (public key + signed proof). + operationId: verifyIdentityControl + parameters: + - $ref: '#/components/parameters/IdentityIdPath' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/VerifyControlRequest' + responses: + '200': + description: Control proven — identity VERIFIED, event sealed + content: + application/json: + schema: + $ref: '#/components/schemas/IdentityDetails' + '403': + description: Caller does not own this identity + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Identity not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: | + Challenge state error — `PRICC_TOKEN_EXPIRED`, + `PRICC_TOKEN_ALREADY_USED`, `IDENTIFIER_CHALLENGE_EXPIRED` + — or the identity is revoked. Recovery from an expired + nonce is the idempotent re-add (re-POST the same value). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '422': + description: | + Proof rejected — `PRICC_SIGNATURE_INVALID`, + `DID_VERIFICATION_METHOD_INVALID`, `DID_RESOLUTION_FAILED`, + `DID_DOCUMENT_ID_MISMATCH`, `IDENTIFIER_PROOF_INVALID`. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /ans/identities/{identityId}/revoke: + post: + tags: + - Verified Identities + summary: Revoke the identity + description: | + Transitions a VERIFIED identity to REVOKED and seals ONE + `IDENTITY_REVOKED` event. A POST (state change), never a + DELETE: an identity cannot be deleted — its history is + append-only in the Transparency Log. Propagation to every + linked agent's badge is the TL's read-time join, not a write + fan-out. + operationId: revokeIdentity + parameters: + - $ref: '#/components/parameters/IdentityIdPath' + responses: + '200': + description: Identity revoked, event sealed + content: + application/json: + schema: + $ref: '#/components/schemas/IdentityDetails' + '403': + description: Caller does not own this identity + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Identity not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: Identity is not VERIFIED (nothing sealed to revoke) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /ans/identities/{identityId}/links: + post: + tags: + - Verified Identities + summary: Link agents to the identity + description: | + Binds a batch of the caller's agents to the identity — a + single owner-gated call with no challenge and no signature: + the caller MUST own the identity AND every named agent (key + possession never authorizes a link). The whole batch seals as + ONE `IDENTITY_LINKED` event on the IDENTITY stream carrying + the agent ids; an agent's own stream and audit history are + never written by identity operations. Already-linked agents + are skipped idempotently; a call that links nothing new seals + nothing. Links attach only while the identity is VERIFIED. + operationId: linkIdentityAgents + parameters: + - $ref: '#/components/parameters/IdentityIdPath' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/IdentityLinkRequest' + responses: + '200': + description: Batch linked (count of newly-created links) + content: + application/json: + schema: + $ref: '#/components/schemas/IdentityLinkResponse' + '403': + description: Caller does not own this identity + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Identity, or a named agent, not found / not the caller's + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: Identity is not VERIFIED (IDENTITY_NOT_VERIFIED) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '422': + description: Invalid link request (empty or oversized batch) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /ans/identities/{identityId}/links/{agentId}: + delete: + tags: + - Verified Identities + summary: Unlink an agent + description: | + Ends one association and seals `IDENTITY_UNLINKED` on the + identity stream. The association's history persists in the + identity's audit chain and the raw log tiles; unlinked pairs + may be re-linked later. + operationId: unlinkIdentityAgent + parameters: + - $ref: '#/components/parameters/IdentityIdPath' + - $ref: '#/components/parameters/AgentIdPath' + responses: + '204': + description: Link removed, event sealed + '403': + description: Caller does not own this identity + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Identity or live link not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + components: # ────────────────────────────────────────────────────────────────── @@ -923,6 +1295,14 @@ components: type: string format: uuid + IdentityIdPath: + name: identityId + in: path + description: Unique identifier of the verified identity (UUIDv7) + required: true + schema: + type: string + # ────────────────────────────────────────────────────────────────── # Schemas # ────────────────────────────────────────────────────────────────── @@ -1155,6 +1535,16 @@ components: type: array items: $ref: '#/components/schemas/Link' + identities: + type: array + description: | + Additive, optional, COMPUTED — the verified identities + currently linked to this agent, joined from the link rows + at read time. Never stored on the registration; identity + rotation/revocation is visible here immediately with zero + agent-side writes. + items: + $ref: '#/components/schemas/LinkedIdentity' required: - agentId - ansName @@ -1585,4 +1975,209 @@ components: found: type: string expected: - type: string \ No newline at end of file + type: string + + # ──────────────────────────────────────────────────────────────── + # Verified Identities + # ──────────────────────────────────────────────────────────────── + IdentityRegistrationRequest: + type: object + description: | + Registers (POST) or rotates (PUT) an identifier. The kind is + inferred from the value's lexical form — `did:web:` prefix, + `did:key:` prefix, or a 20-character LEI (recognized but + postponed) — never caller-asserted. + properties: + value: + type: string + description: The identifier to prove control of + example: did:web:identity.acme-corp.com + required: + - value + + IdentityChallengeResponse: + type: object + description: | + The 202 challenge round. Every entry shares the same + anti-replay nonce and the same signingInput — the input is + key-independent; entries enumerate the keys the resolver + could see in advance (a single unkeyed entry when it could + not — name keys via the JWS `kid` header at verify time). + properties: + identityId: + type: string + description: RA-assigned UUIDv7 — the TL stream key + kind: + type: string + enum: ['did:web', 'did:key', 'lei'] + value: + type: string + description: The canonical identifier this round proves + status: + $ref: '#/components/schemas/IdentityLifecycleStatus' + nonce: + type: string + description: Base64url 32-byte single-use anti-replay nonce + expiresAt: + type: string + format: date-time + challenges: + type: array + items: + $ref: '#/components/schemas/IdentityProofChallenge' + required: + - identityId + - kind + - value + - status + - nonce + - expiresAt + - challenges + + IdentityProofChallenge: + type: object + properties: + kid: + type: string + description: | + Verification-method id eligible to sign this round + (omitted when the resolver could not enumerate keys) + example: did:web:identity.acme-corp.com#key-1 + signingInput: + type: string + description: | + Base64url of the exact RFC 8785 (JCS) canonical + IdentityProofInput bytes — {identifier, identityId, + nonce, purpose:"ans:identity-proof:v1", raId, scheme}. A + compact JWS's payload segment MUST equal this string + verbatim; clients never canonicalize. + required: + - signingInput + + VerifyControlRequest: + type: object + description: | + One compact JWS per proven key. Supported algorithms match + what the verifier implements: EdDSA (Ed25519), ES256 + (ECDSA P-256), and RS256 (RSA >= 2048). Key-agreement keys + (X25519) and curves without a verifier (secp256k1, + P-384/521) are rejected with a precise error. + properties: + signedProofs: + type: array + minItems: 1 + maxItems: 16 + items: + type: string + description: | + Compact JWS over the served signingInput. The protected + header carries `kid` (the claimed verification method) + and MAY carry `jwk` (the signer's public key — required + by the quickstart noop resolver, ignored by the web + resolver, which always uses the resolved document). + required: + - signedProofs + + IdentityLifecycleStatus: + type: string + enum: [PENDING_CONTROL, VERIFIED, REVOKED] + description: | + PENDING_CONTROL → VERIFIED → REVOKED. Rotation keeps the row + VERIFIED (the staged replacement proves control before + anything changes). + + IdentityDetails: + type: object + properties: + identityId: + type: string + kind: + type: string + enum: ['did:web', 'did:key', 'lei'] + value: + type: string + status: + $ref: '#/components/schemas/IdentityLifecycleStatus' + proofMethod: + type: string + description: Control proof that verified this identity + enum: [did-web-sig, did-key-sig, lei-vlei-acdc] + pendingValue: + type: string + description: Staged rotation replacement (empty unless rotating) + verifiedAt: + type: string + format: date-time + createdAt: + type: string + format: date-time + linkedAgents: + type: array + description: Live links (detail responses only) + items: + type: object + properties: + agentId: + type: string + format: uuid + linkedAt: + type: string + format: date-time + required: + - agentId + required: + - identityId + - kind + - value + - status + - createdAt + + IdentityLinkRequest: + type: object + description: | + The batch of the caller's agents to bind — one owner-gated + call, no challenge, no signature; the whole batch seals as + ONE IDENTITY_LINKED event on the identity stream. + properties: + agentIds: + type: array + minItems: 1 + maxItems: 256 + items: + type: string + format: uuid + required: + - agentIds + + IdentityLinkResponse: + type: object + properties: + linked: + type: integer + description: Newly-created links (already-linked agents are skipped) + required: + - linked + + LinkedIdentity: + type: object + description: One computed identities[] entry on AgentDetails. + properties: + identityId: + type: string + kind: + type: string + enum: ['did:web', 'did:key', 'lei'] + value: + type: string + identityStatus: + type: string + enum: [VERIFIED, REVOKED] + description: The identity's CURRENT status — reflects its stream now + linkedAt: + type: string + format: date-time + required: + - identityId + - kind + - value + - identityStatus \ No newline at end of file diff --git a/internal/adapter/store/sqlite/identity.go b/internal/adapter/store/sqlite/identity.go new file mode 100644 index 0000000..daf56af --- /dev/null +++ b/internal/adapter/store/sqlite/identity.go @@ -0,0 +1,304 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + "time" + + "github.com/godaddy/ans/internal/domain" +) + +// IdentityStore implements port.IdentityStore. +type IdentityStore struct{ db *DB } + +// NewIdentityStore returns a new SQLite-backed IdentityStore. +func NewIdentityStore(db *DB) *IdentityStore { return &IdentityStore{db: db} } + +type identityRow struct { + IdentityID string `db:"identity_id"` + ProviderID string `db:"provider_id"` + Kind string `db:"kind"` + Value string `db:"value"` + Status string `db:"status"` + ProofMethod string `db:"proof_method"` + PendingValue string `db:"pending_value"` + ChallengeNonce sql.NullString `db:"challenge_nonce"` + ChallengeExpiresAtMs sql.NullInt64 `db:"challenge_expires_at_ms"` + ChallengeConsumedAtMs sql.NullInt64 `db:"challenge_consumed_at_ms"` + VerifiedAtMs sql.NullInt64 `db:"verified_at_ms"` + CreatedAtMs int64 `db:"created_at_ms"` + UpdatedAtMs int64 `db:"updated_at_ms"` +} + +const identityCols = `identity_id, provider_id, kind, value, status, proof_method, + pending_value, challenge_nonce, challenge_expires_at_ms, + challenge_consumed_at_ms, verified_at_ms, created_at_ms, updated_at_ms` + +func (r identityRow) toDomain() *domain.VerifiedIdentity { + v := &domain.VerifiedIdentity{ + IdentityID: r.IdentityID, + ProviderID: r.ProviderID, + Kind: domain.IdentifierKind(r.Kind), + Value: r.Value, + Status: domain.IdentityStatus(r.Status), + ProofMethod: r.ProofMethod, + PendingValue: r.PendingValue, + CreatedAt: msToTime(r.CreatedAtMs), + UpdatedAt: msToTime(r.UpdatedAtMs), + } + if r.VerifiedAtMs.Valid { + v.VerifiedAt = msToTime(r.VerifiedAtMs.Int64) + } + if r.ChallengeNonce.Valid && r.ChallengeNonce.String != "" { + ch := &domain.IdentityChallenge{Nonce: r.ChallengeNonce.String} + if r.ChallengeExpiresAtMs.Valid { + ch.ExpiresAt = msToTime(r.ChallengeExpiresAtMs.Int64) + } + if r.ChallengeConsumedAtMs.Valid { + t := msToTime(r.ChallengeConsumedAtMs.Int64) + ch.ConsumedAt = &t + } + v.Challenge = ch + } + return v +} + +// Save upserts the aggregate. The partial unique indexes enforce the +// live-row and proven-uniqueness rules; violations surface as +// conflict errors (the service maps them to IDENTIFIER_DUPLICATE). +func (s *IdentityStore) Save(ctx context.Context, v *domain.VerifiedIdentity) error { + var nonce sql.NullString + var expiresAt, consumedAt sql.NullInt64 + if v.Challenge != nil { + nonce = sql.NullString{String: v.Challenge.Nonce, Valid: true} + if !v.Challenge.ExpiresAt.IsZero() { + expiresAt = sql.NullInt64{Int64: v.Challenge.ExpiresAt.UnixMilli(), Valid: true} + } + if v.Challenge.ConsumedAt != nil { + consumedAt = sql.NullInt64{Int64: v.Challenge.ConsumedAt.UnixMilli(), Valid: true} + } + } + var verifiedAt sql.NullInt64 + if !v.VerifiedAt.IsZero() { + verifiedAt = sql.NullInt64{Int64: v.VerifiedAt.UnixMilli(), Valid: true} + } + const q = ` + INSERT INTO identities (` + identityCols + `) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(identity_id) DO UPDATE SET + value = excluded.value, + status = excluded.status, + proof_method = excluded.proof_method, + pending_value = excluded.pending_value, + challenge_nonce = excluded.challenge_nonce, + challenge_expires_at_ms = excluded.challenge_expires_at_ms, + challenge_consumed_at_ms = excluded.challenge_consumed_at_ms, + verified_at_ms = excluded.verified_at_ms, + updated_at_ms = excluded.updated_at_ms` + _, err := s.db.extx(ctx).ExecContext(ctx, q, + v.IdentityID, v.ProviderID, string(v.Kind), v.Value, string(v.Status), + v.ProofMethod, v.PendingValue, + nonce, expiresAt, consumedAt, verifiedAt, + v.CreatedAt.UnixMilli(), v.UpdatedAt.UnixMilli(), + ) + return mapSQLErr(err) +} + +// FindByID returns the identity with the given identityId. +func (s *IdentityStore) FindByID(ctx context.Context, identityID string) (*domain.VerifiedIdentity, error) { + var r identityRow + err := s.db.extx(ctx).GetContext(ctx, &r, + `SELECT `+identityCols+` FROM identities WHERE identity_id = ?`, identityID) + if err != nil { + return nil, mapSQLErr(err) + } + return r.toDomain(), nil +} + +// FindLive returns the owner's non-REVOKED row for (kind, value). +func (s *IdentityStore) FindLive( + ctx context.Context, + providerID string, + kind domain.IdentifierKind, + value string, +) (*domain.VerifiedIdentity, error) { + var r identityRow + err := s.db.extx(ctx).GetContext(ctx, &r, + `SELECT `+identityCols+` + FROM identities + WHERE provider_id = ? AND kind = ? AND value = ? AND status != 'REVOKED'`, + providerID, string(kind), value) + if err != nil { + return nil, mapSQLErr(err) + } + return r.toDomain(), nil +} + +// ExistsVerified reports whether any owner holds a VERIFIED row for +// (kind, value). +func (s *IdentityStore) ExistsVerified(ctx context.Context, kind domain.IdentifierKind, value string) (bool, error) { + var one int + err := s.db.extx(ctx).GetContext(ctx, &one, ` + SELECT 1 FROM identities + WHERE kind = ? AND value = ? AND status = 'VERIFIED'`, + string(kind), value) + switch { + case err == nil: + return true, nil + case errors.Is(err, sql.ErrNoRows): + return false, nil + default: + return false, err + } +} + +// ListByOwner returns every identity owned by the principal, newest +// first. +func (s *IdentityStore) ListByOwner(ctx context.Context, providerID string) ([]*domain.VerifiedIdentity, error) { + var rows []identityRow + err := s.db.extx(ctx).SelectContext(ctx, &rows, + `SELECT `+identityCols+` + FROM identities + WHERE provider_id = ? + ORDER BY created_at_ms DESC, identity_id DESC`, providerID) + if err != nil { + return nil, mapSQLErr(err) + } + out := make([]*domain.VerifiedIdentity, 0, len(rows)) + for _, r := range rows { + out = append(out, r.toDomain()) + } + return out, nil +} + +// ConsumeChallenge atomically consumes the live challenge nonce. The +// conditional UPDATE is the TOCTOU guard: only one of two concurrent +// verify-control calls can flip challenge_consumed_at_ms from NULL, +// and an expired or superseded nonce matches zero rows. +func (s *IdentityStore) ConsumeChallenge(ctx context.Context, identityID, nonce string, now time.Time) error { + res, err := s.db.extx(ctx).ExecContext(ctx, ` + UPDATE identities + SET challenge_consumed_at_ms = ?, updated_at_ms = ? + WHERE identity_id = ? + AND challenge_nonce = ? + AND challenge_consumed_at_ms IS NULL + AND challenge_expires_at_ms > ?`, + now.UnixMilli(), now.UnixMilli(), identityID, nonce, now.UnixMilli()) + if err != nil { + return mapSQLErr(err) + } + n, err := res.RowsAffected() + if err != nil { + return err + } + if n != 1 { + return domain.NewInvalidStateError("PRICC_TOKEN_ALREADY_USED", + "challenge nonce is consumed, expired, or superseded") + } + return nil +} + +// IdentityLinkStore implements port.IdentityLinkStore. +type IdentityLinkStore struct{ db *DB } + +// NewIdentityLinkStore returns a new SQLite-backed IdentityLinkStore. +func NewIdentityLinkStore(db *DB) *IdentityLinkStore { return &IdentityLinkStore{db: db} } + +type identityLinkRow struct { + IdentityID string `db:"identity_id"` + AgentID string `db:"agent_id"` + Status string `db:"status"` + LinkedAtMs sql.NullInt64 `db:"linked_at_ms"` + CreatedAtMs int64 `db:"created_at_ms"` + UpdatedAtMs int64 `db:"updated_at_ms"` +} + +func (r identityLinkRow) toDomain() *domain.IdentityLink { + l := &domain.IdentityLink{ + IdentityID: r.IdentityID, + AgentID: r.AgentID, + Status: domain.LinkStatus(r.Status), + CreatedAt: msToTime(r.CreatedAtMs), + UpdatedAt: msToTime(r.UpdatedAtMs), + } + if r.LinkedAtMs.Valid { + l.LinkedAt = msToTime(r.LinkedAtMs.Int64) + } + return l +} + +// Link upserts a live link for the pair. Idempotent: returns false +// (and no error) when the pair is already LINKED, so batch calls can +// skip already-linked agents in the sealed event. +func (s *IdentityLinkStore) Link(ctx context.Context, identityID, agentID string, now time.Time) (bool, error) { + // Fast path: already live? + var exists int + err := s.db.extx(ctx).GetContext(ctx, &exists, ` + SELECT 1 FROM identity_links + WHERE identity_id = ? AND agent_id = ? AND status = 'LINKED'`, + identityID, agentID) + switch { + case err == nil: + return false, nil + case !errors.Is(err, sql.ErrNoRows): + return false, err + } + nowMs := now.UnixMilli() + _, err = s.db.extx(ctx).ExecContext(ctx, ` + INSERT INTO identity_links (identity_id, agent_id, status, linked_at_ms, created_at_ms, updated_at_ms) + VALUES (?, ?, 'LINKED', ?, ?, ?)`, + identityID, agentID, nowMs, nowMs, nowMs) + if err != nil { + return false, mapSQLErr(err) + } + return true, nil +} + +// Unlink flips the live link to UNLINKED. +func (s *IdentityLinkStore) Unlink(ctx context.Context, identityID, agentID string, now time.Time) error { + res, err := s.db.extx(ctx).ExecContext(ctx, ` + UPDATE identity_links + SET status = 'UNLINKED', updated_at_ms = ? + WHERE identity_id = ? AND agent_id = ? AND status = 'LINKED'`, + now.UnixMilli(), identityID, agentID) + if err != nil { + return mapSQLErr(err) + } + n, err := res.RowsAffected() + if err != nil { + return err + } + if n != 1 { + return domain.NewNotFoundError("LINK_NOT_FOUND", + "no live link exists for this identity and agent") + } + return nil +} + +// ListLiveByIdentity returns the identity's live links. +func (s *IdentityLinkStore) ListLiveByIdentity(ctx context.Context, identityID string) ([]*domain.IdentityLink, error) { + return s.listLive(ctx, `identity_id = ?`, identityID) +} + +// ListLiveByAgent returns the agent's live links. +func (s *IdentityLinkStore) ListLiveByAgent(ctx context.Context, agentID string) ([]*domain.IdentityLink, error) { + return s.listLive(ctx, `agent_id = ?`, agentID) +} + +func (s *IdentityLinkStore) listLive(ctx context.Context, where string, arg string) ([]*domain.IdentityLink, error) { + var rows []identityLinkRow + err := s.db.extx(ctx).SelectContext(ctx, &rows, ` + SELECT identity_id, agent_id, status, linked_at_ms, created_at_ms, updated_at_ms + FROM identity_links + WHERE `+where+` AND status = 'LINKED' + ORDER BY linked_at_ms DESC, id DESC`, arg) + if err != nil { + return nil, mapSQLErr(err) + } + out := make([]*domain.IdentityLink, 0, len(rows)) + for _, r := range rows { + out = append(out, r.toDomain()) + } + return out, nil +} diff --git a/internal/adapter/store/sqlite/identity_test.go b/internal/adapter/store/sqlite/identity_test.go new file mode 100644 index 0000000..7c90608 --- /dev/null +++ b/internal/adapter/store/sqlite/identity_test.go @@ -0,0 +1,299 @@ +package sqlite + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/godaddy/ans/internal/domain" +) + +var identityNow = time.Date(2026, 6, 10, 15, 0, 0, 0, time.UTC) + +func newIdentityFixture(t *testing.T, id, owner, value string) *domain.VerifiedIdentity { + t.Helper() + v, err := domain.NewVerifiedIdentity(id, owner, value, identityNow) + if err != nil { + t.Fatalf("fixture: %v", err) + } + return v +} + +func TestIdentityStore_SaveAndFind(t *testing.T) { + db := newTestDB(t) + store := NewIdentityStore(db) + ctx := context.Background() + + v := newIdentityFixture(t, "id-1", "owner-1", "did:web:identity.acme-corp.com") + if err := v.IssueChallenge("nonce-1", time.Hour, identityNow); err != nil { + t.Fatal(err) + } + if err := store.Save(ctx, v); err != nil { + t.Fatalf("save: %v", err) + } + + got, err := store.FindByID(ctx, "id-1") + if err != nil { + t.Fatalf("find: %v", err) + } + if got.ProviderID != "owner-1" || got.Kind != domain.KindDIDWeb || + got.Status != domain.IdentityPendingControl { + t.Fatalf("loaded identity wrong: %+v", got) + } + if got.Challenge == nil || got.Challenge.Nonce != "nonce-1" || got.Challenge.ConsumedAt != nil { + t.Fatalf("challenge wrong: %+v", got.Challenge) + } + if !got.Challenge.ExpiresAt.Equal(identityNow.Add(time.Hour)) { + t.Fatalf("expiry wrong: %v", got.Challenge.ExpiresAt) + } + + if _, err := store.FindByID(ctx, "missing"); !errors.Is(err, domain.ErrNotFound) { + t.Fatalf("missing should be not-found, got %v", err) + } +} + +func TestIdentityStore_SaveUpsertsLifecycle(t *testing.T) { + db := newTestDB(t) + store := NewIdentityStore(db) + ctx := context.Background() + + v := newIdentityFixture(t, "id-1", "owner-1", "did:web:a.com") + if err := store.Save(ctx, v); err != nil { + t.Fatal(err) + } + if _, err := v.CompleteVerification(identityNow.Add(time.Minute)); err != nil { + t.Fatal(err) + } + if err := store.Save(ctx, v); err != nil { + t.Fatalf("upsert: %v", err) + } + got, err := store.FindByID(ctx, "id-1") + if err != nil { + t.Fatal(err) + } + if got.Status != domain.IdentityVerified || got.ProofMethod != "did-web-sig" || got.VerifiedAt.IsZero() { + t.Fatalf("verified state lost: %+v", got) + } +} + +func TestIdentityStore_FindLiveAndRevokedFallout(t *testing.T) { + db := newTestDB(t) + store := NewIdentityStore(db) + ctx := context.Background() + + v := newIdentityFixture(t, "id-1", "owner-1", "did:web:a.com") + if err := store.Save(ctx, v); err != nil { + t.Fatal(err) + } + + live, err := store.FindLive(ctx, "owner-1", domain.KindDIDWeb, "did:web:a.com") + if err != nil || live.IdentityID != "id-1" { + t.Fatalf("find live: %+v %v", live, err) + } + // Wrong owner / kind / value → not found. + if _, err := store.FindLive(ctx, "owner-2", domain.KindDIDWeb, "did:web:a.com"); !errors.Is(err, domain.ErrNotFound) { + t.Fatalf("cross-owner FindLive: %v", err) + } + + // Revoke → falls out of the live index; re-registering the value + // succeeds with a fresh row. + if _, err := v.CompleteVerification(identityNow); err != nil { + t.Fatal(err) + } + if err := v.Revoke(identityNow.Add(time.Minute)); err != nil { + t.Fatal(err) + } + if err := store.Save(ctx, v); err != nil { + t.Fatal(err) + } + if _, err := store.FindLive(ctx, "owner-1", domain.KindDIDWeb, "did:web:a.com"); !errors.Is(err, domain.ErrNotFound) { + t.Fatalf("revoked row should not be live: %v", err) + } + fresh := newIdentityFixture(t, "id-2", "owner-1", "did:web:a.com") + if err := store.Save(ctx, fresh); err != nil { + t.Fatalf("re-register after revoke: %v", err) + } +} + +func TestIdentityStore_LiveUniquePerOwner(t *testing.T) { + db := newTestDB(t) + store := NewIdentityStore(db) + ctx := context.Background() + + if err := store.Save(ctx, newIdentityFixture(t, "id-1", "owner-1", "did:web:a.com")); err != nil { + t.Fatal(err) + } + err := store.Save(ctx, newIdentityFixture(t, "id-2", "owner-1", "did:web:a.com")) + if !errors.Is(err, domain.ErrConflict) { + t.Fatalf("duplicate live row should conflict, got %v", err) + } + // A different owner may hold a competing pending claim. + if err := store.Save(ctx, newIdentityFixture(t, "id-3", "owner-2", "did:web:a.com")); err != nil { + t.Fatalf("competing pending claim should be allowed: %v", err) + } +} + +func TestIdentityStore_ProvenUniqueAcrossOwners(t *testing.T) { + db := newTestDB(t) + store := NewIdentityStore(db) + ctx := context.Background() + + a := newIdentityFixture(t, "id-1", "owner-1", "did:web:a.com") + b := newIdentityFixture(t, "id-2", "owner-2", "did:web:a.com") + if err := store.Save(ctx, a); err != nil { + t.Fatal(err) + } + if err := store.Save(ctx, b); err != nil { + t.Fatal(err) + } + + // First to prove wins… + if _, err := a.CompleteVerification(identityNow); err != nil { + t.Fatal(err) + } + if err := store.Save(ctx, a); err != nil { + t.Fatalf("first proof: %v", err) + } + taken, err := store.ExistsVerified(ctx, domain.KindDIDWeb, "did:web:a.com") + if err != nil || !taken { + t.Fatalf("ExistsVerified after proof: %v %v", taken, err) + } + + // …the loser's verify-time flip violates the proven index. + if _, err := b.CompleteVerification(identityNow); err != nil { + t.Fatal(err) + } + if err := store.Save(ctx, b); !errors.Is(err, domain.ErrConflict) { + t.Fatalf("second proof should conflict, got %v", err) + } + + none, err := store.ExistsVerified(ctx, domain.KindDIDWeb, "did:web:other.com") + if err != nil || none { + t.Fatalf("ExistsVerified for unproven value: %v %v", none, err) + } +} + +func TestIdentityStore_ListByOwner(t *testing.T) { + db := newTestDB(t) + store := NewIdentityStore(db) + ctx := context.Background() + + first := newIdentityFixture(t, "id-1", "owner-1", "did:web:a.com") + second := newIdentityFixture(t, "id-2", "owner-1", "did:web:b.com") + second.CreatedAt = identityNow.Add(time.Minute) + second.UpdatedAt = second.CreatedAt + other := newIdentityFixture(t, "id-3", "owner-2", "did:web:c.com") + for _, v := range []*domain.VerifiedIdentity{first, second, other} { + if err := store.Save(ctx, v); err != nil { + t.Fatal(err) + } + } + + got, err := store.ListByOwner(ctx, "owner-1") + if err != nil { + t.Fatal(err) + } + if len(got) != 2 || got[0].IdentityID != "id-2" || got[1].IdentityID != "id-1" { + t.Fatalf("list wrong: %+v", got) + } +} + +func TestIdentityStore_ConsumeChallenge(t *testing.T) { + db := newTestDB(t) + store := NewIdentityStore(db) + ctx := context.Background() + + v := newIdentityFixture(t, "id-1", "owner-1", "did:web:a.com") + if err := v.IssueChallenge("nonce-1", time.Hour, identityNow); err != nil { + t.Fatal(err) + } + if err := store.Save(ctx, v); err != nil { + t.Fatal(err) + } + + // Wrong nonce → rejected, nothing consumed. + if err := store.ConsumeChallenge(ctx, "id-1", "wrong", identityNow.Add(time.Minute)); err == nil { + t.Fatal("wrong nonce must not consume") + } + // Expired → rejected. + if err := store.ConsumeChallenge(ctx, "id-1", "nonce-1", identityNow.Add(2*time.Hour)); err == nil { + t.Fatal("expired nonce must not consume") + } + // Fresh → consumed exactly once. + if err := store.ConsumeChallenge(ctx, "id-1", "nonce-1", identityNow.Add(time.Minute)); err != nil { + t.Fatalf("consume: %v", err) + } + err := store.ConsumeChallenge(ctx, "id-1", "nonce-1", identityNow.Add(2*time.Minute)) + if err == nil || !strings.Contains(err.Error(), "PRICC_TOKEN_ALREADY_USED") { + t.Fatalf("double consume: %v", err) + } + + got, err := store.FindByID(ctx, "id-1") + if err != nil { + t.Fatal(err) + } + if got.Challenge == nil || got.Challenge.ConsumedAt == nil { + t.Fatalf("consumption not persisted: %+v", got.Challenge) + } +} + +func TestIdentityLinkStore_Lifecycle(t *testing.T) { + db := newTestDB(t) + agents := NewAgentStore(db) + identities := NewIdentityStore(db) + links := NewIdentityLinkStore(db) + ctx := context.Background() + + // FK targets must exist. + if err := agents.Save(ctx, newAgentFixture(t, "agent-1", "a.example.com")); err != nil { + t.Fatal(err) + } + if err := agents.Save(ctx, newAgentFixture(t, "agent-2", "b.example.com")); err != nil { + t.Fatal(err) + } + if err := identities.Save(ctx, newIdentityFixture(t, "id-1", "owner-1", "did:web:a.com")); err != nil { + t.Fatal(err) + } + + created, err := links.Link(ctx, "id-1", "agent-1", identityNow) + if err != nil || !created { + t.Fatalf("link: created=%v err=%v", created, err) + } + // Idempotent re-link. + created, err = links.Link(ctx, "id-1", "agent-1", identityNow.Add(time.Minute)) + if err != nil || created { + t.Fatalf("re-link should be a no-op: created=%v err=%v", created, err) + } + if _, err := links.Link(ctx, "id-1", "agent-2", identityNow); err != nil { + t.Fatal(err) + } + + byIdentity, err := links.ListLiveByIdentity(ctx, "id-1") + if err != nil || len(byIdentity) != 2 { + t.Fatalf("live by identity: %d %v", len(byIdentity), err) + } + byAgent, err := links.ListLiveByAgent(ctx, "agent-1") + if err != nil || len(byAgent) != 1 || byAgent[0].Status != domain.LinkLinked { + t.Fatalf("live by agent: %+v %v", byAgent, err) + } + + // Unlink → drops out of live views; history row remains. + if err := links.Unlink(ctx, "id-1", "agent-1", identityNow.Add(time.Hour)); err != nil { + t.Fatalf("unlink: %v", err) + } + if err := links.Unlink(ctx, "id-1", "agent-1", identityNow); !errors.Is(err, domain.ErrNotFound) { + t.Fatalf("double unlink: %v", err) + } + byAgent, err = links.ListLiveByAgent(ctx, "agent-1") + if err != nil || len(byAgent) != 0 { + t.Fatalf("live by agent after unlink: %+v %v", byAgent, err) + } + + // Re-link after unlink — UNLINKED history never blocks. + created, err = links.Link(ctx, "id-1", "agent-1", identityNow.Add(2*time.Hour)) + if err != nil || !created { + t.Fatalf("re-link after unlink: created=%v err=%v", created, err) + } +} diff --git a/internal/adapter/store/sqlite/migrations/006_identities.sql b/internal/adapter/store/sqlite/migrations/006_identities.sql new file mode 100644 index 0000000..0675149 --- /dev/null +++ b/internal/adapter/store/sqlite/migrations/006_identities.sql @@ -0,0 +1,74 @@ +-- Verified Identities — the "who" behind an agent (the "what"). +-- +-- An identity is a first-class object owned by the providerId, proven +-- through a per-kind control proof, sealed onto its own Transparency +-- Log stream, and linked to any number of that owner's agents. The +-- agent_registrations table is UNCHANGED — agents carry no identity +-- fields; the association lives in the identity_links junction below. +-- +-- No public-key column anywhere (ANS-0 §6.2 key transience): proven +-- keys are sealed in the identity's TL events, never persisted as +-- live state. +CREATE TABLE IF NOT EXISTS identities ( + identity_id TEXT PRIMARY KEY, -- UUIDv7, RA-assigned + provider_id TEXT NOT NULL, -- owner (authentication principal) + -- kind carries the identifier kind ('did:web', 'did:key', 'lei', + -- and future kinds: 'did:plc', 'did:ion', …). Deliberately NO + -- CHECK constraint: kind validity is enforced by the domain's + -- closed dispatcher (domain.InferIdentifierKind) + the service's + -- control-verifier registry — one source of truth. A CHECK here + -- would force a table rebuild for every kind added. + kind TEXT NOT NULL, + value TEXT NOT NULL, -- canonical identifier + -- status IS checked: the lifecycle machine is genuinely frozen. + status TEXT NOT NULL CHECK (status IN ('PENDING_CONTROL', 'VERIFIED', 'REVOKED')), + proof_method TEXT NOT NULL DEFAULT '', + pending_value TEXT NOT NULL DEFAULT '', -- staged PUT replacement; '' unless rotating + challenge_nonce TEXT, -- transient anti-replay nonce + challenge_expires_at_ms INTEGER, + challenge_consumed_at_ms INTEGER, -- one-time-use guard, set in the verify tx + verified_at_ms INTEGER, + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL +); + +-- One LIVE row per (owner, identifier): re-add idempotency. REVOKED +-- rows fall out, so history never blocks an owner re-proving an +-- identity. +CREATE UNIQUE INDEX IF NOT EXISTS idx_identities_live + ON identities(provider_id, kind, value) WHERE status != 'REVOKED'; + +-- Global uniqueness of PROVEN identities: one (kind, value) is +-- VERIFIED by at most one owner across the RA; an unproven +-- PENDING_CONTROL row cannot squat. Competing claims race to +-- verify-control; first to PROVE wins (the loser's verify-time flip +-- violates this index → IDENTIFIER_DUPLICATE). +CREATE UNIQUE INDEX IF NOT EXISTS idx_identities_proven + ON identities(kind, value) WHERE status = 'VERIFIED'; + +CREATE INDEX IF NOT EXISTS idx_identities_owner + ON identities(provider_id, created_at_ms DESC); + +-- identity_links is the one-to-many (formally many-to-many: an agent +-- legitimately carries several identities — a did:web AND an lei) +-- junction between an owner's identities and that same owner's +-- agents. Rows are read-side caches of the sealed IDENTITY_LINKED / +-- IDENTITY_UNLINKED events. No challenge columns: links carry no +-- proof — a link is a single owner-gated call (§4.3). +CREATE TABLE IF NOT EXISTS identity_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + identity_id TEXT NOT NULL REFERENCES identities(identity_id), + agent_id TEXT NOT NULL REFERENCES agent_registrations(agent_id), + status TEXT NOT NULL CHECK (status IN ('LINKED', 'UNLINKED')), + linked_at_ms INTEGER, + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL +); + +-- One live link per (identity, agent) pair; UNLINKED rows are history +-- and never block re-linking. +CREATE UNIQUE INDEX IF NOT EXISTS idx_identity_links_live + ON identity_links(identity_id, agent_id) WHERE status = 'LINKED'; + +CREATE INDEX IF NOT EXISTS idx_identity_links_agent + ON identity_links(agent_id, status); diff --git a/internal/adapter/store/sqlite/migrations/007_outbox_identity_lane.sql b/internal/adapter/store/sqlite/migrations/007_outbox_identity_lane.sql new file mode 100644 index 0000000..670bee0 --- /dev/null +++ b/internal/adapter/store/sqlite/migrations/007_outbox_identity_lane.sql @@ -0,0 +1,41 @@ +-- Third outbox lane: identity events. +-- +-- The schema_version column routes each outbox row to its TL ingest +-- lane ('V1' → /v1/internal/agents/event, 'V2' → +-- /v2/internal/agents/event). Identity events ride a third lane, +-- 'IDENTITY' → /v1/internal/identities/event — same producer +-- signature, same replay-verbatim invariant, different inner-event +-- schema (keyed by identityId). +-- +-- SQLite cannot widen a column CHECK in place, so this rebuilds the +-- table with the widened constraint (the standard +-- create-copy-drop-rename dance; the migration runner wraps it in one +-- transaction). Index is recreated to match 001's definition. +CREATE TABLE outbox_events_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_type TEXT NOT NULL, + agent_id TEXT NOT NULL, + schema_version TEXT NOT NULL DEFAULT 'V2' + CHECK (schema_version IN ('V1', 'V2', 'IDENTITY')), + payload_json TEXT NOT NULL CHECK (json_valid(payload_json)), + attempts INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + next_attempt_at_ms INTEGER NOT NULL, + sent_at_ms INTEGER, + created_at_ms INTEGER NOT NULL +); + +INSERT INTO outbox_events_new ( + id, event_type, agent_id, schema_version, payload_json, + attempts, last_error, next_attempt_at_ms, sent_at_ms, created_at_ms +) +SELECT id, event_type, agent_id, schema_version, payload_json, + attempts, last_error, next_attempt_at_ms, sent_at_ms, created_at_ms +FROM outbox_events; + +DROP TABLE outbox_events; + +ALTER TABLE outbox_events_new RENAME TO outbox_events; + +CREATE INDEX IF NOT EXISTS idx_outbox_next + ON outbox_events(sent_at_ms, next_attempt_at_ms); diff --git a/internal/adapter/store/sqlite/outbox.go b/internal/adapter/store/sqlite/outbox.go index d17185a..6e59de6 100644 --- a/internal/adapter/store/sqlite/outbox.go +++ b/internal/adapter/store/sqlite/outbox.go @@ -43,8 +43,10 @@ func NewOutboxStore(db *DB) *OutboxStore { return &OutboxStore{db: db} } // preceded it — that's how we guarantee at-least-once delivery // without a dual-write window. // -// schemaVersion must be "V1" or "V2"; the worker reads this value -// to pick the matching TL ingest lane. An empty value is rejected. +// schemaVersion must be "V1", "V2", or "IDENTITY"; the worker reads +// this value to pick the matching TL ingest lane. An empty value is +// rejected. For identity events the agentID parameter carries the +// identityId — the row's subject, whatever the event family. func (s *OutboxStore) Enqueue( ctx context.Context, eventType, agentID, schemaVersion string, @@ -55,9 +57,9 @@ func (s *OutboxStore) Enqueue( return 0, errors.New("sqlite/outbox: payload is empty") } switch schemaVersion { - case "V1", "V2": + case "V1", "V2", "IDENTITY": default: - return 0, fmt.Errorf("sqlite/outbox: invalid schemaVersion %q (want V1 or V2)", schemaVersion) + return 0, fmt.Errorf("sqlite/outbox: invalid schemaVersion %q (want V1, V2, or IDENTITY)", schemaVersion) } const q = ` INSERT INTO outbox_events(event_type, agent_id, schema_version, payload_json, diff --git a/internal/adapter/tlclient/client.go b/internal/adapter/tlclient/client.go index 315b684..d898edc 100644 --- a/internal/adapter/tlclient/client.go +++ b/internal/adapter/tlclient/client.go @@ -57,14 +57,20 @@ type AppendResult struct { // versions error rather than defaulting silently, because a wrong // lane means the TL will reject the body with a 422 that looks like // a signature failure — very hard to debug. +// +// "IDENTITY" is the third lane: the IDENTITY_* event family, keyed +// by identityId, riding the same producer-signature discipline into +// the same Merkle tree via its own ingest route. func ingestPathForVersion(schemaVersion string) (string, error) { switch schemaVersion { case "V1": return "/v1/internal/agents/event", nil case "V2": return "/v2/internal/agents/event", nil + case "IDENTITY": + return "/v1/internal/identities/event", nil default: - return "", fmt.Errorf("tlclient: unknown schemaVersion %q (want V1 or V2)", schemaVersion) + return "", fmt.Errorf("tlclient: unknown schemaVersion %q (want V1, V2, or IDENTITY)", schemaVersion) } } diff --git a/internal/config/config.go b/internal/config/config.go index 4a32a87..e7c813e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -108,6 +108,30 @@ type DNS struct { Server string `koanf:"server"` } +// Identity holds Verified Identity (the "who") configuration — the +// /v2/ans/identities surface (RA only). +type Identity struct { + // Resolver selects the did:web document fetcher. + Resolver IdentityResolver `koanf:"resolver"` + // ChallengeTTL bounds the verify-control nonce (default 1h; 5m + // is the design floor for high-assurance deployments). + ChallengeTTL time.Duration `koanf:"challenge-ttl"` + // RegisterRateLimit is the per-owner register/rotate budget per + // minute (default 10) — each call can trigger an outbound + // did:web fetch before any proof exists. + RegisterRateLimit int `koanf:"register-rate-limit"` +} + +// IdentityResolver selects the did:web resolver adapter. "noop" +// performs no I/O and synthesizes the DID document from the keys +// embedded in the submitted proofs (quickstart — signature +// verification still genuinely runs, only the live-document binding +// is waived; NOT for production); "web" performs the hardened HTTPS +// fetch with WebPKI validation and SSRF dialer guards. +type IdentityResolver struct { + Type string `koanf:"type"` // "noop" | "web" +} + // Keys holds key-manager configuration. type Keys struct { Type string `koanf:"type"` // "file" @@ -194,6 +218,7 @@ type RAConfig struct { Auth Auth `koanf:"auth"` CA CA `koanf:"ca"` DNS DNS `koanf:"dns"` + Identity Identity `koanf:"identity"` Keys Keys `koanf:"keys"` Store Store `koanf:"store"` TLClient TLClient `koanf:"tl-client"` @@ -338,6 +363,17 @@ func (c *RAConfig) Validate() error { default: return fmt.Errorf("dns.type %q not supported (expected 'noop' or 'lookup')", c.DNS.Type) } + 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") + } if err := validateKeys(&c.Keys); err != nil { return err } diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 39817f3..00ee8df 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -20,6 +20,11 @@ func defaultRAConfig() *RAConfig { }, }, DNS: DNS{Type: "noop"}, + Identity: Identity{ + Resolver: IdentityResolver{Type: "noop"}, + ChallengeTTL: time.Hour, + RegisterRateLimit: 10, + }, Keys: Keys{ Type: "file", File: &KeysFile{Path: "./data/ra/keys"}, diff --git a/internal/crypto/jwk.go b/internal/crypto/jwk.go new file mode 100644 index 0000000..5d75584 --- /dev/null +++ b/internal/crypto/jwk.go @@ -0,0 +1,342 @@ +package crypto + +import ( + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "math/big" + "strings" +) + +// This file holds the JWK / multibase key plumbing for the Verified +// Identities control proofs. +// +// Key-type allowlist: everything the JWS layer can honestly verify — +// Ed25519 (EdDSA), ECDSA P-256 (ES256), and RSA ≥ 2048 (RS256). +// Anything else is rejected with a *precise* error rather than a +// generic one: X25519 is a key-agreement key and can never sign; +// secp256k1 (ES256K) and the larger NIST curves have no verifier in +// this codebase. Admitting a new type means adding its verification +// arm in jws.go first — the allowlist here only names what that +// layer supports. + +// ErrJWK classifies JWK parse/validation failures. +var ErrJWK = errors.New("crypto/jwk: invalid JWK") + +// jwkFields is the wire shape of the JWK subset we accept. +type jwkFields struct { + Kty string `json:"kty"` + Crv string `json:"crv"` + X string `json:"x"` + Y string `json:"y"` + N string `json:"n"` + E string `json:"e"` +} + +// ParseJWK parses a JWK into a verifying public key: +// +// kty=EC, crv=P-256 → *ecdsa.PublicKey (ES256) +// kty=OKP, crv=Ed25519 → ed25519.PublicKey (EdDSA) +// kty=RSA → *rsa.PublicKey (RS256; CheckKeyStrength enforced) +// +// Everything else fails with a message naming exactly why. +func ParseJWK(raw json.RawMessage) (any, error) { + var f jwkFields + if err := json.Unmarshal(raw, &f); err != nil { + return nil, fmt.Errorf("%w: %w", ErrJWK, err) + } + switch f.Kty { + case "EC": + return parseECJWK(f) + case "OKP": + return parseOKPJWK(f) + case "RSA": + return parseRSAJWK(f) + default: + return nil, fmt.Errorf("%w: unsupported kty %q (supported: EC/P-256, OKP/Ed25519, RSA)", ErrJWK, f.Kty) + } +} + +func parseECJWK(f jwkFields) (*ecdsa.PublicKey, error) { + if f.Crv != "P-256" { + return nil, fmt.Errorf("%w: EC curve %q has no verifier here (supported: P-256)", ErrJWK, f.Crv) + } + x, err := base64.RawURLEncoding.DecodeString(f.X) + if err != nil { + return nil, fmt.Errorf("%w: decode x: %w", ErrJWK, err) + } + y, err := base64.RawURLEncoding.DecodeString(f.Y) + if err != nil { + return nil, fmt.Errorf("%w: decode y: %w", ErrJWK, err) + } + if len(x) != 32 || len(y) != 32 { + return nil, fmt.Errorf("%w: P-256 coordinates must be 32 bytes", ErrJWK) + } + // 0x04 || X || Y is the uncompressed SEC1 form; + // ParseUncompressedPublicKey performs the on-curve check. + point := make([]byte, 0, 65) + point = append(point, 0x04) + point = append(point, x...) + point = append(point, y...) + pub, err := ecdsa.ParseUncompressedPublicKey(elliptic.P256(), point) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrJWK, err) + } + return pub, nil +} + +func parseOKPJWK(f jwkFields) (ed25519.PublicKey, error) { + switch f.Crv { + case "Ed25519": + x, err := base64.RawURLEncoding.DecodeString(f.X) + if err != nil { + return nil, fmt.Errorf("%w: decode x: %w", ErrJWK, err) + } + if len(x) != ed25519.PublicKeySize { + return nil, fmt.Errorf("%w: Ed25519 key must be %d bytes", ErrJWK, ed25519.PublicKeySize) + } + return ed25519.PublicKey(x), nil + case "X25519": + return nil, fmt.Errorf("%w: X25519 is a key-agreement key, not a signing key — it cannot prove control", ErrJWK) + default: + return nil, fmt.Errorf("%w: unsupported OKP curve %q (supported: Ed25519)", ErrJWK, f.Crv) + } +} + +func parseRSAJWK(f jwkFields) (*rsa.PublicKey, error) { + n, err := base64.RawURLEncoding.DecodeString(f.N) + if err != nil { + return nil, fmt.Errorf("%w: decode n: %w", ErrJWK, err) + } + e, err := base64.RawURLEncoding.DecodeString(f.E) + if err != nil { + return nil, fmt.Errorf("%w: decode e: %w", ErrJWK, err) + } + if len(n) == 0 || len(e) == 0 || len(e) > 8 { + return nil, fmt.Errorf("%w: RSA JWK requires n and a small e", ErrJWK) + } + pub := &rsa.PublicKey{ + N: new(big.Int).SetBytes(n), + E: int(new(big.Int).SetBytes(e).Int64()), + } + if err := CheckKeyStrength(pub); err != nil { + return nil, fmt.Errorf("%w: %w", ErrJWK, err) + } + return pub, nil +} + +// PublicKeyToJWK renders a supported public key as a JWK. Used by +// registrant-side tooling (the demo signer's embedded `jwk` header) +// and tests — never by sealing, which quotes the DID document's +// verification method verbatim instead of re-encoding anything. +func PublicKeyToJWK(pub any) (json.RawMessage, error) { + switch key := pub.(type) { + case *ecdsa.PublicKey: + x, y, err := p256Coordinates(key) + if err != nil { + return nil, err + } + return marshalJWK(map[string]string{ + "kty": "EC", "crv": "P-256", + "x": base64.RawURLEncoding.EncodeToString(x), + "y": base64.RawURLEncoding.EncodeToString(y), + }) + case ed25519.PublicKey: + return marshalJWK(map[string]string{ + "kty": "OKP", "crv": "Ed25519", + "x": base64.RawURLEncoding.EncodeToString(key), + }) + case *rsa.PublicKey: + return marshalJWK(map[string]string{ + "kty": "RSA", + "n": base64.RawURLEncoding.EncodeToString(key.N.Bytes()), + "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.E)).Bytes()), + }) + default: + return nil, fmt.Errorf("%w: unsupported public key type %T", ErrJWK, pub) + } +} + +func marshalJWK(members map[string]string) (json.RawMessage, error) { + raw, err := json.Marshal(members) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrJWK, err) + } + return raw, nil +} + +// p256Coordinates extracts the 32-byte X and Y coordinates from a +// P-256 public key via its uncompressed SEC1 encoding (the +// non-deprecated accessor path). +func p256Coordinates(pub *ecdsa.PublicKey) ([]byte, []byte, error) { + if pub == nil || pub.Curve != elliptic.P256() { + return nil, nil, fmt.Errorf("%w: not a P-256 key", ErrJWK) + } + point, err := pub.Bytes() + if err != nil { + return nil, nil, fmt.Errorf("%w: %w", ErrJWK, err) + } + if len(point) != 65 || point[0] != 0x04 { + return nil, nil, fmt.Errorf("%w: unexpected SEC1 encoding", ErrJWK) + } + return point[1:33], point[33:65], nil +} + +// Multicodec prefixes (unsigned varint encoding) for the key types +// did:key and Multikey carry. ed25519-pub is code 0xed → varint +// {0xed, 0x01}; p256-pub is code 0x1200 → varint {0x80, 0x24}. +const ( + multicodecEd25519Hi = 0xed + multicodecEd25519Lo = 0x01 + multicodecP256Hi = 0x80 + multicodecP256Lo = 0x24 +) + +// DecodeMultibase decodes a multibase-encoded public key (a did:key +// method-specific id or a DID document's publicKeyMultibase) into a +// verifying key. Only base58btc ('z' prefix) is accepted — the +// encoding both specs mandate. Supported multicodecs: ed25519-pub +// and p256-pub; others fail with the prefix named. +func DecodeMultibase(encoded string) (any, error) { + if !strings.HasPrefix(encoded, "z") { + return nil, fmt.Errorf("%w: multibase key must be base58btc ('z' prefix)", ErrJWK) + } + raw, err := base58Decode(encoded[1:]) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrJWK, err) + } + if len(raw) <= 2 { + return nil, fmt.Errorf("%w: multibase payload too short", ErrJWK) + } + switch { + case raw[0] == multicodecEd25519Hi && raw[1] == multicodecEd25519Lo: + key := raw[2:] + if len(key) != ed25519.PublicKeySize { + return nil, fmt.Errorf("%w: ed25519-pub key must be %d bytes, got %d", ErrJWK, ed25519.PublicKeySize, len(key)) + } + return ed25519.PublicKey(key), nil + case raw[0] == multicodecP256Hi && raw[1] == multicodecP256Lo: + key := raw[2:] + if len(key) != 33 { + return nil, fmt.Errorf("%w: p256-pub key must be 33 compressed bytes, got %d", ErrJWK, len(key)) + } + // SAFETY: elliptic.UnmarshalCompressed is the only stdlib + // decoder for compressed SEC1 points (crypto/ecdh and + // ecdsa.ParseUncompressedPublicKey accept uncompressed + // only). It validates on-curve internally and returns nil + // on malformed input; we immediately re-encode to the + // uncompressed form and round-trip through the supported + // parser so the deprecated surface stays contained here. + x, y := elliptic.UnmarshalCompressed(elliptic.P256(), key) + if x == nil { + return nil, fmt.Errorf("%w: invalid compressed P-256 point", ErrJWK) + } + point := make([]byte, 0, 65) + point = append(point, 0x04) + point = append(point, x.FillBytes(make([]byte, 32))...) + point = append(point, y.FillBytes(make([]byte, 32))...) + pub, err := ecdsa.ParseUncompressedPublicKey(elliptic.P256(), point) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrJWK, err) + } + return pub, nil + default: + return nil, fmt.Errorf("%w: unsupported multicodec prefix 0x%02x%02x (supported: ed25519-pub, p256-pub)", ErrJWK, raw[0], raw[1]) + } +} + +// EncodeMultibase renders a supported public key in the did:key / +// Multikey form: 'z' + base58btc(multicodec varint || key bytes). +// The inverse of DecodeMultibase; used by tooling that mints did:key +// identifiers. +func EncodeMultibase(pub any) (string, error) { + switch key := pub.(type) { + case ed25519.PublicKey: + payload := make([]byte, 0, 2+len(key)) + payload = append(payload, multicodecEd25519Hi, multicodecEd25519Lo) + payload = append(payload, key...) + return "z" + base58Encode(payload), nil + case *ecdsa.PublicKey: + x, y, err := p256Coordinates(key) + if err != nil { + return "", err + } + // SAFETY: see DecodeMultibase — MarshalCompressed is the only + // stdlib encoder for compressed SEC1 points. + compressed := elliptic.MarshalCompressed( + elliptic.P256(), new(big.Int).SetBytes(x), new(big.Int).SetBytes(y)) + payload := make([]byte, 0, 2+len(compressed)) + payload = append(payload, multicodecP256Hi, multicodecP256Lo) + payload = append(payload, compressed...) + return "z" + base58Encode(payload), nil + default: + return "", fmt.Errorf("%w: unsupported public key type %T", ErrJWK, pub) + } +} + +// DecodeDIDKey decodes a did:key identifier into its public key and +// the verification-method id the did:key method defines +// ("{did}#{method-specific-id}"). +func DecodeDIDKey(did string) (any, string, error) { + msid, ok := strings.CutPrefix(did, "did:key:") + if !ok || msid == "" { + return nil, "", fmt.Errorf("%w: not a did:key identifier", ErrJWK) + } + pub, err := DecodeMultibase(msid) + if err != nil { + return nil, "", err + } + return pub, did + "#" + msid, nil +} + +// base58Alphabet is the Bitcoin base58 alphabet, the one multibase +// 'z' (base58btc) uses. +const base58Alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + +// base58Decode decodes a base58btc string. Hand-rolled (~30 lines) +// rather than importing the multiformats stack for one codec. +func base58Decode(s string) ([]byte, error) { + if s == "" { + return nil, errors.New("empty base58 input") + } + radix := big.NewInt(58) + n := new(big.Int) + for _, r := range s { + idx := strings.IndexRune(base58Alphabet, r) + if idx < 0 { + return nil, fmt.Errorf("invalid base58 character %q", r) + } + n.Mul(n, radix) + n.Add(n, big.NewInt(int64(idx))) + } + var out []byte + if n.Sign() > 0 { + out = n.Bytes() + } + // Leading '1's encode leading zero bytes. + for i := 0; i < len(s) && s[i] == '1'; i++ { + out = append([]byte{0x00}, out...) + } + return out, nil +} + +// base58Encode encodes bytes as base58btc. +func base58Encode(b []byte) string { + n := new(big.Int).SetBytes(b) + radix := big.NewInt(58) + mod := new(big.Int) + var out []byte + for n.Sign() > 0 { + n.DivMod(n, radix, mod) + out = append([]byte{base58Alphabet[mod.Int64()]}, out...) + } + for i := 0; i < len(b) && b[i] == 0x00; i++ { + out = append([]byte{'1'}, out...) + } + return string(out) +} diff --git a/internal/crypto/jwk_test.go b/internal/crypto/jwk_test.go new file mode 100644 index 0000000..814668c --- /dev/null +++ b/internal/crypto/jwk_test.go @@ -0,0 +1,329 @@ +package crypto + +import ( + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "strings" + "testing" +) + +func genP256(t *testing.T) *ecdsa.PrivateKey { + t.Helper() + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + return priv +} + +func genEd25519(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) { + t.Helper() + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + return pub, priv +} + +func TestJWKRoundTrip_AllKinds(t *testing.T) { + t.Parallel() + + p256 := genP256(t) + edPub, _ := genEd25519(t) + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + + cases := []struct { + name string + pub any + }{ + {"P-256", &p256.PublicKey}, + {"Ed25519", edPub}, + {"RSA", &rsaKey.PublicKey}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + jwk, err := PublicKeyToJWK(tc.pub) + if err != nil { + t.Fatalf("PublicKeyToJWK: %v", err) + } + back, err := ParseJWK(jwk) + if err != nil { + t.Fatalf("ParseJWK: %v", err) + } + switch want := tc.pub.(type) { + case *ecdsa.PublicKey: + if got, ok := back.(*ecdsa.PublicKey); !ok || !got.Equal(want) { + t.Fatal("P-256 round trip lost the key") + } + case ed25519.PublicKey: + if got, ok := back.(ed25519.PublicKey); !ok || !got.Equal(want) { + t.Fatal("Ed25519 round trip lost the key") + } + case *rsa.PublicKey: + if got, ok := back.(*rsa.PublicKey); !ok || !got.Equal(want) { + t.Fatal("RSA round trip lost the key") + } + } + }) + } +} + +func TestParseJWKRejections(t *testing.T) { + t.Parallel() + cases := []struct { + name string + raw string + want string + }{ + {"not json", `{`, "invalid JWK"}, + {"unknown kty", `{"kty":"oct","k":"AA"}`, "unsupported kty"}, + {"EC wrong curve", `{"kty":"EC","crv":"P-384","x":"AA","y":"AA"}`, "no verifier here"}, + {"EC secp256k1", `{"kty":"EC","crv":"secp256k1","x":"AA","y":"AA"}`, "no verifier here"}, + {"EC bad x b64", `{"kty":"EC","crv":"P-256","x":"!!!","y":"AA"}`, "decode x"}, + {"EC bad y b64", `{"kty":"EC","crv":"P-256","x":"AA","y":"!!!"}`, "decode y"}, + {"EC short coords", `{"kty":"EC","crv":"P-256","x":"AA","y":"AA"}`, "32 bytes"}, + {"OKP X25519 is key agreement", `{"kty":"OKP","crv":"X25519","x":"9GXjPGGvmRq9F6Ng5dQQ_s31mfhxrcNZxRGONrmH30k"}`, "key-agreement key"}, + {"OKP unknown curve", `{"kty":"OKP","crv":"Ed448","x":"AA"}`, "unsupported OKP curve"}, + {"OKP bad x b64", `{"kty":"OKP","crv":"Ed25519","x":"!!!"}`, "decode x"}, + {"OKP short key", `{"kty":"OKP","crv":"Ed25519","x":"AA"}`, "32 bytes"}, + {"RSA bad n", `{"kty":"RSA","n":"!!!","e":"AQAB"}`, "decode n"}, + {"RSA bad e", `{"kty":"RSA","n":"AQAB","e":"!!!"}`, "decode e"}, + {"RSA missing members", `{"kty":"RSA"}`, "requires n"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := ParseJWK(json.RawMessage(tc.raw)) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("want error containing %q, got %v", tc.want, err) + } + }) + } + + // Off-curve point: valid lengths, garbage coordinates. + off := `{"kty":"EC","crv":"P-256","x":"` + strings.Repeat("A", 43) + `","y":"` + strings.Repeat("B", 43) + `"}` + if _, err := ParseJWK(json.RawMessage(off)); err == nil { + t.Error("off-curve point should be rejected") + } + // Weak RSA key (1024-bit) fails CheckKeyStrength. + weak, err := rsa.GenerateKey(rand.Reader, 1024) + if err != nil { + t.Fatal(err) + } + weakJWK, err := PublicKeyToJWK(&weak.PublicKey) + if err != nil { + t.Fatal(err) + } + if _, err := ParseJWK(weakJWK); err == nil { + t.Error("1024-bit RSA should be rejected") + } +} + +func TestPublicKeyToJWKRejectsUnsupported(t *testing.T) { + t.Parallel() + if _, err := PublicKeyToJWK(nil); err == nil { + t.Error("nil key should fail") + } + p384, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + if err != nil { + t.Fatal(err) + } + if _, err := PublicKeyToJWK(&p384.PublicKey); err == nil { + t.Error("P-384 key should fail") + } +} + +func TestMultibaseRoundTrip(t *testing.T) { + t.Parallel() + + p256 := genP256(t) + edPub, _ := genEd25519(t) + + for _, tc := range []struct { + name string + pub any + }{ + {"P-256", &p256.PublicKey}, + {"Ed25519", edPub}, + } { + t.Run(tc.name, func(t *testing.T) { + encoded, err := EncodeMultibase(tc.pub) + if err != nil { + t.Fatalf("encode: %v", err) + } + if !strings.HasPrefix(encoded, "z") { + t.Fatalf("multibase prefix: %s", encoded) + } + back, err := DecodeMultibase(encoded) + if err != nil { + t.Fatalf("decode: %v", err) + } + switch want := tc.pub.(type) { + case *ecdsa.PublicKey: + if got, ok := back.(*ecdsa.PublicKey); !ok || !got.Equal(want) { + t.Fatal("P-256 round trip lost the key") + } + case ed25519.PublicKey: + if got, ok := back.(ed25519.PublicKey); !ok || !got.Equal(want) { + t.Fatal("Ed25519 round trip lost the key") + } + } + }) + } + + // The canonical did:key Ed25519 prefix. + encoded, err := EncodeMultibase(edPub) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(encoded, "z6Mk") { + t.Fatalf("ed25519 did:key form should start z6Mk, got %s", encoded[:6]) + } +} + +func TestDecodeMultibaseRejections(t *testing.T) { + t.Parallel() + cases := []struct { + name string + in string + want string + }{ + {"no z prefix", "Qabc", "base58btc"}, + {"bad base58", "z0OIl", "invalid base58"}, + {"empty payload", "z", "empty base58"}, + {"too short", "z" + base58Encode([]byte{0xed}), "too short"}, + {"unknown codec", "z" + base58Encode([]byte{0x01, 0x02, 0x03}), "unsupported multicodec"}, + {"wrong p256 length", "z" + base58Encode(append([]byte{0x80, 0x24}, make([]byte, 10)...)), "33 compressed bytes"}, + {"wrong ed25519 length", "z" + base58Encode(append([]byte{0xed, 0x01}, make([]byte, 10)...)), "32 bytes"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := DecodeMultibase(tc.in) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("want error containing %q, got %v", tc.want, err) + } + }) + } + // Invalid compressed point: right length, but 0x05 is not a + // valid SEC1 compression prefix (must be 0x02/0x03). + garbage := append([]byte{0x80, 0x24, 0x05}, make([]byte, 32)...) + if _, err := DecodeMultibase("z" + base58Encode(garbage)); err == nil { + t.Error("invalid compressed point should fail") + } +} + +func TestEncodeMultibaseRejectsUnsupported(t *testing.T) { + t.Parallel() + if _, err := EncodeMultibase(nil); err == nil { + t.Error("nil key should fail") + } + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + if _, err := EncodeMultibase(&rsaKey.PublicKey); err == nil { + t.Error("RSA has no multicodec form here") + } +} + +func TestDecodeDIDKey(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + pub any + }{ + {"P-256", func() any { k := genP256(t); return &k.PublicKey }()}, + {"Ed25519", func() any { k, _ := genEd25519(t); return k }()}, + } { + t.Run(tc.name, func(t *testing.T) { + msid, err := EncodeMultibase(tc.pub) + if err != nil { + t.Fatal(err) + } + did := "did:key:" + msid + pub, kid, err := DecodeDIDKey(did) + if err != nil { + t.Fatalf("decode: %v", err) + } + if kid != did+"#"+msid { + t.Fatalf("kid = %s", kid) + } + if pub == nil { + t.Fatal("nil key") + } + }) + } + + if _, _, err := DecodeDIDKey("did:web:a.com"); err == nil { + t.Error("non-did:key should fail") + } + if _, _, err := DecodeDIDKey("did:key:"); err == nil { + t.Error("empty msid should fail") + } +} + +func TestBase58RoundTrip(t *testing.T) { + t.Parallel() + cases := [][]byte{ + {}, + {0x00}, + {0x00, 0x00, 0x01}, + {0xff, 0xfe, 0xfd}, + []byte("hello base58 world"), + } + for _, in := range cases { + enc := base58Encode(in) + if len(in) == 0 { + if enc != "" { + t.Errorf("empty input encoded to %q", enc) + } + continue + } + got, err := base58Decode(enc) + if err != nil { + t.Fatalf("decode %q: %v", enc, err) + } + if string(got) != string(in) { + t.Errorf("round trip %x → %s → %x", in, enc, got) + } + } +} + +// TestEdDSAJWSVerify pins the EdDSA arm in the standard-JWS verifier: +// an Ed25519 signature over the raw signing input (RFC 8037 — no +// prehash) verifies, and a P-256 key claiming EdDSA is rejected by +// the alg↔key pin. +func TestEdDSAJWSVerify(t *testing.T) { + t.Parallel() + pub, priv := genEd25519(t) + + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"EdDSA","kid":"k1"}`)) + payload := base64.RawURLEncoding.EncodeToString([]byte(`{"hello":"world"}`)) + signingInput := header + "." + payload + sig := ed25519.Sign(priv, []byte(signingInput)) + jws := signingInput + "." + base64.RawURLEncoding.EncodeToString(sig) + + if _, err := VerifyStandardJWSWithPublicKey(pub, jws); err != nil { + t.Fatalf("EdDSA verify: %v", err) + } + + // Tampered payload fails. + bad := header + "." + base64.RawURLEncoding.EncodeToString([]byte(`{"hello":"mars"}`)) + + "." + base64.RawURLEncoding.EncodeToString(sig) + if _, err := VerifyStandardJWSWithPublicKey(pub, bad); err == nil { + t.Fatal("tampered EdDSA payload must fail") + } + + // Alg/key confusion: EdDSA header with a P-256 key. + p256 := genP256(t) + if _, err := VerifyStandardJWSWithPublicKey(&p256.PublicKey, jws); err == nil { + t.Fatal("EdDSA header must not verify with an ECDSA key") + } +} diff --git a/internal/crypto/jws.go b/internal/crypto/jws.go index 3931fb9..14c1490 100644 --- a/internal/crypto/jws.go +++ b/internal/crypto/jws.go @@ -3,6 +3,7 @@ package crypto import ( "context" "crypto/ecdsa" + "crypto/ed25519" "crypto/rsa" "crypto/sha256" "crypto/x509" @@ -22,6 +23,7 @@ import ( const ( AlgES256 = "ES256" // ECDSA P-256 + SHA-256 (primary) AlgRS256 = "RS256" // RSA PKCS#1v1.5 + SHA-256 (interop only) + AlgEdDSA = "EdDSA" // Ed25519 over the raw signing input (identity proofs) ) // Sentinel errors. @@ -62,6 +64,12 @@ type JWSProtectedHeader struct { // The TL uses this together with Kid to look up the producer key // when verifying. RAID string `json:"raid,omitempty"` + // Jwk optionally embeds the signer's public key (RFC 7515 §4.1.3). + // Identity control proofs set it so the noop DID resolver can + // synthesize a document from the submitted proofs; the web + // resolver ignores it — the authoritatively resolved document is + // always the key source. Never set on producer/TL signatures. + Jwk json.RawMessage `json:"jwk,omitempty"` } // SignDetachedJWS produces a detached JWS — compact-serialization @@ -314,7 +322,8 @@ func VerifyStandardJWSWithPublicKey(pub any, jwsCompact string) (*JWSProtectedHe if err != nil { return nil, fmt.Errorf("%w: decode signature: %w", ErrJWSVerify, err) } - digest := sha256.Sum256([]byte(encodedHeader + "." + encodedPayload)) + signingInput := encodedHeader + "." + encodedPayload + digest := sha256.Sum256([]byte(signingInput)) switch key := pub.(type) { case *ecdsa.PublicKey: r, s, err := P1363ToScalars(sig) @@ -328,12 +337,36 @@ func VerifyStandardJWSWithPublicKey(pub any, jwsCompact string) (*JWSProtectedHe if err := rsaVerifyPKCS1v15(key, digest[:], sig); err != nil { return nil, fmt.Errorf("%w: %w", ErrJWSVerify, err) } + case ed25519.PublicKey: + // EdDSA signs the raw signing input — no prehash (RFC 8037 §3.1). + if !ed25519.Verify(key, []byte(signingInput), sig) { + return nil, fmt.Errorf("%w: ed25519 signature mismatch", ErrJWSVerify) + } default: return nil, fmt.Errorf("%w: unsupported public key type %T", ErrJWSVerify, pub) } return header, nil } +// DecodeStandardJWS splits a standard (non-detached) compact JWS and +// returns its decoded protected header plus the raw base64url payload +// segment, without verifying anything. The identity verify-control +// path uses it to (a) read kid/alg/jwk for key selection and (b) +// enforce payload-equality against the served signingInput BEFORE any +// signature verification — clients sign the served bytes verbatim and +// never canonicalize. +func DecodeStandardJWS(jwsCompact string) (*JWSProtectedHeader, string, error) { + encodedHeader, encodedPayload, _, err := splitStandardJWS(jwsCompact) + if err != nil { + return nil, "", fmt.Errorf("%w: %w", ErrJWSDecode, err) + } + header, err := decodeHeader(encodedHeader) + if err != nil { + return nil, "", err + } + return header, encodedPayload, nil +} + // splitStandardJWS parses the "header.payload.signature" compact form // (payload segment non-empty — the non-detached variant). func splitStandardJWS(jwsCompact string) (string, string, string, error) { @@ -458,6 +491,11 @@ func verifyWithPublicKey(pub any, alg, encodedHeader, encodedSig string, payload if err := rsaVerifyPKCS1v15(key, digest[:], sig); err != nil { return fmt.Errorf("%w: %w", ErrJWSVerify, err) } + case ed25519.PublicKey: + // EdDSA signs the raw signing input — no prehash (RFC 8037 §3.1). + if !ed25519.Verify(key, []byte(signingInput), sig) { + return fmt.Errorf("%w: ed25519 signature mismatch", ErrJWSVerify) + } default: return fmt.Errorf("%w: unsupported public key type %T", ErrJWSVerify, pub) } @@ -491,6 +529,8 @@ func algForPublicKey(pub any) (string, error) { return AlgES256, nil case *rsa.PublicKey: return AlgRS256, nil + case ed25519.PublicKey: + return AlgEdDSA, nil default: return "", fmt.Errorf("jws: unsupported public key type %T", pub) } @@ -508,6 +548,11 @@ func checkAlgMatchesKey(alg string, pub any) error { return fmt.Errorf("jws: alg RS256 requires RSA key, got %T", pub) } return nil + case AlgEdDSA: + if _, ok := pub.(ed25519.PublicKey); !ok { + return fmt.Errorf("jws: alg EdDSA requires Ed25519 key, got %T", pub) + } + return nil default: return fmt.Errorf("jws: unsupported algorithm %q", alg) } @@ -519,6 +564,8 @@ func joseAlgorithm(alg string) (jose.SignatureAlgorithm, error) { return jose.ES256, nil case AlgRS256: return jose.RS256, nil + case AlgEdDSA: + return jose.EdDSA, nil default: return "", fmt.Errorf("jws: unsupported algorithm %q", alg) } diff --git a/internal/crypto/proofinput.go b/internal/crypto/proofinput.go new file mode 100644 index 0000000..b97de6e --- /dev/null +++ b/internal/crypto/proofinput.go @@ -0,0 +1,60 @@ +package crypto + +import ( + "encoding/base64" + "encoding/json" + "fmt" +) + +// IdentityProofPurpose is the domain-separation discriminator inside +// every identity proof signing input. An operator's identity keys +// also sign VCs, DIDComm, and application payloads; a purpose-tagged +// input can never verify as any of those (nor vice versa). +const IdentityProofPurpose = "ans:identity-proof:v1" + +// IdentityProofInput is the ONE signing input in the identity proof +// system — the JSON object whose RFC 8785 (JCS) canonical bytes the +// registrant signs to prove key control. It binds the proof to: +// +// - this identifier (anti-substitution), +// - this identity object (identityId — anti-cross-use), +// - this challenge round (nonce — anti-replay), +// - this protocol (purpose — anti-cross-protocol-confusion), +// - this RA deployment (raId — a signature minted against staging +// can never replay against production), +// - this scheme. +// +// The RA serves the encoded form (SigningInput) in the 202 response; +// clients sign it verbatim and never need a JCS implementation — the +// RA checks payload-equality before verifying any signature, so +// canonicalization-mismatch interop failures are structurally +// impossible. +type IdentityProofInput struct { + Identifier string `json:"identifier"` + IdentityID string `json:"identityId"` + Nonce string `json:"nonce"` + Purpose string `json:"purpose"` + RaID string `json:"raId"` + Scheme string `json:"scheme"` +} + +// Canonical returns the JCS-canonical bytes of the proof input — the +// exact bytes a compact JWS over this input must carry as its payload. +func (in IdentityProofInput) Canonical() ([]byte, error) { + raw, err := json.Marshal(in) + if err != nil { + return nil, fmt.Errorf("proofinput: marshal: %w", err) + } + return Canonicalize(raw) +} + +// SigningInput returns the base64url (unpadded) encoding of the +// canonical bytes — the string served in the 202 challenge response. +// A compact JWS's payload segment MUST equal this string verbatim. +func (in IdentityProofInput) SigningInput() (string, error) { + canonical, err := in.Canonical() + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(canonical), nil +} diff --git a/internal/crypto/proofinput_test.go b/internal/crypto/proofinput_test.go new file mode 100644 index 0000000..0fa0278 --- /dev/null +++ b/internal/crypto/proofinput_test.go @@ -0,0 +1,69 @@ +package crypto + +import ( + "encoding/base64" + "encoding/json" + "strings" + "testing" +) + +func sampleProofInput() IdentityProofInput { + return IdentityProofInput{ + Identifier: "did:web:identity.acme-corp.com", + IdentityID: "01HXKQ00000000000000000000", + Nonce: "bm9uY2U", + Purpose: IdentityProofPurpose, + RaID: "ans-ra-local", + Scheme: "did:web", + } +} + +func TestIdentityProofInputCanonical(t *testing.T) { + in := sampleProofInput() + canonical, err := in.Canonical() + if err != nil { + t.Fatalf("canonical: %v", err) + } + // JCS: keys sorted lexicographically, no whitespace. + s := string(canonical) + order := []string{`"identifier"`, `"identityId"`, `"nonce"`, `"purpose"`, `"raId"`, `"scheme"`} + last := -1 + for _, key := range order { + idx := strings.Index(s, key) + if idx < 0 || idx < last { + t.Fatalf("canonical key order wrong: %s", s) + } + last = idx + } + if strings.Contains(s, " ") { + t.Fatalf("canonical bytes contain whitespace: %s", s) + } + + // Deterministic. + again, err := in.Canonical() + if err != nil || string(again) != s { + t.Fatal("canonicalization must be deterministic") + } +} + +func TestIdentityProofInputSigningInput(t *testing.T) { + in := sampleProofInput() + encoded, err := in.SigningInput() + if err != nil { + t.Fatalf("signing input: %v", err) + } + decoded, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + t.Fatalf("signing input is not base64url: %v", err) + } + var back IdentityProofInput + if err := json.Unmarshal(decoded, &back); err != nil { + t.Fatalf("decoded payload is not a proof input: %v", err) + } + if back != in { + t.Fatalf("round trip mismatch: %+v", back) + } + if back.Purpose != "ans:identity-proof:v1" { + t.Fatalf("purpose: %s", back.Purpose) + } +} diff --git a/internal/domain/identity.go b/internal/domain/identity.go new file mode 100644 index 0000000..6868259 --- /dev/null +++ b/internal/domain/identity.go @@ -0,0 +1,449 @@ +package domain + +import ( + "fmt" + "strings" + "time" +) + +// IdentifierKind classifies a verified identity's identifier scheme. +// The set is spec-frozen: adding a kind is an ANS-spec amendment plus +// a per-kind control-proof implementation, never just a new string. +type IdentifierKind string + +// Identifier kinds. A kind being *recognized* here is independent of +// it being *enabled* — the service layer dispatches per kind and +// returns IDENTIFIER_KIND_UNSUPPORTED for kinds this deployment has +// no control verifier for (lei is recognized but postponed). +const ( + // KindDIDWeb — did:web. The authoritative keys live in the DID + // document fetched from the operator's web host; control is + // possession of the document's assertionMethod keys. + KindDIDWeb IdentifierKind = "did:web" + + // KindDIDKey — did:key. The key IS the identifier (decoded from + // the DID string); zero I/O, pure key possession. + KindDIDKey IdentifierKind = "did:key" + + // KindLEI — an ISO 17442 Legal Entity Identifier, proven through + // a vLEI credential presentation. Postponed: recognized so the + // error is precise, but no control verifier ships yet. + KindLEI IdentifierKind = "lei" +) + +// IdentityStatus is the verified-identity lifecycle state. +type IdentityStatus string + +// Identity lifecycle states. The machine is deliberately tiny: +// +// PENDING_CONTROL → VERIFIED → REVOKED +// +// with VERIFIED → VERIFIED on rotation (a staged replacement proves +// control again before anything changes — the previously sealed +// state stands until the new proof lands). +const ( + IdentityPendingControl IdentityStatus = "PENDING_CONTROL" + IdentityVerified IdentityStatus = "VERIFIED" + IdentityRevoked IdentityStatus = "REVOKED" +) + +// IdentityChallenge is the single-use anti-replay nonce issued at +// register / rotate time. The registrant signs the served +// IdentityProofInput (which embeds this nonce); the nonce is consumed +// exactly once, inside the success transaction of verify-control — +// a failed verification attempt does NOT consume it, so a registrant +// may retry a bad proof until expiry. +type IdentityChallenge struct { + // Nonce is the base64url-encoded 32-byte random value. + Nonce string + // ExpiresAt bounds the challenge's validity. + ExpiresAt time.Time + // ConsumedAt is set when a verify-control succeeded against this + // nonce. A consumed nonce can never verify again. + ConsumedAt *time.Time +} + +// VerifiedIdentity is the "who" aggregate — an identity owned by a +// providerId, proven through a per-kind control proof, sealed onto +// its own Transparency Log stream, and linked to any number of that +// owner's agents. It is NOT part of AgentRegistration: the agent (the +// "what") carries no identity fields, and the two lifecycles never +// cascade into each other. +// +// No public-key field lives on this aggregate (ANS-0 §6.2 key +// transience): proven keys are sealed in the identity's TL events, +// not persisted as live state. +type VerifiedIdentity struct { + // IdentityID is the RA-assigned UUIDv7 — the TL stream key. + IdentityID string + // ProviderID is the owning authentication principal — the same + // principal that owns the agents this identity may link to. + ProviderID string + Kind IdentifierKind + // Value is the canonical identifier (e.g. + // "did:web:identity.acme-corp.com"). + Value string + Status IdentityStatus + // ProofMethod names the control proof that verified this + // identity ("did-web-sig" | "did-key-sig"). Empty until the + // first successful verify-control. + ProofMethod string + // PendingValue stages a same-kind replacement during rotation + // (§4.2): set by StageRotation, applied by CompleteVerification. + // While staged, the previously sealed state stands. + PendingValue string + // Challenge is the live anti-replay nonce, if any. + Challenge *IdentityChallenge + VerifiedAt time.Time // zero until first proof + CreatedAt time.Time + UpdatedAt time.Time +} + +// proofMethodForKind maps a kind to its sealed proofMethod token. +func proofMethodForKind(kind IdentifierKind) string { + switch kind { + case KindDIDWeb: + return "did-web-sig" + case KindDIDKey: + return "did-key-sig" + case KindLEI: + return "lei-vlei-acdc" + default: + return "" + } +} + +// ProofMethodForKind exposes the kind → proofMethod mapping for the +// service layer's event builder. +func ProofMethodForKind(kind IdentifierKind) string { return proofMethodForKind(kind) } + +// InferIdentifierKind lexically classifies a raw identifier value and +// returns its canonical form. Kind inference is pure dispatch — it +// proves nothing; the per-kind control proof is the gate. +func InferIdentifierKind(raw string) (IdentifierKind, string, error) { + value := strings.TrimSpace(raw) + switch { + case strings.HasPrefix(value, "did:web:"): + canonical, err := canonicalizeDIDWeb(value) + if err != nil { + return "", "", err + } + return KindDIDWeb, canonical, nil + case strings.HasPrefix(value, "did:key:"): + if len(value) <= len("did:key:") { + return "", "", NewValidationError("DID_BAD_FORMAT", "did:key value is empty") + } + return KindDIDKey, value, nil + case isLEI(value): + return KindLEI, strings.ToUpper(value), nil + case strings.HasPrefix(value, "did:"): + // A well-formed DID of a method we don't dispatch yet + // (did:plc, did:ion, did:ethr, …) — name the method so the + // caller learns exactly what's missing, not just "no kind". + // Adding a method = a dispatch arm here (canonicalization + // rules are per-method) + a controlVerifier registration in + // the service's identitykinds registry. + method := value[len("did:"):] + if i := strings.IndexByte(method, ':'); i > 0 { + method = method[:i] + } + return "", "", NewValidationError("IDENTIFIER_KIND_UNSUPPORTED", + fmt.Sprintf("did method %q is not supported (supported: did:web, did:key)", method)) + default: + return "", "", NewValidationError("IDENTIFIER_KIND_UNSUPPORTED", + fmt.Sprintf("identifier %q matches no supported kind (did:web, did:key, lei)", value)) + } +} + +// isLEI reports whether the value is shaped like an ISO 17442 LEI: +// exactly 20 alphanumeric characters. (The mod-97 check digit and the +// GLEIF status precondition belong to the lei control verifier, which +// is postponed — this is lexical dispatch only.) +func isLEI(value string) bool { + if len(value) != 20 { + return false + } + for _, r := range value { + switch { + case r >= '0' && r <= '9', r >= 'A' && r <= 'Z', r >= 'a' && r <= 'z': + default: + return false + } + } + return true +} + +// canonicalizeDIDWeb validates a did:web identifier and returns its +// canonical form (host lowercased; path segments preserved). +// +// v1 restrictions (each rejected with DID_BAD_FORMAT): +// - no port (the did:web encoding of a port is %3A — fetches are +// pinned to 443), +// - no userinfo, +// - no percent-encoded characters at all (did:web allows them in +// path segments; v1 keeps the grammar strict so the resolution +// URL is byte-derivable from the DID), +// - host must be a plausible DNS name (letters, digits, hyphens, +// dots; no leading/trailing hyphen or dot in a label). +func canonicalizeDIDWeb(value string) (string, error) { + rest := strings.TrimPrefix(value, "did:web:") + if rest == "" { + return "", NewValidationError("DID_BAD_FORMAT", "did:web value is empty") + } + if strings.Contains(rest, "%") { + return "", NewValidationError("DID_BAD_FORMAT", + "did:web with percent-encoded characters (including ports, %3A) is not supported") + } + if strings.Contains(rest, "@") { + return "", NewValidationError("DID_BAD_FORMAT", "did:web must not carry userinfo") + } + if strings.Contains(rest, "/") { + return "", NewValidationError("DID_BAD_FORMAT", + "did:web path segments are separated by ':' in the DID, not '/'") + } + segments := strings.Split(rest, ":") + host := strings.ToLower(segments[0]) + if err := validateDNSHost(host); err != nil { + return "", err + } + for _, seg := range segments[1:] { + if seg == "" { + return "", NewValidationError("DID_BAD_FORMAT", "did:web has an empty path segment") + } + } + canonical := "did:web:" + host + if len(segments) > 1 { + canonical += ":" + strings.Join(segments[1:], ":") + } + return canonical, nil +} + +// validateDNSHost applies conservative DNS-name validation to the +// did:web host portion. +func validateDNSHost(host string) error { + if host == "" || len(host) > 253 { + return NewValidationError("DID_BAD_FORMAT", "did:web host is empty or too long") + } + for _, label := range strings.Split(host, ".") { + if label == "" || len(label) > 63 { + return NewValidationError("DID_BAD_FORMAT", + fmt.Sprintf("did:web host %q has an invalid label", host)) + } + if label[0] == '-' || label[len(label)-1] == '-' { + return NewValidationError("DID_BAD_FORMAT", + fmt.Sprintf("did:web host label %q must not start or end with '-'", label)) + } + for _, r := range label { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-': + default: + return NewValidationError("DID_BAD_FORMAT", + fmt.Sprintf("did:web host %q contains invalid character %q", host, r)) + } + } + } + return nil +} + +// DIDWebResolutionURL maps a canonical did:web identifier to the URL +// its DID document resolves at, per the did:web method spec: +// +// did:web:example.com → https://example.com/.well-known/did.json +// did:web:example.com:user:al → https://example.com/user/al/did.json +// +// Pure function — the hardened fetcher in the adapter layer owns the +// actual I/O and its SSRF posture. +func DIDWebResolutionURL(canonicalValue string) (string, error) { + if !strings.HasPrefix(canonicalValue, "did:web:") { + return "", NewValidationError("DID_BAD_FORMAT", "not a did:web identifier") + } + segments := strings.Split(strings.TrimPrefix(canonicalValue, "did:web:"), ":") + host := segments[0] + if host == "" { + return "", NewValidationError("DID_BAD_FORMAT", "did:web host is empty") + } + if len(segments) == 1 { + return "https://" + host + "/.well-known/did.json", nil + } + return "https://" + host + "/" + strings.Join(segments[1:], "/") + "/did.json", nil +} + +// NewVerifiedIdentity constructs a fresh identity in PENDING_CONTROL. +// The caller supplies the RA-assigned identityID (UUIDv7) and the +// authenticated owner; the raw value is kind-inferred and +// canonicalized here so every code path shares one grammar. +func NewVerifiedIdentity(identityID, providerID, rawValue string, now time.Time) (*VerifiedIdentity, error) { + if identityID == "" { + return nil, NewValidationError("INVALID_IDENTITY_ID", "identityId is required") + } + if providerID == "" { + return nil, NewValidationError("INVALID_PROVIDER_ID", "providerId is required") + } + kind, canonical, err := InferIdentifierKind(rawValue) + if err != nil { + return nil, err + } + return &VerifiedIdentity{ + IdentityID: identityID, + ProviderID: providerID, + Kind: kind, + Value: canonical, + Status: IdentityPendingControl, + CreatedAt: now.UTC(), + UpdatedAt: now.UTC(), + }, nil +} + +// IssueChallenge mints a fresh anti-replay nonce, superseding any +// prior one (idempotent re-add, §4.2: a re-POST while PENDING_CONTROL +// returns the same identity with a fresh challenge). Valid while the +// identity is PENDING_CONTROL (initial proof) or VERIFIED (rotation +// re-proof); a revoked identity can never be challenged again. +func (v *VerifiedIdentity) IssueChallenge(nonce string, ttl time.Duration, now time.Time) error { + if v.Status == IdentityRevoked { + return NewInvalidStateError("IDENTITY_REVOKED", + "a revoked identity cannot be re-challenged") + } + if nonce == "" { + return NewValidationError("INVALID_CHALLENGE", "challenge nonce is required") + } + if ttl <= 0 { + return NewValidationError("INVALID_CHALLENGE", "challenge ttl must be positive") + } + v.Challenge = &IdentityChallenge{ + Nonce: nonce, + ExpiresAt: now.UTC().Add(ttl), + } + v.UpdatedAt = now.UTC() + return nil +} + +// CheckChallenge reports whether the live challenge can still be +// proven against: present, unconsumed, unexpired. It does NOT consume +// — consumption is a storage-level conditional update inside the +// verify-control success transaction (the TOCTOU guard). +func (v *VerifiedIdentity) CheckChallenge(now time.Time) error { + switch { + case v.Challenge == nil: + return NewInvalidStateError("IDENTIFIER_CHALLENGE_EXPIRED", + "no active challenge; re-register the identifier to receive a fresh one") + case v.Challenge.ConsumedAt != nil: + return NewInvalidStateError("PRICC_TOKEN_ALREADY_USED", + "challenge nonce already consumed") + case !now.Before(v.Challenge.ExpiresAt): + return NewInvalidStateError("PRICC_TOKEN_EXPIRED", + "challenge nonce expired; re-register the identifier to receive a fresh one") + default: + return nil + } +} + +// EffectiveValue is the identifier the current proof round is over: +// the staged replacement during a rotation, otherwise the proven +// value. +func (v *VerifiedIdentity) EffectiveValue() string { + if v.PendingValue != "" { + return v.PendingValue + } + return v.Value +} + +// StageRotation stages a same-kind replacement value (§4.2 PUT). The +// row stays VERIFIED with the old value — nothing changes until the +// replacement proves control. Cross-kind replacement is rejected +// (remove + add, not a rotation). +func (v *VerifiedIdentity) StageRotation(rawValue string, now time.Time) error { + if v.Status != IdentityVerified { + return NewInvalidStateError("IDENTITY_NOT_VERIFIED", + fmt.Sprintf("rotation requires a VERIFIED identity, status is %s", v.Status)) + } + kind, canonical, err := InferIdentifierKind(rawValue) + if err != nil { + return err + } + if kind != v.Kind { + return NewValidationError("IDENTIFIER_KIND_MISMATCH", + fmt.Sprintf("cannot rotate a %s identity to %s; revoke and register a new identity", v.Kind, kind)) + } + v.PendingValue = canonical + v.UpdatedAt = now.UTC() + return nil +} + +// CompleteVerification applies a successful control proof: +// +// - PENDING_CONTROL → VERIFIED (first proof; seals IDENTITY_VERIFIED) +// - VERIFIED with a staged rotation → swap the value (seals +// IDENTITY_UPDATED) +// - VERIFIED without a staged rotation → re-proof of the current +// value (also seals IDENTITY_UPDATED — the proven key set may +// have changed even when the value did not) +// +// Returns the previous value when the proof completes a rotation that +// changed the identifier (for the sealed event's previousValue). +func (v *VerifiedIdentity) CompleteVerification(now time.Time) (string, error) { + previousValue := "" + switch v.Status { + case IdentityPendingControl: + v.Status = IdentityVerified + case IdentityVerified: + if v.PendingValue != "" && v.PendingValue != v.Value { + previousValue = v.Value + v.Value = v.PendingValue + } + case IdentityRevoked: + return "", NewInvalidStateError("IDENTITY_REVOKED", + "a revoked identity cannot be verified") + default: + return "", NewInvalidStateError("IDENTITY_INVALID_STATE", + fmt.Sprintf("unexpected identity status %s", v.Status)) + } + v.PendingValue = "" + v.ProofMethod = proofMethodForKind(v.Kind) + v.VerifiedAt = now.UTC() + v.UpdatedAt = now.UTC() + return previousValue, nil +} + +// Revoke transitions a VERIFIED identity to REVOKED — a state change, +// never a delete: the identity's history is append-only in the TL. +// Revoking an unproven (PENDING_CONTROL) identity is rejected — it +// never sealed anything, so there is nothing to revoke; its challenge +// simply expires. +func (v *VerifiedIdentity) Revoke(now time.Time) error { + if v.Status != IdentityVerified { + return NewInvalidStateError("IDENTITY_NOT_VERIFIED", + fmt.Sprintf("revocation requires a VERIFIED identity, status is %s", v.Status)) + } + v.Status = IdentityRevoked + v.PendingValue = "" + v.Challenge = nil + v.UpdatedAt = now.UTC() + return nil +} + +// LinkStatus is the lifecycle state of one identity↔agent +// association. +type LinkStatus string + +// Link states. UNLINKED rows are history caches — the sealed +// IDENTITY_UNLINKED event is the authoritative record — and never +// block re-linking. +const ( + LinkLinked LinkStatus = "LINKED" + LinkUnlinked LinkStatus = "UNLINKED" +) + +// IdentityLink associates a verified identity with one agent of the +// same owner. It is the only thing that touches an agent — a row plus +// a sealed event on the IDENTITY stream, never a field on the +// registration aggregate. Links carry no proof: within one owner a +// link asserts a fact the owner gate already establishes (§4.3). +type IdentityLink struct { + IdentityID string + AgentID string + Status LinkStatus + LinkedAt time.Time + CreatedAt time.Time + UpdatedAt time.Time +} diff --git a/internal/domain/identity_test.go b/internal/domain/identity_test.go new file mode 100644 index 0000000..ea0644f --- /dev/null +++ b/internal/domain/identity_test.go @@ -0,0 +1,282 @@ +package domain + +import ( + "strings" + "testing" + "time" +) + +var idNow = time.Date(2026, 6, 10, 15, 0, 0, 0, time.UTC) + +func newPendingIdentity(t *testing.T, value string) *VerifiedIdentity { + t.Helper() + v, err := NewVerifiedIdentity("01HXKQTEST", "PID-1", value, idNow) + if err != nil { + t.Fatalf("NewVerifiedIdentity(%s): %v", value, err) + } + return v +} + +func TestInferIdentifierKind(t *testing.T) { + cases := []struct { + in string + wantKind IdentifierKind + wantCanonical string + wantErr string + }{ + {in: "did:web:identity.acme-corp.com", wantKind: KindDIDWeb, wantCanonical: "did:web:identity.acme-corp.com"}, + {in: " did:web:Identity.ACME-corp.COM ", wantKind: KindDIDWeb, wantCanonical: "did:web:identity.acme-corp.com"}, + {in: "did:web:acme-corp.com:identity:agents", wantKind: KindDIDWeb, wantCanonical: "did:web:acme-corp.com:identity:agents"}, + {in: "did:key:zDnaeUm3QkcyZWZTPttxB711jgqRDhkwvhF485SFw1bDZ9AQw", wantKind: KindDIDKey, wantCanonical: "did:key:zDnaeUm3QkcyZWZTPttxB711jgqRDhkwvhF485SFw1bDZ9AQw"}, + {in: "5493001KJTIIGC8Y1R17", wantKind: KindLEI, wantCanonical: "5493001KJTIIGC8Y1R17"}, + {in: "5493001kjtiigc8y1r17", wantKind: KindLEI, wantCanonical: "5493001KJTIIGC8Y1R17"}, + {in: "did:web:", wantErr: "DID_BAD_FORMAT"}, + {in: "did:web:acme.com%3A8443", wantErr: "DID_BAD_FORMAT"}, + {in: "did:web:user@acme.com", wantErr: "DID_BAD_FORMAT"}, + {in: "did:web:acme.com/path", wantErr: "DID_BAD_FORMAT"}, + {in: "did:web:acme.com:", wantErr: "DID_BAD_FORMAT"}, + {in: "did:web:acme..com", wantErr: "DID_BAD_FORMAT"}, + {in: "did:web:-acme.com", wantErr: "DID_BAD_FORMAT"}, + {in: "did:web:acme_corp.com", wantErr: "DID_BAD_FORMAT"}, + {in: "did:key:", wantErr: "DID_BAD_FORMAT"}, + {in: "urn:uuid:1234", wantErr: "IDENTIFIER_KIND_UNSUPPORTED"}, + {in: "", wantErr: "IDENTIFIER_KIND_UNSUPPORTED"}, + {in: "5493001KJTIIGC8Y1R1!", wantErr: "IDENTIFIER_KIND_UNSUPPORTED"}, + // Unrecognized did methods name the method precisely — + // these are the kinds the controlVerifier registry grows + // into (did:plc, did:ion, did:ethr, …). + {in: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", wantErr: `did method "plc" is not supported`}, + {in: "did:ion:EiClkZMDxPKqC9c-umQfTkR8", wantErr: `did method "ion" is not supported`}, + {in: "did:bogus", wantErr: `did method "bogus" is not supported`}, + } + for _, tc := range cases { + t.Run(tc.in, func(t *testing.T) { + kind, canonical, err := InferIdentifierKind(tc.in) + if tc.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("want error %q, got %v", tc.wantErr, err) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if kind != tc.wantKind || canonical != tc.wantCanonical { + t.Fatalf("got (%s, %s), want (%s, %s)", kind, canonical, tc.wantKind, tc.wantCanonical) + } + }) + } +} + +func TestValidateDNSHostEdges(t *testing.T) { + longHost := strings.Repeat("a", 254) + if _, _, err := InferIdentifierKind("did:web:" + longHost); err == nil { + t.Error("over-long host should fail") + } + longLabel := strings.Repeat("a", 64) + ".com" + if _, _, err := InferIdentifierKind("did:web:" + longLabel); err == nil { + t.Error("over-long label should fail") + } + if _, _, err := InferIdentifierKind("did:web:acme-.com"); err == nil { + t.Error("trailing-hyphen label should fail") + } +} + +func TestDIDWebResolutionURL(t *testing.T) { + cases := []struct { + in string + want string + wantErr bool + }{ + {in: "did:web:example.com", want: "https://example.com/.well-known/did.json"}, + {in: "did:web:example.com:user:alice", want: "https://example.com/user/alice/did.json"}, + {in: "did:key:z6Mk", wantErr: true}, + {in: "did:web:", wantErr: true}, + } + for _, tc := range cases { + got, err := DIDWebResolutionURL(tc.in) + if tc.wantErr { + if err == nil { + t.Errorf("%s: want error", tc.in) + } + continue + } + if err != nil || got != tc.want { + t.Errorf("%s: got (%s, %v), want %s", tc.in, got, err, tc.want) + } + } +} + +func TestNewVerifiedIdentityValidation(t *testing.T) { + if _, err := NewVerifiedIdentity("", "PID-1", "did:web:a.com", idNow); err == nil { + t.Error("missing identityId should fail") + } + if _, err := NewVerifiedIdentity("id-1", "", "did:web:a.com", idNow); err == nil { + t.Error("missing providerId should fail") + } + if _, err := NewVerifiedIdentity("id-1", "PID-1", "bogus", idNow); err == nil { + t.Error("bogus value should fail") + } + v := newPendingIdentity(t, "did:web:a.com") + if v.Status != IdentityPendingControl || v.Kind != KindDIDWeb { + t.Fatalf("fresh identity wrong: %+v", v) + } +} + +func TestIssueAndCheckChallenge(t *testing.T) { + v := newPendingIdentity(t, "did:web:a.com") + + if err := v.IssueChallenge("", time.Hour, idNow); err == nil { + t.Error("empty nonce should fail") + } + if err := v.IssueChallenge("nonce-1", 0, idNow); err == nil { + t.Error("zero ttl should fail") + } + if err := v.CheckChallenge(idNow); err == nil || !strings.Contains(err.Error(), "IDENTIFIER_CHALLENGE_EXPIRED") { + t.Errorf("no challenge should report IDENTIFIER_CHALLENGE_EXPIRED, got %v", err) + } + + if err := v.IssueChallenge("nonce-1", time.Hour, idNow); err != nil { + t.Fatalf("issue: %v", err) + } + if err := v.CheckChallenge(idNow.Add(30 * time.Minute)); err != nil { + t.Errorf("fresh challenge should pass: %v", err) + } + if err := v.CheckChallenge(idNow.Add(time.Hour)); err == nil || !strings.Contains(err.Error(), "PRICC_TOKEN_EXPIRED") { + t.Errorf("expired challenge: got %v", err) + } + + consumed := idNow.Add(time.Minute) + v.Challenge.ConsumedAt = &consumed + if err := v.CheckChallenge(idNow.Add(2 * time.Minute)); err == nil || !strings.Contains(err.Error(), "PRICC_TOKEN_ALREADY_USED") { + t.Errorf("consumed challenge: got %v", err) + } + + // Re-issue supersedes (idempotent re-add). + if err := v.IssueChallenge("nonce-2", time.Hour, idNow.Add(3*time.Minute)); err != nil { + t.Fatalf("re-issue: %v", err) + } + if v.Challenge.Nonce != "nonce-2" || v.Challenge.ConsumedAt != nil { + t.Fatalf("re-issue did not supersede: %+v", v.Challenge) + } + + // Revoked identities cannot be challenged. + v.Status = IdentityRevoked + if err := v.IssueChallenge("nonce-3", time.Hour, idNow); err == nil { + t.Error("revoked identity should not be challengeable") + } +} + +func TestCompleteVerificationLifecycle(t *testing.T) { + v := newPendingIdentity(t, "did:web:a.com") + + prev, err := v.CompleteVerification(idNow) + if err != nil || prev != "" { + t.Fatalf("first proof: prev=%q err=%v", prev, err) + } + if v.Status != IdentityVerified || v.ProofMethod != "did-web-sig" || v.VerifiedAt.IsZero() { + t.Fatalf("after first proof: %+v", v) + } + + // Re-proof without rotation: same value, no previousValue. + prev, err = v.CompleteVerification(idNow.Add(time.Hour)) + if err != nil || prev != "" { + t.Fatalf("re-proof: prev=%q err=%v", prev, err) + } + + // Rotation to a new value. + if err := v.StageRotation("did:web:b.com", idNow); err != nil { + t.Fatalf("stage: %v", err) + } + if v.EffectiveValue() != "did:web:b.com" || v.Value != "did:web:a.com" { + t.Fatalf("staged state wrong: %+v", v) + } + prev, err = v.CompleteVerification(idNow.Add(2 * time.Hour)) + if err != nil || prev != "did:web:a.com" { + t.Fatalf("rotation: prev=%q err=%v", prev, err) + } + if v.Value != "did:web:b.com" || v.PendingValue != "" { + t.Fatalf("after rotation: %+v", v) + } + + // Rotation staged to the SAME value: no previousValue reported. + if err := v.StageRotation("did:web:b.com", idNow); err != nil { + t.Fatalf("stage same: %v", err) + } + prev, err = v.CompleteVerification(idNow.Add(3 * time.Hour)) + if err != nil || prev != "" { + t.Fatalf("same-value rotation: prev=%q err=%v", prev, err) + } + + // Revoked → cannot verify. + v.Status = IdentityRevoked + if _, err := v.CompleteVerification(idNow); err == nil { + t.Error("revoked identity should not verify") + } + + // Unknown status → invalid state. + v.Status = IdentityStatus("BOGUS") + if _, err := v.CompleteVerification(idNow); err == nil { + t.Error("unknown status should not verify") + } +} + +func TestStageRotationGuards(t *testing.T) { + v := newPendingIdentity(t, "did:web:a.com") + if err := v.StageRotation("did:web:b.com", idNow); err == nil { + t.Error("rotation requires VERIFIED") + } + if _, err := v.CompleteVerification(idNow); err != nil { + t.Fatal(err) + } + if err := v.StageRotation("bogus", idNow); err == nil { + t.Error("bogus replacement should fail") + } + if err := v.StageRotation("did:key:zDnaeUm3QkcyZWZTPttxB711jgqRDhkwvhF485SFw1bDZ9AQw", idNow); err == nil || + !strings.Contains(err.Error(), "IDENTIFIER_KIND_MISMATCH") { + t.Errorf("cross-kind rotation: got %v", err) + } +} + +func TestRevoke(t *testing.T) { + v := newPendingIdentity(t, "did:web:a.com") + if err := v.Revoke(idNow); err == nil { + t.Error("revoking PENDING_CONTROL should fail — nothing was sealed") + } + if _, err := v.CompleteVerification(idNow); err != nil { + t.Fatal(err) + } + if err := v.IssueChallenge("n", time.Hour, idNow); err != nil { + t.Fatal(err) + } + if err := v.Revoke(idNow.Add(time.Minute)); err != nil { + t.Fatalf("revoke: %v", err) + } + if v.Status != IdentityRevoked || v.Challenge != nil || v.PendingValue != "" { + t.Fatalf("after revoke: %+v", v) + } + if err := v.Revoke(idNow); err == nil { + t.Error("double revoke should fail") + } +} + +func TestProofMethodForKind(t *testing.T) { + cases := map[IdentifierKind]string{ + KindDIDWeb: "did-web-sig", + KindDIDKey: "did-key-sig", + KindLEI: "lei-vlei-acdc", + IdentifierKind("???"): "", + } + for kind, want := range cases { + if got := ProofMethodForKind(kind); got != want { + t.Errorf("ProofMethodForKind(%s) = %q, want %q", kind, got, want) + } + } +} + +func TestEffectiveValueWithoutPending(t *testing.T) { + v := newPendingIdentity(t, "did:web:a.com") + if v.EffectiveValue() != "did:web:a.com" { + t.Fatal("effective value should be the proven value when nothing is staged") + } +} diff --git a/internal/port/didresolver.go b/internal/port/didresolver.go new file mode 100644 index 0000000..77441bb --- /dev/null +++ b/internal/port/didresolver.go @@ -0,0 +1,80 @@ +package port + +import ( + "context" + "encoding/json" +) + +// KeyHint is one kid → public-JWK pair the caller extracted from a +// submitted proof's protected header. Hints exist for the noop +// resolver (below); the web resolver ignores them. +type KeyHint struct { + Kid string + PublicKeyJWK json.RawMessage +} + +// DIDDocument is the subset of a resolved DID document the identity +// control proof needs: the document's id and its assertionMethod +// verification methods. Per DID Core, a key listed under +// assertionMethod is authorized to make assertions as the DID — key +// possession IS what DID control means. +type DIDDocument struct { + ID string + AssertionMethod []VerificationMethod +} + +// VerificationMethod is one assertionMethod entry. Exactly one of +// PublicKeyJwk / PublicKeyMultibase carries the key material. +// +// Raw is the verification-method object EXACTLY as the DID document +// served it — member-for-member, values untouched. Sealed identity +// events quote Raw verbatim: nothing derived, re-encoded, or +// normalized ever enters a seal. The typed fields beside it exist +// for the RA's checks (membership, controller, key parsing) only. +type VerificationMethod struct { + ID string + Controller string + Type string + PublicKeyJwk json.RawMessage + PublicKeyMultibase string + Raw json.RawMessage +} + +// FindAssertionMethod returns the assertionMethod entry with the +// given id, or nil. +func (d *DIDDocument) FindAssertionMethod(kid string) *VerificationMethod { + for i := range d.AssertionMethod { + if d.AssertionMethod[i].ID == kid { + return &d.AssertionMethod[i] + } + } + return nil +} + +// DIDResolver fetches the DID document for a did:web identifier — the +// authoritative key source for did:web control proofs. It is the only +// outbound I/O in the identity proof gate, which makes it the port: +// +// - The "web" adapter performs a hardened HTTPS fetch (WebPKI, +// SSRF dialer guards, timeout, size cap, bounded same-site +// redirects) of the document at the DID's resolution URL. Hints +// are ignored — the resolved document is always the key source. +// +// - The "noop" adapter performs no I/O and synthesizes a document +// from the hints (the kid → JWK pairs embedded in the submitted +// proofs' `jwk` headers). Signature verification still genuinely +// runs against those keys, so sealed events stay self-verifying +// even from quickstart runs — only the binding "the live did.json +// really lists this key" is waived. Mirrors the noop DNS +// verifier: real crypto, waived external-world binding. NOT for +// production. +// +// did:key never reaches this port — its key decodes from the DID +// string with zero I/O. +type DIDResolver interface { + // Resolve returns the DID document for the given canonical + // did:web identifier. Implementations return domain errors with + // the DID_* codes (DID_RESOLUTION_FAILED, + // DID_DOCUMENT_ID_MISMATCH, DID_REDIRECT_DOMAIN_MISMATCH). + Resolve(ctx context.Context, did string, hints []KeyHint) (*DIDDocument, error) +} diff --git a/internal/port/store.go b/internal/port/store.go index 38c79d9..b103b82 100644 --- a/internal/port/store.go +++ b/internal/port/store.go @@ -2,6 +2,7 @@ package port import ( "context" + "time" "github.com/godaddy/ans/internal/domain" ) @@ -148,3 +149,62 @@ type ByocCertificateStore interface { FindByAgentID(ctx context.Context, agentID string) ([]*domain.ByocServerCertificate, error) FindLatestValidByAgentID(ctx context.Context, agentID string) (*domain.ByocServerCertificate, error) } + +// IdentityStore persists VerifiedIdentity aggregates (the "who" — +// owned by a providerId, independent of any agent). +type IdentityStore interface { + // Save upserts the aggregate. The storage layer enforces the two + // uniqueness rules with partial indexes: one live (non-REVOKED) + // row per (provider, kind, value), and one VERIFIED row per + // (kind, value) across all owners — first to prove wins; a save + // that loses the race maps to a conflict error. + Save(ctx context.Context, identity *domain.VerifiedIdentity) error + + // FindByID returns the identity with the given identityId. + FindByID(ctx context.Context, identityID string) (*domain.VerifiedIdentity, error) + + // FindLive returns the owner's non-REVOKED row for (kind, value), + // or a not-found error. Drives the idempotent re-add: a re-POST + // of the same value while PENDING_CONTROL returns the same + // identity with a fresh challenge. + FindLive(ctx context.Context, providerID string, kind domain.IdentifierKind, value string) (*domain.VerifiedIdentity, error) + + // ExistsVerified reports whether ANY owner holds a VERIFIED row + // for (kind, value) — early duplicate feedback at register time. + // The authoritative guard is the proven-uniqueness index at + // verify time. + ExistsVerified(ctx context.Context, kind domain.IdentifierKind, value string) (bool, error) + + // ListByOwner returns every identity owned by the principal, + // newest first. + ListByOwner(ctx context.Context, providerID string) ([]*domain.VerifiedIdentity, error) + + // ConsumeChallenge atomically consumes the live challenge nonce: + // a conditional update that succeeds only while the stored nonce + // matches, is unconsumed, and is unexpired. Exactly one of two + // concurrent verify-control calls can win (the TOCTOU guard); + // the loser receives an invalid-state error. MUST be called + // inside the verify-control success transaction. + ConsumeChallenge(ctx context.Context, identityID, nonce string, now time.Time) error +} + +// IdentityLinkStore persists identity↔agent link rows. Rows are +// read-side caches of the sealed IDENTITY_LINKED / IDENTITY_UNLINKED +// events; UNLINKED rows are history and never block re-linking. +type IdentityLinkStore interface { + // Link upserts a live link for the pair. Returns true when a new + // link was created, false when the pair was already live + // (idempotent — an already-linked agent in a batch is not an + // error, and is excluded from the sealed batch event). + Link(ctx context.Context, identityID, agentID string, now time.Time) (bool, error) + + // Unlink flips the live link to UNLINKED. Not-found error when no + // live link exists. + Unlink(ctx context.Context, identityID, agentID string, now time.Time) error + + // ListLiveByIdentity returns the identity's live links. + ListLiveByIdentity(ctx context.Context, identityID string) ([]*domain.IdentityLink, error) + + // ListLiveByAgent returns the agent's live links. + ListLiveByAgent(ctx context.Context, agentID string) ([]*domain.IdentityLink, error) +} diff --git a/internal/ra/handler/dto.go b/internal/ra/handler/dto.go index d2d3588..a3657ea 100644 --- a/internal/ra/handler/dto.go +++ b/internal/ra/handler/dto.go @@ -85,6 +85,38 @@ type agentDetails struct { LastRenewalTimestamp string `json:"lastRenewalTimestamp,omitempty"` RegistrationPending *registrationPendingResponse `json:"registrationPending,omitempty"` Links []linkDTO `json:"links"` + // Identities is the additive computed view of the verified + // identities currently linked to this agent (design §5.4) — + // computed from the link rows at read time, never stored on the + // registration. + Identities []linkedIdentityDTO `json:"identities,omitempty"` +} + +// linkedIdentityDTO is one computed identities[] entry. +type linkedIdentityDTO struct { + IdentityID string `json:"identityId"` + Kind string `json:"kind"` + Value string `json:"value"` + IdentityStatus string `json:"identityStatus"` + LinkedAt string `json:"linkedAt,omitempty"` +} + +// mapLinkedIdentities converts the service summaries to wire DTOs. +func mapLinkedIdentities(in []service.LinkedIdentitySummary) []linkedIdentityDTO { + out := make([]linkedIdentityDTO, 0, len(in)) + for _, s := range in { + dto := linkedIdentityDTO{ + IdentityID: s.IdentityID, + Kind: string(s.Kind), + Value: s.Value, + IdentityStatus: string(s.IdentityStatus), + } + if !s.LinkedAt.IsZero() { + dto.LinkedAt = s.LinkedAt.UTC().Format("2006-01-02T15:04:05Z07:00") + } + out = append(out, dto) + } + return out } func mapAgentDetails(res *service.DetailResult, r *http.Request, tlPublicBaseURL string) agentDetails { diff --git a/internal/ra/handler/identity.go b/internal/ra/handler/identity.go new file mode 100644 index 0000000..bb9fb58 --- /dev/null +++ b/internal/ra/handler/identity.go @@ -0,0 +1,297 @@ +package handler + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/godaddy/ans/internal/adapter/auth" + "github.com/godaddy/ans/internal/domain" + "github.com/godaddy/ans/internal/ra/service" +) + +// IdentityHandler wires the /v2/ans/identities surface — the "who" +// behind the agents. Ownership is enforced inside the service (the +// owner gate is the link mechanism's security boundary), so these +// handlers only extract the authenticated principal and map DTOs. +type IdentityHandler struct { + svc *service.IdentityService +} + +// NewIdentityHandler constructs an IdentityHandler. +func NewIdentityHandler(svc *service.IdentityService) *IdentityHandler { + return &IdentityHandler{svc: svc} +} + +// identityRegisterRequest is the POST /v2/ans/identities (and PUT +// rotation) body. The kind is inferred from the value's lexical form +// — never caller-asserted. +type identityRegisterRequest struct { + Value string `json:"value"` +} + +// identityChallengeDTO is one entry of the 202 challenge list. +type identityChallengeDTO struct { + Kid string `json:"kid,omitempty"` + SigningInput string `json:"signingInput"` +} + +// identityChallengeResponse is the 202 body returned by register and +// rotate: the identity's id plus the challenge round to sign. +type identityChallengeResponse struct { + IdentityID string `json:"identityId"` + Kind string `json:"kind"` + Value string `json:"value"` + Status string `json:"status"` + Nonce string `json:"nonce"` + ExpiresAt string `json:"expiresAt"` + Challenges []identityChallengeDTO `json:"challenges"` +} + +// verifyControlRequest is the POST .../verify-control body. Members +// are additive per identifier kind — exactly one family is set per +// kind: the JWS schemes (did:web, did:key, and the future did:plc / +// did:ion) submit signedProofs; future kinds add their own optional +// members (lei: cesrSignature; did:ethr: ethSignature) without +// touching existing ones. +type verifyControlRequest struct { + // SignedProofs — one compact JWS per proven key, every payload + // equal to the served signingInput verbatim. + SignedProofs []string `json:"signedProofs"` +} + +// identityDetailResponse is the identity object echoed by +// verify-control, revoke, detail, and list entries. +type identityDetailResponse struct { + IdentityID string `json:"identityId"` + Kind string `json:"kind"` + Value string `json:"value"` + Status string `json:"status"` + ProofMethod string `json:"proofMethod,omitempty"` + PendingValue string `json:"pendingValue,omitempty"` + VerifiedAt string `json:"verifiedAt,omitempty"` + CreatedAt string `json:"createdAt"` + LinkedAgents []linkedAgentDTO `json:"linkedAgents,omitempty"` +} + +type linkedAgentDTO struct { + AgentID string `json:"agentId"` + LinkedAt string `json:"linkedAt,omitempty"` +} + +// linkRequest is the POST .../links body — the batch of the owner's +// agents to bind. One call, one sealed IDENTITY_LINKED event. +type linkRequest struct { + AgentIDs []string `json:"agentIds"` +} + +type linkResponse struct { + Linked int `json:"linked"` +} + +// Register handles POST /v2/ans/identities → 202 + challenges. +func (h *IdentityHandler) Register(w http.ResponseWriter, r *http.Request) { + providerID, ok := callerSubject(w, r) + if !ok { + return + } + var req identityRegisterRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + WriteError(w, domain.NewValidationError("INVALID_REQUEST_BODY", "request body is not valid JSON")) + return + } + if req.Value == "" { + WriteError(w, domain.NewValidationError("INVALID_IDENTIFIER", "value is required")) + return + } + res, err := h.svc.Register(r.Context(), providerID, req.Value) + if err != nil { + WriteError(w, err) + return + } + WriteJSON(w, http.StatusAccepted, toChallengeResponse(res)) +} + +// Rotate handles PUT /v2/ans/identities/{identityId} → 202 + fresh +// challenges over the staged replacement. +func (h *IdentityHandler) Rotate(w http.ResponseWriter, r *http.Request) { + providerID, ok := callerSubject(w, r) + if !ok { + return + } + var req identityRegisterRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + WriteError(w, domain.NewValidationError("INVALID_REQUEST_BODY", "request body is not valid JSON")) + return + } + if req.Value == "" { + WriteError(w, domain.NewValidationError("INVALID_IDENTIFIER", "value is required")) + return + } + res, err := h.svc.Rotate(r.Context(), providerID, chi.URLParam(r, "identityId"), req.Value) + if err != nil { + WriteError(w, err) + return + } + WriteJSON(w, http.StatusAccepted, toChallengeResponse(res)) +} + +// VerifyControl handles POST /v2/ans/identities/{identityId}/verify-control. +// Clean proofs flip the identity to VERIFIED (or complete a rotation) +// and seal the event — the 200 echoes the updated identity. +func (h *IdentityHandler) VerifyControl(w http.ResponseWriter, r *http.Request) { + providerID, ok := callerSubject(w, r) + if !ok { + return + } + var req verifyControlRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + WriteError(w, domain.NewValidationError("INVALID_REQUEST_BODY", "request body is not valid JSON")) + return + } + identity, err := h.svc.VerifyControl(r.Context(), providerID, chi.URLParam(r, "identityId"), + service.ProofSubmission{SignedProofs: req.SignedProofs}) + if err != nil { + WriteError(w, err) + return + } + WriteJSON(w, http.StatusOK, toDetailResponse(identity, nil)) +} + +// Revoke handles POST /v2/ans/identities/{identityId}/revoke — a +// state change (an identity cannot be deleted; its history is +// append-only in the TL), mirroring the agent's revoke verb. +func (h *IdentityHandler) Revoke(w http.ResponseWriter, r *http.Request) { + providerID, ok := callerSubject(w, r) + if !ok { + return + } + identity, err := h.svc.Revoke(r.Context(), providerID, chi.URLParam(r, "identityId")) + if err != nil { + WriteError(w, err) + return + } + WriteJSON(w, http.StatusOK, toDetailResponse(identity, nil)) +} + +// List handles GET /v2/ans/identities — the caller's identities. +func (h *IdentityHandler) List(w http.ResponseWriter, r *http.Request) { + providerID, ok := callerSubject(w, r) + if !ok { + return + } + identities, err := h.svc.List(r.Context(), providerID) + if err != nil { + WriteError(w, err) + return + } + out := make([]identityDetailResponse, 0, len(identities)) + for _, identity := range identities { + out = append(out, toDetailResponse(identity, nil)) + } + WriteJSON(w, http.StatusOK, map[string]any{"identities": out}) +} + +// Detail handles GET /v2/ans/identities/{identityId} — the identity +// plus its live links. +func (h *IdentityHandler) Detail(w http.ResponseWriter, r *http.Request) { + providerID, ok := callerSubject(w, r) + if !ok { + return + } + identity, links, err := h.svc.Detail(r.Context(), providerID, chi.URLParam(r, "identityId")) + if err != nil { + WriteError(w, err) + return + } + WriteJSON(w, http.StatusOK, toDetailResponse(identity, links)) +} + +// Link handles POST /v2/ans/identities/{identityId}/links — the +// owner-gated batch link. 200 {linked: N}; the batch sealed as ONE +// IDENTITY_LINKED event on the identity stream. +func (h *IdentityHandler) Link(w http.ResponseWriter, r *http.Request) { + providerID, ok := callerSubject(w, r) + if !ok { + return + } + var req linkRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + WriteError(w, domain.NewValidationError("INVALID_REQUEST_BODY", "request body is not valid JSON")) + return + } + linked, err := h.svc.Link(r.Context(), providerID, chi.URLParam(r, "identityId"), req.AgentIDs) + if err != nil { + WriteError(w, err) + return + } + WriteJSON(w, http.StatusOK, linkResponse{Linked: linked}) +} + +// Unlink handles DELETE /v2/ans/identities/{identityId}/links/{agentId}. +// The association ends (204); its history persists in the identity's +// audit chain and the raw log tiles. +func (h *IdentityHandler) Unlink(w http.ResponseWriter, r *http.Request) { + providerID, ok := callerSubject(w, r) + if !ok { + return + } + err := h.svc.Unlink(r.Context(), providerID, chi.URLParam(r, "identityId"), chi.URLParam(r, "agentId")) + if err != nil { + WriteError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// callerSubject extracts the authenticated principal, writing the +// 401-equivalent problem when absent. +func callerSubject(w http.ResponseWriter, r *http.Request) (string, bool) { + id, ok := auth.IdentityFromContext(r.Context()) + if !ok || id.Subject == "" { + WriteError(w, domain.NewUnauthorizedError("NO_IDENTITY", "authentication required")) + return "", false + } + return id.Subject, true +} + +func toChallengeResponse(res *service.IdentityChallengeResponse) identityChallengeResponse { + challenges := make([]identityChallengeDTO, 0, len(res.Challenges)) + for _, c := range res.Challenges { + challenges = append(challenges, identityChallengeDTO{Kid: c.Kid, SigningInput: c.SigningInput}) + } + return identityChallengeResponse{ + IdentityID: res.Identity.IdentityID, + Kind: string(res.Identity.Kind), + Value: res.Identity.EffectiveValue(), + Status: string(res.Identity.Status), + Nonce: res.Nonce, + ExpiresAt: res.ExpiresAt.UTC().Format(time.RFC3339), + Challenges: challenges, + } +} + +func toDetailResponse(identity *domain.VerifiedIdentity, links []*domain.IdentityLink) identityDetailResponse { + out := identityDetailResponse{ + IdentityID: identity.IdentityID, + Kind: string(identity.Kind), + Value: identity.Value, + Status: string(identity.Status), + ProofMethod: identity.ProofMethod, + PendingValue: identity.PendingValue, + CreatedAt: identity.CreatedAt.UTC().Format(time.RFC3339), + } + if !identity.VerifiedAt.IsZero() { + out.VerifiedAt = identity.VerifiedAt.UTC().Format(time.RFC3339) + } + for _, l := range links { + dto := linkedAgentDTO{AgentID: l.AgentID} + if !l.LinkedAt.IsZero() { + dto.LinkedAt = l.LinkedAt.UTC().Format(time.RFC3339) + } + out.LinkedAgents = append(out.LinkedAgents, dto) + } + return out +} diff --git a/internal/ra/handler/identity_handler_test.go b/internal/ra/handler/identity_handler_test.go new file mode 100644 index 0000000..e021ec5 --- /dev/null +++ b/internal/ra/handler/identity_handler_test.go @@ -0,0 +1,414 @@ +package handler_test + +// HTTP-level tests for the /v2/ans/identities surface: status codes, +// DTO shapes, auth extraction, and the agent-detail identities[] +// composition. The proof-gate logic itself is covered in depth by the +// service tests; these pin the wire contract. + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-chi/chi/v5" + "github.com/rs/zerolog" + + "github.com/godaddy/ans/internal/adapter/auth" + "github.com/godaddy/ans/internal/adapter/cert" + "github.com/godaddy/ans/internal/adapter/didresolver" + "github.com/godaddy/ans/internal/adapter/eventbus" + "github.com/godaddy/ans/internal/adapter/store/sqlite" + anscrypto "github.com/godaddy/ans/internal/crypto" + "github.com/godaddy/ans/internal/port" + "github.com/godaddy/ans/internal/ra/handler" + ramiddleware "github.com/godaddy/ans/internal/ra/middleware" + "github.com/godaddy/ans/internal/ra/service" +) + +// identityHTTPFixture wires the identity routes (plus the agent +// register + detail routes the link tests need) over real SQLite and +// the noop resolver. No signer — covering the unsigned outbox branch; +// the signed path is pinned by the service tests. +type identityHTTPFixture struct { + router chi.Router +} + +func newIdentityHTTPFixture(t *testing.T) *identityHTTPFixture { + t.Helper() + dir := t.TempDir() + db, err := sqlite.Open(context.Background(), ":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + + agents := sqlite.NewAgentStore(db) + endpoints := sqlite.NewEndpointStore(db) + certsStore := sqlite.NewCertificateStore(db) + byoc := sqlite.NewByocCertificateStore(db) + renewals := sqlite.NewRenewalStore(db) + outbox := sqlite.NewOutboxStore(db) + + identityCA, err := cert.NewSelfCA(dir+"/ca", "Test CA", 365) + if err != nil { + t.Fatal(err) + } + serverCA, err := cert.NewServerSelfCA(dir+"/server-ca", "Test Server CA", 365) + if err != nil { + t.Fatal(err) + } + regSvc := service.NewRegistrationService( + agents, endpoints, certsStore, byoc, renewals, + cert.NewX509Validator(cert.WithSkipChainVerify()), + identityCA, eventbus.NewInMemoryBus(zerolog.Nop()), outbox, db, + ).WithServerCertificateAuthority(serverCA) + + idSvc := service.NewIdentityService( + sqlite.NewIdentityStore(db), + sqlite.NewIdentityLinkStore(db), + agents, + didresolver.NewNoopResolver(), + outbox, + db, + ).WithChallengeTTL(30 * time.Minute) + + r := chi.NewRouter() + regH := handler.NewRegistrationHandler(regSvc) + lifeH := handler.NewLifecycleHandler(regSvc).WithIdentityViews(idSvc) + readOwn := ramiddleware.ReadOwnership(agents) + r.Post("/v2/ans/agents", regH.Register) + r.With(readOwn).Get("/v2/ans/agents/{agentId}", lifeH.Detail) + + idH := handler.NewIdentityHandler(idSvc) + r.Post("/v2/ans/identities", idH.Register) + r.Get("/v2/ans/identities", idH.List) + r.Get("/v2/ans/identities/{identityId}", idH.Detail) + r.Put("/v2/ans/identities/{identityId}", idH.Rotate) + r.Post("/v2/ans/identities/{identityId}/verify-control", idH.VerifyControl) + r.Post("/v2/ans/identities/{identityId}/revoke", idH.Revoke) + r.Post("/v2/ans/identities/{identityId}/links", idH.Link) + r.Delete("/v2/ans/identities/{identityId}/links/{agentId}", idH.Unlink) + + return &identityHTTPFixture{router: r} +} + +// do sends a request as the given owner ("" = unauthenticated) and +// returns the recorder. +func (f *identityHTTPFixture) do(t *testing.T, owner, method, path string, body any) *httptest.ResponseRecorder { + t.Helper() + var reader *bytes.Reader + if body != nil { + raw, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + reader = bytes.NewReader(raw) + } else { + reader = bytes.NewReader(nil) + } + req := httptest.NewRequest(method, path, reader) + req.Header.Set("Content-Type", "application/json") + if owner != "" { + req = req.WithContext(auth.WithIdentity(req.Context(), &port.Identity{Subject: owner})) + } + rec := httptest.NewRecorder() + f.router.ServeHTTP(rec, req) + return rec +} + +// doRaw sends a raw (possibly malformed) body. +func (f *identityHTTPFixture) doRaw(t *testing.T, owner, method, path, raw string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(method, path, bytes.NewReader([]byte(raw))) + req.Header.Set("Content-Type", "application/json") + if owner != "" { + req = req.WithContext(auth.WithIdentity(req.Context(), &port.Identity{Subject: owner})) + } + rec := httptest.NewRecorder() + f.router.ServeHTTP(rec, req) + return rec +} + +type challengeBody struct { + IdentityID string `json:"identityId"` + Kind string `json:"kind"` + Value string `json:"value"` + Status string `json:"status"` + Nonce string `json:"nonce"` + ExpiresAt string `json:"expiresAt"` + Challenges []struct { + Kid string `json:"kid"` + SigningInput string `json:"signingInput"` + } `json:"challenges"` +} + +// signIdentityProof mints a compact JWS over the served signingInput +// with the kid + embedded jwk headers — the registrant-side signing. +func signIdentityProof(t *testing.T, priv *ecdsa.PrivateKey, kid, signingInput string) string { + t.Helper() + jwk, err := anscrypto.PublicKeyToJWK(&priv.PublicKey) + if err != nil { + t.Fatal(err) + } + headerJSON, err := json.Marshal(map[string]any{"alg": "ES256", "kid": kid, "jwk": jwk}) + if err != nil { + t.Fatal(err) + } + encodedHeader := base64.RawURLEncoding.EncodeToString(headerJSON) + toSign := encodedHeader + "." + signingInput + digest := sha256.Sum256([]byte(toSign)) + der, err := ecdsa.SignASN1(rand.Reader, priv, digest[:]) + if err != nil { + t.Fatal(err) + } + p1363, err := anscrypto.DERToP1363(der, 32) + if err != nil { + t.Fatal(err) + } + return toSign + "." + base64.RawURLEncoding.EncodeToString(p1363) +} + +// registerAndVerify drives an identity to VERIFIED over HTTP and +// returns its id and DID. +func (f *identityHTTPFixture) registerAndVerify(t *testing.T, owner, didValue string) string { + t.Helper() + rec := f.do(t, owner, http.MethodPost, "/v2/ans/identities", map[string]string{"value": didValue}) + if rec.Code != http.StatusAccepted { + t.Fatalf("register: %d %s", rec.Code, rec.Body) + } + var ch challengeBody + if err := json.Unmarshal(rec.Body.Bytes(), &ch); err != nil { + t.Fatal(err) + } + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + proof := signIdentityProof(t, priv, didValue+"#key-1", ch.Challenges[0].SigningInput) + rec = f.do(t, owner, http.MethodPost, "/v2/ans/identities/"+ch.IdentityID+"/verify-control", + map[string]any{"signedProofs": []string{proof}}) + if rec.Code != http.StatusOK { + t.Fatalf("verify-control: %d %s", rec.Code, rec.Body) + } + return ch.IdentityID +} + +func TestIdentityHandler_RegisterShape(t *testing.T) { + t.Parallel() + f := newIdentityHTTPFixture(t) + + rec := f.do(t, "owner-1", http.MethodPost, "/v2/ans/identities", + map[string]string{"value": "did:web:identity.acme-corp.com"}) + if rec.Code != http.StatusAccepted { + t.Fatalf("register: %d %s", rec.Code, rec.Body) + } + var ch challengeBody + if err := json.Unmarshal(rec.Body.Bytes(), &ch); err != nil { + t.Fatal(err) + } + if ch.IdentityID == "" || ch.Kind != "did:web" || ch.Status != "PENDING_CONTROL" || + ch.Nonce == "" || ch.ExpiresAt == "" || len(ch.Challenges) != 1 || + ch.Challenges[0].SigningInput == "" { + t.Fatalf("202 shape wrong: %+v", ch) + } +} + +func TestIdentityHandler_AuthAndValidation(t *testing.T) { + t.Parallel() + f := newIdentityHTTPFixture(t) + + // Unauthenticated → 403-shaped problem from callerSubject. + if rec := f.do(t, "", http.MethodPost, "/v2/ans/identities", + map[string]string{"value": "did:web:a.com"}); rec.Code != http.StatusForbidden { + t.Fatalf("unauthenticated register: %d", rec.Code) + } + for _, route := range []struct{ method, path string }{ + {http.MethodGet, "/v2/ans/identities"}, + {http.MethodGet, "/v2/ans/identities/x"}, + {http.MethodPut, "/v2/ans/identities/x"}, + {http.MethodPost, "/v2/ans/identities/x/verify-control"}, + {http.MethodPost, "/v2/ans/identities/x/revoke"}, + {http.MethodPost, "/v2/ans/identities/x/links"}, + {http.MethodDelete, "/v2/ans/identities/x/links/y"}, + } { + if rec := f.do(t, "", route.method, route.path, nil); rec.Code != http.StatusForbidden { + t.Fatalf("unauthenticated %s %s: %d", route.method, route.path, rec.Code) + } + } + + // Malformed bodies → 422. + for _, route := range []struct{ method, path string }{ + {http.MethodPost, "/v2/ans/identities"}, + {http.MethodPut, "/v2/ans/identities/x"}, + {http.MethodPost, "/v2/ans/identities/x/verify-control"}, + {http.MethodPost, "/v2/ans/identities/x/links"}, + } { + if rec := f.doRaw(t, "owner-1", route.method, route.path, "{not json"); rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("bad json %s %s: %d", route.method, route.path, rec.Code) + } + } + // Empty value → 422 on register and rotate. + if rec := f.do(t, "owner-1", http.MethodPost, "/v2/ans/identities", map[string]string{}); rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("empty value register: %d", rec.Code) + } + if rec := f.do(t, "owner-1", http.MethodPut, "/v2/ans/identities/x", map[string]string{}); rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("empty value rotate: %d", rec.Code) + } +} + +func TestIdentityHandler_FullLifecycleOverHTTP(t *testing.T) { + t.Parallel() + f := newIdentityHTTPFixture(t) + owner := "owner-1" + + identityID := f.registerAndVerify(t, owner, "did:web:identity.acme-corp.com") + + // List. + rec := f.do(t, owner, http.MethodGet, "/v2/ans/identities", nil) + if rec.Code != http.StatusOK { + t.Fatalf("list: %d", rec.Code) + } + var list struct { + Identities []struct { + IdentityID string `json:"identityId"` + Status string `json:"status"` + } `json:"identities"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil { + t.Fatal(err) + } + if len(list.Identities) != 1 || list.Identities[0].Status != "VERIFIED" { + t.Fatalf("list shape: %+v", list) + } + + // Register the agent to link. + agentID := registerAgentForIdentity(t, f, owner, "linked.example.com") + + // Link → 200 {linked:1}. + rec = f.do(t, owner, http.MethodPost, "/v2/ans/identities/"+identityID+"/links", + map[string]any{"agentIds": []string{agentID}}) + if rec.Code != http.StatusOK { + t.Fatalf("link: %d %s", rec.Code, rec.Body) + } + var linked struct { + Linked int `json:"linked"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &linked); err != nil || linked.Linked != 1 { + t.Fatalf("link response: %s (%v)", rec.Body, err) + } + + // Identity detail carries the live link. + rec = f.do(t, owner, http.MethodGet, "/v2/ans/identities/"+identityID, nil) + if rec.Code != http.StatusOK { + t.Fatalf("detail: %d", rec.Code) + } + var detail struct { + Status string `json:"status"` + VerifiedAt string `json:"verifiedAt"` + LinkedAgents []struct { + AgentID string `json:"agentId"` + } `json:"linkedAgents"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &detail); err != nil { + t.Fatal(err) + } + if detail.Status != "VERIFIED" || detail.VerifiedAt == "" || + len(detail.LinkedAgents) != 1 || detail.LinkedAgents[0].AgentID != agentID { + t.Fatalf("detail shape: %+v", detail) + } + + // Agent detail carries the computed identities[] (the RA-side + // §5.4 join through WithIdentityViews). + rec = f.do(t, owner, http.MethodGet, "/v2/ans/agents/"+agentID, nil) + if rec.Code != http.StatusOK { + t.Fatalf("agent detail: %d", rec.Code) + } + var agentDetail struct { + Identities []struct { + IdentityID string `json:"identityId"` + IdentityStatus string `json:"identityStatus"` + LinkedAt string `json:"linkedAt"` + } `json:"identities"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &agentDetail); err != nil { + t.Fatal(err) + } + if len(agentDetail.Identities) != 1 || agentDetail.Identities[0].IdentityID != identityID || + agentDetail.Identities[0].IdentityStatus != "VERIFIED" || agentDetail.Identities[0].LinkedAt == "" { + t.Fatalf("agent identities[]: %+v", agentDetail) + } + + // Rotate → 202 with fresh challenges over the staged value. + rec = f.do(t, owner, http.MethodPut, "/v2/ans/identities/"+identityID, + map[string]string{"value": "did:web:identity.acme-corp.com"}) + if rec.Code != http.StatusAccepted { + t.Fatalf("rotate: %d %s", rec.Code, rec.Body) + } + + // Unlink → 204. + rec = f.do(t, owner, http.MethodDelete, "/v2/ans/identities/"+identityID+"/links/"+agentID, nil) + if rec.Code != http.StatusNoContent { + t.Fatalf("unlink: %d %s", rec.Code, rec.Body) + } + // Unlinking again → 404. + rec = f.do(t, owner, http.MethodDelete, "/v2/ans/identities/"+identityID+"/links/"+agentID, nil) + if rec.Code != http.StatusNotFound { + t.Fatalf("double unlink: %d", rec.Code) + } + + // Revoke → 200, REVOKED echoed. + rec = f.do(t, owner, http.MethodPost, "/v2/ans/identities/"+identityID+"/revoke", nil) + if rec.Code != http.StatusOK { + t.Fatalf("revoke: %d %s", rec.Code, rec.Body) + } + if err := json.Unmarshal(rec.Body.Bytes(), &detail); err != nil || detail.Status != "REVOKED" { + t.Fatalf("revoke response: %s (%v)", rec.Body, err) + } + + // Cross-owner: read hides (404), write rejects (403). + other := f.registerAndVerify(t, "owner-2", "did:web:other.example.com") + if rec := f.do(t, owner, http.MethodGet, "/v2/ans/identities/"+other, nil); rec.Code != http.StatusNotFound { + t.Fatalf("cross-owner detail: %d", rec.Code) + } + if rec := f.do(t, owner, http.MethodPost, "/v2/ans/identities/"+other+"/revoke", nil); rec.Code != http.StatusForbidden { + t.Fatalf("cross-owner revoke: %d", rec.Code) + } +} + +// registerAgentForIdentity registers an agent over HTTP for link +// tests, reusing the package CSR helpers. +func registerAgentForIdentity(t *testing.T, f *identityHTTPFixture, owner, host string) string { + t.Helper() + body := map[string]any{ + "agentDisplayName": "Test", + "version": "1.0.0", + "agentHost": host, + "endpoints": []map[string]any{{ + "agentUrl": "https://" + host + "/mcp", + "protocol": "MCP", + "transports": []string{"SSE"}, + }}, + "identityCsrPEM": newTestCSR(t, "ans://v1.0.0."+host), + "serverCsrPEM": newTestServerCSR(t, host), + } + rec := f.do(t, owner, http.MethodPost, "/v2/ans/agents", body) + if rec.Code != http.StatusAccepted { + t.Fatalf("agent register: %d %s", rec.Code, rec.Body) + } + var resp struct { + AgentID string `json:"agentId"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil || resp.AgentID == "" { + t.Fatalf("agent register response: %s (%v)", rec.Body, err) + } + return resp.AgentID +} diff --git a/internal/ra/handler/lifecycle.go b/internal/ra/handler/lifecycle.go index 26db754..f4eedd5 100644 --- a/internal/ra/handler/lifecycle.go +++ b/internal/ra/handler/lifecycle.go @@ -18,6 +18,11 @@ import ( // routes assume the ownership middleware has already run. type LifecycleHandler struct { svc *service.RegistrationService + // identities, when set, decorates agent detail responses with the + // computed identities[] view (design §5.4) — the verified + // identities currently linked to the agent. Optional so the + // agent surface has zero hard dependency on the identity feature. + identities *service.IdentityService } // NewLifecycleHandler constructs a LifecycleHandler. @@ -25,6 +30,13 @@ func NewLifecycleHandler(svc *service.RegistrationService) *LifecycleHandler { return &LifecycleHandler{svc: svc} } +// WithIdentityViews attaches the identity service used to compute the +// additive identities[] field on agent detail responses. +func (h *LifecycleHandler) WithIdentityViews(identities *service.IdentityService) *LifecycleHandler { + h.identities = identities + return h +} + // ----- GET /v2/ans/agents ----- // List handles GET /v2/ans/agents. Ownership-scoped to the caller; @@ -91,7 +103,16 @@ func (h *LifecycleHandler) Detail(w http.ResponseWriter, r *http.Request) { WriteError(w, err) return } - WriteJSON(w, http.StatusOK, mapAgentDetails(res, r, h.svc.TLPublicBaseURL())) + details := mapAgentDetails(res, r, h.svc.TLPublicBaseURL()) + if h.identities != nil { + linked, lerr := h.identities.LinkedIdentitiesForAgent(r.Context(), agentID) + if lerr != nil { + WriteError(w, lerr) + return + } + details.Identities = mapLinkedIdentities(linked) + } + WriteJSON(w, http.StatusOK, details) } // ----- GET /v2/ans/agents/{agentId}/certificates/identity ----- diff --git a/internal/ra/service/identity.go b/internal/ra/service/identity.go new file mode 100644 index 0000000..e9ec09f --- /dev/null +++ b/internal/ra/service/identity.go @@ -0,0 +1,673 @@ +package service + +import ( + "context" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + + anscrypto "github.com/godaddy/ans/internal/crypto" + "github.com/godaddy/ans/internal/domain" + "github.com/godaddy/ans/internal/port" + identityevent "github.com/godaddy/ans/internal/tl/event/identity" +) + +// identityLane is the outbox schema_version value routing identity +// events to the TL's `POST /v1/internal/identities/event` ingest +// lane. Same producer signature and replay-verbatim invariant as the +// V1/V2 agent lanes; different inner-event schema. +const identityLane = "IDENTITY" + +// maxProofsPerVerify bounds the multi-key proof set on one +// verify-control call. did:web legitimately proves several +// assertionMethod keys; sixteen is far beyond any real document while +// keeping the request body and the sealed event bounded. +const maxProofsPerVerify = 16 + +// maxLinkBatch bounds one link call. The design allows the RA to +// chunk very large fleets to bound leaf size; rather than silently +// chunking, v1 rejects oversized batches and lets the caller chunk — +// every accepted call still seals exactly one event. +const maxLinkBatch = 256 + +// ProofChallenge is one entry of the 202 challenge list: a key the +// registrant may prove, plus the exact base64url signing input a +// compact JWS over it must carry as its payload segment. Every entry +// of one round shares the same nonce and the same signing input — +// the input is key-independent. +// +// Kid is empty when the resolver could not enumerate keys (the noop +// resolver before any proofs exist): the registrant then names keys +// via the JWS `kid` header at verify time. +type ProofChallenge struct { + Kid string + SigningInput string +} + +// IdentityChallengeResponse is returned by Register and Rotate — the +// service half of the 202 body. +type IdentityChallengeResponse struct { + Identity *domain.VerifiedIdentity + Nonce string + ExpiresAt time.Time + Challenges []ProofChallenge +} + +// IdentityService owns the Verified Identity lifecycle: register → +// verify-control → (rotate | revoke), plus the owner-gated link +// surface. One service, one aggregate; the only per-kind branching is +// the control-proof dispatch in verify-control (and the advisory +// resolution at register), per the design's minimal-abstraction rule. +type IdentityService struct { + identities port.IdentityStore + links port.IdentityLinkStore + agents port.AgentStore + outbox OutboxEnqueuer + uow port.UnitOfWork + signer *EventSigner + + // verifiers is the per-kind control-proof registry — THE + // extension seam (see identitykinds.go). A kind is enabled iff + // it has an entry here. + verifiers map[domain.IdentifierKind]controlVerifier + + challengeTTL time.Duration + limiter *ownerLimiter + clock func() time.Time + newID func() (string, error) + newNonce func() (string, error) +} + +// NewIdentityService constructs an IdentityService. +func NewIdentityService( + identities port.IdentityStore, + links port.IdentityLinkStore, + agents port.AgentStore, + resolver port.DIDResolver, + outbox OutboxEnqueuer, + uow port.UnitOfWork, +) *IdentityService { + return &IdentityService{ + identities: identities, + links: links, + agents: agents, + verifiers: newControlVerifiers(resolver), + outbox: outbox, + uow: uow, + challengeTTL: time.Hour, + limiter: newOwnerLimiter(defaultRegisterPerMinute), + clock: time.Now, + newID: func() (string, error) { + id, err := uuid.NewV7() + if err != nil { + return "", err + } + return id.String(), nil + }, + newNonce: func() (string, error) { + var b [32]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b[:]), nil + }, + } +} + +// WithSigner attaches the producer event signer (same KeyManager + +// keyID + raID tuple the registration service signs with — one RA, +// one producer identity). +func (s *IdentityService) WithSigner(sig EventSigner) *IdentityService { + s.signer = &sig + return s +} + +// WithChallengeTTL overrides the nonce TTL (default 1h; the design +// floor for high-assurance deployments is 5m). +func (s *IdentityService) WithChallengeTTL(ttl time.Duration) *IdentityService { + if ttl > 0 { + s.challengeTTL = ttl + } + return s +} + +// WithRegisterRateLimit overrides the per-owner register/rotate rate +// limit (default 10/min). Register and rotate trigger an outbound +// fetch for did:web before any proof exists, so they are rate-limited +// per authenticated owner (design §3.7). +func (s *IdentityService) WithRegisterRateLimit(perMinute int) *IdentityService { + if perMinute > 0 { + s.limiter = newOwnerLimiter(perMinute) + } + return s +} + +// WithClock overrides the time source (tests only). +func (s *IdentityService) WithClock(fn func() time.Time) *IdentityService { + s.clock = fn + return s +} + +// raID returns the configured RA identifier stamped into proof inputs +// and sealed events. Empty without a signer (tests). +func (s *IdentityService) raID() string { + if s.signer != nil { + return s.signer.RaID + } + return "" +} + +// verifierFor returns the kind's control verifier, or the precise +// IDENTIFIER_KIND_UNSUPPORTED error when this deployment has none — +// lei is recognized lexically but postponed: the route exists, the +// kind does not until its verifier registers (identitykinds.go). +func (s *IdentityService) verifierFor(kind domain.IdentifierKind) (controlVerifier, error) { + v, ok := s.verifiers[kind] + if !ok { + return nil, domain.NewValidationError("IDENTIFIER_KIND_UNSUPPORTED", + fmt.Sprintf("identifier kind %q is not enabled on this deployment", kind)) + } + return v, nil +} + +// Register creates (or idempotently re-challenges) an identity for +// the authenticated owner and returns the challenge list to sign. +// +// Idempotent re-add (§4.2): while the owner's row for the same +// canonical value is PENDING_CONTROL, re-registering returns the +// SAME identityId with a fresh nonce (the prior nonce is +// superseded). IDENTIFIER_DUPLICATE is reserved for genuine +// conflicts: already VERIFIED by this owner (rotate instead), or +// proven by another owner. +func (s *IdentityService) Register(ctx context.Context, providerID, rawValue string) (*IdentityChallengeResponse, error) { + if providerID == "" { + return nil, domain.NewValidationError("INVALID_PROVIDER_ID", "authenticated owner is required") + } + if !s.limiter.Allow(providerID, s.clock()) { + return nil, domain.NewValidationError("RATE_LIMITED", + "too many identity register/rotate calls; retry later") + } + kind, canonical, err := domain.InferIdentifierKind(rawValue) + if err != nil { + return nil, err + } + if _, err := s.verifierFor(kind); err != nil { + return nil, err + } + + now := s.clock().UTC() + + // Existing live row for this owner? + existing, err := s.identities.FindLive(ctx, providerID, kind, canonical) + switch { + case err == nil && existing.Status == domain.IdentityVerified: + return nil, domain.NewConflictError("IDENTIFIER_DUPLICATE", + "identifier is already verified by this owner; rotate it with PUT instead") + case err == nil: + // PENDING_CONTROL → idempotent re-challenge on the same row. + return s.challenge(ctx, existing, now) + case errors.Is(err, domain.ErrNotFound): + // fall through to creation + default: + return nil, err + } + + // Early duplicate feedback when another owner already proved the + // value. The authoritative guard is the proven-uniqueness index + // at verify time; this just makes the failure arrive sooner. + if taken, terr := s.identities.ExistsVerified(ctx, kind, canonical); terr != nil { + return nil, terr + } else if taken { + return nil, domain.NewConflictError("IDENTIFIER_DUPLICATE", + "identifier is already verified by another owner") + } + + identityID, err := s.newID() + if err != nil { + return nil, domain.NewInternalError("IDENTITY_ID_GENERATION", "could not generate identityId", err) + } + identity, err := domain.NewVerifiedIdentity(identityID, providerID, canonical, now) + if err != nil { + return nil, err + } + return s.challenge(ctx, identity, now) +} + +// Rotate stages a same-kind replacement (§4.2 PUT) and returns fresh +// challenges over it. The previously sealed state stands until the +// new proof lands; a replacement that never verifies expires with its +// nonce. +func (s *IdentityService) Rotate(ctx context.Context, providerID, identityID, rawValue string) (*IdentityChallengeResponse, error) { + if !s.limiter.Allow(providerID, s.clock()) { + return nil, domain.NewValidationError("RATE_LIMITED", + "too many identity register/rotate calls; retry later") + } + identity, err := s.ownedIdentityForWrite(ctx, providerID, identityID) + if err != nil { + return nil, err + } + now := s.clock().UTC() + if err := identity.StageRotation(rawValue, now); err != nil { + return nil, err + } + return s.challenge(ctx, identity, now) +} + +// challenge mints a fresh nonce on the identity, runs the kind's +// advisory resolution to seed the per-key challenge list, persists, +// and assembles the 202 response. Shared by Register and Rotate. +func (s *IdentityService) challenge(ctx context.Context, identity *domain.VerifiedIdentity, now time.Time) (*IdentityChallengeResponse, error) { + nonce, err := s.newNonce() + if err != nil { + return nil, domain.NewInternalError("CHALLENGE_GENERATION", "could not generate challenge nonce", err) + } + if err := identity.IssueChallenge(nonce, s.challengeTTL, now); err != nil { + return nil, err + } + + input := anscrypto.IdentityProofInput{ + Identifier: identity.EffectiveValue(), + IdentityID: identity.IdentityID, + Nonce: nonce, + Purpose: anscrypto.IdentityProofPurpose, + RaID: s.raID(), + Scheme: string(identity.Kind), + } + signingInput, err := input.SigningInput() + if err != nil { + return nil, domain.NewInternalError("CHALLENGE_GENERATION", "could not build signing input", err) + } + + verifier, err := s.verifierFor(identity.Kind) + if err != nil { + return nil, err + } + challenges, err := verifier.Challenges(ctx, identity, signingInput) + if err != nil { + return nil, err + } + + if err := s.identities.Save(ctx, identity); err != nil { + return nil, mapIdentitySaveErr(err) + } + return &IdentityChallengeResponse{ + Identity: identity, + Nonce: nonce, + ExpiresAt: identity.Challenge.ExpiresAt, + Challenges: challenges, + }, nil +} + +// VerifyControl runs the identity's per-kind control proof over the +// submission and, when every proof passes, flips the identity to +// VERIFIED (or completes a staged rotation), consumes the nonce, and +// seals IDENTITY_VERIFIED / IDENTITY_UPDATED on the identity's TL +// stream — all in one transaction. One bad proof fails the call +// closed; a failed attempt does NOT consume the nonce. The per-kind +// logic lives entirely behind the controlVerifier seam +// (identitykinds.go); this method owns the kind-agnostic discipline. +func (s *IdentityService) VerifyControl(ctx context.Context, providerID, identityID string, sub ProofSubmission) (*domain.VerifiedIdentity, error) { + identity, err := s.ownedIdentityForWrite(ctx, providerID, identityID) + if err != nil { + return nil, err + } + now := s.clock().UTC() + if identity.Status == domain.IdentityRevoked { + return nil, domain.NewInvalidStateError("IDENTITY_REVOKED", "identity is revoked") + } + if err := identity.CheckChallenge(now); err != nil { + return nil, err + } + verifier, err := s.verifierFor(identity.Kind) + if err != nil { + return nil, err + } + + expectedInput := anscrypto.IdentityProofInput{ + Identifier: identity.EffectiveValue(), + IdentityID: identity.IdentityID, + Nonce: identity.Challenge.Nonce, + Purpose: anscrypto.IdentityProofPurpose, + RaID: s.raID(), + Scheme: string(identity.Kind), + } + expectedPayload, err := expectedInput.SigningInput() + if err != nil { + return nil, domain.NewInternalError("PROOF_INPUT", "could not rebuild signing input", err) + } + + provenKeys, err := verifier.VerifyProofs(ctx, identity, sub, expectedPayload) + if err != nil { + return nil, err + } + + statusBefore := identity.Status + var sealed *domain.VerifiedIdentity + err = s.uow.Run(ctx, func(txCtx context.Context) error { + // Consume the nonce first — the conditional update is the + // TOCTOU guard; exactly one concurrent verify can win. + if err := s.identities.ConsumeChallenge(txCtx, identity.IdentityID, identity.Challenge.Nonce, now); err != nil { + return err + } + previousValue, err := identity.CompleteVerification(now) + if err != nil { + return err + } + consumed := now + identity.Challenge.ConsumedAt = &consumed + if err := s.identities.Save(txCtx, identity); err != nil { + return mapIdentitySaveErr(err) + } + + eventType := identityevent.TypeIdentityVerified + if statusBefore == domain.IdentityVerified { + eventType = identityevent.TypeIdentityUpdated + } + inner := s.buildIdentityEvent(identity, eventType, now) + inner.Keys = provenKeys + inner.PreviousValue = previousValue + inner.VerifiedAt = now.Format(time.RFC3339) + return s.enqueueIdentityEvent(txCtx, inner, now) + }) + if err != nil { + return nil, err + } + sealed = identity + return sealed, nil +} + +// Revoke transitions a VERIFIED identity to REVOKED and seals +// IDENTITY_REVOKED — one event; propagation to every linked agent's +// badge is the TL's read-time join, never a write fan-out. +func (s *IdentityService) Revoke(ctx context.Context, providerID, identityID string) (*domain.VerifiedIdentity, error) { + identity, err := s.ownedIdentityForWrite(ctx, providerID, identityID) + if err != nil { + return nil, err + } + now := s.clock().UTC() + if err := identity.Revoke(now); err != nil { + return nil, err + } + err = s.uow.Run(ctx, func(txCtx context.Context) error { + if err := s.identities.Save(txCtx, identity); err != nil { + return mapIdentitySaveErr(err) + } + inner := s.buildIdentityEvent(identity, identityevent.TypeIdentityRevoked, now) + inner.RevokedAt = now.Format(time.RFC3339) + return s.enqueueIdentityEvent(txCtx, inner, now) + }) + if err != nil { + return nil, err + } + return identity, nil +} + +// List returns the owner's identities, newest first. +func (s *IdentityService) List(ctx context.Context, providerID string) ([]*domain.VerifiedIdentity, error) { + return s.identities.ListByOwner(ctx, providerID) +} + +// LinkedIdentitySummary is one entry of the RA-side computed +// identities[] view on AgentDetails (design §5.4): additive, +// optional, computed — never stored on the registration. The +// authoritative third-party view is the TL badge join; this is the +// owner's convenience mirror. +type LinkedIdentitySummary struct { + IdentityID string + Kind domain.IdentifierKind + Value string + IdentityStatus domain.IdentityStatus + LinkedAt time.Time +} + +// LinkedIdentitiesForAgent computes the identities currently linked +// to an agent. Callers reach this through the ownership-gated agent +// detail route; links are same-owner by construction, so no further +// gate applies here. +func (s *IdentityService) LinkedIdentitiesForAgent(ctx context.Context, agentID string) ([]LinkedIdentitySummary, error) { + links, err := s.links.ListLiveByAgent(ctx, agentID) + if err != nil { + return nil, err + } + out := make([]LinkedIdentitySummary, 0, len(links)) + for _, l := range links { + identity, err := s.identities.FindByID(ctx, l.IdentityID) + if err != nil { + return nil, err + } + out = append(out, LinkedIdentitySummary{ + IdentityID: identity.IdentityID, + Kind: identity.Kind, + Value: identity.Value, + IdentityStatus: identity.Status, + LinkedAt: l.LinkedAt, + }) + } + return out, nil +} + +// Detail returns one identity plus its live links. +func (s *IdentityService) Detail(ctx context.Context, providerID, identityID string) (*domain.VerifiedIdentity, []*domain.IdentityLink, error) { + identity, err := s.ownedIdentity(ctx, providerID, identityID) + if err != nil { + return nil, nil, err + } + links, err := s.links.ListLiveByIdentity(ctx, identityID) + if err != nil { + return nil, nil, err + } + return identity, links, nil +} + +// Link binds a batch of the owner's agents to the identity — a +// single owner-gated call, no challenge, no signature (§4.3): the +// caller must own the identity AND every named agent; key possession +// never authorizes a link. The whole batch seals as ONE +// IDENTITY_LINKED event on the identity stream; agent streams are +// never written. Already-linked agents are skipped idempotently; a +// call that links nothing new seals nothing. +func (s *IdentityService) Link(ctx context.Context, providerID, identityID string, agentIDs []string) (int, error) { + identity, err := s.ownedIdentityForWrite(ctx, providerID, identityID) + if err != nil { + return 0, err + } + if identity.Status != domain.IdentityVerified { + return 0, domain.NewInvalidStateError("IDENTITY_NOT_VERIFIED", + "links attach only while the identity is VERIFIED") + } + if len(agentIDs) == 0 { + return 0, domain.NewValidationError("INVALID_LINK_REQUEST", "agentIds is required") + } + if len(agentIDs) > maxLinkBatch { + return 0, domain.NewValidationError("INVALID_LINK_REQUEST", + fmt.Sprintf("at most %d agents per link call; chunk larger fleets", maxLinkBatch)) + } + seen := make(map[string]bool, len(agentIDs)) + deduped := make([]string, 0, len(agentIDs)) + for _, id := range agentIDs { + if id == "" { + return 0, domain.NewValidationError("INVALID_LINK_REQUEST", "agentIds contains an empty id") + } + if !seen[id] { + seen[id] = true + deduped = append(deduped, id) + } + } + + // Owner gate, both sides: every agent must exist and belong to + // the caller. A non-owned agent is reported as not-found — the + // caller learns nothing about other owners' agents. + for _, agentID := range deduped { + reg, err := s.agents.FindByAgentID(ctx, agentID) + if err != nil { + return 0, domain.NewNotFoundError("AGENT_NOT_FOUND", + fmt.Sprintf("agent %q not found", agentID)) + } + if reg.OwnerID != providerID { + return 0, domain.NewNotFoundError("AGENT_NOT_FOUND", + fmt.Sprintf("agent %q not found", agentID)) + } + } + + now := s.clock().UTC() + linked := 0 + err = s.uow.Run(ctx, func(txCtx context.Context) error { + newlyLinked := make([]string, 0, len(deduped)) + for _, agentID := range deduped { + created, err := s.links.Link(txCtx, identityID, agentID, now) + if err != nil { + return err + } + if created { + newlyLinked = append(newlyLinked, agentID) + } + } + linked = len(newlyLinked) + if linked == 0 { + return nil // fully idempotent — nothing to seal + } + inner := s.buildIdentityEvent(identity, identityevent.TypeIdentityLinked, now) + inner.AnsIDs = newlyLinked + return s.enqueueIdentityEvent(txCtx, inner, now) + }) + if err != nil { + return 0, err + } + return linked, nil +} + +// Unlink ends one association and seals IDENTITY_UNLINKED on the +// identity stream. The association's history persists in the log. +func (s *IdentityService) Unlink(ctx context.Context, providerID, identityID, agentID string) error { + identity, err := s.ownedIdentityForWrite(ctx, providerID, identityID) + if err != nil { + return err + } + now := s.clock().UTC() + return s.uow.Run(ctx, func(txCtx context.Context) error { + if err := s.links.Unlink(txCtx, identityID, agentID, now); err != nil { + return err + } + inner := s.buildIdentityEvent(identity, identityevent.TypeIdentityUnlinked, now) + inner.AnsIDs = []string{agentID} + return s.enqueueIdentityEvent(txCtx, inner, now) + }) +} + +// ownedIdentity loads an identity and enforces the owner gate with +// read semantics: a non-owner gets not-found — existence is hidden, +// matching the agent ReadOwnership middleware. No admin override: +// the owner gate is the link mechanism's security boundary (§4.3 — +// the caller MUST be the authenticated owner), so identities are +// stricter than the agent routes here. +func (s *IdentityService) ownedIdentity(ctx context.Context, providerID, identityID string) (*domain.VerifiedIdentity, error) { + if identityID == "" { + return nil, domain.NewValidationError("INVALID_IDENTITY_ID", "identityId is required") + } + identity, err := s.identities.FindByID(ctx, identityID) + if err != nil { + return nil, err + } + if identity.ProviderID != providerID { + return nil, domain.NewNotFoundError("IDENTITY_NOT_FOUND", + fmt.Sprintf("identity %q not found", identityID)) + } + return identity, nil +} + +// ownedIdentityForWrite is the write-path owner gate: missing → 404, +// present-but-not-owned → 403 (the agent WriteOwnership split — a +// 404-for-not-owned would hide a real authorization failure from +// operators investigating permissions). +func (s *IdentityService) ownedIdentityForWrite(ctx context.Context, providerID, identityID string) (*domain.VerifiedIdentity, error) { + if identityID == "" { + return nil, domain.NewValidationError("INVALID_IDENTITY_ID", "identityId is required") + } + identity, err := s.identities.FindByID(ctx, identityID) + if err != nil { + return nil, err + } + if identity.ProviderID != providerID { + return nil, domain.NewUnauthorizedError("IDENTITY_NOT_OWNED", + "caller does not own this identity") + } + return identity, nil +} + +// buildIdentityEvent assembles the common fields of an identity +// event. Type-specific fields (keys, ansIds, previousValue, +// verifiedAt, revokedAt) are set by the caller. +func (s *IdentityService) buildIdentityEvent( + identity *domain.VerifiedIdentity, + eventType identityevent.Type, + now time.Time, +) *identityevent.Event { + return &identityevent.Event{ + EventType: eventType, + IdentityID: identity.IdentityID, + Kind: string(identity.Kind), + Value: identity.Value, + ProviderID: identity.ProviderID, + ProofMethod: identity.ProofMethod, + RaID: s.raID(), + Timestamp: now.Format(time.RFC3339), + } +} + +// enqueueIdentityEvent JCS-canonicalizes the inner event, signs it +// once with the producer key, and writes the outbox row on the +// IDENTITY lane. Same replay-verbatim invariant as the agent lanes: +// the worker must POST these exact bytes on every retry. +func (s *IdentityService) enqueueIdentityEvent(ctx context.Context, inner *identityevent.Event, now time.Time) error { + if s.outbox == nil { + return nil + } + innerCanonical, err := identityevent.CanonicalizeEvent(inner) + if err != nil { + return fmt.Errorf("canonicalize identity event: %w", err) + } + var producerSig string + if s.signer != nil { + producerSig, err = anscrypto.SignDetachedJWS( + ctx, s.signer.KeyManager, s.signer.KeyID, + anscrypto.JWSProtectedHeader{ + Typ: "JWT", + Timestamp: now.Unix(), + RAID: s.signer.RaID, + }, + innerCanonical, + ) + if err != nil { + return fmt.Errorf("sign identity event: %w", err) + } + } + payload, err := marshalOutboxPayload(innerCanonical, producerSig) + if err != nil { + return err + } + // The outbox row's subject column carries the identityId — the + // stream key for identity events, exactly as agent rows carry + // the agentId. + if _, err := s.outbox.Enqueue(ctx, string(inner.EventType), inner.IdentityID, identityLane, payload, now); err != nil { + return err + } + return nil +} + +// mapIdentitySaveErr converts the storage layer's generic conflict +// (one of the two partial unique indexes fired) into the wire code +// the design names. +func mapIdentitySaveErr(err error) error { + if err == nil { + return nil + } + if errors.Is(err, domain.ErrConflict) { + return domain.NewConflictError("IDENTIFIER_DUPLICATE", + "identifier is already registered or verified for this scope") + } + return err +} diff --git a/internal/ra/service/identity_test.go b/internal/ra/service/identity_test.go new file mode 100644 index 0000000..c9e2ebd --- /dev/null +++ b/internal/ra/service/identity_test.go @@ -0,0 +1,1036 @@ +package service_test + +// IdentityService tests: the proof gate (payload equality, kid +// selection, signature verification, nonce discipline), the lifecycle +// (register → verify → rotate → revoke), the owner-gated links, and +// the sealed-event emission on the outbox IDENTITY lane. Real SQLite +// stores + real crypto; the resolver is the noop adapter (hint +// synthesis) or a canned-document fake for the did:web rules. + +import ( + "context" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "github.com/godaddy/ans/internal/adapter/didresolver" + "github.com/godaddy/ans/internal/adapter/keymanager" + "github.com/godaddy/ans/internal/adapter/store/sqlite" + anscrypto "github.com/godaddy/ans/internal/crypto" + "github.com/godaddy/ans/internal/domain" + "github.com/godaddy/ans/internal/port" + "github.com/godaddy/ans/internal/ra/service" + identityevent "github.com/godaddy/ans/internal/tl/event/identity" +) + +// fakeResolver returns a canned document — the did:web rule tests +// (kid membership, controller checks, multibase keys) drive it. +type fakeResolver struct { + doc *port.DIDDocument + err error +} + +func (f *fakeResolver) Resolve(context.Context, string, []port.KeyHint) (*port.DIDDocument, error) { + return f.doc, f.err +} + +type identityFixture struct { + svc *service.IdentityService + db *sqlite.DB + outbox *sqlite.OutboxStore + agents port.AgentStore + signerPub any + clock *fakeClock + providerID string +} + +type fakeClock struct{ now time.Time } + +func (c *fakeClock) Now() time.Time { return c.now } + +// newIdentityFixture wires the service against real SQLite + the +// given resolver (nil → noop). +func newIdentityFixture(t *testing.T, resolver port.DIDResolver) *identityFixture { + t.Helper() + db, err := sqlite.Open(context.Background(), ":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + + km, err := keymanager.NewFileKeyManager(t.TempDir() + "/keys") + if err != nil { + t.Fatal(err) + } + if _, err := km.EnsureKey(context.Background(), "ra-signer", port.AlgorithmECDSAP256); err != nil { + t.Fatal(err) + } + pub, err := km.GetPublicKey(context.Background(), "ra-signer") + if err != nil { + t.Fatal(err) + } + + if resolver == nil { + resolver = didresolver.NewNoopResolver() + } + clock := &fakeClock{now: time.Date(2026, 6, 10, 15, 0, 0, 0, time.UTC)} + svc := service.NewIdentityService( + sqlite.NewIdentityStore(db), + sqlite.NewIdentityLinkStore(db), + sqlite.NewAgentStore(db), + resolver, + sqlite.NewOutboxStore(db), + db, + ).WithSigner(service.EventSigner{ + KeyManager: km, + KeyID: "ra-signer", + RaID: "ra-test", + }).WithClock(clock.Now) + + return &identityFixture{ + svc: svc, + db: db, + outbox: sqlite.NewOutboxStore(db), + agents: sqlite.NewAgentStore(db), + signerPub: pub, + clock: clock, + providerID: "owner-1", + } +} + +// saveAgent persists a minimal ACTIVE agent owned by `owner`. +func (fx *identityFixture) saveAgent(t *testing.T, agentID, owner, host string) { + t.Helper() + v, err := domain.NewSemVer(1, 0, 0) + if err != nil { + t.Fatal(err) + } + ansName, err := domain.NewAnsName(v, host) + if err != nil { + t.Fatal(err) + } + reg := &domain.AgentRegistration{ + AgentID: agentID, + OwnerID: owner, + AnsName: ansName, + Status: domain.StatusActive, + Details: domain.RegistrationDetails{ + RegistrationTimestamp: fx.clock.now, + DisplayName: "agent " + agentID, + }, + } + if err := fx.agents.Save(context.Background(), reg); err != nil { + t.Fatal(err) + } +} + +// signProof builds a standard compact JWS over the served +// signingInput: ES256, P1363 signature, kid + optional embedded jwk +// in the protected header — exactly what a registrant's tooling +// produces. +func signProof(t *testing.T, priv *ecdsa.PrivateKey, kid, signingInput string, embedJWK bool) string { + t.Helper() + header := map[string]any{"alg": "ES256", "kid": kid} + if embedJWK { + jwk, err := anscrypto.PublicKeyToJWK(&priv.PublicKey) + if err != nil { + t.Fatal(err) + } + header["jwk"] = jwk + } + headerJSON, err := json.Marshal(header) + if err != nil { + t.Fatal(err) + } + encodedHeader := base64.RawURLEncoding.EncodeToString(headerJSON) + toSign := encodedHeader + "." + signingInput + digest := sha256.Sum256([]byte(toSign)) + der, err := ecdsa.SignASN1(rand.Reader, priv, digest[:]) + if err != nil { + t.Fatal(err) + } + p1363, err := anscrypto.DERToP1363(der, 32) + if err != nil { + t.Fatal(err) + } + return toSign + "." + base64.RawURLEncoding.EncodeToString(p1363) +} + +func genKey(t *testing.T) *ecdsa.PrivateKey { + t.Helper() + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + return priv +} + +// drainOutbox claims and returns all pending outbox rows. +func (fx *identityFixture) drainOutbox(t *testing.T) []sqlite.OutboxEvent { + t.Helper() + rows, err := fx.outbox.Claim(context.Background(), 100) + if err != nil { + t.Fatal(err) + } + for _, row := range rows { + if err := fx.outbox.MarkSent(context.Background(), row.ID); err != nil { + t.Fatal(err) + } + } + return rows +} + +// decodeOutboxEvent parses one outbox row's payload, verifies the +// producer signature against the fixture's signer key, and returns +// the inner identity event. +func (fx *identityFixture) decodeOutboxEvent(t *testing.T, row sqlite.OutboxEvent) *identityevent.Event { + t.Helper() + var payload struct { + InnerEventCanonical json.RawMessage `json:"innerEventCanonical"` + ProducerSignature string `json:"producerSignature"` + } + if err := json.Unmarshal(row.PayloadJSON, &payload); err != nil { + t.Fatalf("payload: %v", err) + } + if _, err := anscrypto.VerifyWithPublicKey(fx.signerPub, payload.ProducerSignature, payload.InnerEventCanonical); err != nil { + t.Fatalf("producer signature: %v", err) + } + var inner identityevent.Event + if err := json.Unmarshal(payload.InnerEventCanonical, &inner); err != nil { + t.Fatalf("inner event: %v", err) + } + if err := inner.Validate(); err != nil { + t.Fatalf("inner event invalid: %v", err) + } + return &inner +} + +// ----- register ----- + +func TestIdentityRegister_DIDWebNoop(t *testing.T) { + t.Parallel() + fx := newIdentityFixture(t, nil) + ctx := context.Background() + + res, err := fx.svc.Register(ctx, fx.providerID, "did:web:Identity.ACME-corp.com") + if err != nil { + t.Fatalf("register: %v", err) + } + if res.Identity.Status != domain.IdentityPendingControl { + t.Fatalf("status: %s", res.Identity.Status) + } + if res.Identity.Value != "did:web:identity.acme-corp.com" { + t.Fatalf("canonicalization: %s", res.Identity.Value) + } + if res.Nonce == "" || len(res.Challenges) != 1 || res.Challenges[0].Kid != "" || + res.Challenges[0].SigningInput == "" { + t.Fatalf("challenge round wrong: %+v", res) + } + + // The signing input decodes to a proof input binding this round. + raw, err := base64.RawURLEncoding.DecodeString(res.Challenges[0].SigningInput) + if err != nil { + t.Fatal(err) + } + var input anscrypto.IdentityProofInput + if err := json.Unmarshal(raw, &input); err != nil { + t.Fatal(err) + } + if input.IdentityID != res.Identity.IdentityID || input.Nonce != res.Nonce || + input.Purpose != anscrypto.IdentityProofPurpose || input.RaID != "ra-test" || + input.Scheme != "did:web" || input.Identifier != res.Identity.Value { + t.Fatalf("proof input wrong: %+v", input) + } + + // Idempotent re-add: same identityId, fresh nonce. + again, err := fx.svc.Register(ctx, fx.providerID, "did:web:identity.acme-corp.com") + if err != nil { + t.Fatalf("re-add: %v", err) + } + if again.Identity.IdentityID != res.Identity.IdentityID { + t.Fatal("re-add must reuse the identityId") + } + if again.Nonce == res.Nonce { + t.Fatal("re-add must supersede the nonce") + } + + // Register seals nothing — only proven control reaches the TL. + if rows := fx.drainOutbox(t); len(rows) != 0 { + t.Fatalf("register must not emit, got %d rows", len(rows)) + } +} + +func TestIdentityRegister_Rejections(t *testing.T) { + t.Parallel() + fx := newIdentityFixture(t, nil) + ctx := context.Background() + + if _, err := fx.svc.Register(ctx, "", "did:web:a.com"); err == nil { + t.Error("missing owner should fail") + } + if _, err := fx.svc.Register(ctx, fx.providerID, "bogus"); err == nil || + !strings.Contains(err.Error(), "IDENTIFIER_KIND_UNSUPPORTED") { + t.Errorf("bogus value: %v", err) + } + // lei is recognized but postponed. + if _, err := fx.svc.Register(ctx, fx.providerID, "5493001KJTIIGC8Y1R17"); err == nil || + !strings.Contains(err.Error(), "IDENTIFIER_KIND_UNSUPPORTED") { + t.Errorf("lei: %v", err) + } +} + +func TestIdentityRegister_RateLimited(t *testing.T) { + t.Parallel() + fx := newIdentityFixture(t, nil) + fx.svc.WithRegisterRateLimit(2) + ctx := context.Background() + + for i := range 2 { + if _, err := fx.svc.Register(ctx, fx.providerID, "did:web:a.com"); err != nil { + t.Fatalf("call %d: %v", i, err) + } + } + if _, err := fx.svc.Register(ctx, fx.providerID, "did:web:a.com"); err == nil || + !strings.Contains(err.Error(), "RATE_LIMITED") { + t.Fatalf("third call: %v", err) + } + // A different owner has its own budget. + if _, err := fx.svc.Register(ctx, "owner-2", "did:web:b.com"); err != nil { + t.Fatalf("other owner: %v", err) + } + // The window rolls over. + fx.clock.now = fx.clock.now.Add(2 * time.Minute) + if _, err := fx.svc.Register(ctx, fx.providerID, "did:web:a.com"); err != nil { + t.Fatalf("after window: %v", err) + } +} + +// ----- verify-control: did:web (noop hint synthesis) ----- + +// verifyDIDWeb registers + proves a did:web identity through the noop +// resolver, returning the verified identity and the key used. +func verifyDIDWeb(t *testing.T, fx *identityFixture, owner, value string) (*domain.VerifiedIdentity, *ecdsa.PrivateKey) { + t.Helper() + ctx := context.Background() + res, err := fx.svc.Register(ctx, owner, value) + if err != nil { + t.Fatalf("register: %v", err) + } + priv := genKey(t) + kid := res.Identity.Value + "#key-1" + jws := signProof(t, priv, kid, res.Challenges[0].SigningInput, true) + identity, err := fx.svc.VerifyControl(ctx, owner, res.Identity.IdentityID, service.ProofSubmission{SignedProofs: []string{jws}}) + if err != nil { + t.Fatalf("verify-control: %v", err) + } + return identity, priv +} + +func TestIdentityVerifyControl_DIDWebNoop(t *testing.T) { + t.Parallel() + fx := newIdentityFixture(t, nil) + + identity, _ := verifyDIDWeb(t, fx, fx.providerID, "did:web:identity.acme-corp.com") + if identity.Status != domain.IdentityVerified || identity.ProofMethod != "did-web-sig" { + t.Fatalf("verified state: %+v", identity) + } + + rows := fx.drainOutbox(t) + if len(rows) != 1 || rows[0].SchemaVersion != "IDENTITY" { + t.Fatalf("outbox rows: %+v", rows) + } + inner := fx.decodeOutboxEvent(t, rows[0]) + if inner.EventType != identityevent.TypeIdentityVerified || + inner.IdentityID != identity.IdentityID || + inner.ProviderID != fx.providerID || + len(inner.Keys) != 1 { + t.Fatalf("sealed event: %+v", inner) + } + key := inner.Keys[0] + if key.ID() != identity.Value+"#key-1" || key.SignedProof == "" { + t.Fatalf("sealed key shape wrong: %+v", key) + } + // The sealed verification method is quoted VERBATIM: it carries + // the registrant's exact jwk bytes, and the sealed proof verifies + // against the key read out of it — offline, no derived values. + var sealedVM struct { + Controller string `json:"controller"` + Type string `json:"type"` + PublicKeyJwk json.RawMessage `json:"publicKeyJwk"` + } + if err := json.Unmarshal(key.VerificationMethod, &sealedVM); err != nil { + t.Fatalf("sealed verification method not an object: %v", err) + } + if sealedVM.Controller != identity.Value || len(sealedVM.PublicKeyJwk) == 0 { + t.Fatalf("sealed verification method members: %+v", sealedVM) + } + pub, err := anscrypto.ParseJWK(sealedVM.PublicKeyJwk) + if err != nil { + t.Fatal(err) + } + if _, err := anscrypto.VerifyStandardJWSWithPublicKey(pub, key.SignedProof); err != nil { + t.Fatalf("sealed proof does not verify against sealed key: %v", err) + } +} + +func TestIdentityVerifyControl_MultiKey(t *testing.T) { + t.Parallel() + fx := newIdentityFixture(t, nil) + ctx := context.Background() + + res, err := fx.svc.Register(ctx, fx.providerID, "did:web:a.com") + if err != nil { + t.Fatal(err) + } + k1, k2 := genKey(t), genKey(t) + did := res.Identity.Value + jws1 := signProof(t, k1, did+"#key-1", res.Challenges[0].SigningInput, true) + jws2 := signProof(t, k2, did+"#key-2", res.Challenges[0].SigningInput, true) + + if _, err := fx.svc.VerifyControl(ctx, fx.providerID, res.Identity.IdentityID, service.ProofSubmission{SignedProofs: []string{jws1, jws2}}); err != nil { + t.Fatalf("multi-key verify: %v", err) + } + rows := fx.drainOutbox(t) + inner := fx.decodeOutboxEvent(t, rows[0]) + if len(inner.Keys) != 2 { + t.Fatalf("sealed keys: %d", len(inner.Keys)) + } +} + +func TestIdentityVerifyControl_FailClosed(t *testing.T) { + t.Parallel() + fx := newIdentityFixture(t, nil) + ctx := context.Background() + + res, err := fx.svc.Register(ctx, fx.providerID, "did:web:a.com") + if err != nil { + t.Fatal(err) + } + id := res.Identity.IdentityID + did := res.Identity.Value + priv := genKey(t) + good := signProof(t, priv, did+"#key-1", res.Challenges[0].SigningInput, true) + + cases := []struct { + name string + proofs []string + want string + }{ + {"no proofs", nil, "IDENTIFIER_PROOF_INVALID"}, + {"not a jws", []string{"garbage"}, "IDENTIFIER_PROOF_INVALID"}, + {"wrong payload", []string{signProof(t, priv, did+"#key-1", + base64.RawURLEncoding.EncodeToString([]byte(`{"x":1}`)), true)}, "PRICC_SIGNATURE_INVALID"}, + {"missing kid", []string{signProof(t, priv, "", res.Challenges[0].SigningInput, true)}, "DID_VERIFICATION_METHOD_INVALID"}, + {"kid not a fragment of the DID", []string{signProof(t, priv, "did:web:evil.com#key-1", + res.Challenges[0].SigningInput, true)}, "DID_VERIFICATION_METHOD_INVALID"}, + {"duplicate kid", []string{good, good}, "IDENTIFIER_PROOF_INVALID"}, + {"one bad proof fails the batch", []string{good, signProof(t, genKey(t), did+"#key-2", + res.Challenges[0].SigningInput, false)}, "DID_VERIFICATION_METHOD_INVALID"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := fx.svc.VerifyControl(ctx, fx.providerID, id, service.ProofSubmission{SignedProofs: tc.proofs}) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("want %s, got %v", tc.want, err) + } + }) + } + + // Failed attempts never consume the nonce — the good proof still + // lands. + if _, err := fx.svc.VerifyControl(ctx, fx.providerID, id, service.ProofSubmission{SignedProofs: []string{good}}); err != nil { + t.Fatalf("good proof after failures: %v", err) + } + // …and the consumed nonce cannot be replayed. + _, err = fx.svc.VerifyControl(ctx, fx.providerID, id, service.ProofSubmission{SignedProofs: []string{good}}) + if err == nil || !strings.Contains(err.Error(), "PRICC_TOKEN_ALREADY_USED") { + t.Fatalf("replay: %v", err) + } +} + +func TestIdentityVerifyControl_WrongSignature(t *testing.T) { + t.Parallel() + fx := newIdentityFixture(t, nil) + ctx := context.Background() + + res, err := fx.svc.Register(ctx, fx.providerID, "did:web:a.com") + if err != nil { + t.Fatal(err) + } + // The embedded jwk names key A, but the signature is from key B: + // verification against the (noop-synthesized) authoritative key A + // must fail. + keyA, keyB := genKey(t), genKey(t) + jwkA, err := anscrypto.PublicKeyToJWK(&keyA.PublicKey) + if err != nil { + t.Fatal(err) + } + header, err := json.Marshal(map[string]any{ + "alg": "ES256", "kid": res.Identity.Value + "#key-1", "jwk": jwkA, + }) + if err != nil { + t.Fatal(err) + } + encodedHeader := base64.RawURLEncoding.EncodeToString(header) + toSign := encodedHeader + "." + res.Challenges[0].SigningInput + digest := sha256.Sum256([]byte(toSign)) + der, err := ecdsa.SignASN1(rand.Reader, keyB, digest[:]) + if err != nil { + t.Fatal(err) + } + p1363, err := anscrypto.DERToP1363(der, 32) + if err != nil { + t.Fatal(err) + } + forged := toSign + "." + base64.RawURLEncoding.EncodeToString(p1363) + + _, err = fx.svc.VerifyControl(ctx, fx.providerID, res.Identity.IdentityID, service.ProofSubmission{SignedProofs: []string{forged}}) + if err == nil || !strings.Contains(err.Error(), "PRICC_SIGNATURE_INVALID") { + t.Fatalf("forged signature: %v", err) + } +} + +func TestIdentityVerifyControl_ExpiredNonce(t *testing.T) { + t.Parallel() + fx := newIdentityFixture(t, nil) + ctx := context.Background() + + res, err := fx.svc.Register(ctx, fx.providerID, "did:web:a.com") + if err != nil { + t.Fatal(err) + } + jws := signProof(t, genKey(t), res.Identity.Value+"#key-1", res.Challenges[0].SigningInput, true) + + fx.clock.now = fx.clock.now.Add(2 * time.Hour) + _, err = fx.svc.VerifyControl(ctx, fx.providerID, res.Identity.IdentityID, service.ProofSubmission{SignedProofs: []string{jws}}) + if err == nil || !strings.Contains(err.Error(), "PRICC_TOKEN_EXPIRED") { + t.Fatalf("expired nonce: %v", err) + } + + // Recovery is the idempotent re-add: same id, fresh nonce. + again, err := fx.svc.Register(ctx, fx.providerID, "did:web:a.com") + if err != nil || again.Identity.IdentityID != res.Identity.IdentityID { + t.Fatalf("re-add recovery: %+v %v", again, err) + } +} + +// ----- verify-control: did:web against a canned document (web-mode rules) ----- + +func TestIdentityVerifyControl_DIDWebDocumentRules(t *testing.T) { + t.Parallel() + did := "did:web:identity.acme-corp.com" + keyOK := genKey(t) + keyMB := genKey(t) + jwkOK, err := anscrypto.PublicKeyToJWK(&keyOK.PublicKey) + if err != nil { + t.Fatal(err) + } + multibase, err := anscrypto.EncodeMultibase(&keyMB.PublicKey) + if err != nil { + t.Fatal(err) + } + doc := &port.DIDDocument{ + ID: did, + AssertionMethod: []port.VerificationMethod{ + {ID: did + "#jwk-key", Controller: did, Type: "JsonWebKey2020", PublicKeyJwk: jwkOK}, + {ID: did + "#mb-key", Controller: did, Type: "Multikey", PublicKeyMultibase: multibase}, + {ID: did + "#foreign", Controller: "did:web:other.com", Type: "JsonWebKey2020", PublicKeyJwk: jwkOK}, + {ID: did + "#empty", Controller: did, Type: "JsonWebKey2020"}, + }, + } + fx := newIdentityFixture(t, &fakeResolver{doc: doc}) + ctx := context.Background() + + res, err := fx.svc.Register(ctx, fx.providerID, did) + if err != nil { + t.Fatal(err) + } + // The advisory fetch enumerated the document's kids. + if len(res.Challenges) != 4 { + t.Fatalf("challenge kids: %+v", res.Challenges) + } + id := res.Identity.IdentityID + signingInput := res.Challenges[0].SigningInput + + // JWK-keyed and multibase-keyed methods both verify. + proofs := []string{ + signProof(t, keyOK, did+"#jwk-key", signingInput, false), + signProof(t, keyMB, did+"#mb-key", signingInput, false), + } + if _, err := fx.svc.VerifyControl(ctx, fx.providerID, id, service.ProofSubmission{SignedProofs: proofs}); err != nil { + t.Fatalf("doc-keyed verify: %v", err) + } + + // Rotation round for the rule rejections. + rot, err := fx.svc.Rotate(ctx, fx.providerID, id, did) + if err != nil { + t.Fatal(err) + } + rotInput := rot.Challenges[0].SigningInput + cases := []struct { + name string + jws string + want string + }{ + {"unknown kid", signProof(t, keyOK, did+"#nope", rotInput, false), "DID_VERIFICATION_METHOD_INVALID"}, + {"cross-controller", signProof(t, keyOK, did+"#foreign", rotInput, false), "DID_VERIFICATION_METHOD_INVALID"}, + {"keyless method", signProof(t, keyOK, did+"#empty", rotInput, false), "DID_VERIFICATION_METHOD_INVALID"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := fx.svc.VerifyControl(ctx, fx.providerID, id, service.ProofSubmission{SignedProofs: []string{tc.jws}}) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("want %s, got %v", tc.want, err) + } + }) + } +} + +func TestIdentityVerifyControl_DocumentIDMismatch(t *testing.T) { + t.Parallel() + doc := &port.DIDDocument{ID: "did:web:other.com"} + fx := newIdentityFixture(t, &fakeResolver{doc: doc}) + ctx := context.Background() + + res, err := fx.svc.Register(ctx, fx.providerID, "did:web:a.com") + if err != nil { + t.Fatal(err) + } + jws := signProof(t, genKey(t), res.Identity.Value+"#k", res.Challenges[0].SigningInput, false) + _, err = fx.svc.VerifyControl(ctx, fx.providerID, res.Identity.IdentityID, service.ProofSubmission{SignedProofs: []string{jws}}) + if err == nil || !strings.Contains(err.Error(), "DID_DOCUMENT_ID_MISMATCH") { + t.Fatalf("doc id mismatch: %v", err) + } +} + +func TestIdentityRegister_ResolutionFailureFailsFast(t *testing.T) { + t.Parallel() + fx := newIdentityFixture(t, &fakeResolver{ + err: domain.NewValidationError("DID_RESOLUTION_FAILED", "boom"), + }) + _, err := fx.svc.Register(context.Background(), fx.providerID, "did:web:a.com") + if err == nil || !strings.Contains(err.Error(), "DID_RESOLUTION_FAILED") { + t.Fatalf("advisory fetch failure: %v", err) + } +} + +// ----- verify-control: did:key ----- + +func TestIdentityLifecycle_DIDKey(t *testing.T) { + t.Parallel() + fx := newIdentityFixture(t, nil) + ctx := context.Background() + + priv := genKey(t) + msid, err := anscrypto.EncodeMultibase(&priv.PublicKey) + if err != nil { + t.Fatal(err) + } + did := "did:key:" + msid + + res, err := fx.svc.Register(ctx, fx.providerID, did) + if err != nil { + t.Fatalf("register did:key: %v", err) + } + // did:key has exactly one challenge entry naming the method id. + if len(res.Challenges) != 1 || res.Challenges[0].Kid != did+"#"+msid { + t.Fatalf("did:key challenges: %+v", res.Challenges) + } + + // A wrong kid is rejected even with a valid signature. + bad := signProof(t, priv, did+"#wrong", res.Challenges[0].SigningInput, false) + if _, err := fx.svc.VerifyControl(ctx, fx.providerID, res.Identity.IdentityID, service.ProofSubmission{SignedProofs: []string{bad}}); err == nil || + !strings.Contains(err.Error(), "DID_VERIFICATION_METHOD_INVALID") { + t.Fatalf("wrong did:key kid: %v", err) + } + // A different key's signature fails against the DID's key. + forged := signProof(t, genKey(t), did+"#"+msid, res.Challenges[0].SigningInput, false) + if _, err := fx.svc.VerifyControl(ctx, fx.providerID, res.Identity.IdentityID, service.ProofSubmission{SignedProofs: []string{forged}}); err == nil || + !strings.Contains(err.Error(), "PRICC_SIGNATURE_INVALID") { + t.Fatalf("forged did:key proof: %v", err) + } + + good := signProof(t, priv, res.Challenges[0].Kid, res.Challenges[0].SigningInput, false) + identity, err := fx.svc.VerifyControl(ctx, fx.providerID, res.Identity.IdentityID, service.ProofSubmission{SignedProofs: []string{good}}) + if err != nil { + t.Fatalf("did:key verify: %v", err) + } + if identity.Status != domain.IdentityVerified || identity.ProofMethod != "did-key-sig" { + t.Fatalf("did:key verified state: %+v", identity) + } + rows := fx.drainOutbox(t) + inner := fx.decodeOutboxEvent(t, rows[0]) + if inner.Kind != "did:key" || len(inner.Keys) != 1 { + t.Fatalf("did:key sealed event: %+v", inner) + } +} + +// TestIdentityLifecycle_Ed25519 drives a did:key Ed25519 identity +// end to end: EdDSA proofs (raw-signing-input signatures, RFC 8037), +// the z6Mk did:key form, and the verbatim Multikey seal. +func TestIdentityLifecycle_Ed25519(t *testing.T) { + t.Parallel() + fx := newIdentityFixture(t, nil) + ctx := context.Background() + + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + msid, err := anscrypto.EncodeMultibase(pub) + if err != nil { + t.Fatal(err) + } + did := "did:key:" + msid + if !strings.HasPrefix(msid, "z6Mk") { + t.Fatalf("ed25519 did:key form: %s", msid) + } + + res, err := fx.svc.Register(ctx, fx.providerID, did) + if err != nil { + t.Fatalf("register ed25519 did:key: %v", err) + } + kid := res.Challenges[0].Kid + + // EdDSA compact JWS: signature over the raw signing input. + header, err := json.Marshal(map[string]any{"alg": "EdDSA", "kid": kid}) + if err != nil { + t.Fatal(err) + } + toSign := base64.RawURLEncoding.EncodeToString(header) + "." + res.Challenges[0].SigningInput + jws := toSign + "." + base64.RawURLEncoding.EncodeToString(ed25519.Sign(priv, []byte(toSign))) + + identity, err := fx.svc.VerifyControl(ctx, fx.providerID, res.Identity.IdentityID, service.ProofSubmission{SignedProofs: []string{jws}}) + if err != nil { + t.Fatalf("ed25519 verify-control: %v", err) + } + if identity.Status != domain.IdentityVerified { + t.Fatalf("status: %s", identity.Status) + } + + // The seal quotes the did:key Multikey method — its key material + // is the method-specific id verbatim from the identifier. + rows := fx.drainOutbox(t) + inner := fx.decodeOutboxEvent(t, rows[0]) + var vm struct { + Type string `json:"type"` + PublicKeyMultibase string `json:"publicKeyMultibase"` + } + if err := json.Unmarshal(inner.Keys[0].VerificationMethod, &vm); err != nil { + t.Fatal(err) + } + if vm.Type != "Multikey" || vm.PublicKeyMultibase != msid { + t.Fatalf("sealed did:key method: %+v", vm) + } +} + +// TestIdentityVerifyControl_X25519Rejected pins the precise +// rejection: a key-agreement key listed as an assertionMethod can +// never prove control. +func TestIdentityVerifyControl_X25519Rejected(t *testing.T) { + t.Parallel() + did := "did:web:identity.acme-corp.com" + doc := &port.DIDDocument{ + ID: did, + AssertionMethod: []port.VerificationMethod{{ + ID: did + "#x25519", + Controller: did, + Type: "JsonWebKey2020", + PublicKeyJwk: json.RawMessage(`{"kty":"OKP","crv":"X25519","x":"9GXjPGGvmRq9F6Ng5dQQ_s31mfhxrcNZxRGONrmH30k"}`), + Raw: json.RawMessage(`{"id":"` + did + `#x25519"}`), + }}, + } + fx := newIdentityFixture(t, &fakeResolver{doc: doc}) + ctx := context.Background() + + res, err := fx.svc.Register(ctx, fx.providerID, did) + if err != nil { + t.Fatal(err) + } + jws := signProof(t, genKey(t), did+"#x25519", res.Challenges[0].SigningInput, false) + _, err = fx.svc.VerifyControl(ctx, fx.providerID, res.Identity.IdentityID, service.ProofSubmission{SignedProofs: []string{jws}}) + if err == nil || !strings.Contains(err.Error(), "key-agreement key") { + t.Fatalf("X25519 rejection: %v", err) + } +} + +// ----- rotation, revocation, duplicates ----- + +func TestIdentityRotation_SealsUpdated(t *testing.T) { + t.Parallel() + fx := newIdentityFixture(t, nil) + ctx := context.Background() + + identity, _ := verifyDIDWeb(t, fx, fx.providerID, "did:web:a.com") + fx.drainOutbox(t) + + rot, err := fx.svc.Rotate(ctx, fx.providerID, identity.IdentityID, "did:web:b.com") + if err != nil { + t.Fatalf("rotate: %v", err) + } + if rot.Identity.Value != "did:web:a.com" || rot.Identity.PendingValue != "did:web:b.com" { + t.Fatalf("staged state: %+v", rot.Identity) + } + // Until the proof lands, the previously sealed state stands — + // nothing emitted by the PUT itself. + if rows := fx.drainOutbox(t); len(rows) != 0 { + t.Fatalf("PUT must not seal, got %d rows", len(rows)) + } + + newKey := genKey(t) + jws := signProof(t, newKey, "did:web:b.com#key-1", rot.Challenges[0].SigningInput, true) + rotated, err := fx.svc.VerifyControl(ctx, fx.providerID, identity.IdentityID, service.ProofSubmission{SignedProofs: []string{jws}}) + if err != nil { + t.Fatalf("rotation verify: %v", err) + } + if rotated.Value != "did:web:b.com" || rotated.PendingValue != "" { + t.Fatalf("rotated state: %+v", rotated) + } + + rows := fx.drainOutbox(t) + if len(rows) != 1 { + t.Fatalf("rotation rows: %d", len(rows)) + } + inner := fx.decodeOutboxEvent(t, rows[0]) + if inner.EventType != identityevent.TypeIdentityUpdated || + inner.Value != "did:web:b.com" || inner.PreviousValue != "did:web:a.com" { + t.Fatalf("IDENTITY_UPDATED event: %+v", inner) + } +} + +func TestIdentityRevoke(t *testing.T) { + t.Parallel() + fx := newIdentityFixture(t, nil) + ctx := context.Background() + + identity, _ := verifyDIDWeb(t, fx, fx.providerID, "did:web:a.com") + fx.drainOutbox(t) + + revoked, err := fx.svc.Revoke(ctx, fx.providerID, identity.IdentityID) + if err != nil { + t.Fatalf("revoke: %v", err) + } + if revoked.Status != domain.IdentityRevoked { + t.Fatalf("status: %s", revoked.Status) + } + rows := fx.drainOutbox(t) + inner := fx.decodeOutboxEvent(t, rows[0]) + if inner.EventType != identityevent.TypeIdentityRevoked || inner.RevokedAt == "" { + t.Fatalf("IDENTITY_REVOKED event: %+v", inner) + } + + // Terminal: no rotate, no verify, no re-revoke. + if _, err := fx.svc.Rotate(ctx, fx.providerID, identity.IdentityID, "did:web:b.com"); err == nil { + t.Error("rotate after revoke should fail") + } + if _, err := fx.svc.Revoke(ctx, fx.providerID, identity.IdentityID); err == nil { + t.Error("double revoke should fail") + } + // The owner can re-register the value on a fresh row. + if _, err := fx.svc.Register(ctx, fx.providerID, "did:web:a.com"); err != nil { + t.Errorf("re-register after revoke: %v", err) + } +} + +func TestIdentityDuplicates(t *testing.T) { + t.Parallel() + fx := newIdentityFixture(t, nil) + ctx := context.Background() + + verifyDIDWeb(t, fx, fx.providerID, "did:web:a.com") + + // Same owner re-registering a VERIFIED value → rotate instead. + _, err := fx.svc.Register(ctx, fx.providerID, "did:web:a.com") + if err == nil || !strings.Contains(err.Error(), "IDENTIFIER_DUPLICATE") { + t.Fatalf("owner duplicate: %v", err) + } + // Another owner registering an already-proven value → early + // duplicate feedback. + _, err = fx.svc.Register(ctx, "owner-2", "did:web:a.com") + if err == nil || !strings.Contains(err.Error(), "IDENTIFIER_DUPLICATE") { + t.Fatalf("cross-owner duplicate: %v", err) + } +} + +func TestIdentityProvenUniquenessRace(t *testing.T) { + t.Parallel() + fx := newIdentityFixture(t, nil) + ctx := context.Background() + + // Two owners hold competing PENDING claims (registered before + // either proved). + resA, err := fx.svc.Register(ctx, "owner-1", "did:web:contested.com") + if err != nil { + t.Fatal(err) + } + resB, err := fx.svc.Register(ctx, "owner-2", "did:web:contested.com") + if err != nil { + t.Fatal(err) + } + + // First to PROVE wins. + keyA := genKey(t) + jwsA := signProof(t, keyA, resA.Identity.Value+"#key-1", resA.Challenges[0].SigningInput, true) + if _, err := fx.svc.VerifyControl(ctx, "owner-1", resA.Identity.IdentityID, service.ProofSubmission{SignedProofs: []string{jwsA}}); err != nil { + t.Fatalf("winner: %v", err) + } + + // The loser's verify-time flip hits the proven-uniqueness index. + keyB := genKey(t) + jwsB := signProof(t, keyB, resB.Identity.Value+"#key-1", resB.Challenges[0].SigningInput, true) + _, err = fx.svc.VerifyControl(ctx, "owner-2", resB.Identity.IdentityID, service.ProofSubmission{SignedProofs: []string{jwsB}}) + if err == nil || !strings.Contains(err.Error(), "IDENTIFIER_DUPLICATE") { + t.Fatalf("loser: %v", err) + } + // The losing transaction rolled back whole — including the nonce + // consumption — and only the winner's event sealed. + rows := fx.drainOutbox(t) + if len(rows) != 1 { + t.Fatalf("sealed events after race: %d", len(rows)) + } +} + +// ----- owner gates ----- + +func TestIdentityOwnerGates(t *testing.T) { + t.Parallel() + fx := newIdentityFixture(t, nil) + ctx := context.Background() + + identity, _ := verifyDIDWeb(t, fx, fx.providerID, "did:web:a.com") + + // Reads hide existence (404-shaped not-found). + if _, _, err := fx.svc.Detail(ctx, "owner-2", identity.IdentityID); !errors.Is(err, domain.ErrNotFound) { + t.Fatalf("cross-owner detail: %v", err) + } + // Writes surface the authorization failure (403-shaped). + if _, err := fx.svc.Revoke(ctx, "owner-2", identity.IdentityID); !errors.Is(err, domain.ErrUnauthorized) { + t.Fatalf("cross-owner revoke: %v", err) + } + if _, err := fx.svc.Rotate(ctx, "owner-2", identity.IdentityID, "did:web:b.com"); !errors.Is(err, domain.ErrUnauthorized) { + t.Fatalf("cross-owner rotate: %v", err) + } + // List is owner-scoped. + mine, err := fx.svc.List(ctx, fx.providerID) + if err != nil || len(mine) != 1 { + t.Fatalf("list mine: %d %v", len(mine), err) + } + theirs, err := fx.svc.List(ctx, "owner-2") + if err != nil || len(theirs) != 0 { + t.Fatalf("list theirs: %d %v", len(theirs), err) + } +} + +// ----- links ----- + +func TestIdentityLinks(t *testing.T) { + t.Parallel() + fx := newIdentityFixture(t, nil) + ctx := context.Background() + + identity, _ := verifyDIDWeb(t, fx, fx.providerID, "did:web:a.com") + fx.drainOutbox(t) + fx.saveAgent(t, "agent-1", fx.providerID, "one.example.com") + fx.saveAgent(t, "agent-2", fx.providerID, "two.example.com") + fx.saveAgent(t, "agent-x", "owner-2", "theirs.example.com") + + // Owner gate, agent side: naming someone else's agent fails the + // whole call without revealing the agent's existence. + _, err := fx.svc.Link(ctx, fx.providerID, identity.IdentityID, []string{"agent-1", "agent-x"}) + if !errors.Is(err, domain.ErrNotFound) { + t.Fatalf("cross-owner agent in batch: %v", err) + } + if rows := fx.drainOutbox(t); len(rows) != 0 { + t.Fatal("failed batch must seal nothing") + } + + // Batch of two (with a duplicate id deduped) → ONE sealed event. + linked, err := fx.svc.Link(ctx, fx.providerID, identity.IdentityID, []string{"agent-1", "agent-2", "agent-1"}) + if err != nil || linked != 2 { + t.Fatalf("link batch: %d %v", linked, err) + } + rows := fx.drainOutbox(t) + if len(rows) != 1 { + t.Fatalf("link batch rows: %d", len(rows)) + } + inner := fx.decodeOutboxEvent(t, rows[0]) + if inner.EventType != identityevent.TypeIdentityLinked || len(inner.AnsIDs) != 2 { + t.Fatalf("IDENTITY_LINKED event: %+v", inner) + } + + // Fully idempotent repeat seals nothing. + linked, err = fx.svc.Link(ctx, fx.providerID, identity.IdentityID, []string{"agent-1"}) + if err != nil || linked != 0 { + t.Fatalf("idempotent link: %d %v", linked, err) + } + if rows := fx.drainOutbox(t); len(rows) != 0 { + t.Fatal("idempotent link must seal nothing") + } + + // Detail surfaces the live links. + _, links, err := fx.svc.Detail(ctx, fx.providerID, identity.IdentityID) + if err != nil || len(links) != 2 { + t.Fatalf("detail links: %d %v", len(links), err) + } + + // Unlink seals IDENTITY_UNLINKED for the one agent. + if err := fx.svc.Unlink(ctx, fx.providerID, identity.IdentityID, "agent-1"); err != nil { + t.Fatalf("unlink: %v", err) + } + rows = fx.drainOutbox(t) + inner = fx.decodeOutboxEvent(t, rows[0]) + if inner.EventType != identityevent.TypeIdentityUnlinked || + len(inner.AnsIDs) != 1 || inner.AnsIDs[0] != "agent-1" { + t.Fatalf("IDENTITY_UNLINKED event: %+v", inner) + } + // Unlinking a non-link 404s and seals nothing. + if err := fx.svc.Unlink(ctx, fx.providerID, identity.IdentityID, "agent-1"); !errors.Is(err, domain.ErrNotFound) { + t.Fatalf("double unlink: %v", err) + } + if rows := fx.drainOutbox(t); len(rows) != 0 { + t.Fatal("failed unlink must seal nothing") + } +} + +func TestIdentityLinkGuards(t *testing.T) { + t.Parallel() + fx := newIdentityFixture(t, nil) + ctx := context.Background() + + // Links attach only while VERIFIED. + res, err := fx.svc.Register(ctx, fx.providerID, "did:web:a.com") + if err != nil { + t.Fatal(err) + } + fx.saveAgent(t, "agent-1", fx.providerID, "one.example.com") + _, err = fx.svc.Link(ctx, fx.providerID, res.Identity.IdentityID, []string{"agent-1"}) + if err == nil || !strings.Contains(err.Error(), "IDENTITY_NOT_VERIFIED") { + t.Fatalf("link pending identity: %v", err) + } + + identity, _ := verifyDIDWeb(t, fx, "owner-2", "did:web:b.com") + // Cross-owner identity write → 403-shaped. + if _, err := fx.svc.Link(ctx, fx.providerID, identity.IdentityID, []string{"agent-1"}); !errors.Is(err, domain.ErrUnauthorized) { + t.Fatalf("cross-owner identity link: %v", err) + } + // Empty and oversized batches. + if _, err := fx.svc.Link(ctx, "owner-2", identity.IdentityID, nil); err == nil { + t.Error("empty batch should fail") + } + huge := make([]string, 257) + for i := range huge { + huge[i] = "agent" + } + if _, err := fx.svc.Link(ctx, "owner-2", identity.IdentityID, huge); err == nil || + !strings.Contains(err.Error(), "at most") { + t.Errorf("oversized batch: %v", err) + } +} diff --git a/internal/ra/service/identitykinds.go b/internal/ra/service/identitykinds.go new file mode 100644 index 0000000..19f7bad --- /dev/null +++ b/internal/ra/service/identitykinds.go @@ -0,0 +1,317 @@ +package service + +// This file is THE extension seam for identifier kinds. Each kind — +// did:web, did:key today; did:plc, did:ion, did:ethr, lei (vLEI) +// next — implements controlVerifier and registers itself in +// newControlVerifiers. Everything else in the identity service is +// kind-agnostic: the aggregate lifecycle, the nonce discipline, the +// owner gates, the links, and the sealing pipeline never branch on +// kind. +// +// How to add a kind, end to end: +// +// 1. Teach domain.InferIdentifierKind the kind's lexical form and +// canonicalization (the kind set is spec-frozen — a new kind is +// an ANS-spec amendment, so a new dispatch arm there is correct, +// not a smell). Add its proofMethod token. +// 2. Implement controlVerifier. Inject whatever I/O the kind needs +// through ports (did:plc → a PLC-directory fetcher; did:ion → an +// ION-node fetcher; lei → port-wrapped GLEIF L1 + vlei-verifier +// clients), each with a noop adapter for the quickstart and a +// real adapter selected by config — the DNS-verifier pattern. +// 3. Register it in newControlVerifiers. Unregistered kinds fail +// with IDENTIFIER_KIND_UNSUPPORTED — the 404-is-the-signal rule: +// no stubs, a kind exists only when its proof is real. +// +// Per-kind WIRE shapes are additive, never branching: +// +// - Request side: ProofSubmission carries every kind's proof +// material as optional members. JWS kinds read SignedProofs; +// lei will add CESRSignature, did:ethr will add EthSignature. +// Exactly one family of members is set per kind (the design's +// "exactly one proof field is set per kind" rule); each verifier +// validates its own members and ignores the rest. +// - Response side: the 202 challenge offer is the shared +// {nonce, expiresAt, challenges[]} envelope. A kind needing +// extra offer fields (lei's presentationStatus) adds an optional +// capability interface here — the same discover-by-type-assertion +// pattern the TL store uses for identity indexing — so existing +// kinds never grow dead fields. +// - Seal side: identityevent.ProvenKey quotes the kind's +// authoritative verification material verbatim. Kinds with no +// document to quote commit minimally (lei: subject AID + +// thumbprint — the KEL is the authoritative key history). + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + anscrypto "github.com/godaddy/ans/internal/crypto" + "github.com/godaddy/ans/internal/domain" + "github.com/godaddy/ans/internal/port" + identityevent "github.com/godaddy/ans/internal/tl/event/identity" +) + +// ProofSubmission carries the kind-specific proof material from the +// verify-control request body. Members are additive per kind; each +// controlVerifier validates exactly the members its kind defines. +type ProofSubmission struct { + // SignedProofs is the JWS-scheme proof set (did:web, did:key, + // and the future did:plc / did:ion): one compact JWS per proven + // key, every payload equal to the served signingInput verbatim. + SignedProofs []string +} + +// controlVerifier is the per-kind control-proof gate — the design's +// §3 centerpiece, one implementation per identifier kind. +type controlVerifier interface { + // Challenges runs the kind's advisory resolution and returns the + // 202 challenge entries, all sharing the served signingInput + // (the input is key-independent; entries enumerate the keys the + // kind could see in advance). Advisory only — verify-time + // resolution is the authoritative key source. + Challenges(ctx context.Context, identity *domain.VerifiedIdentity, signingInput string) ([]ProofChallenge, error) + + // VerifyProofs runs the kind's control proof over the submission + // and returns the proven keys to seal. Every proof must pass — + // one bad proof fails the call closed. Implementations never + // consume the nonce; the service owns nonce/transaction + // discipline so it stays uniform across kinds. + VerifyProofs(ctx context.Context, identity *domain.VerifiedIdentity, sub ProofSubmission, signingInput string) ([]identityevent.ProvenKey, error) +} + +// newControlVerifiers builds the kind registry. lei (vLEI), did:plc, +// did:ion, and did:ethr slot in here when their verifiers are real; +// until then domain.InferIdentifierKind may recognize a value's form +// but the missing registry entry yields IDENTIFIER_KIND_UNSUPPORTED. +func newControlVerifiers(resolver port.DIDResolver) map[domain.IdentifierKind]controlVerifier { + // NOTE: deliberately NOT exhaustive over IdentifierKind — a + // recognized-but-absent kind (lei, until its vlei-verifier + // integration ships) MUST fail with IDENTIFIER_KIND_UNSUPPORTED + // rather than register a stub. The 404-is-the-signal rule. + return map[domain.IdentifierKind]controlVerifier{ //nolint:exhaustive // absence == kind not enabled, by design + domain.KindDIDWeb: &didWebVerifier{resolver: resolver}, + domain.KindDIDKey: &didKeyVerifier{}, + } +} + +// ----- shared JWS proof machinery ----- +// +// Every JWS-scheme kind (did:web, did:key, did:plc, did:ion) shares +// the same proof envelope: standard compact JWSes whose payload +// segment equals the served signingInput verbatim, each naming its +// claimed key by `kid`. What differs per kind is only the +// authoritative key source, expressed as the selectKey callback. + +// jwsProof is one parsed submission entry. +type jwsProof struct { + jws string + header *anscrypto.JWSProtectedHeader +} + +// parseJWSProofs validates the submission envelope: presence, batch +// bound, compact-JWS form, payload equality (BEFORE any signature +// work — clients never canonicalize), kid presence, kid uniqueness. +// Returns the parsed proofs plus the kid → embedded-jwk hints the +// noop resolver synthesizes documents from. +func parseJWSProofs(sub ProofSubmission, signingInput string) ([]jwsProof, []port.KeyHint, error) { + proofs := sub.SignedProofs + if len(proofs) == 0 { + return nil, nil, domain.NewValidationError("IDENTIFIER_PROOF_INVALID", "signedProofs is required") + } + if len(proofs) > maxProofsPerVerify { + return nil, nil, domain.NewValidationError("IDENTIFIER_PROOF_INVALID", + fmt.Sprintf("at most %d proofs per call", maxProofsPerVerify)) + } + + parsed := make([]jwsProof, 0, len(proofs)) + hints := make([]port.KeyHint, 0, len(proofs)) + seenKids := make(map[string]bool, len(proofs)) + for i, jws := range proofs { + header, payloadSeg, err := anscrypto.DecodeStandardJWS(jws) + if err != nil { + return nil, nil, domain.NewValidationError("IDENTIFIER_PROOF_INVALID", + fmt.Sprintf("signedProofs[%d] is not a compact JWS", i)) + } + if payloadSeg != signingInput { + return nil, nil, domain.NewValidationError("PRICC_SIGNATURE_INVALID", + fmt.Sprintf("signedProofs[%d] payload does not equal the served signingInput", i)) + } + if header.Kid == "" { + return nil, nil, domain.NewValidationError("DID_VERIFICATION_METHOD_INVALID", + fmt.Sprintf("signedProofs[%d] names no kid", i)) + } + if seenKids[header.Kid] { + return nil, nil, domain.NewValidationError("IDENTIFIER_PROOF_INVALID", + fmt.Sprintf("signedProofs[%d] duplicates kid %q", i, header.Kid)) + } + seenKids[header.Kid] = true + parsed = append(parsed, jwsProof{jws: jws, header: header}) + hints = append(hints, port.KeyHint{Kid: header.Kid, PublicKeyJWK: header.Jwk}) + } + return parsed, hints, nil +} + +// sealJWSProofs verifies each parsed proof against the key the +// kind's selectKey callback resolves for its kid, and returns the +// proven set: the kind's authoritative verification-method material +// quoted VERBATIM (never a derived, re-encoded, or normalized value) +// plus the registrant's proof. The alg is pinned to the key type +// inside the verifier (alg-confusion defense): ES256 ↔ P-256, +// EdDSA ↔ Ed25519, RS256 ↔ RSA. +func sealJWSProofs( + parsed []jwsProof, + did string, + selectKey func(kid string) (any, json.RawMessage, error), +) ([]identityevent.ProvenKey, error) { + proven := make([]identityevent.ProvenKey, 0, len(parsed)) + for i, p := range parsed { + pub, sealVM, err := selectKey(p.header.Kid) + if err != nil { + return nil, err + } + if _, err := anscrypto.VerifyStandardJWSWithPublicKey(pub, p.jws); err != nil { + return nil, domain.NewValidationError("PRICC_SIGNATURE_INVALID", + fmt.Sprintf("signedProofs[%d] does not verify against %s's key %q", i, did, p.header.Kid)) + } + proven = append(proven, identityevent.ProvenKey{ + VerificationMethod: sealVM, + SignedProof: p.jws, + }) + } + return proven, nil +} + +// ----- did:web ----- + +// didWebVerifier proves control of a did:web identifier: possession +// of one or more keys the DID document lists under assertionMethod, +// any host (design §3.3/§3.6). The document fetch is the kind's only +// I/O and rides the injected resolver port (noop or hardened web). +type didWebVerifier struct { + resolver port.DIDResolver +} + +// Challenges runs the advisory fetch and enumerates the document's +// assertionMethod kids. With an unenumerable key set (the noop +// resolver before any proofs exist, or a document listing none), a +// single unkeyed entry tells the registrant to name keys via the +// JWS `kid` header at verify time. +func (v *didWebVerifier) Challenges(ctx context.Context, identity *domain.VerifiedIdentity, signingInput string) ([]ProofChallenge, error) { + doc, err := v.resolver.Resolve(ctx, identity.EffectiveValue(), nil) + if err != nil { + return nil, err + } + if len(doc.AssertionMethod) == 0 { + return []ProofChallenge{{SigningInput: signingInput}}, nil + } + challenges := make([]ProofChallenge, 0, len(doc.AssertionMethod)) + for _, vm := range doc.AssertionMethod { + challenges = append(challenges, ProofChallenge{Kid: vm.ID, SigningInput: signingInput}) + } + return challenges, nil +} + +// VerifyProofs re-fetches the document authoritatively (the +// verify-time document is the key source — §3.6) and checks each +// proof against its named assertionMethod: the kid must be a +// fragment of THIS DID, the method must carry no cross-controller +// indirection, and the key must parse under the supported types. +// Seals the document's verification-method objects verbatim. +func (v *didWebVerifier) VerifyProofs(ctx context.Context, identity *domain.VerifiedIdentity, sub ProofSubmission, signingInput string) ([]identityevent.ProvenKey, error) { + parsed, hints, err := parseJWSProofs(sub, signingInput) + if err != nil { + return nil, err + } + did := identity.EffectiveValue() + doc, err := v.resolver.Resolve(ctx, did, hints) + if err != nil { + return nil, err + } + if doc.ID != did { + return nil, domain.NewValidationError("DID_DOCUMENT_ID_MISMATCH", + fmt.Sprintf("resolved document id %q does not match %q", doc.ID, did)) + } + return sealJWSProofs(parsed, did, func(kid string) (any, json.RawMessage, error) { + if !strings.HasPrefix(kid, did+"#") { + return nil, nil, domain.NewValidationError("DID_VERIFICATION_METHOD_INVALID", + fmt.Sprintf("kid %q is not a fragment of %q", kid, did)) + } + vm := doc.FindAssertionMethod(kid) + if vm == nil { + return nil, nil, domain.NewValidationError("DID_VERIFICATION_METHOD_INVALID", + fmt.Sprintf("kid %q is not an assertionMethod of %q", kid, did)) + } + if vm.Controller != "" && vm.Controller != did { + return nil, nil, domain.NewValidationError("DID_VERIFICATION_METHOD_INVALID", + fmt.Sprintf("verification method %q is controlled by %q, not %q", kid, vm.Controller, did)) + } + var pub any + switch { + case len(vm.PublicKeyJwk) > 0: + pub, err = anscrypto.ParseJWK(vm.PublicKeyJwk) + case vm.PublicKeyMultibase != "": + pub, err = anscrypto.DecodeMultibase(vm.PublicKeyMultibase) + default: + return nil, nil, domain.NewValidationError("DID_VERIFICATION_METHOD_INVALID", + fmt.Sprintf("verification method %q carries no key material", kid)) + } + if err != nil { + return nil, nil, domain.NewValidationError("DID_VERIFICATION_METHOD_INVALID", err.Error()) + } + return pub, vm.Raw, nil + }) +} + +// ----- did:key ----- + +// didKeyVerifier proves control of a did:key identifier — the key +// IS the identifier, decoded from the DID string with zero I/O. +// The keyless-future test track (§2.2): any kind whose key material +// is self-contained follows this shape. +type didKeyVerifier struct{} + +// Challenges decodes the DID and returns its single legal kid, +// {did}#{method-specific-id}. +func (v *didKeyVerifier) Challenges(_ context.Context, identity *domain.VerifiedIdentity, signingInput string) ([]ProofChallenge, error) { + _, kid, err := anscrypto.DecodeDIDKey(identity.EffectiveValue()) + if err != nil { + return nil, domain.NewValidationError("DID_BAD_FORMAT", err.Error()) + } + return []ProofChallenge{{Kid: kid, SigningInput: signingInput}}, nil +} + +// VerifyProofs decodes the key from the identifier and verifies the +// (single-key) proof set against it. The sealed verification method +// is the did:key method's derived Multikey entry; its key material +// (publicKeyMultibase) is the method-specific id quoted verbatim +// from the identifier itself. +func (v *didKeyVerifier) VerifyProofs(_ context.Context, identity *domain.VerifiedIdentity, sub ProofSubmission, signingInput string) ([]identityevent.ProvenKey, error) { + parsed, _, err := parseJWSProofs(sub, signingInput) + if err != nil { + return nil, err + } + did := identity.EffectiveValue() + pub, expectedKid, err := anscrypto.DecodeDIDKey(did) + if err != nil { + return nil, domain.NewValidationError("DID_BAD_FORMAT", err.Error()) + } + sealVM, err := json.Marshal(map[string]string{ + "id": expectedKid, + "type": "Multikey", + "controller": did, + "publicKeyMultibase": strings.TrimPrefix(did, "did:key:"), + }) + if err != nil { + return nil, domain.NewInternalError("PROOF_SEAL", "could not build did:key verification method", err) + } + return sealJWSProofs(parsed, did, func(kid string) (any, json.RawMessage, error) { + if kid != expectedKid { + return nil, nil, domain.NewValidationError("DID_VERIFICATION_METHOD_INVALID", + fmt.Sprintf("kid %q is not the did:key verification method %q", kid, expectedKid)) + } + return pub, sealVM, nil + }) +} diff --git a/internal/ra/service/identityratelimit.go b/internal/ra/service/identityratelimit.go new file mode 100644 index 0000000..129a3dd --- /dev/null +++ b/internal/ra/service/identityratelimit.go @@ -0,0 +1,80 @@ +package service + +import ( + "encoding/json" + "sync" + "time" +) + +// defaultRegisterPerMinute is the default per-owner budget for +// identity register/rotate calls. Each call can trigger an outbound +// fetch (did:web advisory resolution) before any proof exists, so +// the budget exists to stop an authenticated owner from turning the +// RA into a fetch proxy (design §3.7 "bounded fetch"). +const defaultRegisterPerMinute = 10 + +// ownerLimiter is a fixed-window per-owner rate limiter. In-process +// and intentionally simple: the window is a minute, the state is one +// counter per owner, and stale owners are pruned opportunistically. +// Deployments needing distributed rate limiting put it in front of +// the RA; this is the in-depth backstop. +type ownerLimiter struct { + mu sync.Mutex + perMinute int + windows map[string]*ownerWindow +} + +type ownerWindow struct { + start time.Time + count int +} + +// newOwnerLimiter constructs a limiter allowing perMinute calls per +// owner per fixed one-minute window. +func newOwnerLimiter(perMinute int) *ownerLimiter { + return &ownerLimiter{ + perMinute: perMinute, + windows: make(map[string]*ownerWindow), + } +} + +// Allow reports whether the owner may proceed at the given instant, +// consuming one slot when it does. +func (l *ownerLimiter) Allow(owner string, now time.Time) bool { + l.mu.Lock() + defer l.mu.Unlock() + + w, ok := l.windows[owner] + if !ok || now.Sub(w.start) >= time.Minute { + l.prune(now) + l.windows[owner] = &ownerWindow{start: now, count: 1} + return true + } + if w.count >= l.perMinute { + return false + } + w.count++ + return true +} + +// prune drops windows older than two minutes so the map stays +// bounded by the set of recently-active owners. Called with the lock +// held, only on the window-rollover path (amortized — not per call). +func (l *ownerLimiter) prune(now time.Time) { + for owner, w := range l.windows { + if now.Sub(w.start) >= 2*time.Minute { + delete(l.windows, owner) + } + } +} + +// marshalOutboxPayload renders the {innerEventCanonical, +// producerSignature} outbox payload — the bytes the worker replays +// verbatim. Shared by every event family; the inner canonical bytes +// are family-specific, the payload wrapper is not. +func marshalOutboxPayload(innerCanonical []byte, producerSig string) ([]byte, error) { + return json.Marshal(OutboxPayload{ + InnerEventCanonical: json.RawMessage(innerCanonical), + ProducerSignature: producerSig, + }) +} diff --git a/spec/api-spec-v2.yaml b/spec/api-spec-v2.yaml index 9ad2749..98073c1 100644 --- a/spec/api-spec-v2.yaml +++ b/spec/api-spec-v2.yaml @@ -59,6 +59,12 @@ tags: description: Agent certificate revocation operations - name: Certificate Management description: Certificate retrieval, CSR submission, and renewal operations + - name: Verified Identities + description: | + The "who" behind agents — owner-level identities (did:web, + did:key) proven through challenge-bound control proofs, sealed + on their own Transparency Log stream, and linked to the owner's + agents # Global security requirement. Every path inherits this and can # override with `security: []` to declare itself anonymous. The @@ -889,6 +895,372 @@ paths: schema: $ref: '#/components/schemas/ErrorResponse' + # ────────────────────────────────────────────────────────────────── + # Verified Identities — the "who" behind agents + # + # An identity is a first-class object owned by the authenticated + # principal (the same owner as the agents), proven through a + # per-kind control proof, sealed onto its own Transparency Log + # stream, and linked to any number of that owner's agents. The + # agent registration surface above is unchanged — agents carry no + # identity fields; the association is the links sub-resource. + # + # The single security invariant: the RA seals an identity + # attestation only after control is PROVEN — a challenge-bound + # signature verified against the identifier's authoritative keys — + # never on resolution alone. + # ────────────────────────────────────────────────────────────────── + /ans/identities: + post: + tags: + - Verified Identities + summary: Register a verified identity + description: | + Registers an identifier (the kind — `did:web`, `did:key` — is + inferred from the value's lexical form, never caller-asserted) + and returns the challenge round to sign: one entry per + eligible key, all over a single anti-replay nonce. The + identity is created in `PENDING_CONTROL`; nothing is sealed + until verify-control passes. + + Re-POSTing the same value while the row is `PENDING_CONTROL` + is the idempotent re-add: the same `identityId` returns with + a fresh nonce (the prior nonce is superseded). A value + already verified — by this owner or any other — returns 409 + `IDENTIFIER_DUPLICATE`. Kinds without an enabled control + verifier (`lei`, postponed) return 422 + `IDENTIFIER_KIND_UNSUPPORTED`. + operationId: registerIdentity + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/IdentityRegistrationRequest' + responses: + '202': + description: Identity registered — challenges to sign + content: + application/json: + schema: + $ref: '#/components/schemas/IdentityChallengeResponse' + '401': + description: Authentication failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: Identifier already verified (IDENTIFIER_DUPLICATE) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '422': + description: Invalid or unsupported identifier + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + get: + tags: + - Verified Identities + summary: List my identities + description: Returns every identity owned by the caller, newest first. + operationId: listIdentities + responses: + '200': + description: The caller's identities + content: + application/json: + schema: + type: object + properties: + identities: + type: array + items: + $ref: '#/components/schemas/IdentityDetails' + '401': + description: Authentication failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /ans/identities/{identityId}: + get: + tags: + - Verified Identities + summary: Get identity detail + description: | + Returns the identity plus its live links. Ownership-scoped: + an identity that doesn't exist or isn't the caller's returns + 404 (existence is hidden). + operationId: getIdentity + parameters: + - $ref: '#/components/parameters/IdentityIdPath' + responses: + '200': + description: Identity detail + content: + application/json: + schema: + $ref: '#/components/schemas/IdentityDetails' + '404': + description: Identity not found or not accessible + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + put: + tags: + - Verified Identities + summary: Rotate / replace the identifier + description: | + Stages a same-kind replacement and returns fresh challenges + over it. Until the new proof lands the previously sealed + state stands; a replacement that never verifies expires with + its nonce. On a clean verify-control the RA swaps the value + and seals ONE `IDENTITY_UPDATED` event — regardless of how + many agents are linked. Cross-kind replacement is rejected + (revoke and register a new identity instead). + operationId: rotateIdentity + parameters: + - $ref: '#/components/parameters/IdentityIdPath' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/IdentityRegistrationRequest' + responses: + '202': + description: Rotation staged — fresh challenges to sign + content: + application/json: + schema: + $ref: '#/components/schemas/IdentityChallengeResponse' + '403': + description: Caller does not own this identity + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Identity not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: Identity is not in a rotatable state + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '422': + description: Invalid replacement value (incl. IDENTIFIER_KIND_MISMATCH) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /ans/identities/{identityId}/verify-control: + post: + tags: + - Verified Identities + summary: Prove control of the identifier + description: | + Submits the control proofs: one compact JWS per proven key, + each payload equal — verbatim — to the served `signingInput` + (clients never canonicalize; the RA checks payload equality + before verifying any signature). Every proof must verify + against the identifier's AUTHORITATIVE key for its `kid` + (resolved from the DID document for `did:web`, decoded from + the identifier for `did:key`); one bad proof fails the call + closed. The nonce is consumed exactly once, inside the + success transaction — a failed attempt does not consume it. + + On success the identity flips to `VERIFIED` (or completes a + staged rotation) and the RA seals `IDENTITY_VERIFIED` / + `IDENTITY_UPDATED` on the identity's own Transparency Log + stream, with every proven key sealed self-verifyingly + (public key + signed proof). + operationId: verifyIdentityControl + parameters: + - $ref: '#/components/parameters/IdentityIdPath' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/VerifyControlRequest' + responses: + '200': + description: Control proven — identity VERIFIED, event sealed + content: + application/json: + schema: + $ref: '#/components/schemas/IdentityDetails' + '403': + description: Caller does not own this identity + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Identity not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: | + Challenge state error — `PRICC_TOKEN_EXPIRED`, + `PRICC_TOKEN_ALREADY_USED`, `IDENTIFIER_CHALLENGE_EXPIRED` + — or the identity is revoked. Recovery from an expired + nonce is the idempotent re-add (re-POST the same value). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '422': + description: | + Proof rejected — `PRICC_SIGNATURE_INVALID`, + `DID_VERIFICATION_METHOD_INVALID`, `DID_RESOLUTION_FAILED`, + `DID_DOCUMENT_ID_MISMATCH`, `IDENTIFIER_PROOF_INVALID`. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /ans/identities/{identityId}/revoke: + post: + tags: + - Verified Identities + summary: Revoke the identity + description: | + Transitions a VERIFIED identity to REVOKED and seals ONE + `IDENTITY_REVOKED` event. A POST (state change), never a + DELETE: an identity cannot be deleted — its history is + append-only in the Transparency Log. Propagation to every + linked agent's badge is the TL's read-time join, not a write + fan-out. + operationId: revokeIdentity + parameters: + - $ref: '#/components/parameters/IdentityIdPath' + responses: + '200': + description: Identity revoked, event sealed + content: + application/json: + schema: + $ref: '#/components/schemas/IdentityDetails' + '403': + description: Caller does not own this identity + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Identity not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: Identity is not VERIFIED (nothing sealed to revoke) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /ans/identities/{identityId}/links: + post: + tags: + - Verified Identities + summary: Link agents to the identity + description: | + Binds a batch of the caller's agents to the identity — a + single owner-gated call with no challenge and no signature: + the caller MUST own the identity AND every named agent (key + possession never authorizes a link). The whole batch seals as + ONE `IDENTITY_LINKED` event on the IDENTITY stream carrying + the agent ids; an agent's own stream and audit history are + never written by identity operations. Already-linked agents + are skipped idempotently; a call that links nothing new seals + nothing. Links attach only while the identity is VERIFIED. + operationId: linkIdentityAgents + parameters: + - $ref: '#/components/parameters/IdentityIdPath' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/IdentityLinkRequest' + responses: + '200': + description: Batch linked (count of newly-created links) + content: + application/json: + schema: + $ref: '#/components/schemas/IdentityLinkResponse' + '403': + description: Caller does not own this identity + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Identity, or a named agent, not found / not the caller's + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: Identity is not VERIFIED (IDENTITY_NOT_VERIFIED) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '422': + description: Invalid link request (empty or oversized batch) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /ans/identities/{identityId}/links/{agentId}: + delete: + tags: + - Verified Identities + summary: Unlink an agent + description: | + Ends one association and seals `IDENTITY_UNLINKED` on the + identity stream. The association's history persists in the + identity's audit chain and the raw log tiles; unlinked pairs + may be re-linked later. + operationId: unlinkIdentityAgent + parameters: + - $ref: '#/components/parameters/IdentityIdPath' + - $ref: '#/components/parameters/AgentIdPath' + responses: + '204': + description: Link removed, event sealed + '403': + description: Caller does not own this identity + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Identity or live link not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + components: # ────────────────────────────────────────────────────────────────── @@ -923,6 +1295,14 @@ components: type: string format: uuid + IdentityIdPath: + name: identityId + in: path + description: Unique identifier of the verified identity (UUIDv7) + required: true + schema: + type: string + # ────────────────────────────────────────────────────────────────── # Schemas # ────────────────────────────────────────────────────────────────── @@ -1155,6 +1535,16 @@ components: type: array items: $ref: '#/components/schemas/Link' + identities: + type: array + description: | + Additive, optional, COMPUTED — the verified identities + currently linked to this agent, joined from the link rows + at read time. Never stored on the registration; identity + rotation/revocation is visible here immediately with zero + agent-side writes. + items: + $ref: '#/components/schemas/LinkedIdentity' required: - agentId - ansName @@ -1585,4 +1975,209 @@ components: found: type: string expected: - type: string \ No newline at end of file + type: string + + # ──────────────────────────────────────────────────────────────── + # Verified Identities + # ──────────────────────────────────────────────────────────────── + IdentityRegistrationRequest: + type: object + description: | + Registers (POST) or rotates (PUT) an identifier. The kind is + inferred from the value's lexical form — `did:web:` prefix, + `did:key:` prefix, or a 20-character LEI (recognized but + postponed) — never caller-asserted. + properties: + value: + type: string + description: The identifier to prove control of + example: did:web:identity.acme-corp.com + required: + - value + + IdentityChallengeResponse: + type: object + description: | + The 202 challenge round. Every entry shares the same + anti-replay nonce and the same signingInput — the input is + key-independent; entries enumerate the keys the resolver + could see in advance (a single unkeyed entry when it could + not — name keys via the JWS `kid` header at verify time). + properties: + identityId: + type: string + description: RA-assigned UUIDv7 — the TL stream key + kind: + type: string + enum: ['did:web', 'did:key', 'lei'] + value: + type: string + description: The canonical identifier this round proves + status: + $ref: '#/components/schemas/IdentityLifecycleStatus' + nonce: + type: string + description: Base64url 32-byte single-use anti-replay nonce + expiresAt: + type: string + format: date-time + challenges: + type: array + items: + $ref: '#/components/schemas/IdentityProofChallenge' + required: + - identityId + - kind + - value + - status + - nonce + - expiresAt + - challenges + + IdentityProofChallenge: + type: object + properties: + kid: + type: string + description: | + Verification-method id eligible to sign this round + (omitted when the resolver could not enumerate keys) + example: did:web:identity.acme-corp.com#key-1 + signingInput: + type: string + description: | + Base64url of the exact RFC 8785 (JCS) canonical + IdentityProofInput bytes — {identifier, identityId, + nonce, purpose:"ans:identity-proof:v1", raId, scheme}. A + compact JWS's payload segment MUST equal this string + verbatim; clients never canonicalize. + required: + - signingInput + + VerifyControlRequest: + type: object + description: | + One compact JWS per proven key. Supported algorithms match + what the verifier implements: EdDSA (Ed25519), ES256 + (ECDSA P-256), and RS256 (RSA >= 2048). Key-agreement keys + (X25519) and curves without a verifier (secp256k1, + P-384/521) are rejected with a precise error. + properties: + signedProofs: + type: array + minItems: 1 + maxItems: 16 + items: + type: string + description: | + Compact JWS over the served signingInput. The protected + header carries `kid` (the claimed verification method) + and MAY carry `jwk` (the signer's public key — required + by the quickstart noop resolver, ignored by the web + resolver, which always uses the resolved document). + required: + - signedProofs + + IdentityLifecycleStatus: + type: string + enum: [PENDING_CONTROL, VERIFIED, REVOKED] + description: | + PENDING_CONTROL → VERIFIED → REVOKED. Rotation keeps the row + VERIFIED (the staged replacement proves control before + anything changes). + + IdentityDetails: + type: object + properties: + identityId: + type: string + kind: + type: string + enum: ['did:web', 'did:key', 'lei'] + value: + type: string + status: + $ref: '#/components/schemas/IdentityLifecycleStatus' + proofMethod: + type: string + description: Control proof that verified this identity + enum: [did-web-sig, did-key-sig, lei-vlei-acdc] + pendingValue: + type: string + description: Staged rotation replacement (empty unless rotating) + verifiedAt: + type: string + format: date-time + createdAt: + type: string + format: date-time + linkedAgents: + type: array + description: Live links (detail responses only) + items: + type: object + properties: + agentId: + type: string + format: uuid + linkedAt: + type: string + format: date-time + required: + - agentId + required: + - identityId + - kind + - value + - status + - createdAt + + IdentityLinkRequest: + type: object + description: | + The batch of the caller's agents to bind — one owner-gated + call, no challenge, no signature; the whole batch seals as + ONE IDENTITY_LINKED event on the identity stream. + properties: + agentIds: + type: array + minItems: 1 + maxItems: 256 + items: + type: string + format: uuid + required: + - agentIds + + IdentityLinkResponse: + type: object + properties: + linked: + type: integer + description: Newly-created links (already-linked agents are skipped) + required: + - linked + + LinkedIdentity: + type: object + description: One computed identities[] entry on AgentDetails. + properties: + identityId: + type: string + kind: + type: string + enum: ['did:web', 'did:key', 'lei'] + value: + type: string + identityStatus: + type: string + enum: [VERIFIED, REVOKED] + description: The identity's CURRENT status — reflects its stream now + linkedAt: + type: string + format: date-time + required: + - identityId + - kind + - value + - identityStatus \ No newline at end of file From 40378cd467c1a12f41dcf80f135bb6569c27537f Mon Sep 17 00:00:00 2001 From: Connor Snitker Date: Wed, 10 Jun 2026 18:10:17 -0500 Subject: [PATCH 03/13] feat(demo): identity lifecycle demos + registrant-side signproof helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Makefile | 18 +- config/ra-local.yaml | 23 ++ scripts/demo/common.sh | 23 ++ scripts/demo/identity-lifecycle.sh | 368 +++++++++++++++++++++++++++++ scripts/demo/run-lifecycle.sh | 51 ++++ scripts/demo/signproof/main.go | 190 +++++++++++++++ scripts/demo/start.sh | 9 + 7 files changed, 674 insertions(+), 8 deletions(-) create mode 100755 scripts/demo/identity-lifecycle.sh create mode 100644 scripts/demo/signproof/main.go diff --git a/Makefile b/Makefile index e16bdb9..d93b801 100644 --- a/Makefile +++ b/Makefile @@ -63,14 +63,16 @@ test: test-cover: @echo "Running tests with coverage..." - @# Exclude cmd/* from the instrumented set. The three command - @# binaries (ans-ra, ans-tl, ans-verify) are thin glue: flag - @# parsing, config loading, dependency wiring, then hand off to - @# library code under internal/. We don't write unit tests for - @# main() — counting those ~30 unexercised statements toward the - @# 90% gate would only penalize real logic coverage. The library - @# packages under internal/ are where the gate has teeth. - @pkgs=$$(go list ./... | grep -v '/cmd/' | tr '\n' ',' | sed 's/,$$//'); \ + @# Exclude cmd/* and scripts/* from the instrumented set. The + @# command binaries (ans-ra, ans-tl, ans-verify) are thin glue: + @# flag parsing, config loading, dependency wiring, then hand off + @# to library code under internal/. scripts/* holds demo-side + @# tooling (e.g. the signproof helper) exercised end-to-end by + @# the scripts/demo lifecycle runs, not unit tests. Counting + @# either's unexercised main() statements toward the 90% gate + @# would only penalize real logic coverage. The library packages + @# under internal/ are where the gate has teeth. + @pkgs=$$(go list ./... | grep -v -e '/cmd/' -e '/scripts/' | tr '\n' ',' | sed 's/,$$//'); \ go test ./... -count=1 -coverpkg=$$pkgs -coverprofile=coverage.out -covermode=atomic @go tool cover -func=coverage.out @echo "" diff --git a/config/ra-local.yaml b/config/ra-local.yaml index f4e67b2..cef22ed 100644 --- a/config/ra-local.yaml +++ b/config/ra-local.yaml @@ -25,6 +25,29 @@ dns: type: noop # server: "127.0.0.1:15353" +identity: + # Verified identities — the "who" behind agents, proven through a + # challenge-bound key proof and sealed on their own TL stream. + # The did:web resolver mirrors the DNS verifier's noop/real split: + # "noop" — no I/O; the DID document is synthesized from the keys + # embedded in the submitted proofs' `jwk` headers. + # Signature verification still genuinely runs; only the + # "does the live did.json list this key" binding is + # waived. Quickstart only — NOT for production. + # "web" — hardened HTTPS fetch of the DID document: WebPKI + # validation, SSRF dialer guards, 5s timeout, bounded + # same-site redirects. + # did:key identities never resolve anything — the key decodes from + # the identifier itself, so they work fully in both modes. + resolver: + type: noop + # challenge-ttl bounds the verify-control nonce (default 1h; 5m is + # the recommended floor for high-assurance deployments). + challenge-ttl: 1h + # register-rate-limit caps per-owner register/rotate calls per + # minute (each can trigger an outbound did:web fetch). + register-rate-limit: 10 + keys: type: file file: diff --git a/scripts/demo/common.sh b/scripts/demo/common.sh index 4533604..63258cb 100755 --- a/scripts/demo/common.sh +++ b/scripts/demo/common.sh @@ -317,6 +317,29 @@ poll_tl_audit() { fail "TL not ready for $agent_id in ${timeout}s (records=${count:-0}, with merkle proof=${withproof:-0}; want $expected of each)" } +# poll_tl_identity_audit IDENTITY_ID EXPECTED_COUNT [TIMEOUT_SECONDS] +# +# Sibling of poll_tl_audit for the identity stream: polls +# /v1/identities/{identityId}/audit until it shows at least +# EXPECTED_COUNT events with Merkle proofs. +poll_tl_identity_audit() { + local identity_id="$1" expected="$2" timeout="${3:-30}" + local i=0 count withproof + while [ "$i" -lt "$timeout" ]; do + local resp + resp=$(curl -sSf -H "Authorization: Bearer $TL_API_KEY" \ + "$TL_URL/v1/identities/$identity_id/audit" 2>/dev/null || true) + count=$(printf '%s' "$resp" | jq -r '(.records | length) // 0') + withproof=$(printf '%s' "$resp" | jq -r '[.records[]? | select(.merkleProof)] | length // 0') + if [ "${count:-0}" -ge "$expected" ] && [ "${withproof:-0}" -ge "$expected" ]; then + return 0 + fi + sleep 1 + i=$((i + 1)) + done + fail "TL not ready for identity $identity_id in ${timeout}s (records=${count:-0}, with merkle proof=${withproof:-0}; want $expected of each)" +} + # wait_ready URL [TIMEOUT_SECONDS] # # Polls URL once a second until it returns 200, or TIMEOUT expires. diff --git a/scripts/demo/identity-lifecycle.sh b/scripts/demo/identity-lifecycle.sh new file mode 100755 index 0000000..faf01d2 --- /dev/null +++ b/scripts/demo/identity-lifecycle.sh @@ -0,0 +1,368 @@ +#!/usr/bin/env bash +# Exercise EVERY Verified Identity operation end-to-end, printing +# every request + response pair in color. The identity is the "who" +# behind an agent (the "what"): proven once through a control proof, +# sealed onto its own Transparency Log stream, and linked to any +# number of the owner's agents. +# +# 1. POST /v2/ans/identities register did:web → 202 + challenges +# 2. POST /v2/ans/identities idempotent re-add (same identityId, fresh nonce) +# 3. POST .../verify-control MULTI-KEY proof — one ES256 + one EdDSA JWS over the +# same nonce → VERIFIED, seals IDENTITY_VERIFIED with +# BOTH verification methods quoted verbatim +# 4. GET /v2/ans/identities list (mine) +# 5. GET /v2/ans/identities/{id} detail +# 6. POST /v2/ans/identities register did:key (zero-I/O kind) +# 7. POST .../verify-control did:key proof → VERIFIED +# 8. (register TWO fresh agents to ACTIVE — the fleet) +# 9. POST .../links did:web → BOTH agents in one call (ONE IDENTITY_LINKED, +# ansIds[2]); did:key → agent #1 (its own stream's event) +# — one agent now carries TWO identities +# 10. GET /v2/ans/agents/{agentId} RA detail now carries computed identities[] +# 11. TL /v1/identities/{id} + /audit + /agents both identity badges (did:web AND did:key Multikey +# seal), chains, reverse joins +# 12. TL /v1/agents/{agentId} agent-1 badge shows identities[2] (did:web + did:key); +# agent-2 shows the did:web +# 13. TL /v1/agents/{agentId}/identities{,/history} agent-side computed views +# 14. PUT /v2/ans/identities/{id} rotate → fresh challenges +# 15. POST .../verify-control new key proof → seals ONE IDENTITY_UPDATED +# (proven set goes 2 keys → 1 key) +# 16. TL both linked badges reflect the rotation — ONE event, zero agent-stream writes +# 17. TL /v1/identities/{id}/receipt SCITT COSE receipt for the identity leaf +# 18. DELETE .../links/{agentId} unlink the did:web from agent #1 ONLY → agent #1 +# keeps its did:key link; agent #2 keeps the did:web +# 19. POST /v2/ans/identities/{id}/revoke revoke the did:web → IDENTITY_REVOKED (one event) +# 20. TL did:web badge REVOKED — agent-2's join shows it at the next read; agent-1's did:key +# stays VERIFIED; both agents stay ACTIVE (the whats survive the who) +# +# The demo runs against the noop did:web resolver (the default — +# `identity.resolver.type: noop`): signature verification is real, +# only the "does the live did.json list this key" binding is waived. +# Point `identity.resolver.type: web` at a real deployment and the +# same flow works against a hosted did.json. did:key (step 6-7) uses +# real key decoding either way — zero I/O by construction. +# +# Usage: +# scripts/demo/identity-lifecycle.sh # random did:web value +# scripts/demo/identity-lifecycle.sh acme.test # did:web:acme.test + +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" + +require_cmd jq +require_cmd openssl +require_cmd go + +DID_HOST="${1:-identity-$(openssl rand -hex 4).example.com}" +DID_WEB="did:web:${DID_HOST}" + +header "Demo target" +printf " identity %s\n" "$DID_WEB" >&2 +printf " resolver noop (quickstart) — real JWS verification, waived live-document binding\n" >&2 + +if ! curl -sSf "$RA_URL/v2/admin/ready" >/dev/null 2>&1; then + fail "ans-ra isn't reachable at $RA_URL — run scripts/demo/start.sh first" +fi + +KEY_DIR="$DATA/identity-keys" +rm -rf "$KEY_DIR" +mkdir -p "$KEY_DIR" + +# signproof is the registrant-side tool: keys are minted and proofs +# are signed locally; the private key never touches the RA. +signproof() { (cd "$ROOT" && go run ./scripts/demo/signproof "$@"); } + +# register_agent HOST — drive a fresh agent to ACTIVE and echo its +# agentId on stdout. The identity demo needs a small fleet to link. +register_agent() { + local host="$1" + local ans_name="ans://v1.0.0.${host}" + local csr_dir="$DATA/identity-agent-csr/$host" + rm -rf "$csr_dir" && mkdir -p "$csr_dir" + cat >"$csr_dir/openssl.cnf" </dev/null + openssl req -new -key "$csr_dir/key.pem" -config "$csr_dir/openssl.cnf" -out "$csr_dir/csr.pem" 2>/dev/null + cat >"$csr_dir/server.cnf" </dev/null + openssl req -new -key "$csr_dir/server.key" -config "$csr_dir/server.cnf" -out "$csr_dir/server.csr" 2>/dev/null + + local resp agent_id + resp=$(curl_json POST /v2/ans/agents "$(jq -n \ + --arg host "$host" \ + --arg csr "$(cat "$csr_dir/csr.pem")" \ + --arg srv "$(cat "$csr_dir/server.csr")" ' + { agentDisplayName: "identity-demo-agent", + version: "1.0.0", + agentHost: $host, + endpoints: [{agentUrl: ("https://" + $host + "/mcp"), protocol: "MCP", transports: ["SSE"]}], + identityCsrPEM: $csr, + serverCsrPEM: $srv }')") + agent_id=$(printf '%s' "$resp" | jq -r '.agentId // empty') + [ -n "$agent_id" ] || fail "agent registration failed for $host" + curl_json POST "/v2/ans/agents/$agent_id/verify-acme" >/dev/null + assert_2xx "verify-acme ($host)" + if [ -n "${ANS_DNS_ZONE:-}" ] && [ -x "$BIN/ans-dns" ]; then + "$BIN/ans-dns" install --zone "$ANS_DNS_ZONE" --api-key "$RA_API_KEY" "$RA_URL" "$agent_id" + fi + curl_json POST "/v2/ans/agents/$agent_id/verify-dns" >/dev/null + assert_2xx "verify-dns ($host)" + printf '%s' "$agent_id" +} + +header "0. Mint the operator's identity keypairs (locally — the RA never sees them)" +signproof keygen -out "$KEY_DIR/who-p256.pem" >/dev/null +signproof keygen -alg ed25519 -out "$KEY_DIR/who-ed25519.pem" >/dev/null +ok "two keys minted: P-256 (ES256 proofs) + Ed25519 (EdDSA proofs)" + +# ----- 1. Register the did:web identity ----- +header "1. POST /v2/ans/identities (register $DID_WEB → 202 + challenges)" +REG_RESP=$(curl_json POST /v2/ans/identities "$(jq -n --arg v "$DID_WEB" '{value: $v}')") +IDENTITY_ID=$(printf '%s' "$REG_RESP" | jq -r '.identityId // empty') +SIGNING_INPUT=$(printf '%s' "$REG_RESP" | jq -r '.challenges[0].signingInput') +NONCE_1=$(printf '%s' "$REG_RESP" | jq -r '.nonce') +[ -n "$IDENTITY_ID" ] || fail "no identityId in register response" +echo "$IDENTITY_ID" >"$DATA/last-identity-id" +ok "identityId=$IDENTITY_ID (PENDING_CONTROL)" + +# ----- 2. Idempotent re-add ----- +header "2. POST /v2/ans/identities (re-add while PENDING_CONTROL → same identityId, fresh nonce)" +READD_RESP=$(curl_json POST /v2/ans/identities "$(jq -n --arg v "$DID_WEB" '{value: $v}')") +READD_ID=$(printf '%s' "$READD_RESP" | jq -r '.identityId // empty') +NONCE_2=$(printf '%s' "$READD_RESP" | jq -r '.nonce') +[ "$READD_ID" = "$IDENTITY_ID" ] || fail "re-add returned a different identityId" +[ "$NONCE_1" != "$NONCE_2" ] || fail "re-add did not supersede the nonce" +SIGNING_INPUT=$(printf '%s' "$READD_RESP" | jq -r '.challenges[0].signingInput') +ok "same identityId, superseded nonce — the §4.2 idempotent re-add" + +# ----- 3. Verify control — MULTI-KEY ----- +header "3. POST /v2/ans/identities/$IDENTITY_ID/verify-control (TWO keys, one nonce → seals both verbatim)" +note "did:web supports multi-key attestation: one JWS per key over the SAME signingInput" +note " #key-1 → P-256 / ES256; #key-2 → Ed25519 / EdDSA" +PROOF_1=$(signproof sign -key "$KEY_DIR/who-p256.pem" -kid "${DID_WEB}#key-1" -input "$SIGNING_INPUT") +PROOF_2=$(signproof sign -key "$KEY_DIR/who-ed25519.pem" -kid "${DID_WEB}#key-2" -input "$SIGNING_INPUT") +curl_json POST "/v2/ans/identities/$IDENTITY_ID/verify-control" \ + "$(jq -n --arg p1 "$PROOF_1" --arg p2 "$PROOF_2" '{signedProofs: [$p1, $p2]}')" >/dev/null +assert_2xx "multi-key verify-control" +ok "BOTH keys proven against one nonce — every proof must pass (one bad proof fails the call closed)" + +# ----- 4. List ----- +header "4. GET /v2/ans/identities (list mine)" +curl_json GET /v2/ans/identities >/dev/null + +# ----- 5. Detail ----- +header "5. GET /v2/ans/identities/$IDENTITY_ID (detail)" +curl_json GET "/v2/ans/identities/$IDENTITY_ID" >/dev/null + +# ----- 6-7. did:key — the zero-I/O kind ----- +header "6. POST /v2/ans/identities (register an Ed25519 did:key — the key IS the identifier)" +DID_KEY=$(signproof keygen -alg ed25519 -out "$KEY_DIR/didkey.pem") +note "minted $DID_KEY (z6Mk… = ed25519-pub; proofs use EdDSA)" +DK_RESP=$(curl_json POST /v2/ans/identities "$(jq -n --arg v "$DID_KEY" '{value: $v}')") +DK_ID=$(printf '%s' "$DK_RESP" | jq -r '.identityId // empty') +[ -n "$DK_ID" ] || fail "did:key register failed" +DK_KID=$(printf '%s' "$DK_RESP" | jq -r '.challenges[0].kid') +DK_INPUT=$(printf '%s' "$DK_RESP" | jq -r '.challenges[0].signingInput') + +header "7. POST /v2/ans/identities/$DK_ID/verify-control (did:key proof — real crypto, zero I/O)" +DK_PROOF=$(signproof sign -key "$KEY_DIR/didkey.pem" -kid "$DK_KID" -input "$DK_INPUT") +curl_json POST "/v2/ans/identities/$DK_ID/verify-control" \ + "$(jq -n --arg p "$DK_PROOF" '{signedProofs: [$p]}')" >/dev/null +assert_2xx "did:key verify-control" +ok "did:key identity VERIFIED — the keyless-future test track" + +# ----- 8. Register a small fleet (the WHATs) ----- +header "8. Register TWO fresh agents to ACTIVE (the fleet to link)" +AGENT_1=$(register_agent "linked-a-$(openssl rand -hex 4).example.com") +AGENT_2=$(register_agent "linked-b-$(openssl rand -hex 4).example.com") +ok "fleet ready: $AGENT_1 + $AGENT_2" + +# ----- 9. Link the fleet — one owner-gated call per identity ----- +header "9a. POST /v2/ans/identities/$IDENTITY_ID/links (did:web → BOTH agents, one call)" +LINK_RESP=$(curl_json POST "/v2/ans/identities/$IDENTITY_ID/links" \ + "$(jq -n --arg a "$AGENT_1" --arg b "$AGENT_2" '{agentIds: [$a, $b]}')") +LINKED_COUNT=$(printf '%s' "$LINK_RESP" | jq -r '.linked // 0') +[ "$LINKED_COUNT" = "2" ] || fail "expected linked: 2, got $LINKED_COUNT" +ok "linked: 2 — ONE IDENTITY_LINKED carries the whole batch; fleet linking is O(1) sealed events" + +header "9b. POST /v2/ans/identities/$DK_ID/links (did:key → agent #1 — a second WHO on the same agent)" +note "an agent legitimately carries several identities; each link seals on ITS identity's stream" +DK_LINK=$(curl_json POST "/v2/ans/identities/$DK_ID/links" \ + "$(jq -n --arg a "$AGENT_1" '{agentIds: [$a]}')") +[ "$(printf '%s' "$DK_LINK" | jq -r '.linked // 0')" = "1" ] || fail "did:key link failed" +ok "agent-1 now carries TWO identities: the multi-key did:web and the Ed25519 did:key" + +# ----- 10. RA-side computed identities[] ----- +header "10. GET /v2/ans/agents/$AGENT_1 (RA detail — computed identities[], never stored on the agent)" +curl_json GET "/v2/ans/agents/$AGENT_1" >/dev/null + +# ----- 11. TL identity stream ----- +header "11. Wait for the outbox worker, then read the identity stream from the TL" +poll_tl_identity_audit "$IDENTITY_ID" 2 30 +ok "TL sealed the IDENTITY_VERIFIED + IDENTITY_LINKED leaves" + +header "11a. TL: GET /v1/identities/$IDENTITY_ID (identity badge — latest sealed event + proof + status)" +curl_tl GET "/v1/identities/$IDENTITY_ID" >/dev/null + +header "11b. TL: GET /v1/identities/$IDENTITY_ID/audit (the WHO's full chain — standard audit envelope)" +AUDIT=$(curl_tl GET "/v1/identities/$IDENTITY_ID/audit") +# The multi-key proof sealed BOTH verification methods verbatim, and +# the batch link sealed ONE event naming both agents. +SEALED_KEY_COUNT=$(printf '%s' "$AUDIT" | \ + jq -r '[.records[].payload.producer.event | select(.keys)][0].keys | length') +LINK_ANSIDS=$(printf '%s' "$AUDIT" | \ + jq -r '[.records[].payload.producer.event | select(.eventType == "IDENTITY_LINKED")][0].ansIds | length') +[ "$SEALED_KEY_COUNT" = "2" ] || fail "sealed proof should carry 2 keys, got $SEALED_KEY_COUNT" +[ "$LINK_ANSIDS" = "2" ] || fail "sealed link event should carry 2 ansIds, got $LINK_ANSIDS" +ok "sealed: IDENTITY_VERIFIED.keys[2] (ES256 + EdDSA, verbatim) and IDENTITY_LINKED.ansIds[2] (one event)" + +header "11c. TL: GET /v1/identities/$IDENTITY_ID/agents (reverse join: both linked agents)" +AGENTS_VIEW=$(curl_tl GET "/v1/identities/$IDENTITY_ID/agents") +AGENTS_COUNT=$(printf '%s' "$AGENTS_VIEW" | jq -r '.agents | length') +[ "$AGENTS_COUNT" = "2" ] || fail "reverse join should list 2 agents, got $AGENTS_COUNT" + +header "11d. TL: GET /v1/identities/$DK_ID (did:key badge — the sealed Multikey verification method)" +poll_tl_identity_audit "$DK_ID" 2 30 +DK_BADGE_VM=$(curl_tl GET "/v1/identities/$DK_ID/audit" | \ + jq -r '[.records[].payload.producer.event | select(.keys)][0].keys[0].verificationMethod') +DK_VM_TYPE=$(printf '%s' "$DK_BADGE_VM" | jq -r '.type // empty') +DK_VM_MB=$(printf '%s' "$DK_BADGE_VM" | jq -r '.publicKeyMultibase // empty') +[ "$DK_VM_TYPE" = "Multikey" ] || fail "did:key seal should be a Multikey method, got $DK_VM_TYPE" +[ "did:key:$DK_VM_MB" = "$DID_KEY" ] || fail "did:key seal's publicKeyMultibase must be the identifier's msid verbatim" +ok "did:key sealed verbatim: type=Multikey, publicKeyMultibase = the did:key msid itself" + +# ----- 12-13. Agent-side computed views (both badges) ----- +poll_tl_audit "$AGENT_1" 1 30 +poll_tl_audit "$AGENT_2" 1 30 +header "12. TL: GET /v1/agents/{both} (agent-1 carries BOTH whos; agent-2 carries the did:web)" +BADGE_1=$(curl_tl GET "/v1/agents/$AGENT_1") +IDS_1=$(printf '%s' "$BADGE_1" | jq -r '.identities | length') +[ "$IDS_1" = "2" ] || fail "agent-1 badge should show 2 identities, got $IDS_1" +KEYIDS_1=$(printf '%s' "$BADGE_1" | jq -r '.identities[] | select(.kind == "did:web") | .provenKeyIds | length') +[ "$KEYIDS_1" = "2" ] || fail "agent-1's did:web entry should show 2 provenKeyIds, got $KEYIDS_1" +DK_ON_1=$(printf '%s' "$BADGE_1" | jq -r '.identities[] | select(.kind == "did:key") | .value // empty') +[ "$DK_ON_1" = "$DID_KEY" ] || fail "agent-1's did:key entry missing" +BADGE_2=$(curl_tl GET "/v1/agents/$AGENT_2") +WHO_2=$(printf '%s' "$BADGE_2" | jq -r '.identities[0].value // empty') +[ "$WHO_2" = "$DID_WEB" ] || fail "agent-2 badge missing the identity join" +# Capture the sealed verbatim key material so the rotation step can +# show it change (read from the latest PROOF event on the stream). +SEALED_X_BEFORE=$(printf '%s' "$AUDIT" | \ + jq -r '[.records[].payload.producer.event | select(.keys)][0].keys[0].verificationMethod.publicKeyJwk.x // empty') +[ -n "$SEALED_X_BEFORE" ] || fail "sealed proof event is missing the verbatim verification method" +ok "agent-1: did:web (provenKeyIds[2]) + did:key (Ed25519) side by side; agent-2: $WHO_2" + +header "13. TL: GET /v1/agents/$AGENT_1/identities + /identities/history" +curl_tl GET "/v1/agents/$AGENT_1/identities" >/dev/null +curl_tl GET "/v1/agents/$AGENT_1/identities/history" >/dev/null + +# ----- 14-16. Rotation — ONE event, no fan-out ----- +header "14. PUT /v2/ans/identities/$IDENTITY_ID (rotate — stage a re-proof under ONE new key)" +signproof keygen -out "$KEY_DIR/who-rotated.pem" >/dev/null +ROT_RESP=$(curl_json PUT "/v2/ans/identities/$IDENTITY_ID" "$(jq -n --arg v "$DID_WEB" '{value: $v}')") +ROT_INPUT=$(printf '%s' "$ROT_RESP" | jq -r '.challenges[0].signingInput // empty') +[ -n "$ROT_INPUT" ] || fail "rotate did not return a fresh challenge" +note "until the new proof lands, the previously sealed state (2 keys) stands" + +header "15. POST /v2/ans/identities/$IDENTITY_ID/verify-control (new key → seals ONE IDENTITY_UPDATED)" +ROT_PROOF=$(signproof sign -key "$KEY_DIR/who-rotated.pem" -kid "${DID_WEB}#key-1" -input "$ROT_INPUT") +curl_json POST "/v2/ans/identities/$IDENTITY_ID/verify-control" \ + "$(jq -n --arg p "$ROT_PROOF" '{signedProofs: [$p]}')" >/dev/null +assert_2xx "rotation verify-control" + +header "16. TL: the rotation sealed ONE IDENTITY_UPDATED — proven set 2 keys → 1, key material changed" +poll_tl_identity_audit "$IDENTITY_ID" 3 30 +AUDIT=$(curl_tl GET "/v1/identities/$IDENTITY_ID/audit") +SEALED_X_AFTER=$(printf '%s' "$AUDIT" | \ + jq -r '[.records[].payload.producer.event | select(.keys)][0].keys[0].verificationMethod.publicKeyJwk.x // empty') +SEALED_KEYS_AFTER=$(printf '%s' "$AUDIT" | \ + jq -r '[.records[].payload.producer.event | select(.keys)][0].keys | length') +[ -n "$SEALED_X_AFTER" ] && [ "$SEALED_X_AFTER" != "$SEALED_X_BEFORE" ] || \ + fail "rotation not sealed (before=$SEALED_X_BEFORE after=$SEALED_X_AFTER)" +[ "$SEALED_KEYS_AFTER" = "1" ] || fail "rotated proven set should be 1 key, got $SEALED_KEYS_AFTER" +# Both linked badges reflect it immediately (read-time join) — with +# 10,000 linked agents this would still be ONE sealed event. +KEYIDS_1_AFTER=$(curl_tl GET "/v1/agents/$AGENT_1" | \ + jq -r '.identities[] | select(.kind == "did:web") | .provenKeyIds | length') +[ "$KEYIDS_1_AFTER" = "1" ] || fail "agent-1's did:web entry should now show 1 provenKeyId, got $KEYIDS_1_AFTER" +ok "sealed key material flipped (${SEALED_X_BEFORE:0:12}… → ${SEALED_X_AFTER:0:12}…), set 2→1 — ONE event, zero agent-stream writes" + +# ----- 17. Identity receipt ----- +header "17. TL: GET /v1/identities/$IDENTITY_ID/receipt (SCITT COSE_Sign1 for the identity leaf)" +IDENTITY_RECEIPT="$DATA/identity-receipt.cbor" +receipt_status=$(curl_tl_binary GET "/v1/identities/$IDENTITY_ID/receipt" "$IDENTITY_RECEIPT") +if [ "$receipt_status" = "200" ]; then + first_byte=$(od -An -tx1 -N1 "$IDENTITY_RECEIPT" | tr -d ' \n') + ok "identity receipt saved to $IDENTITY_RECEIPT (first byte=0x${first_byte}, want 0xd2 for COSE_Sign1)" +elif [ "$receipt_status" = "503" ]; then + warn "receipt 503 — checkpoint has not yet covered the leaf; retry in a few seconds" +else + fail "unexpected identity receipt status $receipt_status" +fi + +# ----- 18. Unlink ONE agent — the other stays linked ----- +header "18. DELETE /v2/ans/identities/$IDENTITY_ID/links/$AGENT_1 (unlink the did:web from agent #1 only)" +curl_json DELETE "/v2/ans/identities/$IDENTITY_ID/links/$AGENT_1" >/dev/null +assert_2xx "unlink" +poll_tl_identity_audit "$IDENTITY_ID" 4 30 +BADGE_1=$(curl_tl GET "/v1/agents/$AGENT_1") +REMAINING_1=$(printf '%s' "$BADGE_1" | jq -r '(.identities | length) // 0') +KIND_1=$(printf '%s' "$BADGE_1" | jq -r '.identities[0].kind // empty') +REMAINING_2=$(curl_tl GET "/v1/agents/$AGENT_2" | jq -r '(.identities | length) // 0') +[ "$REMAINING_1" = "1" ] && [ "$KIND_1" = "did:key" ] || \ + fail "agent-1 should keep exactly its did:key link (got $REMAINING_1 × $KIND_1)" +[ "$REMAINING_2" = "1" ] || fail "agent-2 badge lost its link — unlink must be per-pair" +ok "the did:web↔agent-1 pair ended; agent-1's did:key link and agent-2's did:web link are untouched" +curl_tl GET "/v1/agents/$AGENT_1/identities/history" >/dev/null + +# ----- 19-20. Revoke — the who dies, the whats survive ----- +header "19. POST /v2/ans/identities/$IDENTITY_ID/revoke (state change — an identity cannot be deleted)" +curl_json POST "/v2/ans/identities/$IDENTITY_ID/revoke" >/dev/null +assert_2xx "revoke" +poll_tl_identity_audit "$IDENTITY_ID" 5 30 + +header "20. TL: did:web REVOKED; agent-2's join shows it; agent-1's did:key stays VERIFIED" +ID_STATUS=$(curl_tl GET "/v1/identities/$IDENTITY_ID" | jq -r '.status') +[ "$ID_STATUS" = "REVOKED" ] || fail "identity badge status=$ID_STATUS, want REVOKED" +BADGE_2=$(curl_tl GET "/v1/agents/$AGENT_2") +WHO_STATUS_2=$(printf '%s' "$BADGE_2" | jq -r '.identities[0].identityStatus // empty') +AGENT_STATUS_2=$(printf '%s' "$BADGE_2" | jq -r '.status') +[ "$WHO_STATUS_2" = "REVOKED" ] || fail "agent-2's identities[] should show REVOKED, got $WHO_STATUS_2" +[ "$AGENT_STATUS_2" = "ACTIVE" ] || fail "agent-2 status=$AGENT_STATUS_2, want ACTIVE — identity ops must never touch the agent" +# Each identity has its own lifecycle: the did:key on agent-1 is +# unaffected by the did:web's revocation. +DK_STATUS_1=$(curl_tl GET "/v1/agents/$AGENT_1" | \ + jq -r '.identities[] | select(.kind == "did:key") | .identityStatus // empty') +[ "$DK_STATUS_1" = "VERIFIED" ] || fail "agent-1's did:key should stay VERIFIED, got $DK_STATUS_1" +ok "ONE IDENTITY_REVOKED propagated to agent-2's join at read time; agent-1's did:key untouched; agents ACTIVE" + +# ----- summary ----- +header "Identity lifecycle complete" +printf " did:web %s (VERIFIED w/ 2 keys → rotated to 1 → REVOKED)\n" "$IDENTITY_ID" >&2 +printf " did:key %s (Ed25519 — VERIFIED, linked to agent-1 throughout)\n" "$DK_ID" >&2 +printf " agent-1 %s (carried BOTH whos; kept the did:key after the did:web unlink)\n" "$AGENT_1" >&2 +printf " agent-2 %s (did:web-linked throughout; saw rotation + revocation at read time)\n" "$AGENT_2" >&2 +printf " receipt %s\n" "$IDENTITY_RECEIPT" >&2 +printf "\n" >&2 +printf " sealed: did:web stream — IDENTITY_VERIFIED (multi-key), IDENTITY_LINKED (ansIds[2]),\n" >&2 +printf " IDENTITY_UPDATED, IDENTITY_UNLINKED, IDENTITY_REVOKED; did:key stream —\n" >&2 +printf " IDENTITY_VERIFIED (Multikey, verbatim), IDENTITY_LINKED.\n" >&2 diff --git a/scripts/demo/run-lifecycle.sh b/scripts/demo/run-lifecycle.sh index f0a4437..355a6df 100755 --- a/scripts/demo/run-lifecycle.sh +++ b/scripts/demo/run-lifecycle.sh @@ -17,6 +17,15 @@ # 13. GET TL /v1/agents/{id}/receipt (SCITT COSE) # 14. go test ./internal/tl/receipt -run TestSmokeVerifyDemoReceipt (offline verify) # 15. GET TL /internal/v1/producer-keys/ra/{raId} (admin CRUD) +# 16. POST /v2/ans/identities (register the WHO — a did:web verified identity) +# 17. POST /v2/ans/identities/{id}/verify-control (control proof → VERIFIED, seals IDENTITY_VERIFIED) +# 18. POST /v2/ans/identities/{id}/links (link the agent — ONE IDENTITY_LINKED on the identity stream) +# 19. GET TL /v1/agents/{id} (badge now carries the computed identities[] join) +# +# Step 16-19 show the verified-identity surface in the standard +# lifecycle; scripts/demo/identity-lifecycle.sh exercises EVERY +# identity operation (re-add, did:key, rotation, unlink, revoke, +# receipts, history views). # # Usage: # scripts/demo/run-lifecycle.sh # random host, version 1.0.0 @@ -308,12 +317,54 @@ fi header "15. TL: GET /internal/v1/producer-keys/ra/ans-ra-local (admin)" curl_tl GET "/internal/v1/producer-keys/ra/ans-ra-local" >/dev/null +# ----- 16-19. Verified identity (the WHO behind this agent) ----- +# +# The agent registration above is the "what": one FQDN, its +# endpoints, its certificates. The verified identity is the "who" +# behind it — proven through a challenge-bound key proof, sealed on +# its own TL stream, and linked to the agent. Rotating or revoking +# the identity later is ONE sealed event regardless of how many +# agents are linked; every linked badge reflects it at read time. +header "16. POST /v2/ans/identities (register the WHO — did:web)" +require_cmd go +IDENTITY_DID="did:web:who-$(openssl rand -hex 4).example.com" +IDENTITY_KEY="$DATA/identity-who.pem" +(cd "$ROOT" && go run ./scripts/demo/signproof keygen -out "$IDENTITY_KEY") >/dev/null +ID_REG=$(curl_json POST /v2/ans/identities "$(jq -n --arg v "$IDENTITY_DID" '{value: $v}')") +IDENTITY_ID=$(printf '%s' "$ID_REG" | jq -r '.identityId // empty') +ID_INPUT=$(printf '%s' "$ID_REG" | jq -r '.challenges[0].signingInput // empty') +[ -n "$IDENTITY_ID" ] && [ -n "$ID_INPUT" ] || fail "identity register failed" +echo "$IDENTITY_ID" >"$DATA/last-identity-id" +ok "identityId=$IDENTITY_ID (PENDING_CONTROL — challenge issued)" + +header "17. POST /v2/ans/identities/$IDENTITY_ID/verify-control (→ VERIFIED)" +ID_PROOF=$(cd "$ROOT" && go run ./scripts/demo/signproof sign \ + -key "$IDENTITY_KEY" -kid "${IDENTITY_DID}#key-1" -input "$ID_INPUT") +curl_json POST "/v2/ans/identities/$IDENTITY_ID/verify-control" \ + "$(jq -n --arg p "$ID_PROOF" '{signedProofs: [$p]}')" >/dev/null +assert_2xx "identity verify-control" +ok "control proven — IDENTITY_VERIFIED seals on the identity's own TL stream" + +header "18. POST /v2/ans/identities/$IDENTITY_ID/links (link this agent — one call, one sealed event)" +curl_json POST "/v2/ans/identities/$IDENTITY_ID/links" \ + "$(jq -n --arg a "$AGENT_ID" '{agentIds: [$a]}')" >/dev/null +assert_2xx "identity link" + +header "19. TL: GET /v1/agents/$AGENT_ID (badge — computed identities[] join)" +poll_tl_identity_audit "$IDENTITY_ID" 2 30 +BADGE_WITH_WHO=$(curl_tl GET "/v1/agents/$AGENT_ID") +WHO_VALUE=$(printf '%s' "$BADGE_WITH_WHO" | jq -r '.identities[0].value // empty') +[ "$WHO_VALUE" = "$IDENTITY_DID" ] || fail "badge identities[] join missing (got: $WHO_VALUE)" +ok "one hop answers \"who is behind this agent\": $WHO_VALUE (VERIFIED)" + # ----- summary ----- header "Lifecycle complete (both sides)" printf " agentId %s\n" "$AGENT_ID" >&2 printf " ansName %s\n" "$ANS_NAME" >&2 +printf " identityId %s (%s)\n" "$IDENTITY_ID" "$IDENTITY_DID" >&2 printf " saved to %s\n" "$DATA/last-agent-id" >&2 printf " root-keys %s\n" "$ROOT_KEYS_FILE" >&2 printf " receipt %s\n" "$RECEIPT_FILE" >&2 printf "\n" >&2 printf " revoke: %s\n" "scripts/demo/revoke.sh" >&2 +printf " identities: %s\n" "scripts/demo/identity-lifecycle.sh (every identity operation)" >&2 diff --git a/scripts/demo/signproof/main.go b/scripts/demo/signproof/main.go new file mode 100644 index 0000000..5573114 --- /dev/null +++ b/scripts/demo/signproof/main.go @@ -0,0 +1,190 @@ +// signproof is the demo-side identity-proof tool: it mints keypairs +// and signs identity verify-control proofs (compact JWS over the +// RA-served signingInput). +// +// This is what a registrant's own tooling does in production — the +// private key never touches the RA. Run via `go run`: +// +// go run ./scripts/demo/signproof keygen -alg ed25519 -out key.pem +// → writes the key, prints the did:key identifier on stdout +// +// go run ./scripts/demo/signproof sign -key key.pem -kid KID -input SIGNING_INPUT +// → prints the compact JWS on stdout (alg auto-selected from +// the key type: Ed25519 → EdDSA, P-256 → ES256) +// +// The JWS protected header carries kid + the embedded public jwk. +// The jwk header is what lets the RA's noop resolver synthesize a +// DID document for local development; the hardened web resolver +// ignores it and uses the fetched did.json. +package main + +import ( + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "flag" + "fmt" + "os" + + anscrypto "github.com/godaddy/ans/internal/crypto" +) + +func main() { + if err := run(os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, "signproof:", err) + os.Exit(1) + } +} + +func run(args []string) error { + if len(args) < 1 { + return errors.New("usage: signproof [flags]") + } + switch args[0] { + case "keygen": + return keygen(args[1:]) + case "sign": + return sign(args[1:]) + default: + return fmt.Errorf("unknown subcommand %q (want keygen or sign)", args[0]) + } +} + +// keygen mints a keypair, writes it as PKCS#8 PEM, and prints the +// matching did:key identifier — handy both for did:key registrations +// and as the kid fragment for did:web proofs. +func keygen(args []string) error { + fs := flag.NewFlagSet("keygen", flag.ContinueOnError) + out := fs.String("out", "", "path to write the private key PEM (required)") + alg := fs.String("alg", "p256", "key algorithm: p256 | ed25519") + if err := fs.Parse(args); err != nil { + return err + } + if *out == "" { + return errors.New("keygen: -out is required") + } + + var priv any + var pub any + switch *alg { + case "p256": + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return fmt.Errorf("generate p256 key: %w", err) + } + priv, pub = key, &key.PublicKey + case "ed25519": + pubKey, privKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return fmt.Errorf("generate ed25519 key: %w", err) + } + priv, pub = privKey, pubKey + default: + return fmt.Errorf("keygen: unsupported -alg %q (want p256 or ed25519)", *alg) + } + + der, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + return fmt.Errorf("marshal key: %w", err) + } + pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}) + if err := os.WriteFile(*out, pemBytes, 0o600); err != nil { + return fmt.Errorf("write key: %w", err) + } + + multibase, err := anscrypto.EncodeMultibase(pub) + if err != nil { + return fmt.Errorf("encode multibase: %w", err) + } + // stdout carries exactly the did:key identifier so shell callers + // can capture it: DID=$(go run ./scripts/demo/signproof keygen ...). + fmt.Fprintln(os.Stdout, "did:key:"+multibase) + return nil +} + +// sign produces the compact JWS the verify-control endpoint expects: +// the payload segment is the RA-served signingInput VERBATIM (clients +// never canonicalize — the RA checks payload equality first), and the +// algorithm follows the key type: P-256 → ES256 (SHA-256 prehash, +// P1363 signature), Ed25519 → EdDSA (raw signing input, RFC 8037). +func sign(args []string) error { + fs := flag.NewFlagSet("sign", flag.ContinueOnError) + keyPath := fs.String("key", "", "path to the private key PEM (required)") + kid := fs.String("kid", "", "verification method id to claim (required)") + input := fs.String("input", "", "the RA-served base64url signingInput (required)") + if err := fs.Parse(args); err != nil { + return err + } + if *keyPath == "" || *kid == "" || *input == "" { + return errors.New("sign: -key, -kid, and -input are all required") + } + + raw, err := os.ReadFile(*keyPath) + if err != nil { + return fmt.Errorf("read key: %w", err) + } + block, _ := pem.Decode(raw) + if block == nil { + return errors.New("key file is not PEM") + } + parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return fmt.Errorf("parse key: %w", err) + } + + var alg string + var pub any + switch key := parsed.(type) { + case *ecdsa.PrivateKey: + if key.Curve != elliptic.P256() { + return errors.New("ECDSA key must be P-256") + } + alg, pub = "ES256", &key.PublicKey + case ed25519.PrivateKey: + alg, pub = "EdDSA", key.Public() + default: + return fmt.Errorf("unsupported key type %T (want P-256 or Ed25519)", parsed) + } + + jwk, err := anscrypto.PublicKeyToJWK(pub) + if err != nil { + return fmt.Errorf("encode jwk: %w", err) + } + headerJSON, err := json.Marshal(map[string]any{ + "alg": alg, + "kid": *kid, + "jwk": jwk, + }) + if err != nil { + return fmt.Errorf("marshal header: %w", err) + } + encodedHeader := base64.RawURLEncoding.EncodeToString(headerJSON) + toSign := encodedHeader + "." + *input + + var sig []byte + switch key := parsed.(type) { + case *ecdsa.PrivateKey: + digest := sha256.Sum256([]byte(toSign)) + derSig, err := ecdsa.SignASN1(rand.Reader, key, digest[:]) + if err != nil { + return fmt.Errorf("sign: %w", err) + } + sig, err = anscrypto.DERToP1363(derSig, 32) + if err != nil { + return fmt.Errorf("encode signature: %w", err) + } + case ed25519.PrivateKey: + // EdDSA signs the raw signing input — no prehash. + sig = ed25519.Sign(key, []byte(toSign)) + } + + fmt.Fprintln(os.Stdout, toSign+"."+base64.RawURLEncoding.EncodeToString(sig)) + return nil +} diff --git a/scripts/demo/start.sh b/scripts/demo/start.sh index 415a09e..979a3e9 100755 --- a/scripts/demo/start.sh +++ b/scripts/demo/start.sh @@ -120,6 +120,15 @@ dns: type: ${ANS_DNS_TYPE:-noop} server: "${ANS_DNS_SERVER:-}" +identity: + # Verified identities (the "who" behind agents). The "noop" + # resolver synthesizes did:web documents from the keys embedded in + # submitted proofs — real signature verification, no hosting + # needed; flip to "web" for the hardened HTTPS did.json fetch. + resolver: + type: ${ANS_IDENTITY_RESOLVER:-noop} + challenge-ttl: 1h + keys: type: file file: From c673548f7667c0f5449fe6f6e0c60d0f29c92637 Mon Sep 17 00:00:00 2001 From: Connor Snitker Date: Wed, 10 Jun 2026 18:10:17 -0500 Subject: [PATCH 04/13] docs: verified-identities implementation plan and as-built notes 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 --- docs/PLAN-verified-identities.md | 544 +++++++++++++++++++++++++++++++ 1 file changed, 544 insertions(+) create mode 100644 docs/PLAN-verified-identities.md diff --git a/docs/PLAN-verified-identities.md b/docs/PLAN-verified-identities.md new file mode 100644 index 0000000..4e2f22a --- /dev/null +++ b/docs/PLAN-verified-identities.md @@ -0,0 +1,544 @@ +# Implementation Plan — Verified Identities (the "who" behind an agent) + +> **As-built notes (2026-06-10).** The implementation landed per this +> plan with three deliberate deviations, each truer to the design's +> own principles than the plan's first sketch: +> +> 1. **TL storage: one table, not two.** Instead of a parallel +> `tl_identity_events` table, `tl_events` gained a nullable +> `identity_id` column (+ partial index) and a +> `tl_identity_event_agents` fan-out table for the link read-join +> — the literal embodiment of "streams are read indexes over one +> log". One mirror, one badge builder, one receipt path, one +> Merkle-proof path; the identity envelope exposes its index keys +> through an optional capability interface so `event.View` stayed +> frozen for existing implementers. +> 2. **Key-type support: everything the JWS layer verifies.** +> Ed25519 (EdDSA), ECDSA P-256 (ES256), and RSA ≥ 2048 (RS256) +> are all accepted for identity proofs — the allowlist names +> exactly what jws.go implements. Keys that structurally cannot +> prove control are rejected with precise errors: X25519 is a +> key-agreement key; secp256k1 and P-384/521 have no verifier +> here (adding one is the gate for admitting them). +> 2a. **Seals quote the verification method verbatim.** Sealed +> `ProvenKey` = `{verificationMethod, signedProof}` where +> `verificationMethod` is the DID document's object exactly as +> served — member-for-member, values untouched (JCS signing +> preserves member values, so the quoted material survives +> intact). Nothing derived, re-encoded, or normalized enters a +> seal; thumbprints are compute-at-read conveniences. The badge +> join surfaces `provenKeyIds` (verification-method ids). did:key +> seals the method-spec-derived Multikey entry whose key material +> is the method-specific id verbatim from the identifier. The +> postponed lei kind remains the deliberate exception (subject +> AID + thumbprint only — no document to quote, ACDC is PII, the +> KEL is the authoritative key history). +> 3. **No localhost did:web host for development.** The hardened web +> resolver pins fetches to port 443, requires WebPKI, and its SSRF +> denylist rejects loopback — a local did.json server is +> structurally unusable with it, by design. Local development is +> the noop resolver (real JWS verification, waived live-document +> binding); the web resolver is covered by TLS-server tests at the +> parse seam plus dialer-level SSRF tests. + +| | | +|---|---| +| **Source design** | `DESIGN-multi-identity-anchors.md` rev 4 (2026-06-10, who/what pivot) — design of record from the ans-registry working sessions | +| **Target repo** | `godaddy/ans` (this repo), branched from `main` @ `d94d531` | +| **Scope** | Identity aggregate + proof-of-control gate (did:web, did:key), identity links, the five `IDENTITY_*` TL events on a dedicated ingest lane, TL identity read surface + computed badge join, offline verification. **lei postponed** (design §3.6/§10.7 retained as design of record). | +| **Pattern requirement** | Every outbound I/O dependency ships a **noop adapter for local/quickstart** and a **fully functional adapter selected by config** — exactly the `dns.type: noop \| lookup` precedent (`cmd/ans-ra/main.go` `selectDNSVerifier`, `internal/adapter/dns/{noop,lookup}.go`). | + +--- + +## 0. Design → this-repo mapping + +The design doc was written against the `ans-registry-poc` codebase. Every code +anchor it cites maps onto this repo as follows; the *wire shapes and rules* in +the design (§3.2 IdentityProofInput, §5.3 bodies, §5.5 events, §5.7 schema) +carry over verbatim. + +| Design reference | This repo | +|---|---| +| `AgentRegistration` aggregate, `agent.go:68/129/150/155` | `internal/domain/agent.go:54-179` (`AgentRegistration`, `NewRegistration`) | +| Status machine `status.go:62-71` | `internal/domain/status.go` (`ValidTransitions`) | +| `lifecycle.go` `VerifyACME` | `internal/ra/service/registration.go` + `lifecycle.go` (`VerifyACME`/`VerifyDNS`) | +| TL `event.go:92-102` `Type.IsValid()` closed switch | `internal/tl/event/event.go:92-102` — **stays frozen**; identity events get their own package (see §2.1) | +| V1 enum frozen (`internal/tl/event/v1/event.go:50-65`) | same path here — untouched | +| `migrations/001_initial.sql` (RA) | `internal/adapter/store/sqlite/migrations/` — latest is `005_agent_acme_challenge.sql`, so the identity migration is **006** | +| TL migrations | `internal/adapter/store/sqlitetl/migrations/` — latest is `002_producer_keys.sql`, so identity events migration is **003** | +| `spec/api-spec-v2.yaml`, `spec/api-spec-tl-v2.yaml` | same paths — the canonical contracts; every PR pastes its shape diff per CLAUDE.md | +| Producer lane / outbox | `internal/ra/service/registration.go` (`signAndMarshalPayload`, `enqueueTLEvent`), `internal/ra/outbox/worker.go`, `internal/adapter/tlclient/client.go` | +| `noop` vs real verification | `internal/adapter/dns/noop.go` / `lookup.go`, selected by `cfg.DNS.Type` in `cmd/ans-ra/main.go:410-421` | + +One deliberate divergence from the design text: design §5.5 says the V2 +`Type.IsValid()` switch "MUST widen." In this repo the cleaner equivalent is a +**parallel identity event package** with its own closed five-token enum — the +agent codec stays byte-frozen, and the cross-lane guard falls out of each +codec's `Validate()` (an `IDENTITY_*` token fails the agent enum **and** lacks +`ansId`; an `AGENT_*` token fails the identity enum and lacks `identityId` — +both 422 `INVALID_EVENT`). This satisfies the same normative requirement +(identity tokens accepted on the identity lane, V1 lane frozen) without +touching the existing agent contract. + +--- + +## 1. The noop/full pattern, applied per kind + +The single design invariant: **the RA seals an identity attestation only +after control is proven** — a challenge-bound signature verified against the +identifier's *authoritative* key. The only outbound I/O in Slices 1–3 is the +did:web `did.json` HTTPS fetch. That fetch is the port. + +| Kind | Outbound I/O | noop adapter (quickstart) | full adapter (configured) | +|---|---|---|---| +| `did:key` | **none** — key decodes from the DID string | not needed; real crypto runs locally out of the box | same code path | +| `did:web` | HTTPS GET of `did.json` (registrant-steered host) | `didresolver.Noop` — never dials; synthesizes the DID document from the submitted proofs' embedded keys (below) | `didresolver.Web` — hardened fetcher: WebPKI, SSRF dialer guards, 5 s timeout, size cap, ≤5 same-registrable-domain redirects (design §3.7) | +| `lei` *(postponed)* | GLEIF L1 GET + internal `vlei-verifier` | `Noop` variants of both clients | real HTTP clients behind `port.LEIControlVerifier` | + +**Noop semantics — mirror the DNS precedent precisely.** The noop DNS +verifier waives the *external-world binding* (does the zone really contain +the record?) while every pure-crypto check (CSR self-signature) still runs. +The did:web analog: the noop resolver waives "does the live `did.json` +really list this key?" while the JWS verification still genuinely runs. + +Port shape that makes both adapters uniform from the service's view: + +```go +// internal/port/didresolver.go +type DIDResolver interface { + // Resolve returns the DID document for did. Hints carry the + // kid → public-JWK pairs the caller extracted from the submitted + // proofs' protected headers; the web resolver ignores them + // (authoritative fetch), the noop resolver synthesizes a document + // from them so local flows run with zero hosting. + Resolve(ctx context.Context, did string, hints []KeyHint) (*DIDDocument, error) +} + +type KeyHint struct { + Kid string + PublicKeyJWK json.RawMessage // from the proof's `jwk` protected header +} + +type DIDDocument struct { + ID string + AssertionMethod []VerificationMethod +} + +type VerificationMethod struct { + ID string + Controller string + Type string + PublicKeyJwk json.RawMessage + PublicKeyMultibase string +} +``` + +Consequences, stated honestly: + +- In **noop mode** the registrant's JWS proofs must carry the `jwk` protected + header (standard JOSE) so the synthesized document has key material. The + 202 challenge list contains a single `{kid: "", signingInput}` entry (the + `signingInput` is key-independent — design §5.3 notes every per-key entry + carries the same bytes), and the registrant names keys via the JWS `kid` + + `jwk` headers. +- In **web mode** the register-time advisory fetch enumerates the document's + `assertionMethod` kids into the challenge list, and verify-control + **re-fetches authoritatively** (design §3.6); the `jwk` header, if present, + is ignored — the resolved document is always the key source. +- Sealed events remain **self-verifying in both modes**: the sealed + `signedProof` really verifies against the sealed `publicKeyJwk`. Noop mode + waives only the authoritative web binding — the documented quickstart + caveat, same as noop DNS ("accepts any state; NOT for production"). + +Config (mirrors `dns:`): + +```yaml +identity: + resolver: + type: noop # noop | web (default noop — quickstart parity) + challengeTTL: 1h # nonce TTL; design floor 5m for high-assurance +``` + +`cmd/ans-ra/main.go` gains `selectDIDResolver(cfg)` next to +`selectDNSVerifier`. `IdentityProofInput.raId` reuses the already-configured +producer identity (`cfg.Signer.RAID`) — one RA, one `raId`, no new key. + +--- + +## 2. PR slices + +Ordered so TL lands before anything emits to it, and so the event vocabulary +(append-only-forever) is settled in the first sealing-relevant PR — design +§5.5's sequencing note. Each PR: `make check` green, spec shape-diff pasted +into the PR description, DCO sign-off + GPG signature, no AI trailers. + +``` +PR1 (TL ingest) ──> PR2 (TL reads/join) ──> PR3 (RA identity + did:web) ──> PR4 (RA links + views + demo) + └────────────────> PR5 (did:key) ──> PR6 (ans-verify) +``` + +### PR 1 — `feat(tl): identity event family + ingest lane` + +**The vocabulary-freezing PR.** The five tokens and the identity event shape +land here and are forever after. + +New package `internal/tl/event/identity/` (import alias `identityevent`), +mirroring `internal/tl/event/event.go` structure exactly: + +- `Type` enum: `IDENTITY_VERIFIED`, `IDENTITY_UPDATED`, `IDENTITY_REVOKED`, + `IDENTITY_LINKED`, `IDENTITY_UNLINKED` + closed `IsValid()`. +- `Event` struct (design §5.5 shapes): + `identityId` (required, stream key), `kind`, `value`, `providerId`, + `proofMethod`, `keys[]` (`ProvenKey{verificationMethodId, keyThumbprint, + publicKeyJwk, signedProof}` — JWK/proof omitted for lei's thumbprint-only + tier), `ansIds[]` (LINKED/UNLINKED only), `previousValue` (UPDATED only), + `verifiedAt`, `revokedAt`, `raId`, `timestamp`. +- Own `Envelope`/`Payload`/`Producer` wrapper (same outer shape, typed inner + event) implementing `event.Signable`; `SchemaVersion = "V2"`; + identical JCS canonicalization + RFC 6962 `SHA-256(0x00 || leaf)` rules. +- `Validate()` per-type required-key matrix (design §5.6.1): proofs/rotation/ + revocation require `identityId`; link events additionally require non-empty + `ansIds[]`; `keys[]` required and non-empty on VERIFIED/UPDATED. + +TL service + handler: + +- `internal/tl/service/codec.go`: add `identityCodec` implementing + `envelopeCodec` (same `ParseAndBuild` contract, `RAID_MISMATCH` guard + included). +- `internal/tl/service/log.go`: the `append()` pipeline is already + version-agnostic except the SQLite mirror; introduce a small per-lane + bundle `{codec envelopeCodec; mirror eventMirror}` where `eventMirror` is + `{ExistsByEventHash; StoreEvent}` — the agent lanes keep the existing + store, the identity lane mirrors into the new tables. +- `internal/tl/handler/handler.go`: route `POST /v1/internal/identities/event` + (same producer-signature discipline, same 256 KiB cap, same response shape). + Producer-key verification is untouched — same trust store, same `kid` + lookup. + +Storage — `internal/adapter/store/sqlitetl/migrations/003_identity_events.sql`: + +```sql +CREATE TABLE tl_identity_events ( + id INTEGER PRIMARY KEY, + leaf_index INTEGER NOT NULL UNIQUE, + leaf_hash TEXT NOT NULL, + event_hash TEXT NOT NULL UNIQUE, -- dedup key, SHA-256(JCS inner) + log_id TEXT NOT NULL, + identity_id TEXT NOT NULL, + provider_id TEXT, + kind TEXT, + value TEXT, + event_type TEXT NOT NULL, + raw_event TEXT NOT NULL CHECK (json_valid(raw_event)), + created_at_ms INTEGER NOT NULL +); +CREATE INDEX idx_tl_identity_events_identity_leaf + ON tl_identity_events(identity_id, leaf_index DESC); + +-- the read-join index: one row per (link event, named agent) — design §5.6.1 +CREATE TABLE tl_identity_event_agents ( + id INTEGER PRIMARY KEY, + leaf_index INTEGER NOT NULL, + identity_id TEXT NOT NULL, + ans_id TEXT NOT NULL, + event_type TEXT NOT NULL, -- IDENTITY_LINKED | IDENTITY_UNLINKED + created_at_ms INTEGER NOT NULL +); +CREATE INDEX idx_tl_identity_event_agents_ans + ON tl_identity_event_agents(ans_id, leaf_index DESC); +``` + +Plus `internal/adapter/store/sqlitetl/identityevents.go` (StoreEvent — also +fans the `ansIds[]` into the agent-index table; GetLatestByIdentityID; +GetByIdentityID paginated; ExistsByEventHash; ListLinkEventsByAgent; +ListAgentLinkStateByIdentity). + +Spec: `spec/api-spec-tl-v2.yaml` — the ingest path + `IdentityProducerEvent` +schema + the five-token enum. + +Tests: codec round-trip + leaf-hash vectors; per-type validation matrix; +**cross-lane guards both directions** (V2 agent body on the identity route → +422 `INVALID_EVENT`; identity body on `/v1/` and `/v2/internal/agents/event` +→ 422; V1 lane rejects all `IDENTITY_*`); ingest handler tests with real +producer signatures; dedup-by-content-hash including ansIds-index idempotence +on duplicate. + +**Acceptance:** one Merkle tree — identity and agent leaves interleave in the +same tiles; checkpoints/witnesses/receipt machinery untouched; `/tile/*` +serves both. + +### PR 2 — `feat(tl): identity read surface + computed badge join` + +No new persistence beyond PR 1 — everything here is read-time computation +(design §5.6.3), the same pattern as the V2 badge's computed `status`. + +- `internal/tl/service/identitybadge.go` — `Get` (latest identity event + + Merkle proof + computed status `VERIFIED|REVOKED`), `Audit` (paginated, + **format-identical to the agent audit envelope** — no bespoke shape). +- Receipt support for identity leaves: `ReceiptService.ForIdentity` — reuse + the leaf-index receipt machinery; `tl_receipts` keying generalizes (add a + nullable `identity_id` column or a `subject_type` discriminator in the same + 003 migration — implementation detail, decide at code time). +- The **join**, both directions, computed in the service from PR 1's stores: + a link is *effective* iff latest link/unlink for `(identityId, ansId)` is + `LINKED` ∧ identity stream says `VERIFIED` ∧ agent badge status is live. +- Routes (`internal/tl/handler/handler.go`): + - `GET /v1/identities/{identityId}` — identity badge. + - `GET /v1/identities/{identityId}/audit` — full chain, audit envelope. + - `GET /v1/identities/{identityId}/receipt` — COSE receipt. + - `GET /v1/identities/{identityId}/agents` — reverse join (currently linked). + - `GET /v1/agents/{agentId}` — **gains computed `identities[]`** (design + §5.6.3 badge shape: identityId, kind, value, identityStatus, + provenKeyThumbprints, linkedAt, linkLogId, identityLogId). Covered by the + TL's response signature, never by the seal. + - `GET /v1/agents/{agentId}/identities` and `…/identities/history` — the + audit envelope filtered through the agent index. +- Spec: full read-surface delta to `spec/api-spec-tl-v2.yaml`. + +Tests: join truth-table (link → badge shows it; rotate → thumbprints flip on +every linked badge with **one** sealed event; revoke identity → all badges +show REVOKED; unlink → gone; agent revoked → link ineffective, identity +stream untouched); audit pagination; receipt round-trip; agent audit remains +pure `AGENT_*`. + +### PR 3 — `feat(ra): verified-identity aggregate + proof gate + did:web` + +The RA half: domain, storage, the generalized verify-control gate, sealing, +and the noop/web resolver pair. Internal milestone "2a" of the design. + +**Domain** (`internal/domain/identity.go`, 100% coverage): + +- `IdentifierKind` + lexical `InferKind(value)` (`did:web:` / `did:key:` / + `lei` — only did:web *enabled* this PR; others → + `IDENTIFIER_KIND_UNSUPPORTED`), canonicalization rules, did:web → + resolution-URL mapping (root → `/.well-known/did.json`, path-bearing → + `/{path}/did.json`, **reject port/userinfo** → `DID_BAD_FORMAT`). +- `VerifiedIdentity` aggregate (design §2.1): identityId (UUIDv7 — + `google/uuid` v1.6 has `NewV7`), providerId, kind, value, status + (`PENDING_CONTROL → VERIFIED → REVOKED`), proofMethod, staged + `pendingValue`, challenge (nonce/expiry/consumed); transitions + `IssueChallenge`, `MarkVerified`, `StageRotation`, `CompleteRotation`, + `Revoke`; **no public-key field** (ANS-0 §6.2). +- `IdentityLink` (status `LINKED|UNLINKED`) — used in PR 4, lands with the + aggregate. +- Domain events appended to `internal/domain/events.go` pattern. + +**Crypto** (`internal/crypto/`, ≥95–100%): + +- `jwk.go` — JWK → `crypto.PublicKey` (Ed25519 OKP, ECDSA P-256 allowlist), + RFC 7638 thumbprint, `publicKeyMultibase`/Multikey → JWK conversion (the + pinned thumbprint rule, design §3.6 semantic note). +- `proofinput.go` — `IdentityProofInput` builder: exactly the §3.2 JCS object + `{identifier, identityId, nonce, purpose:"ans:identity-proof:v1", raId, + scheme}` via the existing `Canonicalize`; the served `signingInput` is the + base64url of those bytes, and verify-control checks **payload-equality + before signature** (clients never canonicalize). +- JWS verification reuses `VerifyWithPublicKey` (go-jose v4 already supports + EdDSA + ES256); pin `alg` to the resolved key type. + +**Port + adapters:** + +- `internal/port/didresolver.go` — as §1 above. +- `internal/adapter/didresolver/web.go` — hardened fetcher (design §3.7): + custom dialer that re-resolves, **rejects RFC 1918 / loopback / link-local + / ULA / metadata at connect time**, pins the resolved IP per + verify-control call; WebPKI + hostname verification on every fetch; 5 s + timeout; response cap; ≤5 redirects within the same registrable domain + (needs `golang.org/x/net/publicsuffix`); error `detail` never echoes + resolved IPs/redirect chains. +- `internal/adapter/didresolver/noop.go` — synthesizes from hints (§1). +- `internal/adapter/store/sqlite/identity.go` (+ link store) implementing new + `port.IdentityStore` / `port.IdentityLinkStore` in `internal/port/store.go`. + Challenge consumption is a **conditional + `UPDATE … WHERE challenge_consumed_at_ms IS NULL`** inside the + verify-success `uow.Run` transaction — the TOCTOU guard (§3.2). + +**Storage** — `internal/adapter/store/sqlite/migrations/006_identities.sql`: +verbatim design §5.7 (`identities` + `identity_links`, partial unique +indexes `idx_identities_live` on `(provider_id, kind, value) WHERE status != +'REVOKED'` and `idx_identities_proven` on `(kind, value) WHERE status = +'VERIFIED'` — first-to-prove wins, no squatting). Plus +`007_outbox_identity_lane.sql`: SQLite cannot widen a CHECK in place, so +rebuild `outbox_events` (create-copy-drop-rename inside the migration tx) +with `schema_version IN ('V1','V2','IDENTITY')`. + +**Service** (`internal/ra/service/identity.go`): + +- `Register` — infer/canonicalize kind, advisory resolve (noop: empty doc), + idempotent re-add (same owner + same value while `PENDING_CONTROL` → same + identityId, fresh nonce; `IDENTIFIER_DUPLICATE` only for genuine conflicts + per §4.2/§9 Q1), 202 with `{identityId, nonce, expiresAt, challenges[]}`. +- `VerifyControl` — authoritative re-resolve; per-proof checks in design + §3.6 order (payload-equality; `kid ∈ assertionMethod`; `{DID}#fragment` + rule; `controller == DID`; key-type allowlist; verify **every** JWS, + one bad proof fails closed; nonce fresh, consumed once in-tx); flip to + `VERIFIED` (or swap staged rotation) and **seal in the same flow**: + build `identityevent.Event`, JCS + sign **once** via the existing + `signAndMarshalPayload` pattern, enqueue with lane `IDENTITY` — + the outbox-replay invariant applies unchanged. +- `Rotate` (PUT — stage `pending_value`, old state stands, fresh challenges), + `Revoke` (POST — seal `IDENTITY_REVOKED`), `List`, `Detail`. + +**Outbox/tlclient:** `tlclient.Client` URL map gains +`IDENTITY → /v1/internal/identities/event`; worker untouched (lane passes +through `Sender.Append`). + +**HTTP** (`internal/ra/handler/identity.go` + routes in `cmd/ans-ra/main.go`): + +``` +POST /v2/ans/identities 202 + challenges +POST /v2/ans/identities/{identityId}/verify-control 200 VERIFIED (seals) +PUT /v2/ans/identities/{identityId} 202 + fresh challenges +POST /v2/ans/identities/{identityId}/revoke 200 (seals; POST, never DELETE) +GET /v2/ans/identities list (mine) +GET /v2/ans/identities/{identityId} detail +``` + +New `internal/ra/middleware/identityownership.go` mirroring the agent +ownership middleware (read → 404 hides existence, write → 403). RFC 7807 +codes added to the handler error map: `IDENTIFIER_KIND_UNSUPPORTED`, +`IDENTIFIER_DUPLICATE`, `IDENTIFIER_CHALLENGE_EXPIRED`, +`PRICC_SIGNATURE_INVALID`, `PRICC_TOKEN_EXPIRED`, `PRICC_TOKEN_ALREADY_USED`, +`DID_BAD_FORMAT`, `DID_RESOLUTION_FAILED`, `DID_DOCUMENT_ID_MISMATCH`, +`DID_REDIRECT_DOMAIN_MISMATCH`, `DID_VERIFICATION_METHOD_INVALID`. + +**Config:** `Identity` block in `internal/config/config.go` (§1 above); +`selectDIDResolver` in main.go; `config/ra-local.yaml` gains the block with +`resolver.type: noop`. + +**Spec:** `spec/api-spec-v2.yaml` — six identity routes + request/response/ +challenge schemas (design §5.2/§5.3), shape diff pasted in the PR. + +Per CLAUDE.md "no placeholder routes": the link routes and the RA-side +computed `identities[]` do **not** register in this PR — they land +implemented in PR 4. + +Tests: domain 100% (state machine, kind inference, challenge lifecycle); +proof-gate table tests (good/bad kid, wrong controller, external reference, +alg confusion, expired/consumed nonce, multi-key one-bad-fails-closed, +concurrent verify double-consume race via `make test-race`); web resolver +against `httptest.NewTLSServer` + SSRF dialer unit tests (denylist matrix, +rebind pin); noop resolver; handler 202/422/403/404 paths; end-to-end +RA→outbox→TL integration sealing `IDENTITY_VERIFIED` against the PR 1 lane. + +### PR 4 — `feat(ra): identity links + computed agent views + demo` + +Internal milestone "2b": the link mechanism + read-side joins + quickstart. + +- Service `Link`/`Unlink`: **single owner-gated call, no signature** (§4.3 — + caller's principal must own the identity **and every** agent; identity must + be `VERIFIED`); batch upsert against `idx_identity_links_live`; seal **one** + `IDENTITY_LINKED` carrying `ansIds[]` (chunk very large batches to bound + leaf size); unlink seals `IDENTITY_UNLINKED`. Cascade rules §4.4: agent + revocation emits zero identity events and vice versa — enforced by tests. +- Routes: `POST /v2/ans/identities/{identityId}/links` (200 `{linked: N}`), + `DELETE /v2/ans/identities/{identityId}/links/{agentId}`. +- RA `AgentDetails` gains the additive computed `identities[]` (design §5.4) + in lifecycle `Detail` — computed from the link + identity tables, never + stored on the registration; `agent_registrations` untouched. +- Spec deltas for both files. +- **Demo** (`scripts/demo/`): `identity-lifecycle.sh` against the noop + resolver — register identity → sign challenge → verify-control → link to + the demo agent → show TL badge `identities[]` → rotate (one event) → + revoke. JWS signing from shell needs a helper: small + `scripts/demo/signproof/main.go` run via `go run` (mints an Ed25519 key, + emits the compact JWS with `kid`+`jwk` headers over the served + `signingInput`). README quickstart section updated. + +### PR 5 — `feat(ra): did:key` + +Reuses every PR 3 seam; **zero I/O — no noop needed**, the keyless test +track (§2.2). + +- `internal/crypto/didkey.go` — decode `did:key:z…`: multibase base58btc + + multicodec varint (`0xed01` Ed25519; optionally `0x1200` P-256). Hand-roll + base58btc (~40 lines, fully testable) rather than adding multiformats deps. +- Kind dispatch: challenges return exactly one entry + (`kid = {did}#{method-specific-id}` per did:key convention); verify against + the key decoded **from the DID string**; `alg` pinned. +- Seal `keys[]` with thumbprint + JWK + signedProof (self-verifying; key also + derivable from the DID itself). +- Demo extension + table tests with fixed vectors. + +### PR 6 — `feat(verify): offline verification of IDENTITY_* leaves` + +`ans-verify` learns the identity stream so third parties can check seals +offline (design D5 / §5.6.3 verifier walk): + +- Parse identity envelopes; verify TL attestation JWS, producer JWS, + inclusion proof to checkpoint — all existing machinery, new inner shape. +- **Self-verifying key proofs**: verify each sealed `signedProof` against its + sealed `publicKeyJwk`; decode the payload and confirm it is an + `IdentityProofInput` binding this `identityId` + `identifier` + `purpose` + — offline, without trusting the RA. +- CLI surface consistent with existing verify commands. + +### PR 7 *(postponed — do not schedule)* — `lei` + +Design §3.6/§10.7 retained as design of record. When resumed: +`port.LEIControlVerifier` (present / authorization / verify-signature) with +a **noop adapter** + a real adapter for the internal GLEIF `vlei-verifier`; +fixed-host GLEIF L1 client (noop + real); present-once at register / +prove-repeatably at verify-control; AID pinned at presentation; +thumbprint-only sealing (PII rule). Until then the kind returns +`IDENTIFIER_KIND_UNSUPPORTED`. + +--- + +## 3. Cross-cutting rules (gates on every PR) + +1. **Event vocabulary is frozen by PR 1.** Sealed shapes are + append-only-forever; nothing seals to a shared TL until the five tokens + + payload schemas in PR 1 are reviewed against design §5.5 and the spec + delta is merged (design §5.9 amendment rows A–F note the upstream ANS-spec + ratification — track it in the PR description). +2. **Outbox-replay invariant** extends verbatim to identity events: payload + JCS-canonicalized + signed exactly once at enqueue; retries replay bytes; + TL dedups on content hash. +3. **No placeholder routes.** Every route registers only in the PR that + implements it end-to-end; unsupported kinds are `IDENTIFIER_KIND_UNSUPPORTED` + at dispatch, never a stubbed seal. +4. **Streams never cross at write time.** Identity operations write zero + agent-stream events and vice versa; all propagation is read-time join. + Every PR 2/4 test suite asserts the negative. +5. **Minimal abstraction** (design §6): per-kind control logic starts as a + switch + functions inside the identity service; no `port.ControlVerifier` + until did:key is the second real caller (extract the seam in PR 5 only if + it pays for itself). No `LocationChallenger` interface — one implementer + (ACME) exists and it stays where it is. +6. **Coverage**: `internal/domain` 100%; `internal/crypto` 100% target + (≥95% with annotated SAFETY/NOTE exceptions); overall ≥90% via + `make test-cover`. `make check` before every commit; `make test-race` for + the nonce-consumption race. +7. **Commits**: conventional commits (`feat(tl):`, `feat(ra):` …), + `git commit -s` (DCO), GPG-signed, **no AI Co-Authored-By trailers**. + +--- + +## 4. Open decisions (flagged for review before/during PR 1 & 3) + +| # | Decision | Recommendation | Where it lands | +|---|---|---|---| +| 1 | Outbox lane mechanism: widen `schema_version` CHECK (table rebuild) vs dispatch on `IDENTITY_*` event-type prefix in the worker | **Widen the CHECK** — explicit lane column beats stringly dispatch; SQLite rebuild migration is routine | PR 3, migration 007 | +| 2 | Noop resolver semantics: synthesize from `jwk`-header hints (real JWS verification, waived web binding) vs accept-any | **Hint synthesis** — keeps sealed events self-verifying even from quickstart runs | PR 3, §1 above | +| 3 | `IdentityProofInput.raId` source | **Reuse `cfg.Signer.RAID`** — one RA identity everywhere; deployment docs say to set it to the external base URL per design §3.2 | PR 3 | +| 4 | did:key multibase: hand-rolled base58btc vs `go-multiformats` deps | **Hand-roll** (~40 lines + vectors) — two deps for one decode is poor trade | PR 5 | +| 5 | Key-type allowlist v1 | **Ed25519 + ECDSA P-256** (matches go-jose EdDSA/ES256 and the TL's own P-256 posture) | PR 3 | +| 6 | Must linked agents be `ACTIVE` at link time, or any live (non-terminal) status? | **Any live status** — effectiveness is computed at read time anyway (§5.6.3); blocking on ACTIVE adds a write-time race for no read-time gain | PR 4 | +| 7 | Identity envelope `schemaVersion` token | **`"V2"`** — same producer-lane generation; lane separation is the route + event family, not the version string | PR 1 | +| 8 | Receipt table generalization for identity leaves (`tl_receipts`) | Nullable `identity_id` column in migration 003 | PR 2 | + +--- + +## 5. What this plan explicitly does NOT do + +- No change to the agent registration path — `AgentRegistration`, + `agent_registrations`, the ACME/CSR/BYOC flows, and `AGENT_*` event shapes + are byte-for-byte untouched (design §2.1, §5.1). +- No keyless *primary* agents (§2.3) — the policy guard stands; did:key + exercises the keyless path as a linked identity only. +- No cross-owner links, no `EquivalenceLink`, no `clientCatalog` (§2.3). +- No AIM/monitoring emission to the TL (§4.6 — operator alerting concern, + out of scope for this repo today). +- V1 RA/TL lanes frozen — zero identity surface on `/v1/agents/*` RA routes + or the V1 ingest lane. From 6418cff15bff4ef538f83a6de7d8091789d5e9bb Mon Sep 17 00:00:00 2001 From: Connor Snitker Date: Thu, 11 Jun 2026 18:29:03 -0500 Subject: [PATCH 05/13] feat(ra): seal-before-success for the identity lane + race guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd/ans-ra/main.go | 18 +- internal/adapter/docsui/openapi/ra.yaml | 100 +++- internal/adapter/store/sqlite/identity.go | 197 ++++++- .../adapter/store/sqlite/identity_test.go | 188 +++++- .../migrations/008_identity_seal_claim.sql | 10 + internal/adapter/tlclient/client.go | 28 + internal/adapter/tlclient/client_test.go | 54 ++ internal/config/config.go | 8 + internal/config/defaults.go | 2 + internal/crypto/jws.go | 14 + internal/crypto/jws_crit_test.go | 26 + internal/domain/errors.go | 10 + internal/domain/identity.go | 15 + internal/domain/identity_test.go | 28 + internal/port/store.go | 40 +- internal/ra/handler/errors.go | 4 + internal/ra/handler/errors_internal_test.go | 38 ++ internal/ra/handler/identity.go | 27 +- internal/ra/handler/identity_handler_test.go | 84 ++- internal/ra/service/identity.go | 420 ++++++++++---- internal/ra/service/identity_test.go | 536 +++++++++++++++--- internal/ra/service/identityratelimit.go | 19 +- spec/api-spec-v2.yaml | 100 +++- 23 files changed, 1741 insertions(+), 225 deletions(-) create mode 100644 internal/adapter/store/sqlite/migrations/008_identity_seal_claim.sql create mode 100644 internal/crypto/jws_crit_test.go create mode 100644 internal/ra/handler/errors_internal_test.go diff --git a/cmd/ans-ra/main.go b/cmd/ans-ra/main.go index bc1c032..1464706 100644 --- a/cmd/ans-ra/main.go +++ b/cmd/ans-ra/main.go @@ -187,15 +187,27 @@ func run(cfgPath string) error { // Verified identities — the "who" behind the agents. Shares the // producer signer with the registration service: one RA, one - // producer identity on every TL lane. + // producer identity on every TL lane. Identity events seal + // SYNCHRONOUSLY (seal-before-success, design §5.6.1): they never + // ride the outbox, so the service gets the TL client directly. A + // disabled TL client means identity sealing operations fail + // closed with TL_UNAVAILABLE — there is no "seal later" mode. + var identitySealer service.IdentityEventSealer + if !cfg.TLClient.Disabled { + identitySealer = tlclient.New(cfg.TLClient.BaseURL, cfg.TLClient.APIKey, cfg.TLClient.Timeout) + } else { + logger.Warn().Msg("TL client disabled — identity verify/revoke/link operations will fail with TL_UNAVAILABLE (seal-before-success)") + } identitySvc := service.NewIdentityService( - identityStore, identityLinks, agents, didResolver, outbox, db, + identityStore, identityLinks, agents, didResolver, identitySealer, db, ).WithSigner(service.EventSigner{ KeyManager: km, KeyID: signerKeyID, RaID: cfg.Signer.RaID, }).WithChallengeTTL(cfg.Identity.ChallengeTTL). - WithRegisterRateLimit(cfg.Identity.RegisterRateLimit) + WithRegisterRateLimit(cfg.Identity.RegisterRateLimit). + WithLinkRateLimit(cfg.Identity.LinkRateLimit). + WithSealTimeout(cfg.Identity.SealTimeout) // HTTP. r := chi.NewRouter() diff --git a/internal/adapter/docsui/openapi/ra.yaml b/internal/adapter/docsui/openapi/ra.yaml index 98073c1..88eef90 100644 --- a/internal/adapter/docsui/openapi/ra.yaml +++ b/internal/adapter/docsui/openapi/ra.yaml @@ -966,20 +966,39 @@ paths: tags: - Verified Identities summary: List my identities - description: Returns every identity owned by the caller, newest first. + description: | + Returns one page of the caller's identities, newest first, in + the standard limit + opaque-cursor envelope (the agent-list + convention). Pass `nextCursor` back as `cursor` for the next + page; a null `nextCursor` is the last page. operationId: listIdentities + parameters: + - name: limit + in: query + required: false + schema: { type: integer, default: 20, minimum: 1, maximum: 100 } + - name: cursor + in: query + required: false + schema: { type: string } + description: Opaque cursor from the previous page's nextCursor responses: '200': - description: The caller's identities + description: One page of the caller's identities content: application/json: schema: type: object + required: [identities, nextCursor] properties: identities: type: array items: $ref: '#/components/schemas/IdentityDetails' + nextCursor: + type: string + nullable: true + description: Cursor for the next page; null on the last page '401': description: Authentication failed content: @@ -1081,11 +1100,19 @@ paths: closed. The nonce is consumed exactly once, inside the success transaction — a failed attempt does not consume it. - On success the identity flips to `VERIFIED` (or completes a - staged rotation) and the RA seals `IDENTITY_VERIFIED` / + JWS bearing unrecognized `crit` header parameters are + rejected (RFC 7515 §4.1.11) — this verifier implements no + critical extensions. + + Seal-before-success: the RA seals `IDENTITY_VERIFIED` / `IDENTITY_UPDATED` on the identity's own Transparency Log - stream, with every proven key sealed self-verifyingly - (public key + signed proof). + stream — every proven key sealed self-verifyingly (public + key + signed proof) — and reports success ONLY after the TL + acknowledges the seal; the row transition commits with that + acknowledgment, so anything this API reports as set up is + resolvable in the TL at that moment. If the TL is + unavailable the call fails 503 `TL_UNAVAILABLE`, retryable: + the nonce is NOT consumed and the prior state stands. operationId: verifyIdentityControl parameters: - $ref: '#/components/parameters/IdentityIdPath' @@ -1118,8 +1145,12 @@ paths: description: | Challenge state error — `PRICC_TOKEN_EXPIRED`, `PRICC_TOKEN_ALREADY_USED`, `IDENTIFIER_CHALLENGE_EXPIRED` - — or the identity is revoked. Recovery from an expired - nonce is the idempotent re-add (re-POST the same value). + — the identity is revoked, a concurrent verify attempt + holds the seal claim (`VERIFICATION_IN_FLIGHT` — retry + shortly; the claim expires within ~30s), or the + identifier is already verified by another owner + (`IDENTIFIER_DUPLICATE`). Recovery from an expired nonce + is the idempotent re-add (re-POST the same value). content: application/json: schema: @@ -1134,6 +1165,13 @@ paths: schema: $ref: '#/components/schemas/ErrorResponse' + '503': + description: Transparency Log unavailable (TL_UNAVAILABLE — retryable; the nonce is NOT consumed) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /ans/identities/{identityId}/revoke: post: tags: @@ -1146,6 +1184,10 @@ paths: append-only in the Transparency Log. Propagation to every linked agent's badge is the TL's read-time join, not a write fan-out. + + Seal-before-success: the row flips only after the TL + acknowledges the seal. TL unavailable → 503 `TL_UNAVAILABLE`, + retryable, the identity stays VERIFIED. operationId: revokeIdentity parameters: - $ref: '#/components/parameters/IdentityIdPath' @@ -1174,6 +1216,12 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + '503': + description: Transparency Log unavailable (TL_UNAVAILABLE — retryable, nothing changed) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /ans/identities/{identityId}/links: post: @@ -1189,7 +1237,19 @@ paths: the agent ids; an agent's own stream and audit history are never written by identity operations. Already-linked agents are skipped idempotently; a call that links nothing new seals - nothing. Links attach only while the identity is VERIFIED. + nothing. + + Liveness gate: links attach only while the identity is + VERIFIED, and every named agent must be live — ACTIVE or + DEPRECATED; a terminal or pre-activation agent fails the + whole batch with 422 `AGENT_NOT_LINKABLE` (all-or-nothing, + matching the one-event batch semantics). The route is + per-owner rate limited (429-equivalent `RATE_LIMITED`). + + Seal-before-success: success is reported only after the TL + acknowledges the `IDENTITY_LINKED` seal; link rows commit + with the acknowledgment. TL unavailable → 503 + `TL_UNAVAILABLE`, retryable, nothing linked. operationId: linkIdentityAgents parameters: - $ref: '#/components/parameters/IdentityIdPath' @@ -1225,7 +1285,14 @@ paths: schema: $ref: '#/components/schemas/ErrorResponse' '422': - description: Invalid link request (empty or oversized batch) + description: Invalid link request (empty or oversized batch — INVALID_LINK_REQUEST), or a named agent is not live (AGENT_NOT_LINKABLE — links require ACTIVE or DEPRECATED; rejected all-or-nothing) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + '503': + description: Transparency Log unavailable (TL_UNAVAILABLE — retryable, nothing linked) content: application/json: schema: @@ -1240,7 +1307,12 @@ paths: Ends one association and seals `IDENTITY_UNLINKED` on the identity stream. The association's history persists in the identity's audit chain and the raw log tiles; unlinked pairs - may be re-linked later. + may be re-linked later. Shares the link route's per-owner + rate limit. + + Seal-before-success: the link row flips only after the TL + acknowledges the seal. TL unavailable → 503 `TL_UNAVAILABLE`, + retryable, the link stands. operationId: unlinkIdentityAgent parameters: - $ref: '#/components/parameters/IdentityIdPath' @@ -1260,6 +1332,12 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + '503': + description: Transparency Log unavailable (TL_UNAVAILABLE — retryable, nothing changed) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' components: diff --git a/internal/adapter/store/sqlite/identity.go b/internal/adapter/store/sqlite/identity.go index daf56af..ea35bc4 100644 --- a/internal/adapter/store/sqlite/identity.go +++ b/internal/adapter/store/sqlite/identity.go @@ -3,10 +3,12 @@ package sqlite import ( "context" "database/sql" + "encoding/base64" "errors" "time" "github.com/godaddy/ans/internal/domain" + "github.com/godaddy/ans/internal/port" ) // IdentityStore implements port.IdentityStore. @@ -94,6 +96,12 @@ func (s *IdentityStore) Save(ctx context.Context, v *domain.VerifiedIdentity) er challenge_nonce = excluded.challenge_nonce, challenge_expires_at_ms = excluded.challenge_expires_at_ms, challenge_consumed_at_ms = excluded.challenge_consumed_at_ms, + -- Every save resets the provisional seal claim: a save + -- either issues a new challenge (the claim must not leak + -- into the new nonce's epoch), commits a verified flip + -- (nonce consumed, claim moot), or revokes (challenge + -- moot). See ClaimChallenge. + challenge_claimed_at_ms = NULL, verified_at_ms = excluded.verified_at_ms, updated_at_ms = excluded.updated_at_ms` _, err := s.db.extx(ctx).ExecContext(ctx, q, @@ -155,21 +163,53 @@ func (s *IdentityStore) ExistsVerified(ctx context.Context, kind domain.Identifi // ListByOwner returns every identity owned by the principal, newest // first. -func (s *IdentityStore) ListByOwner(ctx context.Context, providerID string) ([]*domain.VerifiedIdentity, error) { +func (s *IdentityStore) ListByOwner(ctx context.Context, providerID string, limit int, cursor string) (*port.CursorPage[*domain.VerifiedIdentity], error) { var rows []identityRow - err := s.db.extx(ctx).SelectContext(ctx, &rows, - `SELECT `+identityCols+` + // identity_id is a UUIDv7 — lexicographic order IS creation + // order — so the id doubles as a stable cursor. + const defaultLimit, maxLimit = 20, 100 + if limit <= 0 { + limit = defaultLimit + } + if limit > maxLimit { + limit = maxLimit + } + q := `SELECT ` + identityCols + ` FROM identities - WHERE provider_id = ? - ORDER BY created_at_ms DESC, identity_id DESC`, providerID) + WHERE provider_id = ?` + args := []any{providerID} + if cursor != "" { + raw, err := base64.RawURLEncoding.DecodeString(cursor) + if err != nil { + return nil, domain.NewValidationError("INVALID_CURSOR", "malformed cursor") + } + q += ` AND identity_id < ?` + args = append(args, string(raw)) + } + q += ` ORDER BY identity_id DESC LIMIT ?` + args = append(args, limit+1) + + err := s.db.extx(ctx).SelectContext(ctx, &rows, q, args...) if err != nil { return nil, mapSQLErr(err) } + hasMore := len(rows) > limit + if hasMore { + rows = rows[:limit] + } out := make([]*domain.VerifiedIdentity, 0, len(rows)) for _, r := range rows { out = append(out, r.toDomain()) } - return out, nil + page := &port.CursorPage[*domain.VerifiedIdentity]{ + Items: out, + HasMore: hasMore, + ReturnedCount: len(out), + } + if hasMore && len(out) > 0 { + page.NextCursor = base64.RawURLEncoding.EncodeToString([]byte(out[len(out)-1].IdentityID)) + } + return page, nil } // ConsumeChallenge atomically consumes the live challenge nonce. The @@ -199,6 +239,151 @@ func (s *IdentityStore) ConsumeChallenge(ctx context.Context, identityID, nonce return nil } +// ClaimChallenge takes the short-TTL provisional claim that +// serializes concurrent verify-control attempts across the +// seal-before-success TL round trip (design §5.6.1). The conditional +// UPDATE succeeds only while the nonce is live, unconsumed, and not +// claimed by an attempt fresher than staleBefore — a crashed +// claimer's stale claim is reclaimable. A claim is NOT consumption. +func (s *IdentityStore) ClaimChallenge(ctx context.Context, identityID, nonce string, now, staleBefore time.Time) error { + res, err := s.db.extx(ctx).ExecContext(ctx, ` + UPDATE identities + SET challenge_claimed_at_ms = ?, updated_at_ms = ? + WHERE identity_id = ? + AND challenge_nonce = ? + AND challenge_consumed_at_ms IS NULL + AND challenge_expires_at_ms > ? + AND (challenge_claimed_at_ms IS NULL OR challenge_claimed_at_ms < ?)`, + now.UnixMilli(), now.UnixMilli(), identityID, nonce, + now.UnixMilli(), staleBefore.UnixMilli()) + if err != nil { + return mapSQLErr(err) + } + n, err := res.RowsAffected() + if err != nil { + return err + } + if n != 1 { + return domain.NewInvalidStateError("VERIFICATION_IN_FLIGHT", + "a verify-control attempt for this challenge is already in flight, or the nonce is consumed, expired, or superseded") + } + return nil +} + +// ReleaseChallenge releases a provisional claim after a failed +// attempt (failed attempts never consume — §3.2). Best-effort: a +// consumed or superseded nonce matches zero rows, which is fine. +func (s *IdentityStore) ReleaseChallenge(ctx context.Context, identityID, nonce string) error { + _, err := s.db.extx(ctx).ExecContext(ctx, ` + UPDATE identities + SET challenge_claimed_at_ms = NULL + WHERE identity_id = ? + AND challenge_nonce = ? + AND challenge_consumed_at_ms IS NULL`, + identityID, nonce) + if err != nil { + return mapSQLErr(err) + } + return nil +} + +// StageChallenge persists a freshly issued challenge onto an +// existing row, conditional on the load-time snapshot: the status +// must be unchanged, the nonce must still be the one observed (a +// concurrent verify consumes it; a concurrent re-add supersedes it), +// and no fresh seal claim may be live (an in-flight verify must not +// have its nonce yanked mid-seal). Status, value, and verified_at +// are deliberately NOT written — issuing a challenge is not a state +// transition, so a racing commit can never be clobbered. +func (s *IdentityStore) StageChallenge( + ctx context.Context, + v *domain.VerifiedIdentity, + expectedStatus domain.IdentityStatus, + expectedNonce string, + staleBefore time.Time, +) error { + if v.Challenge == nil { + return domain.NewInternalError("CHALLENGE_STAGE", "no challenge on the aggregate", errors.New("nil challenge")) + } + var expires sql.NullInt64 + if !v.Challenge.ExpiresAt.IsZero() { + expires = sql.NullInt64{Int64: v.Challenge.ExpiresAt.UnixMilli(), Valid: true} + } + res, err := s.db.extx(ctx).ExecContext(ctx, ` + UPDATE identities + SET challenge_nonce = ?, + challenge_expires_at_ms = ?, + challenge_consumed_at_ms = NULL, + challenge_claimed_at_ms = NULL, + pending_value = ?, + updated_at_ms = ? + WHERE identity_id = ? + AND status = ? + AND (challenge_nonce = ? OR (challenge_nonce IS NULL AND ? = '')) + AND (challenge_claimed_at_ms IS NULL OR challenge_claimed_at_ms < ?)`, + v.Challenge.Nonce, expires, v.PendingValue, v.UpdatedAt.UnixMilli(), + v.IdentityID, string(expectedStatus), expectedNonce, expectedNonce, + staleBefore.UnixMilli()) + if err != nil { + return mapSQLErr(err) + } + n, err := res.RowsAffected() + if err != nil { + return err + } + if n == 1 { + return nil + } + // Lost the race — re-read for the precise refusal. + current, rerr := s.FindByID(ctx, v.IdentityID) + if rerr != nil { + return rerr + } + switch { + case current.Status == domain.IdentityRevoked: + return domain.NewInvalidStateError("IDENTITY_REVOKED", "identity was revoked") + case current.Status == domain.IdentityVerified && expectedStatus != domain.IdentityVerified: + return domain.NewConflictError("IDENTIFIER_DUPLICATE", + "identifier was verified concurrently; rotate it with PUT instead") + default: + return domain.NewConflictError("VERIFICATION_IN_FLIGHT", + "a verify-control attempt is in flight or the challenge was superseded; retry shortly") + } +} + +// MarkRevoked is Revoke's conditional Phase C commit: flip VERIFIED +// → REVOKED, clearing the rotation stage and challenge state, only +// while the row is still VERIFIED — a verify/rotate that committed +// during the revoke's seal round trip is never overwritten with the +// revoker's stale snapshot. Zero rows → conflict (the caller's view +// was stale; the sealed IDENTITY_REVOKED is the benign residue the +// retry converges with). +func (s *IdentityStore) MarkRevoked(ctx context.Context, identityID string, now time.Time) error { + res, err := s.db.extx(ctx).ExecContext(ctx, ` + UPDATE identities + SET status = 'REVOKED', + pending_value = '', + challenge_nonce = NULL, + challenge_expires_at_ms = NULL, + challenge_consumed_at_ms = NULL, + challenge_claimed_at_ms = NULL, + updated_at_ms = ? + WHERE identity_id = ? AND status = 'VERIFIED'`, + now.UnixMilli(), identityID) + if err != nil { + return mapSQLErr(err) + } + n, err := res.RowsAffected() + if err != nil { + return err + } + if n != 1 { + return domain.NewConflictError("IDENTITY_CONCURRENTLY_MODIFIED", + "identity changed during the revoke; re-read and retry") + } + return nil +} + // IdentityLinkStore implements port.IdentityLinkStore. type IdentityLinkStore struct{ db *DB } diff --git a/internal/adapter/store/sqlite/identity_test.go b/internal/adapter/store/sqlite/identity_test.go index 7c90608..9644f62 100644 --- a/internal/adapter/store/sqlite/identity_test.go +++ b/internal/adapter/store/sqlite/identity_test.go @@ -191,13 +191,30 @@ func TestIdentityStore_ListByOwner(t *testing.T) { } } - got, err := store.ListByOwner(ctx, "owner-1") + page, err := store.ListByOwner(ctx, "owner-1", 0, "") if err != nil { t.Fatal(err) } + got := page.Items if len(got) != 2 || got[0].IdentityID != "id-2" || got[1].IdentityID != "id-1" { t.Fatalf("list wrong: %+v", got) } + if page.HasMore || page.NextCursor != "" { + t.Fatalf("two rows fit one default page: %+v", page) + } + + // Cursor pagination: limit 1 → first row + cursor → second row. + first1, err := store.ListByOwner(ctx, "owner-1", 1, "") + if err != nil || len(first1.Items) != 1 || first1.Items[0].IdentityID != "id-2" || !first1.HasMore { + t.Fatalf("page 1: %+v (%v)", first1, err) + } + page2, err := store.ListByOwner(ctx, "owner-1", 1, first1.NextCursor) + if err != nil || len(page2.Items) != 1 || page2.Items[0].IdentityID != "id-1" || page2.HasMore { + t.Fatalf("page 2: %+v (%v)", page2, err) + } + if _, err := store.ListByOwner(ctx, "owner-1", 1, "%%%not-base64%%%"); err == nil { + t.Fatal("malformed cursor must be rejected") + } } func TestIdentityStore_ConsumeChallenge(t *testing.T) { @@ -297,3 +314,172 @@ func TestIdentityLinkStore_Lifecycle(t *testing.T) { t.Fatalf("re-link after unlink: created=%v err=%v", created, err) } } + +func TestIdentityStore_ClaimAndReleaseChallenge(t *testing.T) { + db := newTestDB(t) + store := NewIdentityStore(db) + ctx := context.Background() + + v := newIdentityFixture(t, "id-claim", "owner-1", "did:web:claim.example.com") + if err := v.IssueChallenge("nonce-c", time.Hour, identityNow); err != nil { + t.Fatal(err) + } + if err := store.Save(ctx, v); err != nil { + t.Fatal(err) + } + + now := identityNow.Add(time.Minute) + staleBefore := now.Add(-30 * time.Second) + + // First claim wins; a second concurrent claim loses while the + // first is fresh. + if err := store.ClaimChallenge(ctx, "id-claim", "nonce-c", now, staleBefore); err != nil { + t.Fatalf("claim: %v", err) + } + err := store.ClaimChallenge(ctx, "id-claim", "nonce-c", now.Add(time.Second), now.Add(time.Second).Add(-30*time.Second)) + if err == nil || !strings.Contains(err.Error(), "VERIFICATION_IN_FLIGHT") { + t.Fatalf("second claim must lose: %v", err) + } + + // Release → claimable again (failed attempts never consume). + if err := store.ReleaseChallenge(ctx, "id-claim", "nonce-c"); err != nil { + t.Fatal(err) + } + if err := store.ClaimChallenge(ctx, "id-claim", "nonce-c", now.Add(2*time.Second), now.Add(2*time.Second).Add(-30*time.Second)); err != nil { + t.Fatalf("re-claim after release: %v", err) + } + + // A stale claim (crashed claimer) is reclaimable after the TTL. + later := now.Add(time.Minute) + if err := store.ClaimChallenge(ctx, "id-claim", "nonce-c", later, later.Add(-30*time.Second)); err != nil { + t.Fatalf("stale claim must be reclaimable: %v", err) + } + + // Consumption beats any claim; a consumed nonce is unclaimable. + if err := store.ConsumeChallenge(ctx, "id-claim", "nonce-c", later.Add(time.Second)); err != nil { + t.Fatalf("consume: %v", err) + } + if err := store.ClaimChallenge(ctx, "id-claim", "nonce-c", later.Add(2*time.Second), later.Add(2*time.Second)); err == nil { + t.Fatal("consumed nonce must not be claimable") + } + // Releasing a consumed nonce is a harmless no-op. + if err := store.ReleaseChallenge(ctx, "id-claim", "nonce-c"); err != nil { + t.Fatal(err) + } + + // A fresh challenge (Save) resets any prior claim epoch. + if err := v.IssueChallenge("nonce-d", time.Hour, later); err != nil { + t.Fatal(err) + } + if err := store.Save(ctx, v); err != nil { + t.Fatal(err) + } + fresh := later.Add(3 * time.Second) + if err := store.ClaimChallenge(ctx, "id-claim", "nonce-d", fresh, fresh.Add(-30*time.Second)); err != nil { + t.Fatalf("new nonce epoch must be claimable: %v", err) + } +} + +func TestIdentityStore_StageChallengeOptimisticConcurrency(t *testing.T) { + db := newTestDB(t) + store := NewIdentityStore(db) + ctx := context.Background() + + v := newIdentityFixture(t, "id-stage", "owner-1", "did:web:stage.example.com") + if err := v.IssueChallenge("nonce-1", time.Hour, identityNow); err != nil { + t.Fatal(err) + } + if err := store.Save(ctx, v); err != nil { + t.Fatal(err) + } + now := identityNow.Add(time.Minute) + stale := now.Add(-30 * time.Second) + + // Happy path: snapshot matches → fresh nonce persists, status + // untouched. + loaded, _ := store.FindByID(ctx, "id-stage") + if err := loaded.IssueChallenge("nonce-2", time.Hour, now); err != nil { + t.Fatal(err) + } + if err := store.StageChallenge(ctx, loaded, domain.IdentityPendingControl, "nonce-1", stale); err != nil { + t.Fatalf("stage: %v", err) + } + got, _ := store.FindByID(ctx, "id-stage") + if got.Challenge.Nonce != "nonce-2" || got.Status != domain.IdentityPendingControl { + t.Fatalf("staged state: %+v", got) + } + + // A stale snapshot (nonce superseded) is refused — never clobbers. + stale2 := loaded + if err := stale2.IssueChallenge("nonce-3", time.Hour, now); err != nil { + t.Fatal(err) + } + err := store.StageChallenge(ctx, stale2, domain.IdentityPendingControl, "nonce-1", stale) + if err == nil || !strings.Contains(err.Error(), "VERIFICATION_IN_FLIGHT") { + t.Fatalf("superseded snapshot must be refused: %v", err) + } + + // A live seal claim blocks re-challenge (it would yank the nonce + // out from under an in-flight verify). + if err := store.ClaimChallenge(ctx, "id-stage", "nonce-2", now, stale); err != nil { + t.Fatal(err) + } + fresh, _ := store.FindByID(ctx, "id-stage") + if err := fresh.IssueChallenge("nonce-4", time.Hour, now); err != nil { + t.Fatal(err) + } + err = store.StageChallenge(ctx, fresh, domain.IdentityPendingControl, "nonce-2", stale) + if err == nil || !strings.Contains(err.Error(), "VERIFICATION_IN_FLIGHT") { + t.Fatalf("live claim must block re-challenge: %v", err) + } + + // A concurrently committed VERIFIED flip maps to the precise + // duplicate refusal. + if err := store.ReleaseChallenge(ctx, "id-stage", "nonce-2"); err != nil { + t.Fatal(err) + } + committed, _ := store.FindByID(ctx, "id-stage") + committed.Status = domain.IdentityVerified + committed.VerifiedAt = now + if err := store.Save(ctx, committed); err != nil { + t.Fatal(err) + } + again, _ := store.FindByID(ctx, "id-stage") + again.Status = domain.IdentityPendingControl // simulate the stale loader's view + if err := again.IssueChallenge("nonce-5", time.Hour, now); err != nil { + t.Fatal(err) + } + err = store.StageChallenge(ctx, again, domain.IdentityPendingControl, "nonce-2", stale) + if err == nil || !strings.Contains(err.Error(), "IDENTIFIER_DUPLICATE") { + t.Fatalf("concurrent verify must map to IDENTIFIER_DUPLICATE: %v", err) + } +} + +func TestIdentityStore_MarkRevokedConditional(t *testing.T) { + db := newTestDB(t) + store := NewIdentityStore(db) + ctx := context.Background() + + v := newIdentityFixture(t, "id-mr", "owner-1", "did:web:mr.example.com") + v.Status = domain.IdentityVerified + v.VerifiedAt = identityNow + if err := store.Save(ctx, v); err != nil { + t.Fatal(err) + } + now := identityNow.Add(time.Minute) + + if err := store.MarkRevoked(ctx, "id-mr", now); err != nil { + t.Fatalf("revoke: %v", err) + } + got, _ := store.FindByID(ctx, "id-mr") + if got.Status != domain.IdentityRevoked || got.PendingValue != "" || got.Challenge != nil { + t.Fatalf("revoked state: %+v", got) + } + + // Second revoke (or revoke of a non-VERIFIED row): conflict, no + // clobber. + err := store.MarkRevoked(ctx, "id-mr", now.Add(time.Second)) + if err == nil || !strings.Contains(err.Error(), "IDENTITY_CONCURRENTLY_MODIFIED") { + t.Fatalf("conditional revoke must conflict on a non-VERIFIED row: %v", err) + } +} diff --git a/internal/adapter/store/sqlite/migrations/008_identity_seal_claim.sql b/internal/adapter/store/sqlite/migrations/008_identity_seal_claim.sql new file mode 100644 index 0000000..a263366 --- /dev/null +++ b/internal/adapter/store/sqlite/migrations/008_identity_seal_claim.sql @@ -0,0 +1,10 @@ +-- Seal-before-success (design §5.6.1 item 6): identity operations +-- return success only after the TL acknowledges the seal, so the +-- verify path now spans a network call. challenge_claimed_at_ms is a +-- short-TTL provisional claim taken BEFORE the seal — it serializes +-- concurrent verify-control attempts on one nonce so at most one +-- in-flight attempt can seal (the conditional consume remains the +-- authoritative guard at commit). A claim is NOT consumption: failed +-- attempts release it, and a stale claim (crashed claimer) is +-- reclaimable after the claim TTL. +ALTER TABLE identities ADD COLUMN challenge_claimed_at_ms INTEGER; diff --git a/internal/adapter/tlclient/client.go b/internal/adapter/tlclient/client.go index d898edc..ca21ab4 100644 --- a/internal/adapter/tlclient/client.go +++ b/internal/adapter/tlclient/client.go @@ -35,6 +35,8 @@ import ( "io" "net/http" "time" + + "github.com/godaddy/ans/internal/domain" ) // AppendResult is the parsed success response from the TL's ingest @@ -238,3 +240,29 @@ func IsPermanent(err error) bool { var p *PermanentError return errors.As(err, &p) } + +// SealIdentityEvent submits a producer-signed identity event on the +// IDENTITY lane and returns only after the TL acknowledges the seal — +// the client half of seal-before-success (design §5.6.1). Identity +// events never ride the outbox: delivery precedes success, and a +// failed delivery IS a failed operation, so errors are mapped to +// domain kinds the service layer can return directly: +// +// - transient (transport, 5xx, 429) → ErrUnavailable / 503 +// TL_UNAVAILABLE: retryable, the caller consumed nothing; +// - permanent (other 4xx) → ErrInternal / +// TL_REJECTED_EVENT: the RA produced an event the TL refuses — +// a pipeline bug, not weather; operators must see it. +func (c *Client) SealIdentityEvent(ctx context.Context, innerCanonical []byte, producerSig string) error { + _, err := c.Append(ctx, "IDENTITY", innerCanonical, producerSig) + switch { + case err == nil: + return nil + case IsTransient(err): + return domain.NewUnavailableError("TL_UNAVAILABLE", + "the transparency log did not confirm the seal; the operation is retryable and nothing was consumed") + default: + return domain.NewInternalError("TL_REJECTED_EVENT", + "the transparency log rejected the identity event", err) + } +} diff --git a/internal/adapter/tlclient/client_test.go b/internal/adapter/tlclient/client_test.go index 8e529f6..33b1ddb 100644 --- a/internal/adapter/tlclient/client_test.go +++ b/internal/adapter/tlclient/client_test.go @@ -10,6 +10,8 @@ import ( "time" "github.com/godaddy/ans/internal/adapter/tlclient" + + "github.com/godaddy/ans/internal/domain" ) func TestAppend_Created(t *testing.T) { @@ -273,3 +275,55 @@ func TestAppend_UnknownSchemaVersion(t *testing.T) { t.Fatal("expected error on unknown schemaVersion") } } + +// TestSealIdentityEvent_DomainErrorMapping pins the seal-before- +// success client wrapper: success on TL ack; transient failures map +// to ErrUnavailable (TL_UNAVAILABLE — retryable, nothing consumed); +// TL schema rejections map to an internal TL_REJECTED_EVENT. +func TestSealIdentityEvent_DomainErrorMapping(t *testing.T) { + t.Parallel() + + t.Run("acknowledged seal succeeds on the IDENTITY lane", func(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/internal/identities/event" { + t.Errorf("wrong ingest path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"logId":"01900000-0000-7000-8000-000000000002","success":true,"leafIndex":1,"leafHashHex":"ab","treeSize":2}`)) + })) + defer srv.Close() + c := tlclient.New(srv.URL, "test-key", 5*time.Second) + if err := c.SealIdentityEvent(context.Background(), []byte(`{"eventType":"IDENTITY_VERIFIED"}`), "sig..jws"); err != nil { + t.Fatalf("seal: %v", err) + } + }) + + t.Run("5xx maps to ErrUnavailable", func(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadGateway) + })) + defer srv.Close() + c := tlclient.New(srv.URL, "test-key", 5*time.Second) + err := c.SealIdentityEvent(context.Background(), []byte(`{}`), "sig..jws") + if !errors.Is(err, domain.ErrUnavailable) { + t.Fatalf("want ErrUnavailable, got %v", err) + } + }) + + t.Run("4xx maps to internal TL_REJECTED_EVENT", func(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"detail":"INVALID_EVENT"}`)) + })) + defer srv.Close() + c := tlclient.New(srv.URL, "test-key", 5*time.Second) + err := c.SealIdentityEvent(context.Background(), []byte(`{}`), "sig..jws") + var de *domain.Error + if !errors.As(err, &de) || de.Code != "TL_REJECTED_EVENT" { + t.Fatalf("want TL_REJECTED_EVENT, got %v", err) + } + }) +} diff --git a/internal/config/config.go b/internal/config/config.go index e7c813e..0f025e2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -120,6 +120,14 @@ type Identity struct { // minute (default 10) — each call can trigger an outbound // did:web fetch before any proof exists. RegisterRateLimit int `koanf:"register-rate-limit"` + // LinkRateLimit is the per-owner link/unlink budget per minute + // (default 60) — operational hardening on the link route + // (design §4.3). + LinkRateLimit int `koanf:"link-rate-limit"` + // SealTimeout bounds the inline TL seal call identity operations + // make before reporting success (seal-before-success, design + // §5.6.1). Default 5s. + SealTimeout time.Duration `koanf:"seal-timeout"` } // IdentityResolver selects the did:web resolver adapter. "noop" diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 00ee8df..f12e9e7 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -24,6 +24,8 @@ func defaultRAConfig() *RAConfig { Resolver: IdentityResolver{Type: "noop"}, ChallengeTTL: time.Hour, RegisterRateLimit: 10, + LinkRateLimit: 60, + SealTimeout: 5 * time.Second, }, Keys: Keys{ Type: "file", diff --git a/internal/crypto/jws.go b/internal/crypto/jws.go index 14c1490..01d789d 100644 --- a/internal/crypto/jws.go +++ b/internal/crypto/jws.go @@ -70,6 +70,14 @@ type JWSProtectedHeader struct { // resolver ignores it — the authoritatively resolved document is // always the key source. Never set on producer/TL signatures. Jwk json.RawMessage `json:"jwk,omitempty"` + + // Crit (RFC 7515 §4.1.11) names header parameters the signer + // requires verifiers to understand. This implementation supports + // no critical extensions, so decode REJECTS any JWS bearing a + // non-empty crit — required behavior for a conforming verifier, + // and the design's third-party verification recipe (§5.5) states + // the same rule. Never emitted on the signing path. + Crit []string `json:"crit,omitempty"` } // SignDetachedJWS produces a detached JWS — compact-serialization @@ -454,6 +462,12 @@ func decodeHeader(encodedHeader string) (*JWSProtectedHeader, error) { if err := json.Unmarshal(headerJSON, &h); err != nil { return nil, fmt.Errorf("%w: parse header: %w", ErrJWSDecode, err) } + // RFC 7515 §4.1.11: a verifier MUST reject a JWS whose crit names + // extensions it does not implement — and this implementation + // implements none. + if len(h.Crit) > 0 { + return nil, fmt.Errorf("%w: unsupported critical header parameters %v", ErrJWSDecode, h.Crit) + } return &h, nil } diff --git a/internal/crypto/jws_crit_test.go b/internal/crypto/jws_crit_test.go new file mode 100644 index 0000000..6886bfb --- /dev/null +++ b/internal/crypto/jws_crit_test.go @@ -0,0 +1,26 @@ +package crypto_test + +import ( + "encoding/base64" + "strings" + "testing" + + anscrypto "github.com/godaddy/ans/internal/crypto" +) + +// TestDecodeStandardJWS_RejectsCrit pins RFC 7515 §4.1.11: this +// verifier implements no critical extensions, so any JWS bearing a +// crit header is rejected at decode — before key selection, before +// signature verification (design §5.5 third-party recipe). +func TestDecodeStandardJWS_RejectsCrit(t *testing.T) { + t.Parallel() + header := base64.RawURLEncoding.EncodeToString( + []byte(`{"alg":"ES256","kid":"did:web:a.com#k1","crit":["exp"],"exp":1}`)) + payload := base64.RawURLEncoding.EncodeToString([]byte(`{}`)) + jws := header + "." + payload + ".c2ln" + + _, _, err := anscrypto.DecodeStandardJWS(jws) + if err == nil || !strings.Contains(err.Error(), "critical header") { + t.Fatalf("crit must be rejected, got %v", err) + } +} diff --git a/internal/domain/errors.go b/internal/domain/errors.go index 66e977e..a89386f 100644 --- a/internal/domain/errors.go +++ b/internal/domain/errors.go @@ -16,6 +16,11 @@ var ( ErrUnauthorized = errors.New("unauthorized") ErrCertificate = errors.New("certificate") ErrInternal = errors.New("internal") + // ErrUnavailable marks a transient upstream failure: the request + // was valid but a dependency (the TL, the vlei-verifier) could + // not confirm it. Maps to 503; the operation is retryable and no + // state was consumed. + ErrUnavailable = errors.New("unavailable") ) // Error is the base error type for all domain errors. @@ -65,6 +70,11 @@ func NewInternalError(code, message string, cause error) *Error { return &Error{Code: code, Message: message, Cause: fmt.Errorf("%w: %w", ErrInternal, cause)} } +// NewUnavailableError creates a retryable upstream-unavailable error. +func NewUnavailableError(code, message string) *Error { + return &Error{Code: code, Message: message, Cause: ErrUnavailable} +} + // Error implements the error interface. func (e *Error) Error() string { if e.Cause != nil { diff --git a/internal/domain/identity.go b/internal/domain/identity.go index 6868259..85e0dc0 100644 --- a/internal/domain/identity.go +++ b/internal/domain/identity.go @@ -210,6 +210,21 @@ func canonicalizeDIDWeb(value string) (string, error) { if seg == "" { return "", NewValidationError("DID_BAD_FORMAT", "did:web has an empty path segment") } + // Dot segments would re-path the resolution URL; control + // bytes (NUL first among them) have no legitimate use in a + // path segment. The same parsed segments feed both the + // resolution URL and the SSRF dialer check (§3.6/§3.7), so + // the rejection happens exactly once, here. + if seg == "." || seg == ".." { + return "", NewValidationError("DID_BAD_FORMAT", + "did:web path segments must not be '.' or '..'") + } + for _, r := range seg { + if r < 0x20 || r == 0x7f { + return "", NewValidationError("DID_BAD_FORMAT", + "did:web path segment contains a control character") + } + } } canonical := "did:web:" + host if len(segments) > 1 { diff --git a/internal/domain/identity_test.go b/internal/domain/identity_test.go index ea0644f..88000f9 100644 --- a/internal/domain/identity_test.go +++ b/internal/domain/identity_test.go @@ -1,6 +1,7 @@ package domain import ( + "errors" "strings" "testing" "time" @@ -280,3 +281,30 @@ func TestEffectiveValueWithoutPending(t *testing.T) { t.Fatal("effective value should be the proven value when nothing is staged") } } + +// TestInferIdentifierKind_DIDWebSegmentRejections pins the §3.6 +// per-segment rejection list: '.', '..', and control characters are +// DID_BAD_FORMAT (empty segments, '%', '@', '/', and ports are pinned +// by the existing grammar tests). +func TestInferIdentifierKind_DIDWebSegmentRejections(t *testing.T) { + t.Parallel() + for _, value := range []string{ + "did:web:acme-corp.com:.", + "did:web:acme-corp.com:..", + "did:web:acme-corp.com:..:identity", + "did:web:acme-corp.com:iden\x00tity", + "did:web:acme-corp.com:iden\ttity", + } { + _, _, err := InferIdentifierKind(value) + var de *Error + if !errors.As(err, &de) || de.Code != "DID_BAD_FORMAT" { + t.Fatalf("%q: want DID_BAD_FORMAT, got %v", value, err) + } + } + + // Sane multi-segment forms still canonicalize. + kind, canonical, err := InferIdentifierKind("did:web:Acme-Corp.com:user:alice") + if err != nil || kind != KindDIDWeb || canonical != "did:web:acme-corp.com:user:alice" { + t.Fatalf("multi-segment: %v %v %v", kind, canonical, err) + } +} diff --git a/internal/port/store.go b/internal/port/store.go index b103b82..4c2ce43 100644 --- a/internal/port/store.go +++ b/internal/port/store.go @@ -175,9 +175,10 @@ type IdentityStore interface { // verify time. ExistsVerified(ctx context.Context, kind domain.IdentifierKind, value string) (bool, error) - // ListByOwner returns every identity owned by the principal, - // newest first. - ListByOwner(ctx context.Context, providerID string) ([]*domain.VerifiedIdentity, error) + // ListByOwner returns the principal's identities, newest first, + // cursor-paginated with the same opaque-cursor convention as the + // agent list (§5.6.1 pagination inherits, never invents). + ListByOwner(ctx context.Context, providerID string, limit int, cursor string) (*CursorPage[*domain.VerifiedIdentity], error) // ConsumeChallenge atomically consumes the live challenge nonce: // a conditional update that succeeds only while the stored nonce @@ -186,6 +187,39 @@ type IdentityStore interface { // the loser receives an invalid-state error. MUST be called // inside the verify-control success transaction. ConsumeChallenge(ctx context.Context, identityID, nonce string, now time.Time) error + + // ClaimChallenge takes the short-TTL provisional claim on the + // live nonce before the seal-before-success TL round trip + // (design §5.6.1): a conditional update that succeeds only while + // the nonce matches, is unconsumed, and is not already claimed + // by an attempt fresher than staleBefore. Serializes concurrent + // verify-control attempts so at most one can seal; the loser + // gets an invalid-state error. A claim is NOT consumption. + ClaimChallenge(ctx context.Context, identityID, nonce string, now, staleBefore time.Time) error + + // ReleaseChallenge releases a provisional claim after a failed + // attempt so the registrant can retry until the nonce expires + // (§3.2 failed-attempts-don't-consume). Best-effort: releasing + // an already-consumed or superseded nonce is a no-op. + ReleaseChallenge(ctx context.Context, identityID, nonce string) error + + // StageChallenge persists a freshly issued challenge (and any + // staged pending_value) onto an EXISTING row, conditionally on + // the status and nonce observed at load time and on no live seal + // claim — the optimistic-concurrency guard that stops a re-add / + // rotate from clobbering a concurrently committed verify or + // revoke (their commits race the resolver fetch between load and + // persist). Never writes status, value, or verified_at: issuing + // a challenge is not a state transition. A failed condition maps + // to a precise conflict error. + StageChallenge(ctx context.Context, identity *domain.VerifiedIdentity, expectedStatus domain.IdentityStatus, expectedNonce string, staleBefore time.Time) error + + // MarkRevoked flips a VERIFIED row to REVOKED conditionally — + // the seal-before-success Phase C commit for Revoke. The status + // condition (not a blind save) means a verify or rotate that + // committed during the revoke's TL round trip is never clobbered + // with stale column values. Zero rows → conflict. + MarkRevoked(ctx context.Context, identityID string, now time.Time) error } // IdentityLinkStore persists identity↔agent link rows. Rows are diff --git a/internal/ra/handler/errors.go b/internal/ra/handler/errors.go index f7fc75e..c3e2574 100644 --- a/internal/ra/handler/errors.go +++ b/internal/ra/handler/errors.go @@ -73,6 +73,8 @@ func statusForCause(cause error) int { return http.StatusUnprocessableEntity case errors.Is(cause, domain.ErrUnauthorized): return http.StatusForbidden + case errors.Is(cause, domain.ErrUnavailable): + return http.StatusServiceUnavailable default: return http.StatusInternalServerError } @@ -92,6 +94,8 @@ func titleForCause(cause error) string { return "Certificate Error" case errors.Is(cause, domain.ErrUnauthorized): return "Forbidden" + case errors.Is(cause, domain.ErrUnavailable): + return "Service Unavailable" default: return "Internal Server Error" } diff --git a/internal/ra/handler/errors_internal_test.go b/internal/ra/handler/errors_internal_test.go new file mode 100644 index 0000000..9ddc2a4 --- /dev/null +++ b/internal/ra/handler/errors_internal_test.go @@ -0,0 +1,38 @@ +package handler + +import ( + "errors" + "net/http" + "testing" + + "github.com/godaddy/ans/internal/domain" +) + +// TestStatusAndTitleForCause pins the sentinel→HTTP mapping table, +// including the seal-before-success 503 (ErrUnavailable: retryable, +// nothing consumed) and the unknown-error fallthrough. +func TestStatusAndTitleForCause(t *testing.T) { + t.Parallel() + cases := []struct { + cause error + status int + title string + }{ + {domain.ErrValidation, http.StatusUnprocessableEntity, "Validation Failed"}, + {domain.ErrNotFound, http.StatusNotFound, "Not Found"}, + {domain.ErrConflict, http.StatusConflict, "Conflict"}, + {domain.ErrInvalidState, http.StatusConflict, "Invalid State"}, + {domain.ErrCertificate, http.StatusUnprocessableEntity, "Certificate Error"}, + {domain.ErrUnauthorized, http.StatusForbidden, "Forbidden"}, + {domain.ErrUnavailable, http.StatusServiceUnavailable, "Service Unavailable"}, + {errors.New("mystery"), http.StatusInternalServerError, "Internal Server Error"}, + } + for _, tc := range cases { + if got := statusForCause(tc.cause); got != tc.status { + t.Errorf("status(%v) = %d, want %d", tc.cause, got, tc.status) + } + if got := titleForCause(tc.cause); got != tc.title { + t.Errorf("title(%v) = %q, want %q", tc.cause, got, tc.title) + } + } +} diff --git a/internal/ra/handler/identity.go b/internal/ra/handler/identity.go index bb9fb58..7dc1d8f 100644 --- a/internal/ra/handler/identity.go +++ b/internal/ra/handler/identity.go @@ -3,6 +3,7 @@ package handler import ( "encoding/json" "net/http" + "strconv" "time" "github.com/go-chi/chi/v5" @@ -176,22 +177,38 @@ func (h *IdentityHandler) Revoke(w http.ResponseWriter, r *http.Request) { WriteJSON(w, http.StatusOK, toDetailResponse(identity, nil)) } -// List handles GET /v2/ans/identities — the caller's identities. +// List handles GET /v2/ans/identities — one page of the caller's +// identities, in the v2 limit + opaque-cursor envelope (§5.6.1: +// pagination inherits the surface's convention). func (h *IdentityHandler) List(w http.ResponseWriter, r *http.Request) { providerID, ok := callerSubject(w, r) if !ok { return } - identities, err := h.svc.List(r.Context(), providerID) + q := r.URL.Query() + limit := 0 + if lv := q.Get("limit"); lv != "" { + n, err := strconv.Atoi(lv) + if err != nil || n < 1 || n > 100 { + WriteError(w, domain.NewValidationError("INVALID_LIMIT", "limit must be between 1 and 100")) + return + } + limit = n + } + page, err := h.svc.List(r.Context(), providerID, limit, q.Get("cursor")) if err != nil { WriteError(w, err) return } - out := make([]identityDetailResponse, 0, len(identities)) - for _, identity := range identities { + out := make([]identityDetailResponse, 0, len(page.Items)) + for _, identity := range page.Items { out = append(out, toDetailResponse(identity, nil)) } - WriteJSON(w, http.StatusOK, map[string]any{"identities": out}) + resp := map[string]any{"identities": out, "nextCursor": nil} + if page.NextCursor != "" { + resp["nextCursor"] = page.NextCursor + } + WriteJSON(w, http.StatusOK, resp) } // Detail handles GET /v2/ans/identities/{identityId} — the identity diff --git a/internal/ra/handler/identity_handler_test.go b/internal/ra/handler/identity_handler_test.go index e021ec5..a4f241e 100644 --- a/internal/ra/handler/identity_handler_test.go +++ b/internal/ra/handler/identity_handler_test.go @@ -28,6 +28,7 @@ import ( "github.com/godaddy/ans/internal/adapter/eventbus" "github.com/godaddy/ans/internal/adapter/store/sqlite" anscrypto "github.com/godaddy/ans/internal/crypto" + "github.com/godaddy/ans/internal/domain" "github.com/godaddy/ans/internal/port" "github.com/godaddy/ans/internal/ra/handler" ramiddleware "github.com/godaddy/ans/internal/ra/middleware" @@ -36,12 +37,20 @@ import ( // identityHTTPFixture wires the identity routes (plus the agent // register + detail routes the link tests need) over real SQLite and -// the noop resolver. No signer — covering the unsigned outbox branch; -// the signed path is pinned by the service tests. +// the noop resolver. No signer — the seal goes through an always-ok +// stub sealer with an unsigned payload; the signed seal path is +// pinned by the service tests. type identityHTTPFixture struct { router chi.Router + agents *sqlite.AgentStore } +// okSealer acknowledges every seal — the HTTP tests pin the wire +// contract, not the seal discipline (service tests own that). +type okSealer struct{} + +func (okSealer) SealIdentityEvent(context.Context, []byte, string) error { return nil } + func newIdentityHTTPFixture(t *testing.T) *identityHTTPFixture { t.Helper() dir := t.TempDir() @@ -77,7 +86,7 @@ func newIdentityHTTPFixture(t *testing.T) *identityHTTPFixture { sqlite.NewIdentityLinkStore(db), agents, didresolver.NewNoopResolver(), - outbox, + okSealer{}, db, ).WithChallengeTTL(30 * time.Minute) @@ -98,7 +107,7 @@ func newIdentityHTTPFixture(t *testing.T) *identityHTTPFixture { r.Post("/v2/ans/identities/{identityId}/links", idH.Link) r.Delete("/v2/ans/identities/{identityId}/links/{agentId}", idH.Unlink) - return &identityHTTPFixture{router: r} + return &identityHTTPFixture{router: r, agents: agents} } // do sends a request as the given owner ("" = unauthenticated) and @@ -410,5 +419,72 @@ func registerAgentForIdentity(t *testing.T, f *identityHTTPFixture, owner, host if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil || resp.AgentID == "" { t.Fatalf("agent register response: %s (%v)", rec.Body, err) } + + // Drive the agent to ACTIVE directly through the store — the link + // liveness gate (§4.3) rejects pre-activation agents, and these + // tests pin the identity wire contract, not the ACME lifecycle. + reg, err := f.agents.FindByAgentID(context.Background(), resp.AgentID) + if err != nil { + t.Fatal(err) + } + reg.Status = domain.StatusActive + if err := f.agents.Save(context.Background(), reg); err != nil { + t.Fatal(err) + } return resp.AgentID } + +// TestIdentityHandler_ListPagination pins the v2 limit + opaque- +// cursor envelope on GET /v2/ans/identities. +func TestIdentityHandler_ListPagination(t *testing.T) { + t.Parallel() + f := newIdentityHTTPFixture(t) + owner := "owner-pages" + f.registerAndVerify(t, owner, "did:web:page-a.example.com") + f.registerAndVerify(t, owner, "did:web:page-b.example.com") + f.registerAndVerify(t, owner, "did:web:page-c.example.com") + + // Invalid limit → 422 INVALID_LIMIT. + rec := f.do(t, owner, http.MethodGet, "/v2/ans/identities?limit=0", nil) + if rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("limit=0: %d %s", rec.Code, rec.Body) + } + + type page struct { + Identities []struct { + IdentityID string `json:"identityId"` + } `json:"identities"` + NextCursor *string `json:"nextCursor"` + } + var p1 page + rec = f.do(t, owner, http.MethodGet, "/v2/ans/identities?limit=2", nil) + if rec.Code != http.StatusOK { + t.Fatalf("page 1: %d %s", rec.Code, rec.Body) + } + if err := json.Unmarshal(rec.Body.Bytes(), &p1); err != nil { + t.Fatal(err) + } + if len(p1.Identities) != 2 || p1.NextCursor == nil { + t.Fatalf("page 1 shape: %+v", p1) + } + + var p2 page + rec = f.do(t, owner, http.MethodGet, "/v2/ans/identities?limit=2&cursor="+*p1.NextCursor, nil) + if rec.Code != http.StatusOK { + t.Fatalf("page 2: %d %s", rec.Code, rec.Body) + } + if err := json.Unmarshal(rec.Body.Bytes(), &p2); err != nil { + t.Fatal(err) + } + if len(p2.Identities) != 1 || p2.NextCursor != nil { + t.Fatalf("page 2 shape: %+v", p2) + } + // No overlap between pages. + seen := map[string]bool{} + for _, e := range append(p1.Identities, p2.Identities...) { + if seen[e.IdentityID] { + t.Fatalf("duplicate across pages: %s", e.IdentityID) + } + seen[e.IdentityID] = true + } +} diff --git a/internal/ra/service/identity.go b/internal/ra/service/identity.go index e9ec09f..f9c38a3 100644 --- a/internal/ra/service/identity.go +++ b/internal/ra/service/identity.go @@ -16,12 +16,6 @@ import ( identityevent "github.com/godaddy/ans/internal/tl/event/identity" ) -// identityLane is the outbox schema_version value routing identity -// events to the TL's `POST /v1/internal/identities/event` ingest -// lane. Same producer signature and replay-verbatim invariant as the -// V1/V2 agent lanes; different inner-event schema. -const identityLane = "IDENTITY" - // maxProofsPerVerify bounds the multi-key proof set on one // verify-control call. did:web legitimately proves several // assertionMethod keys; sixteen is far beyond any real document while @@ -34,6 +28,28 @@ const maxProofsPerVerify = 16 // every accepted call still seals exactly one event. const maxLinkBatch = 256 +// sealClaimTTL bounds the provisional verify-control claim taken +// across the seal-before-success TL round trip: a claim older than +// this (a crashed claimer) is reclaimable. Comfortably above the +// seal timeout, comfortably below the nonce TTL. +const sealClaimTTL = 30 * time.Second + +// defaultSealTimeout bounds the inline TL seal call (§5.6.1) — +// parity with the outbound-fetch budget (§3.7). +const defaultSealTimeout = 5 * time.Second + +// IdentityEventSealer submits one producer-signed identity event to +// the TL's IDENTITY ingest lane and returns only after the TL +// acknowledges the seal — the dependency seal-before-success (design +// §5.6.1) hangs on. Identity events never ride the outbox: delivery +// precedes success, a failed delivery IS a failed operation, and +// there is nothing for a background worker to retry (retrying would +// seal an event whose row transition never happened). Implementations +// map failures to domain error kinds (ErrUnavailable for transient). +type IdentityEventSealer interface { + SealIdentityEvent(ctx context.Context, innerCanonical []byte, producerSig string) error +} + // ProofChallenge is one entry of the 202 challenge list: a key the // registrant may prove, plus the exact base64url signing input a // compact JWS over it must carry as its payload segment. Every entry @@ -66,7 +82,7 @@ type IdentityService struct { identities port.IdentityStore links port.IdentityLinkStore agents port.AgentStore - outbox OutboxEnqueuer + sealer IdentityEventSealer uow port.UnitOfWork signer *EventSigner @@ -76,19 +92,23 @@ type IdentityService struct { verifiers map[domain.IdentifierKind]controlVerifier challengeTTL time.Duration + sealTimeout time.Duration limiter *ownerLimiter + linkLimiter *ownerLimiter clock func() time.Time newID func() (string, error) newNonce func() (string, error) } -// NewIdentityService constructs an IdentityService. +// NewIdentityService constructs an IdentityService. A nil sealer +// fails every sealing operation closed with TL_UNAVAILABLE — the +// seal-before-success rule (§5.6.1) admits no "seal later" mode. func NewIdentityService( identities port.IdentityStore, links port.IdentityLinkStore, agents port.AgentStore, resolver port.DIDResolver, - outbox OutboxEnqueuer, + sealer IdentityEventSealer, uow port.UnitOfWork, ) *IdentityService { return &IdentityService{ @@ -96,10 +116,12 @@ func NewIdentityService( links: links, agents: agents, verifiers: newControlVerifiers(resolver), - outbox: outbox, + sealer: sealer, uow: uow, challengeTTL: time.Hour, + sealTimeout: defaultSealTimeout, limiter: newOwnerLimiter(defaultRegisterPerMinute), + linkLimiter: newOwnerLimiter(defaultLinkPerMinute), clock: time.Now, newID: func() (string, error) { id, err := uuid.NewV7() @@ -146,6 +168,24 @@ func (s *IdentityService) WithRegisterRateLimit(perMinute int) *IdentityService return s } +// WithLinkRateLimit overrides the per-owner link/unlink rate limit +// (default 60/min — design §4.3 operational hardening: the bounds +// limit blast radius and TL noise, not the named risk). +func (s *IdentityService) WithLinkRateLimit(perMinute int) *IdentityService { + if perMinute > 0 { + s.linkLimiter = newOwnerLimiter(perMinute) + } + return s +} + +// WithSealTimeout overrides the inline TL seal budget (default 5s). +func (s *IdentityService) WithSealTimeout(timeout time.Duration) *IdentityService { + if timeout > 0 { + s.sealTimeout = timeout + } + return s +} + // WithClock overrides the time source (tests only). func (s *IdentityService) WithClock(fn func() time.Time) *IdentityService { s.clock = fn @@ -209,7 +249,7 @@ func (s *IdentityService) Register(ctx context.Context, providerID, rawValue str "identifier is already verified by this owner; rotate it with PUT instead") case err == nil: // PENDING_CONTROL → idempotent re-challenge on the same row. - return s.challenge(ctx, existing, now) + return s.challenge(ctx, existing, now, false) case errors.Is(err, domain.ErrNotFound): // fall through to creation default: @@ -234,7 +274,7 @@ func (s *IdentityService) Register(ctx context.Context, providerID, rawValue str if err != nil { return nil, err } - return s.challenge(ctx, identity, now) + return s.challenge(ctx, identity, now, true) } // Rotate stages a same-kind replacement (§4.2 PUT) and returns fresh @@ -254,13 +294,27 @@ func (s *IdentityService) Rotate(ctx context.Context, providerID, identityID, ra if err := identity.StageRotation(rawValue, now); err != nil { return nil, err } - return s.challenge(ctx, identity, now) + return s.challenge(ctx, identity, now, false) } // challenge mints a fresh nonce on the identity, runs the kind's // advisory resolution to seed the per-key challenge list, persists, // and assembles the 202 response. Shared by Register and Rotate. -func (s *IdentityService) challenge(ctx context.Context, identity *domain.VerifiedIdentity, now time.Time) (*IdentityChallengeResponse, error) { +// +// isNew selects the persist path: a brand-new identity INSERTs +// (fresh UUIDv7 — unracable); an existing row persists through the +// store's CONDITIONAL StageChallenge — the resolver fetch between +// load and persist spans seconds, and a blind upsert here could +// clobber a verify or revoke that committed in that window (status +// regression = a different owner could then take the identifier). +func (s *IdentityService) challenge(ctx context.Context, identity *domain.VerifiedIdentity, now time.Time, isNew bool) (*IdentityChallengeResponse, error) { + // Load-time snapshot for the conditional persist. + expectedStatus := identity.Status + expectedNonce := "" + if identity.Challenge != nil { + expectedNonce = identity.Challenge.Nonce + } + nonce, err := s.newNonce() if err != nil { return nil, domain.NewInternalError("CHALLENGE_GENERATION", "could not generate challenge nonce", err) @@ -291,8 +345,14 @@ func (s *IdentityService) challenge(ctx context.Context, identity *domain.Verifi return nil, err } - if err := s.identities.Save(ctx, identity); err != nil { - return nil, mapIdentitySaveErr(err) + if isNew { + if err := s.identities.Save(ctx, identity); err != nil { + return nil, mapIdentitySaveErr(err) + } + } else { + if err := s.identities.StageChallenge(ctx, identity, expectedStatus, expectedNonce, now.Add(-sealClaimTTL)); err != nil { + return nil, err + } } return &IdentityChallengeResponse{ Identity: identity, @@ -303,12 +363,15 @@ func (s *IdentityService) challenge(ctx context.Context, identity *domain.Verifi } // VerifyControl runs the identity's per-kind control proof over the -// submission and, when every proof passes, flips the identity to -// VERIFIED (or completes a staged rotation), consumes the nonce, and -// seals IDENTITY_VERIFIED / IDENTITY_UPDATED on the identity's TL -// stream — all in one transaction. One bad proof fails the call -// closed; a failed attempt does NOT consume the nonce. The per-kind -// logic lives entirely behind the controlVerifier seam +// submission and, when every proof passes, seals IDENTITY_VERIFIED / +// IDENTITY_UPDATED on the identity's TL stream and THEN flips the +// identity to VERIFIED (or completes a staged rotation), consuming +// the nonce in the commit transaction — seal-before-success (§5.6.1): +// success is reported only after the TL acknowledges the seal, and +// the RA row can never be ahead of the log. One bad proof fails the +// call closed; a failed attempt does NOT consume the nonce (the +// provisional claim taken across the seal round trip is released). +// The per-kind logic lives entirely behind the controlVerifier seam // (identitykinds.go); this method owns the kind-agnostic discipline. func (s *IdentityService) VerifyControl(ctx context.Context, providerID, identityID string, sub ProofSubmission) (*domain.VerifiedIdentity, error) { identity, err := s.ownedIdentityForWrite(ctx, providerID, identityID) @@ -345,16 +408,56 @@ func (s *IdentityService) VerifyControl(ctx context.Context, providerID, identit return nil, err } + // Advisory cross-owner duplicate check before sealing — narrows + // the window in which a competing owner's verify could leave a + // sealed event whose row transition loses the proven-uniqueness + // index race. The index at commit stays the authoritative guard; + // a sealed loser is a benign true fact (control WAS proven) whose + // row never flips and whose identity never becomes linkable. statusBefore := identity.Status - var sealed *domain.VerifiedIdentity - err = s.uow.Run(ctx, func(txCtx context.Context) error { - // Consume the nonce first — the conditional update is the - // TOCTOU guard; exactly one concurrent verify can win. - if err := s.identities.ConsumeChallenge(txCtx, identity.IdentityID, identity.Challenge.Nonce, now); err != nil { - return err + if statusBefore != domain.IdentityVerified { + if taken, terr := s.identities.ExistsVerified(ctx, identity.Kind, identity.EffectiveValue()); terr != nil { + return nil, terr + } else if taken { + return nil, domain.NewConflictError("IDENTIFIER_DUPLICATE", + "identifier is already verified by another owner") } - previousValue, err := identity.CompleteVerification(now) - if err != nil { + } + + // Phase A — claim. Serializes concurrent verify attempts on this + // nonce across the seal round trip: at most one in-flight attempt + // can seal. A claim is NOT consumption; every failure path below + // releases it. + nonce := identity.Challenge.Nonce + if err := s.identities.ClaimChallenge(ctx, identity.IdentityID, nonce, now, now.Add(-sealClaimTTL)); err != nil { + return nil, err + } + + previousValue, err := identity.CompleteVerification(now) + if err != nil { + s.releaseClaim(ctx, identity.IdentityID, nonce) + return nil, err + } + eventType := identityevent.TypeIdentityVerified + if statusBefore == domain.IdentityVerified { + eventType = identityevent.TypeIdentityUpdated + } + inner := s.buildIdentityEvent(identity, eventType, now) + inner.Keys = provenKeys + inner.PreviousValue = previousValue + inner.VerifiedAt = now.Format(time.RFC3339) + + // Phase B — seal. No success without the TL's acknowledgment. + if err := s.sealIdentityEvent(ctx, inner, now); err != nil { + s.releaseClaim(ctx, identity.IdentityID, nonce) + return nil, err + } + + // Phase C — commit with the ack: consume the nonce (the + // conditional update stays the authoritative TOCTOU guard) and + // flip the row. + err = s.uow.Run(ctx, func(txCtx context.Context) error { + if err := s.identities.ConsumeChallenge(txCtx, identity.IdentityID, nonce, now); err != nil { return err } consumed := now @@ -362,27 +465,25 @@ func (s *IdentityService) VerifyControl(ctx context.Context, providerID, identit if err := s.identities.Save(txCtx, identity); err != nil { return mapIdentitySaveErr(err) } - - eventType := identityevent.TypeIdentityVerified - if statusBefore == domain.IdentityVerified { - eventType = identityevent.TypeIdentityUpdated - } - inner := s.buildIdentityEvent(identity, eventType, now) - inner.Keys = provenKeys - inner.PreviousValue = previousValue - inner.VerifiedAt = now.Format(time.RFC3339) - return s.enqueueIdentityEvent(txCtx, inner, now) + return nil }) if err != nil { + s.releaseClaim(ctx, identity.IdentityID, nonce) return nil, err } - sealed = identity - return sealed, nil + return identity, nil +} + +// releaseClaim is the best-effort failure-path release of the +// verify-control seal claim — failed attempts never consume (§3.2). +func (s *IdentityService) releaseClaim(ctx context.Context, identityID, nonce string) { + _ = s.identities.ReleaseChallenge(ctx, identityID, nonce) } // Revoke transitions a VERIFIED identity to REVOKED and seals // IDENTITY_REVOKED — one event; propagation to every linked agent's -// badge is the TL's read-time join, never a write fan-out. +// badge is the TL's read-time join, never a write fan-out. Seal +// before success (§5.6.1): the row flips only after the TL ack. func (s *IdentityService) Revoke(ctx context.Context, providerID, identityID string) (*domain.VerifiedIdentity, error) { identity, err := s.ownedIdentityForWrite(ctx, providerID, identityID) if err != nil { @@ -392,23 +493,27 @@ func (s *IdentityService) Revoke(ctx context.Context, providerID, identityID str if err := identity.Revoke(now); err != nil { return nil, err } - err = s.uow.Run(ctx, func(txCtx context.Context) error { - if err := s.identities.Save(txCtx, identity); err != nil { - return mapIdentitySaveErr(err) - } - inner := s.buildIdentityEvent(identity, identityevent.TypeIdentityRevoked, now) - inner.RevokedAt = now.Format(time.RFC3339) - return s.enqueueIdentityEvent(txCtx, inner, now) - }) - if err != nil { + inner := s.buildIdentityEvent(identity, identityevent.TypeIdentityRevoked, now) + inner.RevokedAt = now.Format(time.RFC3339) + if err := s.sealIdentityEvent(ctx, inner, now); err != nil { + return nil, err + } + // Phase C — CONDITIONAL commit (re-read + compare, §plan W1): the + // seal round trip is a window a concurrent verify/rotate commit + // can land in; a blind save would overwrite it with this call's + // stale snapshot. On conflict the sealed IDENTITY_REVOKED is the + // benign residue (the TL's read-time status is terminal on ANY + // revocation leaf) and the caller retries against fresh state. + if err := s.identities.MarkRevoked(ctx, identity.IdentityID, now); err != nil { return nil, err } return identity, nil } -// List returns the owner's identities, newest first. -func (s *IdentityService) List(ctx context.Context, providerID string) ([]*domain.VerifiedIdentity, error) { - return s.identities.ListByOwner(ctx, providerID) +// List returns one page of the owner's identities, newest first +// (opaque-cursor pagination, the agent-list convention). +func (s *IdentityService) List(ctx context.Context, providerID string, limit int, cursor string) (*port.CursorPage[*domain.VerifiedIdentity], error) { + return s.identities.ListByOwner(ctx, providerID, limit, cursor) } // LinkedIdentitySummary is one entry of the RA-side computed @@ -425,10 +530,21 @@ type LinkedIdentitySummary struct { } // LinkedIdentitiesForAgent computes the identities currently linked -// to an agent. Callers reach this through the ownership-gated agent -// detail route; links are same-owner by construction, so no further -// gate applies here. +// to an agent under the §5.6.3 visibility predicate: link LINKED ∧ +// agent live — a terminal agent's view is empty (its links are no +// longer visible; history stays in the TL). REVOKED identities stay +// visible with their status: a reader must see the who behind a +// still-linked agent was revoked. Callers reach this through the +// ownership-gated agent detail route; links are same-owner by +// construction, so no further gate applies here. func (s *IdentityService) LinkedIdentitiesForAgent(ctx context.Context, agentID string) ([]LinkedIdentitySummary, error) { + reg, err := s.agents.FindByAgentID(ctx, agentID) + if err != nil { + return nil, err + } + if !agentLinkable(reg.Status) { + return []LinkedIdentitySummary{}, nil + } links, err := s.links.ListLiveByAgent(ctx, agentID) if err != nil { return nil, err @@ -450,7 +566,10 @@ func (s *IdentityService) LinkedIdentitiesForAgent(ctx context.Context, agentID return out, nil } -// Detail returns one identity plus its live links. +// Detail returns one identity plus its visible links — the §5.6.3 +// visibility predicate applies to the linked-agent list and count +// exactly as to every other "current" view: a link to a terminal +// agent drops out (its history stays in the TL). func (s *IdentityService) Detail(ctx context.Context, providerID, identityID string) (*domain.VerifiedIdentity, []*domain.IdentityLink, error) { identity, err := s.ownedIdentity(ctx, providerID, identityID) if err != nil { @@ -460,17 +579,39 @@ func (s *IdentityService) Detail(ctx context.Context, providerID, identityID str if err != nil { return nil, nil, err } - return identity, links, nil + visible := make([]*domain.IdentityLink, 0, len(links)) + for _, l := range links { + reg, err := s.agents.FindByAgentID(ctx, l.AgentID) + if err != nil { + if errors.Is(err, domain.ErrNotFound) { + continue // agent row gone (admin cleanup) — not visible + } + return nil, nil, err // infra failure must surface, never under-count + } + if !agentLinkable(reg.Status) { + continue + } + visible = append(visible, l) + } + return identity, visible, nil } // Link binds a batch of the owner's agents to the identity — a // single owner-gated call, no challenge, no signature (§4.3): the // caller must own the identity AND every named agent; key possession -// never authorizes a link. The whole batch seals as ONE -// IDENTITY_LINKED event on the identity stream; agent streams are -// never written. Already-linked agents are skipped idempotently; a -// call that links nothing new seals nothing. +// never authorizes a link. Liveness gate (§4.3): the identity must +// be VERIFIED and every agent live (ACTIVE or DEPRECATED — a +// deprecated agent still serves during migration and the who stays +// true); terminal or pre-activation agents are AGENT_NOT_LINKABLE. +// The whole batch seals as ONE IDENTITY_LINKED event on the identity +// stream — before success is reported (§5.6.1) — and agent streams +// are never written. Already-linked agents are skipped idempotently; +// a call that links nothing new seals nothing. func (s *IdentityService) Link(ctx context.Context, providerID, identityID string, agentIDs []string) (int, error) { + if !s.linkLimiter.Allow(providerID, s.clock()) { + return 0, domain.NewValidationError("RATE_LIMITED", + "too many link/unlink calls; retry later") + } identity, err := s.ownedIdentityForWrite(ctx, providerID, identityID) if err != nil { return 0, err @@ -500,7 +641,9 @@ func (s *IdentityService) Link(ctx context.Context, providerID, identityID strin // Owner gate, both sides: every agent must exist and belong to // the caller. A non-owned agent is reported as not-found — the - // caller learns nothing about other owners' agents. + // caller learns nothing about other owners' agents. Then the + // liveness gate: rejected atomically, all-or-nothing, matching + // the one-event batch semantics. for _, agentID := range deduped { reg, err := s.agents.FindByAgentID(ctx, agentID) if err != nil { @@ -511,50 +654,124 @@ func (s *IdentityService) Link(ctx context.Context, providerID, identityID strin return 0, domain.NewNotFoundError("AGENT_NOT_FOUND", fmt.Sprintf("agent %q not found", agentID)) } + if !agentLinkable(reg.Status) { + return 0, domain.NewValidationError("AGENT_NOT_LINKABLE", + fmt.Sprintf("agent %q is %s — links require a live agent (ACTIVE or DEPRECATED)", agentID, reg.Status)) + } + } + + // Compute the batch BEFORE sealing: the sealed ansIds[] must be + // exactly the pairs this call creates. + existingLinks, err := s.links.ListLiveByIdentity(ctx, identityID) + if err != nil { + return 0, err + } + alreadyLinked := make(map[string]bool, len(existingLinks)) + for _, l := range existingLinks { + alreadyLinked[l.AgentID] = true + } + newlyLinked := make([]string, 0, len(deduped)) + for _, agentID := range deduped { + if !alreadyLinked[agentID] { + newlyLinked = append(newlyLinked, agentID) + } + } + if len(newlyLinked) == 0 { + return 0, nil // fully idempotent — nothing to seal } now := s.clock().UTC() - linked := 0 + inner := s.buildIdentityEvent(identity, identityevent.TypeIdentityLinked, now) + inner.AnsIDs = newlyLinked + + // Seal before success (§5.6.1), then commit the rows with the + // ack. A concurrent call winning a pair's row in between is + // benign: both sealed events assert LINKED, the row upsert is + // idempotent, and latest-event-wins reads are unaffected. + if err := s.sealIdentityEvent(ctx, inner, now); err != nil { + return 0, err + } err = s.uow.Run(ctx, func(txCtx context.Context) error { - newlyLinked := make([]string, 0, len(deduped)) - for _, agentID := range deduped { - created, err := s.links.Link(txCtx, identityID, agentID, now) - if err != nil { + // Re-read + compare (§plan W1): a revoke that committed + // during the seal round trip must not gain live link rows — + // the §4.3 VERIFIED gate holds at commit, not just at entry. + current, err := s.identities.FindByID(txCtx, identityID) + if err != nil { + return err + } + if current.Status != domain.IdentityVerified { + return domain.NewInvalidStateError("IDENTITY_NOT_VERIFIED", + "identity was revoked while the link was sealing; the sealed link event is inert") + } + for _, agentID := range newlyLinked { + if _, err := s.links.Link(txCtx, identityID, agentID, now); err != nil { return err } - if created { - newlyLinked = append(newlyLinked, agentID) - } } - linked = len(newlyLinked) - if linked == 0 { - return nil // fully idempotent — nothing to seal - } - inner := s.buildIdentityEvent(identity, identityevent.TypeIdentityLinked, now) - inner.AnsIDs = newlyLinked - return s.enqueueIdentityEvent(txCtx, inner, now) + return nil }) if err != nil { return 0, err } - return linked, nil + return len(newlyLinked), nil +} + +// agentLinkable is the link liveness gate (§4.3): live states only. +// DEPRECATED is deliberately linkable; terminal and pre-activation +// states are not (a terminal link is dead on arrival under the +// visibility predicate, and a pre-activation agent has no sealed TL +// presence to join). +func agentLinkable(status domain.RegistrationStatus) bool { + return status == domain.StatusActive || status == domain.StatusDeprecated } // Unlink ends one association and seals IDENTITY_UNLINKED on the -// identity stream. The association's history persists in the log. +// identity stream — before success is reported (§5.6.1). The +// association's history persists in the log. func (s *IdentityService) Unlink(ctx context.Context, providerID, identityID, agentID string) error { + if !s.linkLimiter.Allow(providerID, s.clock()) { + return domain.NewValidationError("RATE_LIMITED", + "too many link/unlink calls; retry later") + } identity, err := s.ownedIdentityForWrite(ctx, providerID, identityID) if err != nil { return err } + + // The live link must exist before anything seals. + existingLinks, err := s.links.ListLiveByIdentity(ctx, identityID) + if err != nil { + return err + } + live := false + for _, l := range existingLinks { + if l.AgentID == agentID { + live = true + break + } + } + if !live { + return domain.NewNotFoundError("LINK_NOT_FOUND", + "no live link exists for this identity and agent") + } + now := s.clock().UTC() + inner := s.buildIdentityEvent(identity, identityevent.TypeIdentityUnlinked, now) + inner.AnsIDs = []string{agentID} + if err := s.sealIdentityEvent(ctx, inner, now); err != nil { + return err + } return s.uow.Run(ctx, func(txCtx context.Context) error { if err := s.links.Unlink(txCtx, identityID, agentID, now); err != nil { + // A concurrent unlink winning the row after our liveness + // read is benign: the association ended and both sealed + // events say so. + if errors.Is(err, domain.ErrNotFound) { + return nil + } return err } - inner := s.buildIdentityEvent(identity, identityevent.TypeIdentityUnlinked, now) - inner.AnsIDs = []string{agentID} - return s.enqueueIdentityEvent(txCtx, inner, now) + return nil }) } @@ -618,13 +835,18 @@ func (s *IdentityService) buildIdentityEvent( } } -// enqueueIdentityEvent JCS-canonicalizes the inner event, signs it -// once with the producer key, and writes the outbox row on the -// IDENTITY lane. Same replay-verbatim invariant as the agent lanes: -// the worker must POST these exact bytes on every retry. -func (s *IdentityService) enqueueIdentityEvent(ctx context.Context, inner *identityevent.Event, now time.Time) error { - if s.outbox == nil { - return nil +// sealIdentityEvent JCS-canonicalizes the inner event, signs it once +// with the producer key, and submits it inline to the TL's IDENTITY +// ingest lane, returning only on the TL's acknowledgment — +// seal-before-success (§5.6.1). Identity events never ride the +// outbox: sign once, submit once; a failed submission is a failed +// operation, surfaced retryable (TL_UNAVAILABLE) with nothing +// consumed. A nil sealer fails closed for the same reason — there is +// no "seal later" mode. +func (s *IdentityService) sealIdentityEvent(ctx context.Context, inner *identityevent.Event, now time.Time) error { + if s.sealer == nil { + return domain.NewUnavailableError("TL_UNAVAILABLE", + "identity sealing is not configured; identity operations cannot report success without a sealed event") } innerCanonical, err := identityevent.CanonicalizeEvent(inner) if err != nil { @@ -645,17 +867,9 @@ func (s *IdentityService) enqueueIdentityEvent(ctx context.Context, inner *ident return fmt.Errorf("sign identity event: %w", err) } } - payload, err := marshalOutboxPayload(innerCanonical, producerSig) - if err != nil { - return err - } - // The outbox row's subject column carries the identityId — the - // stream key for identity events, exactly as agent rows carry - // the agentId. - if _, err := s.outbox.Enqueue(ctx, string(inner.EventType), inner.IdentityID, identityLane, payload, now); err != nil { - return err - } - return nil + sealCtx, cancel := context.WithTimeout(ctx, s.sealTimeout) + defer cancel() + return s.sealer.SealIdentityEvent(sealCtx, innerCanonical, producerSig) } // mapIdentitySaveErr converts the storage layer's generic conflict diff --git a/internal/ra/service/identity_test.go b/internal/ra/service/identity_test.go index c9e2ebd..bdc2f10 100644 --- a/internal/ra/service/identity_test.go +++ b/internal/ra/service/identity_test.go @@ -3,9 +3,10 @@ package service_test // IdentityService tests: the proof gate (payload equality, kid // selection, signature verification, nonce discipline), the lifecycle // (register → verify → rotate → revoke), the owner-gated links, and -// the sealed-event emission on the outbox IDENTITY lane. Real SQLite -// stores + real crypto; the resolver is the noop adapter (hint -// synthesis) or a canned-document fake for the did:web rules. +// the synchronous seal-before-success emission (§5.6.1) through a +// recording sealer. Real SQLite stores + real crypto; the resolver is +// the noop adapter (hint synthesis) or a canned-document fake for the +// did:web rules. import ( "context" @@ -18,6 +19,7 @@ import ( "encoding/json" "errors" "strings" + "sync" "testing" "time" @@ -45,13 +47,57 @@ func (f *fakeResolver) Resolve(context.Context, string, []port.KeyHint) (*port.D type identityFixture struct { svc *service.IdentityService db *sqlite.DB - outbox *sqlite.OutboxStore + sealer *recordingSealer agents port.AgentStore signerPub any clock *fakeClock providerID string } +// recordingSealer is the test IdentityEventSealer: it records every +// sealed (innerCanonical, producerSig) pair the service submitted — +// each entry is one TL-acknowledged seal — and can be primed to +// fail, exercising the seal-before-success failure paths. +type recordingSealer struct { + mu sync.Mutex + events []sealedEvent + err error + // hook runs inside SealIdentityEvent before recording — the test + // stand-in for "something committed during the TL round trip", + // which is exactly the window the Phase C conditional commits + // must survive. + hook func() +} + +type sealedEvent struct { + Inner []byte + Sig string +} + +func (r *recordingSealer) SealIdentityEvent(_ context.Context, innerCanonical []byte, producerSig string) error { + r.mu.Lock() + defer r.mu.Unlock() + if r.hook != nil { + r.hook() + } + if r.err != nil { + return r.err + } + r.events = append(r.events, sealedEvent{ + Inner: append([]byte(nil), innerCanonical...), + Sig: producerSig, + }) + return nil +} + +// fail primes the sealer to reject every seal with err (nil restores +// normal operation). +func (r *recordingSealer) fail(err error) { + r.mu.Lock() + defer r.mu.Unlock() + r.err = err +} + type fakeClock struct{ now time.Time } func (c *fakeClock) Now() time.Time { return c.now } @@ -82,12 +128,13 @@ func newIdentityFixture(t *testing.T, resolver port.DIDResolver) *identityFixtur resolver = didresolver.NewNoopResolver() } clock := &fakeClock{now: time.Date(2026, 6, 10, 15, 0, 0, 0, time.UTC)} + sealer := &recordingSealer{} svc := service.NewIdentityService( sqlite.NewIdentityStore(db), sqlite.NewIdentityLinkStore(db), sqlite.NewAgentStore(db), resolver, - sqlite.NewOutboxStore(db), + sealer, db, ).WithSigner(service.EventSigner{ KeyManager: km, @@ -98,7 +145,7 @@ func newIdentityFixture(t *testing.T, resolver port.DIDResolver) *identityFixtur return &identityFixture{ svc: svc, db: db, - outbox: sqlite.NewOutboxStore(db), + sealer: sealer, agents: sqlite.NewAgentStore(db), signerPub: pub, clock: clock, @@ -108,6 +155,13 @@ func newIdentityFixture(t *testing.T, resolver port.DIDResolver) *identityFixtur // saveAgent persists a minimal ACTIVE agent owned by `owner`. func (fx *identityFixture) saveAgent(t *testing.T, agentID, owner, host string) { + t.Helper() + fx.saveAgentWithStatus(t, agentID, owner, host, domain.StatusActive) +} + +// saveAgentWithStatus persists a minimal agent in the given lifecycle +// state — the link liveness-gate tests need every state. +func (fx *identityFixture) saveAgentWithStatus(t *testing.T, agentID, owner, host string, status domain.RegistrationStatus) { t.Helper() v, err := domain.NewSemVer(1, 0, 0) if err != nil { @@ -121,7 +175,7 @@ func (fx *identityFixture) saveAgent(t *testing.T, agentID, owner, host string) AgentID: agentID, OwnerID: owner, AnsName: ansName, - Status: domain.StatusActive, + Status: status, Details: domain.RegistrationDetails{ RegistrationTimestamp: fx.clock.now, DisplayName: "agent " + agentID, @@ -173,38 +227,27 @@ func genKey(t *testing.T) *ecdsa.PrivateKey { return priv } -// drainOutbox claims and returns all pending outbox rows. -func (fx *identityFixture) drainOutbox(t *testing.T) []sqlite.OutboxEvent { +// drainSealed returns (and clears) everything the recording sealer +// accepted — the events the TL acknowledged, in seal order. +func (fx *identityFixture) drainSealed(t *testing.T) []sealedEvent { t.Helper() - rows, err := fx.outbox.Claim(context.Background(), 100) - if err != nil { - t.Fatal(err) - } - for _, row := range rows { - if err := fx.outbox.MarkSent(context.Background(), row.ID); err != nil { - t.Fatal(err) - } - } - return rows + fx.sealer.mu.Lock() + defer fx.sealer.mu.Unlock() + out := fx.sealer.events + fx.sealer.events = nil + return out } -// decodeOutboxEvent parses one outbox row's payload, verifies the -// producer signature against the fixture's signer key, and returns -// the inner identity event. -func (fx *identityFixture) decodeOutboxEvent(t *testing.T, row sqlite.OutboxEvent) *identityevent.Event { +// decodeSealed verifies one sealed record's producer signature +// against the fixture's signer key and returns the inner identity +// event. +func (fx *identityFixture) decodeSealed(t *testing.T, rec sealedEvent) *identityevent.Event { t.Helper() - var payload struct { - InnerEventCanonical json.RawMessage `json:"innerEventCanonical"` - ProducerSignature string `json:"producerSignature"` - } - if err := json.Unmarshal(row.PayloadJSON, &payload); err != nil { - t.Fatalf("payload: %v", err) - } - if _, err := anscrypto.VerifyWithPublicKey(fx.signerPub, payload.ProducerSignature, payload.InnerEventCanonical); err != nil { + if _, err := anscrypto.VerifyWithPublicKey(fx.signerPub, rec.Sig, rec.Inner); err != nil { t.Fatalf("producer signature: %v", err) } var inner identityevent.Event - if err := json.Unmarshal(payload.InnerEventCanonical, &inner); err != nil { + if err := json.Unmarshal(rec.Inner, &inner); err != nil { t.Fatalf("inner event: %v", err) } if err := inner.Validate(); err != nil { @@ -263,7 +306,7 @@ func TestIdentityRegister_DIDWebNoop(t *testing.T) { } // Register seals nothing — only proven control reaches the TL. - if rows := fx.drainOutbox(t); len(rows) != 0 { + if rows := fx.drainSealed(t); len(rows) != 0 { t.Fatalf("register must not emit, got %d rows", len(rows)) } } @@ -343,11 +386,11 @@ func TestIdentityVerifyControl_DIDWebNoop(t *testing.T) { t.Fatalf("verified state: %+v", identity) } - rows := fx.drainOutbox(t) - if len(rows) != 1 || rows[0].SchemaVersion != "IDENTITY" { - t.Fatalf("outbox rows: %+v", rows) + rows := fx.drainSealed(t) + if len(rows) != 1 { + t.Fatalf("sealed events: %d", len(rows)) } - inner := fx.decodeOutboxEvent(t, rows[0]) + inner := fx.decodeSealed(t, rows[0]) if inner.EventType != identityevent.TypeIdentityVerified || inner.IdentityID != identity.IdentityID || inner.ProviderID != fx.providerID || @@ -398,8 +441,8 @@ func TestIdentityVerifyControl_MultiKey(t *testing.T) { if _, err := fx.svc.VerifyControl(ctx, fx.providerID, res.Identity.IdentityID, service.ProofSubmission{SignedProofs: []string{jws1, jws2}}); err != nil { t.Fatalf("multi-key verify: %v", err) } - rows := fx.drainOutbox(t) - inner := fx.decodeOutboxEvent(t, rows[0]) + rows := fx.drainSealed(t) + inner := fx.decodeSealed(t, rows[0]) if len(inner.Keys) != 2 { t.Fatalf("sealed keys: %d", len(inner.Keys)) } @@ -666,8 +709,8 @@ func TestIdentityLifecycle_DIDKey(t *testing.T) { if identity.Status != domain.IdentityVerified || identity.ProofMethod != "did-key-sig" { t.Fatalf("did:key verified state: %+v", identity) } - rows := fx.drainOutbox(t) - inner := fx.decodeOutboxEvent(t, rows[0]) + rows := fx.drainSealed(t) + inner := fx.decodeSealed(t, rows[0]) if inner.Kind != "did:key" || len(inner.Keys) != 1 { t.Fatalf("did:key sealed event: %+v", inner) } @@ -718,8 +761,8 @@ func TestIdentityLifecycle_Ed25519(t *testing.T) { // The seal quotes the did:key Multikey method — its key material // is the method-specific id verbatim from the identifier. - rows := fx.drainOutbox(t) - inner := fx.decodeOutboxEvent(t, rows[0]) + rows := fx.drainSealed(t) + inner := fx.decodeSealed(t, rows[0]) var vm struct { Type string `json:"type"` PublicKeyMultibase string `json:"publicKeyMultibase"` @@ -770,7 +813,7 @@ func TestIdentityRotation_SealsUpdated(t *testing.T) { ctx := context.Background() identity, _ := verifyDIDWeb(t, fx, fx.providerID, "did:web:a.com") - fx.drainOutbox(t) + fx.drainSealed(t) rot, err := fx.svc.Rotate(ctx, fx.providerID, identity.IdentityID, "did:web:b.com") if err != nil { @@ -781,7 +824,7 @@ func TestIdentityRotation_SealsUpdated(t *testing.T) { } // Until the proof lands, the previously sealed state stands — // nothing emitted by the PUT itself. - if rows := fx.drainOutbox(t); len(rows) != 0 { + if rows := fx.drainSealed(t); len(rows) != 0 { t.Fatalf("PUT must not seal, got %d rows", len(rows)) } @@ -795,11 +838,11 @@ func TestIdentityRotation_SealsUpdated(t *testing.T) { t.Fatalf("rotated state: %+v", rotated) } - rows := fx.drainOutbox(t) + rows := fx.drainSealed(t) if len(rows) != 1 { t.Fatalf("rotation rows: %d", len(rows)) } - inner := fx.decodeOutboxEvent(t, rows[0]) + inner := fx.decodeSealed(t, rows[0]) if inner.EventType != identityevent.TypeIdentityUpdated || inner.Value != "did:web:b.com" || inner.PreviousValue != "did:web:a.com" { t.Fatalf("IDENTITY_UPDATED event: %+v", inner) @@ -812,7 +855,7 @@ func TestIdentityRevoke(t *testing.T) { ctx := context.Background() identity, _ := verifyDIDWeb(t, fx, fx.providerID, "did:web:a.com") - fx.drainOutbox(t) + fx.drainSealed(t) revoked, err := fx.svc.Revoke(ctx, fx.providerID, identity.IdentityID) if err != nil { @@ -821,8 +864,8 @@ func TestIdentityRevoke(t *testing.T) { if revoked.Status != domain.IdentityRevoked { t.Fatalf("status: %s", revoked.Status) } - rows := fx.drainOutbox(t) - inner := fx.decodeOutboxEvent(t, rows[0]) + rows := fx.drainSealed(t) + inner := fx.decodeSealed(t, rows[0]) if inner.EventType != identityevent.TypeIdentityRevoked || inner.RevokedAt == "" { t.Fatalf("IDENTITY_REVOKED event: %+v", inner) } @@ -892,7 +935,7 @@ func TestIdentityProvenUniquenessRace(t *testing.T) { } // The losing transaction rolled back whole — including the nonce // consumption — and only the winner's event sealed. - rows := fx.drainOutbox(t) + rows := fx.drainSealed(t) if len(rows) != 1 { t.Fatalf("sealed events after race: %d", len(rows)) } @@ -918,14 +961,14 @@ func TestIdentityOwnerGates(t *testing.T) { if _, err := fx.svc.Rotate(ctx, "owner-2", identity.IdentityID, "did:web:b.com"); !errors.Is(err, domain.ErrUnauthorized) { t.Fatalf("cross-owner rotate: %v", err) } - // List is owner-scoped. - mine, err := fx.svc.List(ctx, fx.providerID) - if err != nil || len(mine) != 1 { - t.Fatalf("list mine: %d %v", len(mine), err) + // List is owner-scoped (one page, default limit). + mine, err := fx.svc.List(ctx, fx.providerID, 0, "") + if err != nil || len(mine.Items) != 1 { + t.Fatalf("list mine: %+v %v", mine, err) } - theirs, err := fx.svc.List(ctx, "owner-2") - if err != nil || len(theirs) != 0 { - t.Fatalf("list theirs: %d %v", len(theirs), err) + theirs, err := fx.svc.List(ctx, "owner-2", 0, "") + if err != nil || len(theirs.Items) != 0 { + t.Fatalf("list theirs: %+v %v", theirs, err) } } @@ -937,7 +980,7 @@ func TestIdentityLinks(t *testing.T) { ctx := context.Background() identity, _ := verifyDIDWeb(t, fx, fx.providerID, "did:web:a.com") - fx.drainOutbox(t) + fx.drainSealed(t) fx.saveAgent(t, "agent-1", fx.providerID, "one.example.com") fx.saveAgent(t, "agent-2", fx.providerID, "two.example.com") fx.saveAgent(t, "agent-x", "owner-2", "theirs.example.com") @@ -948,7 +991,7 @@ func TestIdentityLinks(t *testing.T) { if !errors.Is(err, domain.ErrNotFound) { t.Fatalf("cross-owner agent in batch: %v", err) } - if rows := fx.drainOutbox(t); len(rows) != 0 { + if rows := fx.drainSealed(t); len(rows) != 0 { t.Fatal("failed batch must seal nothing") } @@ -957,11 +1000,11 @@ func TestIdentityLinks(t *testing.T) { if err != nil || linked != 2 { t.Fatalf("link batch: %d %v", linked, err) } - rows := fx.drainOutbox(t) + rows := fx.drainSealed(t) if len(rows) != 1 { t.Fatalf("link batch rows: %d", len(rows)) } - inner := fx.decodeOutboxEvent(t, rows[0]) + inner := fx.decodeSealed(t, rows[0]) if inner.EventType != identityevent.TypeIdentityLinked || len(inner.AnsIDs) != 2 { t.Fatalf("IDENTITY_LINKED event: %+v", inner) } @@ -971,7 +1014,7 @@ func TestIdentityLinks(t *testing.T) { if err != nil || linked != 0 { t.Fatalf("idempotent link: %d %v", linked, err) } - if rows := fx.drainOutbox(t); len(rows) != 0 { + if rows := fx.drainSealed(t); len(rows) != 0 { t.Fatal("idempotent link must seal nothing") } @@ -985,8 +1028,8 @@ func TestIdentityLinks(t *testing.T) { if err := fx.svc.Unlink(ctx, fx.providerID, identity.IdentityID, "agent-1"); err != nil { t.Fatalf("unlink: %v", err) } - rows = fx.drainOutbox(t) - inner = fx.decodeOutboxEvent(t, rows[0]) + rows = fx.drainSealed(t) + inner = fx.decodeSealed(t, rows[0]) if inner.EventType != identityevent.TypeIdentityUnlinked || len(inner.AnsIDs) != 1 || inner.AnsIDs[0] != "agent-1" { t.Fatalf("IDENTITY_UNLINKED event: %+v", inner) @@ -995,7 +1038,7 @@ func TestIdentityLinks(t *testing.T) { if err := fx.svc.Unlink(ctx, fx.providerID, identity.IdentityID, "agent-1"); !errors.Is(err, domain.ErrNotFound) { t.Fatalf("double unlink: %v", err) } - if rows := fx.drainOutbox(t); len(rows) != 0 { + if rows := fx.drainSealed(t); len(rows) != 0 { t.Fatal("failed unlink must seal nothing") } } @@ -1034,3 +1077,364 @@ func TestIdentityLinkGuards(t *testing.T) { t.Errorf("oversized batch: %v", err) } } + +// TestVerifyControl_SealFailureIsRetryable pins seal-before-success +// (§5.6.1): a TL failure surfaces retryable, consumes nothing — +// including the provisional claim — and the SAME proof succeeds once +// the TL returns. +func TestVerifyControl_SealFailureIsRetryable(t *testing.T) { + t.Parallel() + fx := newIdentityFixture(t, nil) + ctx := context.Background() + + res, err := fx.svc.Register(ctx, fx.providerID, "did:web:seal-fail.example.com") + if err != nil { + t.Fatal(err) + } + priv := genKey(t) + jws := signProof(t, priv, res.Identity.Value+"#key-1", res.Challenges[0].SigningInput, true) + sub := service.ProofSubmission{SignedProofs: []string{jws}} + + fx.sealer.fail(domain.NewUnavailableError("TL_UNAVAILABLE", "down")) + if _, err := fx.svc.VerifyControl(ctx, fx.providerID, res.Identity.IdentityID, sub); !errors.Is(err, domain.ErrUnavailable) { + t.Fatalf("want ErrUnavailable, got %v", err) + } + // Nothing sealed, row untouched, nonce unconsumed. + if rows := fx.drainSealed(t); len(rows) != 0 { + t.Fatalf("failed seal must record nothing, got %d", len(rows)) + } + identity, _, err := fx.svc.Detail(ctx, fx.providerID, res.Identity.IdentityID) + if err != nil { + t.Fatal(err) + } + if identity.Status != domain.IdentityPendingControl { + t.Fatalf("row must stand on seal failure, got %s", identity.Status) + } + + // TL back: the same proof (same nonce) succeeds — the claim was + // released, the nonce never consumed. + fx.sealer.fail(nil) + verified, err := fx.svc.VerifyControl(ctx, fx.providerID, res.Identity.IdentityID, sub) + if err != nil { + t.Fatalf("retry after TL recovery: %v", err) + } + if verified.Status != domain.IdentityVerified { + t.Fatalf("status: %s", verified.Status) + } + if rows := fx.drainSealed(t); len(rows) != 1 { + t.Fatalf("exactly one seal after recovery, got %d", len(rows)) + } +} + +// TestLink_LivenessGate pins §4.3: terminal and pre-activation agents +// are AGENT_NOT_LINKABLE (atomically — one bad agent fails the whole +// batch); DEPRECATED is deliberately linkable. +func TestLink_LivenessGate(t *testing.T) { + t.Parallel() + fx := newIdentityFixture(t, nil) + ctx := context.Background() + identity, _ := verifyDIDWeb(t, fx, fx.providerID, "did:web:liveness.example.com") + fx.drainSealed(t) + + fx.saveAgentWithStatus(t, "agent-active", fx.providerID, "a.example.com", domain.StatusActive) + fx.saveAgentWithStatus(t, "agent-deprecated", fx.providerID, "d.example.com", domain.StatusDeprecated) + fx.saveAgentWithStatus(t, "agent-revoked", fx.providerID, "r.example.com", domain.StatusRevoked) + fx.saveAgentWithStatus(t, "agent-pending", fx.providerID, "p.example.com", domain.StatusPendingValidation) + + for _, tc := range []struct { + name string + agentID string + }{ + {"terminal", "agent-revoked"}, + {"pre-activation", "agent-pending"}, + {"mixed batch is atomic", "agent-revoked"}, + } { + batch := []string{tc.agentID} + if tc.name == "mixed batch is atomic" { + batch = []string{"agent-active", tc.agentID} + } + _, err := fx.svc.Link(ctx, fx.providerID, identity.IdentityID, batch) + var de *domain.Error + if !errors.As(err, &de) || de.Code != "AGENT_NOT_LINKABLE" { + t.Fatalf("%s: want AGENT_NOT_LINKABLE, got %v", tc.name, err) + } + } + // The atomic rejection linked nothing. + if rows := fx.drainSealed(t); len(rows) != 0 { + t.Fatalf("rejected batches must seal nothing, got %d", len(rows)) + } + + // ACTIVE and DEPRECATED both link. + linked, err := fx.svc.Link(ctx, fx.providerID, identity.IdentityID, []string{"agent-active", "agent-deprecated"}) + if err != nil || linked != 2 { + t.Fatalf("live link: %d %v", linked, err) + } +} + +// TestLink_SealFailureLeavesNoRows pins seal-before-success on the +// link path: a failed seal writes no link rows and the retry links +// the full batch. +func TestLink_SealFailureLeavesNoRows(t *testing.T) { + t.Parallel() + fx := newIdentityFixture(t, nil) + ctx := context.Background() + identity, _ := verifyDIDWeb(t, fx, fx.providerID, "did:web:link-seal.example.com") + fx.drainSealed(t) + fx.saveAgent(t, "agent-ls", fx.providerID, "ls.example.com") + + fx.sealer.fail(domain.NewUnavailableError("TL_UNAVAILABLE", "down")) + if _, err := fx.svc.Link(ctx, fx.providerID, identity.IdentityID, []string{"agent-ls"}); !errors.Is(err, domain.ErrUnavailable) { + t.Fatalf("want ErrUnavailable, got %v", err) + } + if _, links, _ := fx.svc.Detail(ctx, fx.providerID, identity.IdentityID); len(links) != 0 { + t.Fatalf("failed seal must write no rows, got %d", len(links)) + } + + fx.sealer.fail(nil) + if linked, err := fx.svc.Link(ctx, fx.providerID, identity.IdentityID, []string{"agent-ls"}); err != nil || linked != 1 { + t.Fatalf("retry: %d %v", linked, err) + } +} + +// TestLink_RateLimited pins the §4.3 link-route limiter. +func TestLink_RateLimited(t *testing.T) { + t.Parallel() + fx := newIdentityFixture(t, nil) + fx.svc.WithLinkRateLimit(1) + ctx := context.Background() + identity, _ := verifyDIDWeb(t, fx, fx.providerID, "did:web:ratelimit.example.com") + fx.saveAgent(t, "agent-rl-1", fx.providerID, "rl1.example.com") + fx.saveAgent(t, "agent-rl-2", fx.providerID, "rl2.example.com") + + if _, err := fx.svc.Link(ctx, fx.providerID, identity.IdentityID, []string{"agent-rl-1"}); err != nil { + t.Fatalf("first link: %v", err) + } + _, err := fx.svc.Link(ctx, fx.providerID, identity.IdentityID, []string{"agent-rl-2"}) + var de *domain.Error + if !errors.As(err, &de) || de.Code != "RATE_LIMITED" { + t.Fatalf("want RATE_LIMITED, got %v", err) + } +} + +// TestUnlink_Discipline pins the unlink guards: rate limit shared +// with link, LINK_NOT_FOUND before anything seals, seal failure +// leaves the link standing, and a clean unlink seals exactly one +// IDENTITY_UNLINKED. +func TestUnlink_Discipline(t *testing.T) { + t.Parallel() + fx := newIdentityFixture(t, nil) + fx.svc.WithSealTimeout(2 * time.Second) + ctx := context.Background() + identity, _ := verifyDIDWeb(t, fx, fx.providerID, "did:web:unlink.example.com") + fx.saveAgent(t, "agent-ud", fx.providerID, "ud.example.com") + + // Unlink before any link: LINK_NOT_FOUND, nothing sealed. + err := fx.svc.Unlink(ctx, fx.providerID, identity.IdentityID, "agent-ud") + var de *domain.Error + if !errors.As(err, &de) || de.Code != "LINK_NOT_FOUND" { + t.Fatalf("want LINK_NOT_FOUND, got %v", err) + } + + if _, err := fx.svc.Link(ctx, fx.providerID, identity.IdentityID, []string{"agent-ud"}); err != nil { + t.Fatal(err) + } + fx.drainSealed(t) + + // Seal failure: the link stands, retry works. + fx.sealer.fail(domain.NewUnavailableError("TL_UNAVAILABLE", "down")) + if err := fx.svc.Unlink(ctx, fx.providerID, identity.IdentityID, "agent-ud"); !errors.Is(err, domain.ErrUnavailable) { + t.Fatalf("want ErrUnavailable, got %v", err) + } + if _, links, _ := fx.svc.Detail(ctx, fx.providerID, identity.IdentityID); len(links) != 1 { + t.Fatalf("failed unlink seal must leave the link, got %d", len(links)) + } + fx.sealer.fail(nil) + if err := fx.svc.Unlink(ctx, fx.providerID, identity.IdentityID, "agent-ud"); err != nil { + t.Fatalf("retry unlink: %v", err) + } + rows := fx.drainSealed(t) + if len(rows) != 1 { + t.Fatalf("unlink seals exactly one event, got %d", len(rows)) + } + if inner := fx.decodeSealed(t, rows[0]); inner.EventType != identityevent.TypeIdentityUnlinked { + t.Fatalf("event type: %s", inner.EventType) + } +} + +// TestVisibilityPredicate_RASide pins §5.6.3 on the management +// plane: a terminal agent's AgentDetails identities[] is empty, and +// the identity detail's linked list drops links to terminal agents — +// while the link rows (history) stay in place. +func TestVisibilityPredicate_RASide(t *testing.T) { + t.Parallel() + fx := newIdentityFixture(t, nil) + ctx := context.Background() + identity, _ := verifyDIDWeb(t, fx, fx.providerID, "did:web:predicate.example.com") + fx.saveAgent(t, "agent-vp-1", fx.providerID, "vp1.example.com") + fx.saveAgent(t, "agent-vp-2", fx.providerID, "vp2.example.com") + if _, err := fx.svc.Link(ctx, fx.providerID, identity.IdentityID, []string{"agent-vp-1", "agent-vp-2"}); err != nil { + t.Fatal(err) + } + + // Both live: both views full. + if got, err := fx.svc.LinkedIdentitiesForAgent(ctx, "agent-vp-1"); err != nil || len(got) != 1 { + t.Fatalf("live agent view: %v %v", got, err) + } + if _, links, _ := fx.svc.Detail(ctx, fx.providerID, identity.IdentityID); len(links) != 2 { + t.Fatalf("want 2 visible links, got %d", len(links)) + } + + // Terminal agent: drops from every current view. Flip the status + // on the EXISTING row (a fresh save would collide on ans_name). + reg, err := fx.agents.FindByAgentID(ctx, "agent-vp-2") + if err != nil { + t.Fatal(err) + } + reg.Status = domain.StatusRevoked + if err := fx.agents.Save(ctx, reg); err != nil { + t.Fatal(err) + } + if got, err := fx.svc.LinkedIdentitiesForAgent(ctx, "agent-vp-2"); err != nil || len(got) != 0 { + t.Fatalf("terminal agent view must be empty: %v %v", got, err) + } + if _, links, _ := fx.svc.Detail(ctx, fx.providerID, identity.IdentityID); len(links) != 1 { + t.Fatalf("terminal agent's link must drop from the count, got %d", len(links)) + } +} + +// TestVerifyControl_ClaimSerializesAttempts pins the seal claim: a +// held claim rejects a second attempt (VERIFICATION_IN_FLIGHT) +// without consuming anything. +func TestVerifyControl_ClaimSerializesAttempts(t *testing.T) { + t.Parallel() + fx := newIdentityFixture(t, nil) + ctx := context.Background() + res, err := fx.svc.Register(ctx, fx.providerID, "did:web:claimrace.example.com") + if err != nil { + t.Fatal(err) + } + priv := genKey(t) + jws := signProof(t, priv, res.Identity.Value+"#key-1", res.Challenges[0].SigningInput, true) + sub := service.ProofSubmission{SignedProofs: []string{jws}} + + // Simulate a concurrent in-flight attempt holding the claim. + store := sqlite.NewIdentityStore(fx.db) + now := fx.clock.now + if err := store.ClaimChallenge(ctx, res.Identity.IdentityID, res.Nonce, now, now.Add(-30*time.Second)); err != nil { + t.Fatal(err) + } + _, err = fx.svc.VerifyControl(ctx, fx.providerID, res.Identity.IdentityID, sub) + var de *domain.Error + if !errors.As(err, &de) || de.Code != "VERIFICATION_IN_FLIGHT" { + t.Fatalf("want VERIFICATION_IN_FLIGHT, got %v", err) + } + + // The holder releases (failed attempt) — the next attempt wins. + if err := store.ReleaseChallenge(ctx, res.Identity.IdentityID, res.Nonce); err != nil { + t.Fatal(err) + } + if _, err := fx.svc.VerifyControl(ctx, fx.providerID, res.Identity.IdentityID, sub); err != nil { + t.Fatalf("verify after release: %v", err) + } +} + +// TestLink_RevokeDuringSealRoundTrip pins the Phase C re-read: a +// revoke committing while the IDENTITY_LINKED seal is in flight must +// not gain live link rows — the §4.3 VERIFIED gate holds at commit, +// and the sealed link event is inert (the TL's read-time status is +// terminal on any revocation leaf). +func TestLink_RevokeDuringSealRoundTrip(t *testing.T) { + t.Parallel() + fx := newIdentityFixture(t, nil) + ctx := context.Background() + identity, _ := verifyDIDWeb(t, fx, fx.providerID, "did:web:race-link.example.com") + fx.saveAgent(t, "agent-race", fx.providerID, "race.example.com") + fx.drainSealed(t) + + store := sqlite.NewIdentityStore(fx.db) + fx.sealer.hook = func() { + if err := store.MarkRevoked(ctx, identity.IdentityID, fx.clock.now); err != nil { + t.Errorf("hook revoke: %v", err) + } + } + _, err := fx.svc.Link(ctx, fx.providerID, identity.IdentityID, []string{"agent-race"}) + var de *domain.Error + if !errors.As(err, &de) || de.Code != "IDENTITY_NOT_VERIFIED" { + t.Fatalf("want IDENTITY_NOT_VERIFIED at commit, got %v", err) + } + fx.sealer.hook = nil + if _, links, _ := fx.svc.Detail(ctx, fx.providerID, identity.IdentityID); len(links) != 0 { + t.Fatalf("revoked identity must gain no live links, got %d", len(links)) + } +} + +// TestVerifyControl_RevokeDuringSealRoundTrip pins the other side of +// the race: a revoke committing while a rotation's IDENTITY_UPDATED +// seal is in flight clears the nonce, so the verifier's conditional +// consume fails closed — the row stays REVOKED, never resurrected. +func TestVerifyControl_RevokeDuringSealRoundTrip(t *testing.T) { + t.Parallel() + fx := newIdentityFixture(t, nil) + ctx := context.Background() + identity, _ := verifyDIDWeb(t, fx, fx.providerID, "did:web:race-verify.example.com") + fx.drainSealed(t) + + // Stage a rotation (same value — key rotation) → fresh nonce. + res, err := fx.svc.Rotate(ctx, fx.providerID, identity.IdentityID, identity.Value) + if err != nil { + t.Fatal(err) + } + priv := genKey(t) + jws := signProof(t, priv, identity.Value+"#key-1", res.Challenges[0].SigningInput, true) + + store := sqlite.NewIdentityStore(fx.db) + fx.sealer.hook = func() { + if err := store.MarkRevoked(ctx, identity.IdentityID, fx.clock.now); err != nil { + t.Errorf("hook revoke: %v", err) + } + } + _, err = fx.svc.VerifyControl(ctx, fx.providerID, identity.IdentityID, service.ProofSubmission{SignedProofs: []string{jws}}) + if err == nil { + t.Fatal("verify racing a committed revoke must fail closed") + } + fx.sealer.hook = nil + got, _, gerr := fx.svc.Detail(ctx, fx.providerID, identity.IdentityID) + if gerr != nil || got.Status != domain.IdentityRevoked { + t.Fatalf("row must stay REVOKED, got %v (%v)", got.Status, gerr) + } +} + +// TestNilSealerFailsClosed pins seal-before-success's no-"seal +// later" rule: without a configured sealer every sealing operation +// refuses with TL_UNAVAILABLE and consumes nothing. +func TestNilSealerFailsClosed(t *testing.T) { + t.Parallel() + db, err := sqlite.Open(context.Background(), ":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + svc := service.NewIdentityService( + sqlite.NewIdentityStore(db), + sqlite.NewIdentityLinkStore(db), + sqlite.NewAgentStore(db), + didresolver.NewNoopResolver(), + nil, // no sealer + db, + ) + ctx := context.Background() + res, err := svc.Register(ctx, "owner-ns", "did:web:nosealer.example.com") + if err != nil { + t.Fatalf("register (no seal needed): %v", err) + } + priv := genKey(t) + jws := signProof(t, priv, res.Identity.Value+"#key-1", res.Challenges[0].SigningInput, true) + _, err = svc.VerifyControl(ctx, "owner-ns", res.Identity.IdentityID, service.ProofSubmission{SignedProofs: []string{jws}}) + if !errors.Is(err, domain.ErrUnavailable) { + t.Fatalf("nil sealer must fail closed with ErrUnavailable, got %v", err) + } + got, _, gerr := svc.Detail(ctx, "owner-ns", res.Identity.IdentityID) + if gerr != nil || got.Status != domain.IdentityPendingControl { + t.Fatalf("row must stand: %v (%v)", got.Status, gerr) + } +} diff --git a/internal/ra/service/identityratelimit.go b/internal/ra/service/identityratelimit.go index 129a3dd..33f947a 100644 --- a/internal/ra/service/identityratelimit.go +++ b/internal/ra/service/identityratelimit.go @@ -1,7 +1,6 @@ package service import ( - "encoding/json" "sync" "time" ) @@ -13,6 +12,13 @@ import ( // RA into a fetch proxy (design §3.7 "bounded fetch"). const defaultRegisterPerMinute = 10 +// defaultLinkPerMinute is the default per-owner budget for the link +// route (design §4.3): operational/DoS hardening — one link suffices +// for the named stolen-credential risk, so the bound limits blast +// radius and TL noise, not the risk itself. Links seal synchronously +// (§5.6.1), so the limiter also shields the TL ingest lane. +const defaultLinkPerMinute = 60 + // ownerLimiter is a fixed-window per-owner rate limiter. In-process // and intentionally simple: the window is a minute, the state is one // counter per owner, and stale owners are pruned opportunistically. @@ -67,14 +73,3 @@ func (l *ownerLimiter) prune(now time.Time) { } } } - -// marshalOutboxPayload renders the {innerEventCanonical, -// producerSignature} outbox payload — the bytes the worker replays -// verbatim. Shared by every event family; the inner canonical bytes -// are family-specific, the payload wrapper is not. -func marshalOutboxPayload(innerCanonical []byte, producerSig string) ([]byte, error) { - return json.Marshal(OutboxPayload{ - InnerEventCanonical: json.RawMessage(innerCanonical), - ProducerSignature: producerSig, - }) -} diff --git a/spec/api-spec-v2.yaml b/spec/api-spec-v2.yaml index 98073c1..88eef90 100644 --- a/spec/api-spec-v2.yaml +++ b/spec/api-spec-v2.yaml @@ -966,20 +966,39 @@ paths: tags: - Verified Identities summary: List my identities - description: Returns every identity owned by the caller, newest first. + description: | + Returns one page of the caller's identities, newest first, in + the standard limit + opaque-cursor envelope (the agent-list + convention). Pass `nextCursor` back as `cursor` for the next + page; a null `nextCursor` is the last page. operationId: listIdentities + parameters: + - name: limit + in: query + required: false + schema: { type: integer, default: 20, minimum: 1, maximum: 100 } + - name: cursor + in: query + required: false + schema: { type: string } + description: Opaque cursor from the previous page's nextCursor responses: '200': - description: The caller's identities + description: One page of the caller's identities content: application/json: schema: type: object + required: [identities, nextCursor] properties: identities: type: array items: $ref: '#/components/schemas/IdentityDetails' + nextCursor: + type: string + nullable: true + description: Cursor for the next page; null on the last page '401': description: Authentication failed content: @@ -1081,11 +1100,19 @@ paths: closed. The nonce is consumed exactly once, inside the success transaction — a failed attempt does not consume it. - On success the identity flips to `VERIFIED` (or completes a - staged rotation) and the RA seals `IDENTITY_VERIFIED` / + JWS bearing unrecognized `crit` header parameters are + rejected (RFC 7515 §4.1.11) — this verifier implements no + critical extensions. + + Seal-before-success: the RA seals `IDENTITY_VERIFIED` / `IDENTITY_UPDATED` on the identity's own Transparency Log - stream, with every proven key sealed self-verifyingly - (public key + signed proof). + stream — every proven key sealed self-verifyingly (public + key + signed proof) — and reports success ONLY after the TL + acknowledges the seal; the row transition commits with that + acknowledgment, so anything this API reports as set up is + resolvable in the TL at that moment. If the TL is + unavailable the call fails 503 `TL_UNAVAILABLE`, retryable: + the nonce is NOT consumed and the prior state stands. operationId: verifyIdentityControl parameters: - $ref: '#/components/parameters/IdentityIdPath' @@ -1118,8 +1145,12 @@ paths: description: | Challenge state error — `PRICC_TOKEN_EXPIRED`, `PRICC_TOKEN_ALREADY_USED`, `IDENTIFIER_CHALLENGE_EXPIRED` - — or the identity is revoked. Recovery from an expired - nonce is the idempotent re-add (re-POST the same value). + — the identity is revoked, a concurrent verify attempt + holds the seal claim (`VERIFICATION_IN_FLIGHT` — retry + shortly; the claim expires within ~30s), or the + identifier is already verified by another owner + (`IDENTIFIER_DUPLICATE`). Recovery from an expired nonce + is the idempotent re-add (re-POST the same value). content: application/json: schema: @@ -1134,6 +1165,13 @@ paths: schema: $ref: '#/components/schemas/ErrorResponse' + '503': + description: Transparency Log unavailable (TL_UNAVAILABLE — retryable; the nonce is NOT consumed) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /ans/identities/{identityId}/revoke: post: tags: @@ -1146,6 +1184,10 @@ paths: append-only in the Transparency Log. Propagation to every linked agent's badge is the TL's read-time join, not a write fan-out. + + Seal-before-success: the row flips only after the TL + acknowledges the seal. TL unavailable → 503 `TL_UNAVAILABLE`, + retryable, the identity stays VERIFIED. operationId: revokeIdentity parameters: - $ref: '#/components/parameters/IdentityIdPath' @@ -1174,6 +1216,12 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + '503': + description: Transparency Log unavailable (TL_UNAVAILABLE — retryable, nothing changed) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /ans/identities/{identityId}/links: post: @@ -1189,7 +1237,19 @@ paths: the agent ids; an agent's own stream and audit history are never written by identity operations. Already-linked agents are skipped idempotently; a call that links nothing new seals - nothing. Links attach only while the identity is VERIFIED. + nothing. + + Liveness gate: links attach only while the identity is + VERIFIED, and every named agent must be live — ACTIVE or + DEPRECATED; a terminal or pre-activation agent fails the + whole batch with 422 `AGENT_NOT_LINKABLE` (all-or-nothing, + matching the one-event batch semantics). The route is + per-owner rate limited (429-equivalent `RATE_LIMITED`). + + Seal-before-success: success is reported only after the TL + acknowledges the `IDENTITY_LINKED` seal; link rows commit + with the acknowledgment. TL unavailable → 503 + `TL_UNAVAILABLE`, retryable, nothing linked. operationId: linkIdentityAgents parameters: - $ref: '#/components/parameters/IdentityIdPath' @@ -1225,7 +1285,14 @@ paths: schema: $ref: '#/components/schemas/ErrorResponse' '422': - description: Invalid link request (empty or oversized batch) + description: Invalid link request (empty or oversized batch — INVALID_LINK_REQUEST), or a named agent is not live (AGENT_NOT_LINKABLE — links require ACTIVE or DEPRECATED; rejected all-or-nothing) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + '503': + description: Transparency Log unavailable (TL_UNAVAILABLE — retryable, nothing linked) content: application/json: schema: @@ -1240,7 +1307,12 @@ paths: Ends one association and seals `IDENTITY_UNLINKED` on the identity stream. The association's history persists in the identity's audit chain and the raw log tiles; unlinked pairs - may be re-linked later. + may be re-linked later. Shares the link route's per-owner + rate limit. + + Seal-before-success: the link row flips only after the TL + acknowledges the seal. TL unavailable → 503 `TL_UNAVAILABLE`, + retryable, the link stands. operationId: unlinkIdentityAgent parameters: - $ref: '#/components/parameters/IdentityIdPath' @@ -1260,6 +1332,12 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + '503': + description: Transparency Log unavailable (TL_UNAVAILABLE — retryable, nothing changed) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' components: From 97bdbb0a9aaffa4c2f7530727ddf7cddbf88bc23 Mon Sep 17 00:00:00 2001 From: Connor Snitker Date: Thu, 11 Jun 2026 18:29:31 -0500 Subject: [PATCH 06/13] feat(tl): visibility predicate, verbatim keys[] views, terminal revocation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/adapter/docsui/openapi/tl.yaml | 146 ++++++++++++--- internal/adapter/store/sqlitetl/events.go | 22 +++ internal/tl/handler/handler.go | 69 ++++++- internal/tl/handler/handler_identity_test.go | 171 +++++++++++++++-- internal/tl/handler/paginate_internal_test.go | 26 +++ internal/tl/service/badge.go | 23 +++ internal/tl/service/identitybadge.go | 174 +++++++++++++----- internal/tl/service/log.go | 7 + spec/api-spec-tl-v2.yaml | 146 ++++++++++++--- 9 files changed, 660 insertions(+), 124 deletions(-) create mode 100644 internal/tl/handler/paginate_internal_test.go diff --git a/internal/adapter/docsui/openapi/tl.yaml b/internal/adapter/docsui/openapi/tl.yaml index fa98ebc..4664a78 100644 --- a/internal/adapter/docsui/openapi/tl.yaml +++ b/internal/adapter/docsui/openapi/tl.yaml @@ -332,11 +332,21 @@ paths: summary: Identity badge description: | The latest sealed identity event + inclusion proof + computed - status (`VERIFIED` | `REVOKED`). The proof events seal every - proven key self-verifyingly: any third party reads the key - out of the sealed verbatim `verificationMethod`, verifies the - sealed `signedProof` against it, and confirms the payload - binds this identityId — offline, without trusting the RA. + `{status, keys[], keysLogId}`: the latest entry may be a + link/unlink/revocation carrying no key material, so the badge + quotes the CURRENT proven key set verbatim from the latest + sealed proof event, with `keysLogId` pointing at that seal + (omitted when REVOKED — the keys are no longer attested). + + Third-party verification is byte-recompute, not + parse-and-inspect: verify the sealed `signedProof` JWS + against the key inside the sealed `verificationMethod`; take + the nonce from the decoded payload; reconstruct the + IdentityProofInput from the sealed event plus that nonce; + JCS-serialize; and require the JWS payload segment to equal + those bytes verbatim. Verifiers MUST reject a JWS bearing + unrecognized `crit` headers (RFC 7515 §4.1.11). All offline, + without trusting the RA. operationId: getIdentityBadge parameters: - $ref: '#/components/parameters/IdentityIdPath' @@ -425,13 +435,23 @@ paths: tags: [Transparency Log] summary: Agents currently linked to the identity (reverse join) description: | - Computed at query time from the link events' agent index: - every agent whose latest link/unlink fact naming it is - LINKED, each decorated with its own computed badge status so - a reader checks both ends of the link in one response. + Computed at query time from the link events' agent index + under the visibility predicate: every agent whose latest + link/unlink fact naming it is LINKED and whose own computed + status is live, decorated with that status. Paginated — the + reverse join is the genuinely unbounded read (an identity + links to unlimited agents). operationId: getIdentityAgents parameters: - $ref: '#/components/parameters/IdentityIdPath' + - name: limit + in: query + required: false + schema: { type: integer, default: 50, minimum: 1, maximum: 200 } + - name: offset + in: query + required: false + schema: { type: integer, default: 0, minimum: 0 } responses: '200': description: Currently-linked agents. @@ -439,12 +459,15 @@ paths: application/json: schema: type: object - required: [agents] + required: [agents, total] properties: agents: type: array items: $ref: '#/components/schemas/LinkedAgentView' + total: + type: integer + description: Full visible-link count across all pages '404': description: No events for this identity. content: @@ -458,11 +481,22 @@ paths: summary: Identities currently linked to the agent (computed) description: | The same entries as the agent badge's `identities[]` field, - served alone. Computed at read time — link live ∧ identity - stream state — never stored on, or sealed into, the agent. + served alone and paginated — the overflow target for the + badge's embedded safety cap. Computed at read time under the + visibility predicate (link LINKED ∧ agent live; a REVOKED + identity stays visible with no keys) — never stored on, or + sealed into, the agent. operationId: getAgentIdentities parameters: - $ref: '#/components/parameters/AgentIdPath' + - name: limit + in: query + required: false + schema: { type: integer, default: 50, minimum: 1, maximum: 200 } + - name: offset + in: query + required: false + schema: { type: integer, default: 0, minimum: 0 } responses: '200': description: Currently-linked identities. @@ -470,12 +504,15 @@ paths: application/json: schema: type: object - required: [identities] + required: [identities, total] properties: identities: type: array items: $ref: '#/components/schemas/LinkedIdentityView' + total: + type: integer + description: Full visible-link count across all pages /v1/agents/{agentId}/identities/history: get: @@ -1030,12 +1067,49 @@ components: type: array description: | Agent badges only — the COMPUTED read-time join of the - agent's currently-linked verified identities. Covered by - the TL's response signature, never by any seal; identity - rotation/revocation is visible here immediately with - zero agent-stream writes. + agent's currently-linked verified identities, under the + visibility predicate (link LINKED AND agent live). A + REVOKED identity stays visible with identityStatus + REVOKED and no keys[] — a verifier must see the who was + revoked. Covered by the TL's response signature, never by + any seal; identity rotation/revocation is visible here + immediately with zero agent-stream writes. Embeds at most + a small safety cap of entries; identitiesTotal carries + the full count and /v1/agents/{agentId}/identities is the + paginated overflow target. items: $ref: '#/components/schemas/LinkedIdentityView' + identitiesTotal: + type: integer + description: | + Agent badges only — the full count of visible links when + identities[] is present (the embedded array is capped). + identitiesUnavailable: + type: boolean + description: | + Agent badges only — set when the identities join could + not be computed. Join failure is explicit, never silent: + an absent or empty identities[] always means "no visible + links", never "the join failed". + keys: + type: array + description: | + Identity badges only — the CURRENT proven key set quoted + VERBATIM from the latest sealed proof event + (IDENTITY_VERIFIED / IDENTITY_UPDATED): the latest stream + entry may be a link/unlink/revocation carrying no key + material, so the badge answers "what are the attested + keys" directly. Verification methods only — the + signedProof evidence lives in the seal at keysLogId, one + hop away. Omitted when the identity is REVOKED. + items: + type: object + description: A sealed verification method, member-for-member verbatim + keysLogId: + type: string + description: | + Identity badges only — the sealed proof event keys[] is + quoted from. Omitted when the identity is REVOKED. TransparencyLogAudit: type: object @@ -1151,7 +1225,12 @@ components: LinkedIdentityView: type: object - description: One computed identities[] entry on the agent badge. + description: | + One computed identities[] entry on the agent badge, under the + visibility predicate: the entry appears while the link is + LINKED and the agent is live; identityStatus reports the + identity's current stream state (a REVOKED identity stays + visible — the attestation rule withholds only its keys). required: [identityId, kind, value, identityStatus] properties: identityId: { type: string } @@ -1161,20 +1240,26 @@ components: type: string enum: [VERIFIED, REVOKED] description: Reflects the identity stream NOW - provenKeyIds: + keys: type: array description: | - Verification-method ids of the current proven key set - (post-rotation). The full verbatim methods live in the - sealed proof event. - items: { type: string } + The CURRENT proven key set quoted VERBATIM from the + latest sealed proof event — verification methods only, + member-for-member as sealed, so a verifier checks + operator signatures from the badge alone (no audit + walk). The signedProof evidence lives in the seal at + keysLogId. Omitted when identityStatus is REVOKED: the + keys are no longer attested. + items: + type: object + description: A sealed verification method, verbatim + keysLogId: + type: string + description: The sealed proof event keys[] is quoted from — fetch for signedProofs / offline evidence. Omitted when REVOKED. linkedAt: { type: string, format: date-time } linkLogId: type: string description: The sealed IDENTITY_LINKED entry on the identity stream — fetch for link evidence - identityLogId: - type: string - description: Latest identity-stream entry — fetch for the identity evidence/history LinkedAgentView: type: object @@ -1185,8 +1270,13 @@ components: linkedAt: { type: string, format: date-time } agentStatus: type: string - enum: [ACTIVE, REVOKED, DEPRECATED, EXPIRED, WARNING] - description: The linked agent's own computed badge status — a link is effective only while both ends are live + enum: [ACTIVE, DEPRECATED, WARNING] + description: | + The linked agent's own computed badge status. The reverse + join applies the visibility predicate — only live agents + (ACTIVE / DEPRECATED / WARNING) appear; terminal agents' + links drop out of every "current" view (history stays in + the audit chain). # ── Admin: producer keys ─────────────────────────────────── ProducerKeyRequest: diff --git a/internal/adapter/store/sqlitetl/events.go b/internal/adapter/store/sqlitetl/events.go index 84aa033..3ca7283 100644 --- a/internal/adapter/store/sqlitetl/events.go +++ b/internal/adapter/store/sqlitetl/events.go @@ -323,6 +323,28 @@ func (s *EventStore) GetLatestProofByIdentityID(ctx context.Context, identityID return &r, nil } +// HasIdentityRevoked reports whether the identity's stream contains +// an IDENTITY_REVOKED event. Revocation is terminal at READ time +// regardless of stream tail order: a racing operation's event can +// land after the revocation leaf (the RA's seal spans a network +// round trip), and a late leaf must never resurrect a revoked +// identity on the public surface. +func (s *EventStore) HasIdentityRevoked(ctx context.Context, identityID string) (bool, error) { + var one int + err := s.db.db.GetContext(ctx, &one, ` + SELECT 1 FROM tl_events + WHERE identity_id = ? AND event_type = 'IDENTITY_REVOKED' + LIMIT 1`, identityID) + switch { + case err == nil: + return true, nil + case errors.Is(err, sql.ErrNoRows): + return false, nil + default: + return false, err + } +} + // LinkStatesByAgent returns, for one agent, the latest link/unlink // fact per identity that ever named it — the badge-join input. Rows // where Linked() is true are the agent's live links. diff --git a/internal/tl/handler/handler.go b/internal/tl/handler/handler.go index 88b4566..928a263 100644 --- a/internal/tl/handler/handler.go +++ b/internal/tl/handler/handler.go @@ -262,32 +262,71 @@ func (h *Handlers) GetBadge(w http.ResponseWriter, r *http.Request) { return } if h.identityBadge != nil { - identities, jerr := h.identityBadge.LinkedIdentitiesForAgent(r.Context(), agentID) + identities, jerr := h.identityBadge.LinkedIdentitiesForAgent(r.Context(), agentID, tl.Status) if jerr != nil { - writeError(w, jerr) - return + // Join failure is explicit, never silent (§5.6.3): the + // badge still serves the agent's sealed material; the + // flag says the identities view could not be computed — + // an empty identities[] always means "no visible links". + tl.IdentitiesUnavailable = true + } else { + tl.IdentitiesTotal = len(identities) + if len(identities) > badgeIdentitiesCap { + identities = identities[:badgeIdentitiesCap] + } + tl.Identities = identities } - tl.Identities = identities } writeJSON(w, http.StatusOK, tl) } +// badgeIdentitiesCap bounds the badge's embedded identities[] — +// identities-per-agent is realistically tiny, and the standalone +// paginated /v1/agents/{agentId}/identities route is the overflow +// target (§5.6.1). identitiesTotal always carries the full count. +const badgeIdentitiesCap = 25 + // GetAgentIdentities handles GET /v1/agents/{agentId}/identities — // the computed list of identities currently linked to the agent. // Identical entries to the badge's identities[] field, served alone -// for callers who don't need the full badge. +// and paginated (TL limit/offset convention) — the overflow target +// for the badge's embedded cap. func (h *Handlers) GetAgentIdentities(w http.ResponseWriter, r *http.Request) { agentID := chi.URLParam(r, "agentId") if agentID == "" { writeError(w, domain.NewValidationError("MISSING_AGENT_ID", "agentId is required")) return } - identities, err := h.identityBadge.LinkedIdentitiesForAgent(r.Context(), agentID) + // The agent's computed status is the liveness conjunct of the + // visibility predicate — and resolving it 404s unknown agents, + // parity with the badge route. + agentTL, err := h.badge.Get(r.Context(), agentID) + if err != nil { + writeError(w, err) + return + } + identities, err := h.identityBadge.LinkedIdentitiesForAgent(r.Context(), agentID, agentTL.Status) if err != nil { writeError(w, err) return } - writeJSON(w, http.StatusOK, map[string]any{"identities": identities}) + limit, offset := parsePagination(r) + total := len(identities) + identities = paginateIdentityViews(identities, limit, offset) + writeJSON(w, http.StatusOK, map[string]any{"identities": identities, "total": total}) +} + +// paginateIdentityViews applies the TL limit/offset convention to a +// computed (already fully materialized) view slice. +func paginateIdentityViews[T any](views []T, limit, offset int) []T { + if offset >= len(views) { + return []T{} + } + views = views[offset:] + if limit > 0 && limit < len(views) { + views = views[:limit] + } + return views } // GetAgentIdentityHistory handles @@ -395,7 +434,12 @@ func (h *Handlers) GetIdentityAgents(w http.ResponseWriter, r *http.Request) { writeError(w, err) return } - writeJSON(w, http.StatusOK, map[string]any{"agents": agents}) + // Paginated (TL limit/offset convention): the reverse join is the + // genuinely unbounded read — an identity links to unlimited agents. + limit, offset := parsePagination(r) + total := len(agents) + agents = paginateIdentityViews(agents, limit, offset) + writeJSON(w, http.StatusOK, map[string]any{"agents": agents, "total": total}) } // GetAudit handles GET /v1/agents/{agentId}/audit. Matches the @@ -650,7 +694,14 @@ func writeJSON(w http.ResponseWriter, status int, body any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) if body != nil { - _ = json.NewEncoder(w).Encode(body) + enc := json.NewEncoder(w) + // The TL serves SEALED bytes quoted verbatim (keys[], the + // echoed payload): Go's default HTML escaping would rewrite + // &, <, > inside json.RawMessage, breaking the byte-verbatim + // contract a verifier byte-compares against the seal. This + // is a pure-JSON API — HTML escaping protects nothing here. + enc.SetEscapeHTML(false) + _ = enc.Encode(body) } } diff --git a/internal/tl/handler/handler_identity_test.go b/internal/tl/handler/handler_identity_test.go index 18b79ce..04877b7 100644 --- a/internal/tl/handler/handler_identity_test.go +++ b/internal/tl/handler/handler_identity_test.go @@ -22,7 +22,7 @@ const testIdentityID = "01HXKQTESTIDENTITY0000000A" // keyed to the testbed's identity fixture. timestamps vary per call // site so dedup never collides across events in one test; proof // events name their verification method by the given kid so the -// read-join's provenKeyIds visibly flips on rotation. +// read-join's quoted keys[] visibly flip on rotation. func identityInner(typ identityevent.Type, ts string, ansIDs []string, kid string) identityevent.Event { ev := identityevent.Event{ EventType: typ, @@ -79,18 +79,31 @@ type badgeView struct { SchemaVersion string `json:"schemaVersion"` Status string `json:"status"` Signature string `json:"signature"` - Identities []struct { - IdentityID string `json:"identityId"` - Kind string `json:"kind"` - Value string `json:"value"` - IdentityStatus string `json:"identityStatus"` - ProvenKeyIDs []string `json:"provenKeyIds"` - LinkedAt string `json:"linkedAt"` - LinkLogID string `json:"linkLogId"` - IdentityLogID string `json:"identityLogId"` + + // Identity badge only: the computed current attestation. + Keys []verificationMethodView `json:"keys"` + KeysLogID string `json:"keysLogId"` + + IdentitiesTotal int `json:"identitiesTotal"` + IdentitiesUnavailable bool `json:"identitiesUnavailable"` + Identities []struct { + IdentityID string `json:"identityId"` + Kind string `json:"kind"` + Value string `json:"value"` + IdentityStatus string `json:"identityStatus"` + Keys []verificationMethodView `json:"keys"` + KeysLogID string `json:"keysLogId"` + LinkedAt string `json:"linkedAt"` + LinkLogID string `json:"linkLogId"` } `json:"identities"` } +// verificationMethodView decodes the id member of a quoted verbatim +// verification method — enough to assert which key is attested. +type verificationMethodView struct { + ID string `json:"id"` +} + // auditView decodes the subset of audit responses the stages assert // on. type auditView struct { @@ -141,6 +154,11 @@ func stageVerify(t *testing.T, tb *tlTestbed, agentID string) { if idBadge.SchemaVersion != "V2" || idBadge.Signature == "" { t.Fatalf("identity badge missing schema/attestation: %+v", idBadge) } + // Computed current attestation (§5.6.3): the badge quotes the + // proven keys + the seal they came from. + if len(idBadge.Keys) != 1 || idBadge.Keys[0].ID != "did:web:identity.acme-corp.com#key-1" || idBadge.KeysLogID == "" { + t.Fatalf("identity badge keys quote: %+v", idBadge) + } var agentBadge badgeView if code := getJSON(t, tb, "/v1/agents/"+agentID, &agentBadge); code != http.StatusOK { @@ -170,22 +188,33 @@ func stageLink(t *testing.T, tb *tlTestbed, agentID string) { got.Kind != "did:web" || got.Value != "did:web:identity.acme-corp.com" { t.Fatalf("join entry wrong: %+v", got) } - if len(got.ProvenKeyIDs) != 1 || got.ProvenKeyIDs[0] != "did:web:identity.acme-corp.com#key-1" { - t.Fatalf("provenKeyIds = %v", got.ProvenKeyIDs) + if len(got.Keys) != 1 || got.Keys[0].ID != "did:web:identity.acme-corp.com#key-1" || got.KeysLogID == "" { + t.Fatalf("quoted keys = %+v", got) } - if got.LinkedAt != "2026-06-10T11:00:00Z" || got.LinkLogID == "" || got.IdentityLogID == "" { + if got.LinkedAt != "2026-06-10T11:00:00Z" || got.LinkLogID == "" { t.Fatalf("link evidence fields missing: %+v", got) } + if agentBadge.IdentitiesTotal != 1 { + t.Fatalf("identitiesTotal = %d, want 1", agentBadge.IdentitiesTotal) + } - // The standalone computed view matches the badge join. + // The standalone computed view matches the badge join — and is + // paginated with a total (the badge-cap overflow target). var identitiesResp struct { Identities []json.RawMessage `json:"identities"` + Total int `json:"total"` } if code := getJSON(t, tb, "/v1/agents/"+agentID+"/identities", &identitiesResp); code != http.StatusOK { t.Fatalf("agent identities view: %d", code) } - if len(identitiesResp.Identities) != 1 { - t.Fatalf("agent identities view count = %d", len(identitiesResp.Identities)) + if len(identitiesResp.Identities) != 1 || identitiesResp.Total != 1 { + t.Fatalf("agent identities view: %d of %d", len(identitiesResp.Identities), identitiesResp.Total) + } + if code := getJSON(t, tb, "/v1/agents/"+agentID+"/identities?limit=1&offset=1", &identitiesResp); code != http.StatusOK { + t.Fatalf("paged identities view: %d", code) + } + if len(identitiesResp.Identities) != 0 || identitiesResp.Total != 1 { + t.Fatalf("offset page must be empty with total intact: %d of %d", len(identitiesResp.Identities), identitiesResp.Total) } // Reverse join: identity → agents, with the agent's own status. @@ -203,6 +232,13 @@ func stageLink(t *testing.T, tb *tlTestbed, agentID string) { agentsResp.Agents[0].AgentStatus != "ACTIVE" { t.Fatalf("reverse join: %+v", agentsResp.Agents) } + // Reverse join is paginated too (the genuinely unbounded read). + if code := getJSON(t, tb, "/v1/identities/"+testIdentityID+"/agents?limit=1&offset=1", &agentsResp); code != http.StatusOK { + t.Fatalf("paged reverse join: %d", code) + } + if len(agentsResp.Agents) != 0 { + t.Fatalf("offset page of reverse join must be empty: %+v", agentsResp.Agents) + } } // stageRotate seals IDENTITY_UPDATED and checks the proven-key flip @@ -216,8 +252,8 @@ func stageRotate(t *testing.T, tb *tlTestbed, agentID string) { if code := getJSON(t, tb, "/v1/agents/"+agentID, &agentBadge); code != http.StatusOK { t.Fatalf("agent badge after rotation: %d", code) } - if ids := agentBadge.Identities[0].ProvenKeyIDs; len(ids) != 1 || ids[0] != "did:web:identity.acme-corp.com#key-2" { - t.Fatalf("rotation not visible on linked badge: %v", ids) + if keys := agentBadge.Identities[0].Keys; len(keys) != 1 || keys[0].ID != "did:web:identity.acme-corp.com#key-2" { + t.Fatalf("rotation not visible on linked badge: %+v", keys) } // The agent's own audit history stays purely AGENT_* — identity @@ -240,6 +276,14 @@ func stageRotate(t *testing.T, tb *tlTestbed, agentID string) { if audit.Records[0].Payload.Producer.Event.EventType != "IDENTITY_UPDATED" { t.Fatalf("identity audit order: %+v", audit.Records[0]) } + // Pagination clamps apply on the audit too (newest-first: offset 1 + // of the 3-event chain is the IDENTITY_LINKED leaf). + if code := getJSON(t, tb, "/v1/identities/"+testIdentityID+"/audit?limit=1&offset=1", &audit); code != http.StatusOK { + t.Fatalf("paged identity audit: %d", code) + } + if len(audit.Records) != 1 || audit.Records[0].Payload.Producer.Event.EventType != "IDENTITY_LINKED" { + t.Fatalf("paged identity audit: %+v", audit.Records) + } // Association history for the agent: the standard audit envelope // filtered to link events naming it. @@ -272,6 +316,14 @@ func stageRevoke(t *testing.T, tb *tlTestbed, agentID string) { if len(agentBadge.Identities) != 1 || agentBadge.Identities[0].IdentityStatus != "REVOKED" { t.Fatalf("revocation not visible on linked badge: %+v", agentBadge.Identities) } + // Visibility ≠ attestation (§5.6.3): the entry stays — a verifier + // must SEE the revocation — but the keys are withheld. + if len(agentBadge.Identities[0].Keys) != 0 || agentBadge.Identities[0].KeysLogID != "" { + t.Fatalf("revoked identity must carry no attested keys: %+v", agentBadge.Identities[0]) + } + if len(idBadge.Keys) != 0 || idBadge.KeysLogID != "" { + t.Fatalf("revoked identity badge must carry no keys quote: %+v", idBadge) + } // The agent itself is untouched (the what survives the who's // revocation, and vice versa). if agentBadge.Status != "ACTIVE" { @@ -293,6 +345,20 @@ func stageUnlink(t *testing.T, tb *tlTestbed, agentID string) { if len(unlinkedBadge.Identities) != 0 { t.Fatalf("identities after unlink: %+v", unlinkedBadge.Identities) } + // REVOKED is terminal at read time: this unlink leaf landed AFTER + // the revocation, and the identity badge must NOT flip back to + // VERIFIED on latest-event-wins — a late leaf can never resurrect + // a revoked identity (nor re-attach its keys). + var idBadge badgeView + if code := getJSON(t, tb, "/v1/identities/"+testIdentityID, &idBadge); code != http.StatusOK { + t.Fatalf("identity badge after post-revoke leaf: %d", code) + } + if idBadge.Status != "REVOKED" { + t.Fatalf("post-revoke leaf resurrected the identity: status=%q", idBadge.Status) + } + if len(idBadge.Keys) != 0 || idBadge.KeysLogID != "" { + t.Fatalf("post-revoke leaf re-attached keys: %+v", idBadge) + } var audit auditView if code := getJSON(t, tb, "/v1/agents/"+agentID+"/identities/history", &audit); code != http.StatusOK { t.Fatalf("identity history after unlink: %d", code) @@ -405,3 +471,72 @@ func TestIdentityReads_NotFound(t *testing.T) { t.Errorf("unknown identity audit: code=%d records=%d", code, len(audit.Records)) } } + +// TestAgentLivenessConjunct pins the §5.6.3 visibility predicate's +// agent leg on the TL: once the agent's stream goes terminal +// (AGENT_REVOKED), its identities[] views empty out — the link +// history stays recoverable — while the identity itself is untouched. +func TestAgentLivenessConjunct(t *testing.T) { + tb := newTLTestbed(t) + + agentBody := []byte(mustJSON(t, tb.inner)) + tb.postEvent(t, agentBody, tb.signWithProducer(t, agentBody)) + agentID := tb.inner.AnsID + + postIdentityEvent(t, tb, + identityInner(identityevent.TypeIdentityVerified, "2026-06-10T10:00:00Z", nil, "did:web:identity.acme-corp.com#key-1")) + postIdentityEvent(t, tb, + identityInner(identityevent.TypeIdentityLinked, "2026-06-10T11:00:00Z", []string{agentID}, "")) + + var badge badgeView + if code := getJSON(t, tb, "/v1/agents/"+agentID, &badge); code != http.StatusOK { + t.Fatalf("badge: %d", code) + } + if len(badge.Identities) != 1 { + t.Fatalf("pre-revoke identities: %+v", badge.Identities) + } + + // Terminal agent event → the agent leg of the predicate fails. + revoked := tb.inner + revoked.EventType = "AGENT_REVOKED" + revoked.IssuedAt = "2026-06-10T12:00:00Z" + revBody := []byte(mustJSON(t, revoked)) + tb.postEvent(t, revBody, tb.signWithProducer(t, revBody)) + + // Fresh struct: omitted fields (identities, identitiesTotal) must + // not inherit the pre-revoke read's values. + var afterBadge badgeView + if code := getJSON(t, tb, "/v1/agents/"+agentID, &afterBadge); code != http.StatusOK { + t.Fatalf("badge after agent revoke: %d", code) + } + if afterBadge.Status != "REVOKED" || len(afterBadge.Identities) != 0 || afterBadge.IdentitiesTotal != 0 { + t.Fatalf("terminal agent must show no identities: status=%s %+v total=%d", + afterBadge.Status, afterBadge.Identities, afterBadge.IdentitiesTotal) + } + // Reverse join drops the terminal agent too. + var agentsResp struct { + Agents []json.RawMessage `json:"agents"` + } + if code := getJSON(t, tb, "/v1/identities/"+testIdentityID+"/agents", &agentsResp); code != http.StatusOK { + t.Fatalf("reverse join after agent revoke: %d", code) + } + if len(agentsResp.Agents) != 0 { + t.Fatalf("terminal agent must drop from the reverse join: %+v", agentsResp.Agents) + } + // The identity itself is untouched (the who survives the what). + var idBadge badgeView + if code := getJSON(t, tb, "/v1/identities/"+testIdentityID, &idBadge); code != http.StatusOK { + t.Fatalf("identity badge: %d", code) + } + if idBadge.Status != "VERIFIED" || len(idBadge.Keys) != 1 { + t.Fatalf("identity must stay VERIFIED with keys: %+v", idBadge) + } + // History keeps the link evidence. + var audit auditView + if code := getJSON(t, tb, "/v1/agents/"+agentID+"/identities/history", &audit); code != http.StatusOK { + t.Fatalf("history: %d", code) + } + if len(audit.Records) != 1 { + t.Fatalf("history must keep the link: %+v", audit.Records) + } +} diff --git a/internal/tl/handler/paginate_internal_test.go b/internal/tl/handler/paginate_internal_test.go new file mode 100644 index 0000000..9bad2f8 --- /dev/null +++ b/internal/tl/handler/paginate_internal_test.go @@ -0,0 +1,26 @@ +package handler + +import "testing" + +// paginateIdentityViews backs both computed-view routes; pin its +// edges (offset past the end, zero limit = all, limit clamps). +func TestPaginateIdentityViews(t *testing.T) { + t.Parallel() + views := []int{1, 2, 3, 4, 5} + + if got := paginateIdentityViews(views, 0, 0); len(got) != 5 { + t.Fatalf("no bounds: %v", got) + } + if got := paginateIdentityViews(views, 2, 0); len(got) != 2 || got[0] != 1 { + t.Fatalf("limit only: %v", got) + } + if got := paginateIdentityViews(views, 2, 4); len(got) != 1 || got[0] != 5 { + t.Fatalf("tail page: %v", got) + } + if got := paginateIdentityViews(views, 2, 9); len(got) != 0 { + t.Fatalf("offset past end: %v", got) + } + if got := paginateIdentityViews(views, 99, 1); len(got) != 4 { + t.Fatalf("oversized limit: %v", got) + } +} diff --git a/internal/tl/service/badge.go b/internal/tl/service/badge.go index 6d6e331..39ff897 100644 --- a/internal/tl/service/badge.go +++ b/internal/tl/service/badge.go @@ -2,6 +2,7 @@ package service import ( "context" + "encoding/json" "errors" "time" @@ -51,6 +52,28 @@ type TransparencyLog struct { // single-purpose. Covered by the TL's response signature, not by // any seal: link facts live on the identity stream. Identities []*LinkedIdentityView `json:"identities,omitempty"` + + // IdentitiesTotal counts every visible link when Identities is + // present — the badge embeds at most a small safety cap of + // entries (§5.6.1); the standalone, paginated + // /v1/agents/{agentId}/identities route is the overflow target. + IdentitiesTotal int `json:"identitiesTotal,omitempty"` + + // IdentitiesUnavailable is set when the identities join could not + // be computed (design §5.6.3: join failure is explicit, never + // silent) — an absent/empty identities[] always means "no visible + // links", never "the join failed". + IdentitiesUnavailable bool `json:"identitiesUnavailable,omitempty"` + + // Keys / KeysLogID are the identity badge's computed quote of the + // CURRENT proven key set (design §5.6.3 "computed views carry the + // keys"): the verification methods verbatim from the latest + // sealed proof event, with KeysLogID pointing at that seal (fetch + // it for the signedProof evidence). Populated on the identity + // badge only — never on audit entries — and omitted when the + // identity is REVOKED (the keys are no longer attested). + Keys []json.RawMessage `json:"keys,omitempty"` + KeysLogID string `json:"keysLogId,omitempty"` } // BadgeService computes the badge from the latest mirrored event diff --git a/internal/tl/service/identitybadge.go b/internal/tl/service/identitybadge.go index f1816c0..5ff52bb 100644 --- a/internal/tl/service/identitybadge.go +++ b/internal/tl/service/identitybadge.go @@ -6,6 +6,7 @@ import ( "errors" sqlitetl "github.com/godaddy/ans/internal/adapter/store/sqlitetl" + "github.com/godaddy/ans/internal/domain" ) // Identity badge statuses. Identities have a two-state read-time @@ -31,12 +32,21 @@ type LinkedIdentityView struct { Value string `json:"value"` IdentityStatus string `json:"identityStatus"` // VERIFIED | REVOKED — reflects the identity stream NOW - // ProvenKeyIDs names the identity's current proven key set — the - // verification-method ids from the latest proof event - // (post-rotation). The full verbatim verification methods live in - // the sealed event; thumbprints are compute-at-read conveniences - // derivable from that sealed source. - ProvenKeyIDs []string `json:"provenKeyIds,omitempty"` + // Keys quotes the identity's CURRENT proven key set verbatim + // from the latest sealed proof event (IDENTITY_VERIFIED / + // IDENTITY_UPDATED) — the verification methods exactly as sealed, + // member-for-member (§5.6.3 "computed views carry the keys"): a + // verifier checks operator signatures from the badge alone, no + // audit walk. Methods only — the signedProof evidence lives in + // the seal at KeysLogID, one hop away. Omitted when the identity + // is REVOKED: the keys are no longer attested (the entry itself + // stays visible with identityStatus REVOKED while the link and + // agent are live). + Keys []json.RawMessage `json:"keys,omitempty"` + + // KeysLogID points at the sealed proof event Keys is quoted from + // — fetch it for the signedProofs / offline evidence. + KeysLogID string `json:"keysLogId,omitempty"` // LinkedAt is the producer timestamp of the sealed // IDENTITY_LINKED event that bound this agent. @@ -45,10 +55,6 @@ type LinkedIdentityView struct { // LinkLogID points at the sealed IDENTITY_LINKED entry on the // identity stream — fetch it for link evidence. LinkLogID string `json:"linkLogId,omitempty"` - - // IdentityLogID points at the latest identity-stream entry — - // fetch it (or the audit) for the identity evidence/history. - IdentityLogID string `json:"identityLogId,omitempty"` } // LinkedAgentView is one entry of the reverse join — the agents an @@ -79,13 +85,60 @@ func NewIdentityBadgeService(log *LogService, badge *BadgeService) *IdentityBadg } // Get returns the TransparencyLog view of an identity's most recent -// event — the identity badge. +// event — the identity badge — plus the computed current attestation +// (§5.6.3 "latest entry ≠ current attestation"): the latest entry may +// be a link/unlink/revocation carrying no key material, so the badge +// quotes the current proven key set verbatim from the latest sealed +// proof event, with keysLogId pointing at that seal. Keys are omitted +// when the identity is REVOKED — no longer attested. func (s *IdentityBadgeService) Get(ctx context.Context, identityID string) (*TransparencyLog, error) { rec, err := s.log.LatestEventByIdentity(ctx, identityID) if err != nil { return nil, err } - return s.buildTransparencyLog(ctx, rec) + tl, err := s.buildTransparencyLog(ctx, rec) + if err != nil { + return nil, err + } + status, err := s.identityStatus(ctx, identityID, rec.EventType) + if err != nil { + return nil, err + } + tl.Status = status + if tl.Status == BadgeVerified { + proof, err := s.log.LatestProofByIdentity(ctx, identityID) + if err != nil { + return nil, err + } + _, _, keys := proofSummary(proof.RawEvent) + tl.Keys = keys + tl.KeysLogID = proof.LogID + } + return tl, nil +} + +// identityStatus derives the identity's read-time status with the +// TERMINAL rule: REVOKED iff ANY IDENTITY_REVOKED exists on the +// stream, not merely when it is the tail. The RA's seal spans a +// network round trip, so a racing operation's event can land after +// the revocation leaf — a late leaf must never flip a revoked +// identity's public answer back to VERIFIED. Sound because no +// legitimate flow appends to a revoked identity's stream (every RA +// write op gates on the REVOKED row; re-registration mints a new +// identityId), so the fast path (latest event already REVOKED) +// covers the common case and the EXISTS query the race. +func (s *IdentityBadgeService) identityStatus(ctx context.Context, identityID, latestEventType string) (BadgeStatus, error) { + if identityStatusFromEventType(latestEventType) == BadgeIdentityRevoked { + return BadgeIdentityRevoked, nil + } + revoked, err := s.log.IdentityRevoked(ctx, identityID) + if err != nil { + return "", err + } + if revoked { + return BadgeIdentityRevoked, nil + } + return BadgeVerified, nil } // Audit returns the identity's full event chain, paginated, in the @@ -147,12 +200,18 @@ func identityStatusFromEventType(eventType string) BadgeStatus { const sqlitetlIdentityRevoked = "IDENTITY_REVOKED" // LinkedIdentitiesForAgent computes the identities[] join for an -// agent badge: every identity whose latest link/unlink fact naming -// this agent is LINKED, decorated with that identity's current -// stream state. Revoked identities stay in the list with -// identityStatus REVOKED — the rotation/revocation visibility on -// every linked badge is the point of the read-time join. -func (s *IdentityBadgeService) LinkedIdentitiesForAgent(ctx context.Context, ansID string) ([]*LinkedIdentityView, error) { +// agent badge under the §5.6.3 visibility predicate — link LINKED ∧ +// agent live — given the agent's already-computed badge status (the +// caller has it; recomputing here would double the Merkle work). +// A terminal agent's view is empty: its links are no longer visible. +// Revoked IDENTITIES, by contrast, stay in the list with +// identityStatus REVOKED and no keys — a verifier must see that the +// who behind a still-linked agent was revoked, not have the fact +// silently vanish (the attestation rule withholds only the keys). +func (s *IdentityBadgeService) LinkedIdentitiesForAgent(ctx context.Context, ansID string, agentStatus BadgeStatus) ([]*LinkedIdentityView, error) { + if !agentLive(agentStatus) { + return []*LinkedIdentityView{}, nil + } states, err := s.log.LinkStatesByAgent(ctx, ansID) if err != nil { return nil, err @@ -171,10 +230,24 @@ func (s *IdentityBadgeService) LinkedIdentitiesForAgent(ctx context.Context, ans return out, nil } +// agentLive is the agent conjunct of the §5.6.3 visibility predicate: +// ACTIVE and DEPRECATED are live (a deprecated agent still serves +// during migration); WARNING is live too — it is the ACTIVE overlay +// for an expiring attested cert, not a lifecycle exit. REVOKED, +// EXPIRED, and UNKNOWN are not live. +func agentLive(status BadgeStatus) bool { + switch status { + case BadgeActive, BadgeDeprecated, BadgeWarning: + return true + default: + return false + } +} + // linkedIdentityView decorates one live link with the identity's -// current state: latest event (status + identityLogId), latest proof -// (kind/value/thumbprints), and the sealed link event (linkedAt + -// linkLogId). +// current state: latest event (status), latest proof (kind/value + +// the verbatim keys[] + keysLogId — withheld when REVOKED), and the +// sealed link event (linkedAt + linkLogId). func (s *IdentityBadgeService) linkedIdentityView(ctx context.Context, st *sqlitetl.LinkState) (*LinkedIdentityView, error) { view := &LinkedIdentityView{IdentityID: st.IdentityID} @@ -189,17 +262,23 @@ func (s *IdentityBadgeService) linkedIdentityView(ctx context.Context, st *sqlit if err != nil { return nil, err } - view.IdentityLogID = latest.LogID - view.IdentityStatus = string(identityStatusFromEventType(latest.EventType)) + status, err := s.identityStatus(ctx, st.IdentityID, latest.EventType) + if err != nil { + return nil, err + } + view.IdentityStatus = string(status) proof, err := s.log.LatestProofByIdentity(ctx, st.IdentityID) if err != nil { return nil, err } - kind, value, keyIDs := proofSummary(proof.RawEvent) + kind, value, keys := proofSummary(proof.RawEvent) view.Kind = kind view.Value = value - view.ProvenKeyIDs = keyIDs + if view.IdentityStatus == string(BadgeVerified) { + view.Keys = keys + view.KeysLogID = proof.LogID + } return view, nil } @@ -221,13 +300,26 @@ func (s *IdentityBadgeService) LinkedAgentsForIdentity(ctx context.Context, iden if !st.Linked() { continue } - view := &LinkedAgentView{AnsID: st.AnsID} + // Visibility predicate (§5.6.3): only live agents appear in + // any "current" view — a terminal agent's link history stays + // recoverable from the audit chain. Not-found is a liveness + // answer (the agent lane still seals via the async outbox, so + // a just-activated agent's leaf can lag); any OTHER failure + // propagates — join failure is explicit, never silent. + agentTL, err := s.badge.Get(ctx, st.AnsID) + if err != nil { + if errors.Is(err, domain.ErrNotFound) { + continue + } + return nil, err + } + if !agentLive(agentTL.Status) { + continue + } + view := &LinkedAgentView{AnsID: st.AnsID, AgentStatus: agentTL.Status} if linkRec, err := s.log.EventByLeafIndex(ctx, st.LeafIndex); err == nil { view.LinkedAt = innerTimestamp(linkRec.RawEvent) } - if agentTL, err := s.badge.Get(ctx, st.AnsID); err == nil { - view.AgentStatus = agentTL.Status - } out = append(out, view) } return out, nil @@ -272,11 +364,13 @@ func innerTimestamp(rawEvent string) string { return w.Payload.Producer.Event.Timestamp } -// proofSummary drills kind, value, and the proven verification-method -// ids out of a stored proof event (IDENTITY_VERIFIED / -// IDENTITY_UPDATED). The ids come from the sealed verbatim -// verification methods. -func proofSummary(rawEvent string) (string, string, []string) { +// proofSummary drills kind, value, and the proven verification +// methods out of a stored proof event (IDENTITY_VERIFIED / +// IDENTITY_UPDATED). The methods are returned as the RAW sealed +// bytes, untouched — the computed views quote sealed material +// verbatim, never re-encode it (the seal-verbatim rule extends to +// the quote). The signedProof member stays behind in the seal. +func proofSummary(rawEvent string) (string, string, []json.RawMessage) { var w struct { Payload struct { Producer struct { @@ -284,9 +378,7 @@ func proofSummary(rawEvent string) (string, string, []string) { Kind string `json:"kind"` Value string `json:"value"` Keys []struct { - VerificationMethod struct { - ID string `json:"id"` - } `json:"verificationMethod"` + VerificationMethod json.RawMessage `json:"verificationMethod"` } `json:"keys"` } `json:"event"` } `json:"producer"` @@ -296,11 +388,11 @@ func proofSummary(rawEvent string) (string, string, []string) { return "", "", nil } ev := w.Payload.Producer.Event - ids := make([]string, 0, len(ev.Keys)) + methods := make([]json.RawMessage, 0, len(ev.Keys)) for _, k := range ev.Keys { - if k.VerificationMethod.ID != "" { - ids = append(ids, k.VerificationMethod.ID) + if len(k.VerificationMethod) > 0 { + methods = append(methods, k.VerificationMethod) } } - return ev.Kind, ev.Value, ids + return ev.Kind, ev.Value, methods } diff --git a/internal/tl/service/log.go b/internal/tl/service/log.go index 3b95e55..b973954 100644 --- a/internal/tl/service/log.go +++ b/internal/tl/service/log.go @@ -311,6 +311,13 @@ func (s *LogService) LatestProofByIdentity(ctx context.Context, identityID strin return s.events.GetLatestProofByIdentityID(ctx, identityID) } +// IdentityRevoked reports whether the identity's stream contains an +// IDENTITY_REVOKED event — the terminal read-time rule (§5.6.3): +// once revoked, no later leaf changes the answer. +func (s *LogService) IdentityRevoked(ctx context.Context, identityID string) (bool, error) { + return s.events.HasIdentityRevoked(ctx, identityID) +} + // LinkStatesByAgent returns the latest link/unlink fact per identity // that ever named this agent. func (s *LogService) LinkStatesByAgent(ctx context.Context, ansID string) ([]*sqlitetl.LinkState, error) { diff --git a/spec/api-spec-tl-v2.yaml b/spec/api-spec-tl-v2.yaml index fa98ebc..4664a78 100644 --- a/spec/api-spec-tl-v2.yaml +++ b/spec/api-spec-tl-v2.yaml @@ -332,11 +332,21 @@ paths: summary: Identity badge description: | The latest sealed identity event + inclusion proof + computed - status (`VERIFIED` | `REVOKED`). The proof events seal every - proven key self-verifyingly: any third party reads the key - out of the sealed verbatim `verificationMethod`, verifies the - sealed `signedProof` against it, and confirms the payload - binds this identityId — offline, without trusting the RA. + `{status, keys[], keysLogId}`: the latest entry may be a + link/unlink/revocation carrying no key material, so the badge + quotes the CURRENT proven key set verbatim from the latest + sealed proof event, with `keysLogId` pointing at that seal + (omitted when REVOKED — the keys are no longer attested). + + Third-party verification is byte-recompute, not + parse-and-inspect: verify the sealed `signedProof` JWS + against the key inside the sealed `verificationMethod`; take + the nonce from the decoded payload; reconstruct the + IdentityProofInput from the sealed event plus that nonce; + JCS-serialize; and require the JWS payload segment to equal + those bytes verbatim. Verifiers MUST reject a JWS bearing + unrecognized `crit` headers (RFC 7515 §4.1.11). All offline, + without trusting the RA. operationId: getIdentityBadge parameters: - $ref: '#/components/parameters/IdentityIdPath' @@ -425,13 +435,23 @@ paths: tags: [Transparency Log] summary: Agents currently linked to the identity (reverse join) description: | - Computed at query time from the link events' agent index: - every agent whose latest link/unlink fact naming it is - LINKED, each decorated with its own computed badge status so - a reader checks both ends of the link in one response. + Computed at query time from the link events' agent index + under the visibility predicate: every agent whose latest + link/unlink fact naming it is LINKED and whose own computed + status is live, decorated with that status. Paginated — the + reverse join is the genuinely unbounded read (an identity + links to unlimited agents). operationId: getIdentityAgents parameters: - $ref: '#/components/parameters/IdentityIdPath' + - name: limit + in: query + required: false + schema: { type: integer, default: 50, minimum: 1, maximum: 200 } + - name: offset + in: query + required: false + schema: { type: integer, default: 0, minimum: 0 } responses: '200': description: Currently-linked agents. @@ -439,12 +459,15 @@ paths: application/json: schema: type: object - required: [agents] + required: [agents, total] properties: agents: type: array items: $ref: '#/components/schemas/LinkedAgentView' + total: + type: integer + description: Full visible-link count across all pages '404': description: No events for this identity. content: @@ -458,11 +481,22 @@ paths: summary: Identities currently linked to the agent (computed) description: | The same entries as the agent badge's `identities[]` field, - served alone. Computed at read time — link live ∧ identity - stream state — never stored on, or sealed into, the agent. + served alone and paginated — the overflow target for the + badge's embedded safety cap. Computed at read time under the + visibility predicate (link LINKED ∧ agent live; a REVOKED + identity stays visible with no keys) — never stored on, or + sealed into, the agent. operationId: getAgentIdentities parameters: - $ref: '#/components/parameters/AgentIdPath' + - name: limit + in: query + required: false + schema: { type: integer, default: 50, minimum: 1, maximum: 200 } + - name: offset + in: query + required: false + schema: { type: integer, default: 0, minimum: 0 } responses: '200': description: Currently-linked identities. @@ -470,12 +504,15 @@ paths: application/json: schema: type: object - required: [identities] + required: [identities, total] properties: identities: type: array items: $ref: '#/components/schemas/LinkedIdentityView' + total: + type: integer + description: Full visible-link count across all pages /v1/agents/{agentId}/identities/history: get: @@ -1030,12 +1067,49 @@ components: type: array description: | Agent badges only — the COMPUTED read-time join of the - agent's currently-linked verified identities. Covered by - the TL's response signature, never by any seal; identity - rotation/revocation is visible here immediately with - zero agent-stream writes. + agent's currently-linked verified identities, under the + visibility predicate (link LINKED AND agent live). A + REVOKED identity stays visible with identityStatus + REVOKED and no keys[] — a verifier must see the who was + revoked. Covered by the TL's response signature, never by + any seal; identity rotation/revocation is visible here + immediately with zero agent-stream writes. Embeds at most + a small safety cap of entries; identitiesTotal carries + the full count and /v1/agents/{agentId}/identities is the + paginated overflow target. items: $ref: '#/components/schemas/LinkedIdentityView' + identitiesTotal: + type: integer + description: | + Agent badges only — the full count of visible links when + identities[] is present (the embedded array is capped). + identitiesUnavailable: + type: boolean + description: | + Agent badges only — set when the identities join could + not be computed. Join failure is explicit, never silent: + an absent or empty identities[] always means "no visible + links", never "the join failed". + keys: + type: array + description: | + Identity badges only — the CURRENT proven key set quoted + VERBATIM from the latest sealed proof event + (IDENTITY_VERIFIED / IDENTITY_UPDATED): the latest stream + entry may be a link/unlink/revocation carrying no key + material, so the badge answers "what are the attested + keys" directly. Verification methods only — the + signedProof evidence lives in the seal at keysLogId, one + hop away. Omitted when the identity is REVOKED. + items: + type: object + description: A sealed verification method, member-for-member verbatim + keysLogId: + type: string + description: | + Identity badges only — the sealed proof event keys[] is + quoted from. Omitted when the identity is REVOKED. TransparencyLogAudit: type: object @@ -1151,7 +1225,12 @@ components: LinkedIdentityView: type: object - description: One computed identities[] entry on the agent badge. + description: | + One computed identities[] entry on the agent badge, under the + visibility predicate: the entry appears while the link is + LINKED and the agent is live; identityStatus reports the + identity's current stream state (a REVOKED identity stays + visible — the attestation rule withholds only its keys). required: [identityId, kind, value, identityStatus] properties: identityId: { type: string } @@ -1161,20 +1240,26 @@ components: type: string enum: [VERIFIED, REVOKED] description: Reflects the identity stream NOW - provenKeyIds: + keys: type: array description: | - Verification-method ids of the current proven key set - (post-rotation). The full verbatim methods live in the - sealed proof event. - items: { type: string } + The CURRENT proven key set quoted VERBATIM from the + latest sealed proof event — verification methods only, + member-for-member as sealed, so a verifier checks + operator signatures from the badge alone (no audit + walk). The signedProof evidence lives in the seal at + keysLogId. Omitted when identityStatus is REVOKED: the + keys are no longer attested. + items: + type: object + description: A sealed verification method, verbatim + keysLogId: + type: string + description: The sealed proof event keys[] is quoted from — fetch for signedProofs / offline evidence. Omitted when REVOKED. linkedAt: { type: string, format: date-time } linkLogId: type: string description: The sealed IDENTITY_LINKED entry on the identity stream — fetch for link evidence - identityLogId: - type: string - description: Latest identity-stream entry — fetch for the identity evidence/history LinkedAgentView: type: object @@ -1185,8 +1270,13 @@ components: linkedAt: { type: string, format: date-time } agentStatus: type: string - enum: [ACTIVE, REVOKED, DEPRECATED, EXPIRED, WARNING] - description: The linked agent's own computed badge status — a link is effective only while both ends are live + enum: [ACTIVE, DEPRECATED, WARNING] + description: | + The linked agent's own computed badge status. The reverse + join applies the visibility predicate — only live agents + (ACTIVE / DEPRECATED / WARNING) appear; terminal agents' + links drop out of every "current" view (history stays in + the audit chain). # ── Admin: producer keys ─────────────────────────────────── ProducerKeyRequest: From 9ecfd38d32401a45ba7a020dcc58371d8d33445c Mon Sep 17 00:00:00 2001 From: Connor Snitker Date: Thu, 11 Jun 2026 18:29:31 -0500 Subject: [PATCH 07/13] feat(demo): assert seal-before-success, liveness gate, verbatim keys 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 --- scripts/demo/common.sh | 39 ++++++++++++------ scripts/demo/identity-lifecycle.sh | 66 ++++++++++++++++++++++-------- scripts/demo/run-lifecycle.sh | 4 +- 3 files changed, 79 insertions(+), 30 deletions(-) diff --git a/scripts/demo/common.sh b/scripts/demo/common.sh index 63258cb..eb6208e 100755 --- a/scripts/demo/common.sh +++ b/scripts/demo/common.sh @@ -317,27 +317,40 @@ poll_tl_audit() { fail "TL not ready for $agent_id in ${timeout}s (records=${count:-0}, with merkle proof=${withproof:-0}; want $expected of each)" } -# poll_tl_identity_audit IDENTITY_ID EXPECTED_COUNT [TIMEOUT_SECONDS] +# assert_tl_identity_audit IDENTITY_ID EXPECTED_COUNT [TIMEOUT_SECONDS] # -# Sibling of poll_tl_audit for the identity stream: polls -# /v1/identities/{identityId}/audit until it shows at least -# EXPECTED_COUNT events with Merkle proofs. -poll_tl_identity_audit() { - local identity_id="$1" expected="$2" timeout="${3:-30}" - local i=0 count withproof +# Sibling of poll_tl_audit for the identity stream — with ONE crucial +# difference: the RECORD COUNT is asserted on the very first read, no +# polling. Identity operations are seal-before-success (design +# §5.6.1): the RA reports success only after the TL acknowledges the +# seal, so the events MUST already be in the log the moment the API +# returned — a missing record here is a seal-before-success +# regression, not a timing flake. Merkle INCLUSION PROOFS are the one +# thing allowed to lag: proofs are built against the latest published +# checkpoint, and checkpoint publication is cadence-based (one root, +# one cadence — unchanged by this design), so proof coverage alone is +# polled briefly. +assert_tl_identity_audit() { + local identity_id="$1" expected="$2" timeout="${3:-15}" + local resp count withproof + resp=$(curl -sSf -H "Authorization: Bearer $TL_API_KEY" \ + "$TL_URL/v1/identities/$identity_id/audit" 2>/dev/null || true) + count=$(printf '%s' "$resp" | jq -r '(.records | length) // 0') + if [ "${count:-0}" -lt "$expected" ]; then + fail "seal-before-success violated for identity $identity_id: audit shows ${count:-0} records immediately after the API returned; want $expected" + fi + local i=0 while [ "$i" -lt "$timeout" ]; do - local resp - resp=$(curl -sSf -H "Authorization: Bearer $TL_API_KEY" \ - "$TL_URL/v1/identities/$identity_id/audit" 2>/dev/null || true) - count=$(printf '%s' "$resp" | jq -r '(.records | length) // 0') withproof=$(printf '%s' "$resp" | jq -r '[.records[]? | select(.merkleProof)] | length // 0') - if [ "${count:-0}" -ge "$expected" ] && [ "${withproof:-0}" -ge "$expected" ]; then + if [ "${withproof:-0}" -ge "$expected" ]; then return 0 fi sleep 1 i=$((i + 1)) + resp=$(curl -sSf -H "Authorization: Bearer $TL_API_KEY" \ + "$TL_URL/v1/identities/$identity_id/audit" 2>/dev/null || true) done - fail "TL not ready for identity $identity_id in ${timeout}s (records=${count:-0}, with merkle proof=${withproof:-0}; want $expected of each)" + fail "checkpoint never covered identity $identity_id's leaves in ${timeout}s (records=${count}, with proof=${withproof:-0}; want $expected)" } # wait_ready URL [TIMEOUT_SECONDS] diff --git a/scripts/demo/identity-lifecycle.sh b/scripts/demo/identity-lifecycle.sh index faf01d2..cce0b86 100755 --- a/scripts/demo/identity-lifecycle.sh +++ b/scripts/demo/identity-lifecycle.sh @@ -31,9 +31,17 @@ # 17. TL /v1/identities/{id}/receipt SCITT COSE receipt for the identity leaf # 18. DELETE .../links/{agentId} unlink the did:web from agent #1 ONLY → agent #1 # keeps its did:key link; agent #2 keeps the did:web +# 18b. POST .../links → 422 AGENT_NOT_LINKABLE liveness gate: a REVOKED agent rejects the whole +# batch and seals NOTHING # 19. POST /v2/ans/identities/{id}/revoke revoke the did:web → IDENTITY_REVOKED (one event) # 20. TL did:web badge REVOKED — agent-2's join shows it at the next read; agent-1's did:key -# stays VERIFIED; both agents stay ACTIVE (the whats survive the who) +# stays VERIFIED with no keys quoted; both agents stay ACTIVE (the whats survive the who) +# +# Every sealing operation above is SEAL-BEFORE-SUCCESS (§5.6.1): the +# RA reports success only after the TL acknowledges the seal, so the +# TL reads in this script run immediately after each call — no +# polling anywhere. Badge views quote the current proven keys[] +# VERBATIM from the latest sealed proof event (+ keysLogId to it). # # The demo runs against the noop did:web resolver (the default — # `identity.resolver.type: noop`): signature verification is real, @@ -194,6 +202,13 @@ ok "did:key identity VERIFIED — the keyless-future test track" header "8. Register TWO fresh agents to ACTIVE (the fleet to link)" AGENT_1=$(register_agent "linked-a-$(openssl rand -hex 4).example.com") AGENT_2=$(register_agent "linked-b-$(openssl rand -hex 4).example.com") +# The AGENT lane still seals via the async outbox (flagged 2026-06-11 +# as a bug in the design doc §5.6.1 — agents should also wait for seal +# confirmation; tracked separately). Until that lands, wait for both +# agents' TL streams here: the identity-side reads below join against +# agent TL status, which needs the AGENT_REGISTERED leaves present. +poll_tl_audit "$AGENT_1" 1 30 +poll_tl_audit "$AGENT_2" 1 30 ok "fleet ready: $AGENT_1 + $AGENT_2" # ----- 9. Link the fleet — one owner-gated call per identity ----- @@ -216,9 +231,9 @@ header "10. GET /v2/ans/agents/$AGENT_1 (RA detail — computed identities[], n curl_json GET "/v2/ans/agents/$AGENT_1" >/dev/null # ----- 11. TL identity stream ----- -header "11. Wait for the outbox worker, then read the identity stream from the TL" -poll_tl_identity_audit "$IDENTITY_ID" 2 30 -ok "TL sealed the IDENTITY_VERIFIED + IDENTITY_LINKED leaves" +header "11. Read the identity stream from the TL — NO polling: identity ops are seal-before-success (§5.6.1)" +assert_tl_identity_audit "$IDENTITY_ID" 2 +ok "IDENTITY_VERIFIED + IDENTITY_LINKED were already sealed when the API calls returned" header "11a. TL: GET /v1/identities/$IDENTITY_ID (identity badge — latest sealed event + proof + status)" curl_tl GET "/v1/identities/$IDENTITY_ID" >/dev/null @@ -241,7 +256,7 @@ AGENTS_COUNT=$(printf '%s' "$AGENTS_VIEW" | jq -r '.agents | length') [ "$AGENTS_COUNT" = "2" ] || fail "reverse join should list 2 agents, got $AGENTS_COUNT" header "11d. TL: GET /v1/identities/$DK_ID (did:key badge — the sealed Multikey verification method)" -poll_tl_identity_audit "$DK_ID" 2 30 +assert_tl_identity_audit "$DK_ID" 2 DK_BADGE_VM=$(curl_tl GET "/v1/identities/$DK_ID/audit" | \ jq -r '[.records[].payload.producer.event | select(.keys)][0].keys[0].verificationMethod') DK_VM_TYPE=$(printf '%s' "$DK_BADGE_VM" | jq -r '.type // empty') @@ -251,14 +266,14 @@ DK_VM_MB=$(printf '%s' "$DK_BADGE_VM" | jq -r '.publicKeyMultibase // empty') ok "did:key sealed verbatim: type=Multikey, publicKeyMultibase = the did:key msid itself" # ----- 12-13. Agent-side computed views (both badges) ----- -poll_tl_audit "$AGENT_1" 1 30 -poll_tl_audit "$AGENT_2" 1 30 header "12. TL: GET /v1/agents/{both} (agent-1 carries BOTH whos; agent-2 carries the did:web)" BADGE_1=$(curl_tl GET "/v1/agents/$AGENT_1") IDS_1=$(printf '%s' "$BADGE_1" | jq -r '.identities | length') [ "$IDS_1" = "2" ] || fail "agent-1 badge should show 2 identities, got $IDS_1" -KEYIDS_1=$(printf '%s' "$BADGE_1" | jq -r '.identities[] | select(.kind == "did:web") | .provenKeyIds | length') -[ "$KEYIDS_1" = "2" ] || fail "agent-1's did:web entry should show 2 provenKeyIds, got $KEYIDS_1" +KEYS_1=$(printf '%s' "$BADGE_1" | jq -r '.identities[] | select(.kind == "did:web") | .keys | length') +[ "$KEYS_1" = "2" ] || fail "agent-1's did:web entry should quote 2 verbatim keys, got $KEYS_1" +KEYSLOG_1=$(printf '%s' "$BADGE_1" | jq -r '.identities[] | select(.kind == "did:web") | .keysLogId // empty') +[ -n "$KEYSLOG_1" ] || fail "agent-1's did:web entry is missing keysLogId (the seal the keys are quoted from)" DK_ON_1=$(printf '%s' "$BADGE_1" | jq -r '.identities[] | select(.kind == "did:key") | .value // empty') [ "$DK_ON_1" = "$DID_KEY" ] || fail "agent-1's did:key entry missing" BADGE_2=$(curl_tl GET "/v1/agents/$AGENT_2") @@ -269,7 +284,7 @@ WHO_2=$(printf '%s' "$BADGE_2" | jq -r '.identities[0].value // empty') SEALED_X_BEFORE=$(printf '%s' "$AUDIT" | \ jq -r '[.records[].payload.producer.event | select(.keys)][0].keys[0].verificationMethod.publicKeyJwk.x // empty') [ -n "$SEALED_X_BEFORE" ] || fail "sealed proof event is missing the verbatim verification method" -ok "agent-1: did:web (provenKeyIds[2]) + did:key (Ed25519) side by side; agent-2: $WHO_2" +ok "agent-1: did:web (keys[2] quoted verbatim + keysLogId) + did:key (Ed25519) side by side; agent-2: $WHO_2" header "13. TL: GET /v1/agents/$AGENT_1/identities + /identities/history" curl_tl GET "/v1/agents/$AGENT_1/identities" >/dev/null @@ -290,7 +305,7 @@ curl_json POST "/v2/ans/identities/$IDENTITY_ID/verify-control" \ assert_2xx "rotation verify-control" header "16. TL: the rotation sealed ONE IDENTITY_UPDATED — proven set 2 keys → 1, key material changed" -poll_tl_identity_audit "$IDENTITY_ID" 3 30 +assert_tl_identity_audit "$IDENTITY_ID" 3 AUDIT=$(curl_tl GET "/v1/identities/$IDENTITY_ID/audit") SEALED_X_AFTER=$(printf '%s' "$AUDIT" | \ jq -r '[.records[].payload.producer.event | select(.keys)][0].keys[0].verificationMethod.publicKeyJwk.x // empty') @@ -301,9 +316,9 @@ SEALED_KEYS_AFTER=$(printf '%s' "$AUDIT" | \ [ "$SEALED_KEYS_AFTER" = "1" ] || fail "rotated proven set should be 1 key, got $SEALED_KEYS_AFTER" # Both linked badges reflect it immediately (read-time join) — with # 10,000 linked agents this would still be ONE sealed event. -KEYIDS_1_AFTER=$(curl_tl GET "/v1/agents/$AGENT_1" | \ - jq -r '.identities[] | select(.kind == "did:web") | .provenKeyIds | length') -[ "$KEYIDS_1_AFTER" = "1" ] || fail "agent-1's did:web entry should now show 1 provenKeyId, got $KEYIDS_1_AFTER" +KEYS_1_AFTER=$(curl_tl GET "/v1/agents/$AGENT_1" | \ + jq -r '.identities[] | select(.kind == "did:web") | .keys | length') +[ "$KEYS_1_AFTER" = "1" ] || fail "agent-1's did:web entry should now quote 1 key, got $KEYS_1_AFTER" ok "sealed key material flipped (${SEALED_X_BEFORE:0:12}… → ${SEALED_X_AFTER:0:12}…), set 2→1 — ONE event, zero agent-stream writes" # ----- 17. Identity receipt ----- @@ -323,7 +338,7 @@ fi header "18. DELETE /v2/ans/identities/$IDENTITY_ID/links/$AGENT_1 (unlink the did:web from agent #1 only)" curl_json DELETE "/v2/ans/identities/$IDENTITY_ID/links/$AGENT_1" >/dev/null assert_2xx "unlink" -poll_tl_identity_audit "$IDENTITY_ID" 4 30 +assert_tl_identity_audit "$IDENTITY_ID" 4 BADGE_1=$(curl_tl GET "/v1/agents/$AGENT_1") REMAINING_1=$(printf '%s' "$BADGE_1" | jq -r '(.identities | length) // 0') KIND_1=$(printf '%s' "$BADGE_1" | jq -r '.identities[0].kind // empty') @@ -334,11 +349,26 @@ REMAINING_2=$(curl_tl GET "/v1/agents/$AGENT_2" | jq -r '(.identities | length) ok "the did:web↔agent-1 pair ended; agent-1's did:key link and agent-2's did:web link are untouched" curl_tl GET "/v1/agents/$AGENT_1/identities/history" >/dev/null +# ----- 18b. Link liveness gate — terminal agents are not linkable ----- +header "18b. Liveness gate: linking to a REVOKED agent fails 422 AGENT_NOT_LINKABLE (nothing seals)" +AGENT_3=$(register_agent "dead-$(openssl rand -hex 3).example.com") +curl_json POST "/v2/ans/agents/$AGENT_3/revoke" \ + "$(jq -n '{reason: "CESSATION_OF_OPERATION", comments: "liveness-gate demo"}')" >/dev/null +assert_2xx "revoke agent-3" +GATE_RESP=$(curl_json POST "/v2/ans/identities/$DK_ID/links" \ + "$(jq -n --arg a "$AGENT_3" '{agentIds: [$a]}')" || true) +GATE_CODE=$(printf '%s' "$GATE_RESP" | jq -r '.code // empty') +[ "$GATE_CODE" = "AGENT_NOT_LINKABLE" ] || \ + fail "linking a revoked agent must fail AGENT_NOT_LINKABLE, got: $GATE_RESP" +# Nothing sealed: the did:key stream still has exactly its 2 events. +assert_tl_identity_audit "$DK_ID" 2 +ok "terminal agent rejected all-or-nothing; the did:key stream sealed nothing" + # ----- 19-20. Revoke — the who dies, the whats survive ----- header "19. POST /v2/ans/identities/$IDENTITY_ID/revoke (state change — an identity cannot be deleted)" curl_json POST "/v2/ans/identities/$IDENTITY_ID/revoke" >/dev/null assert_2xx "revoke" -poll_tl_identity_audit "$IDENTITY_ID" 5 30 +assert_tl_identity_audit "$IDENTITY_ID" 5 header "20. TL: did:web REVOKED; agent-2's join shows it; agent-1's did:key stays VERIFIED" ID_STATUS=$(curl_tl GET "/v1/identities/$IDENTITY_ID" | jq -r '.status') @@ -347,6 +377,10 @@ BADGE_2=$(curl_tl GET "/v1/agents/$AGENT_2") WHO_STATUS_2=$(printf '%s' "$BADGE_2" | jq -r '.identities[0].identityStatus // empty') AGENT_STATUS_2=$(printf '%s' "$BADGE_2" | jq -r '.status') [ "$WHO_STATUS_2" = "REVOKED" ] || fail "agent-2's identities[] should show REVOKED, got $WHO_STATUS_2" +# Visibility ≠ attestation: the revoked entry stays on the badge — +# a verifier must SEE the who was revoked — but its keys[] are gone. +REVOKED_KEYS_2=$(printf '%s' "$BADGE_2" | jq -r '(.identities[0].keys | length) // 0') +[ "$REVOKED_KEYS_2" = "0" ] || fail "revoked identity must quote no keys, got $REVOKED_KEYS_2" [ "$AGENT_STATUS_2" = "ACTIVE" ] || fail "agent-2 status=$AGENT_STATUS_2, want ACTIVE — identity ops must never touch the agent" # Each identity has its own lifecycle: the did:key on agent-1 is # unaffected by the did:web's revocation. diff --git a/scripts/demo/run-lifecycle.sh b/scripts/demo/run-lifecycle.sh index 355a6df..67b4a2a 100755 --- a/scripts/demo/run-lifecycle.sh +++ b/scripts/demo/run-lifecycle.sh @@ -351,7 +351,9 @@ curl_json POST "/v2/ans/identities/$IDENTITY_ID/links" \ assert_2xx "identity link" header "19. TL: GET /v1/agents/$AGENT_ID (badge — computed identities[] join)" -poll_tl_identity_audit "$IDENTITY_ID" 2 30 +# No polling: identity ops are seal-before-success — the seals are +# already in the log the moment the link call returned. +assert_tl_identity_audit "$IDENTITY_ID" 2 BADGE_WITH_WHO=$(curl_tl GET "/v1/agents/$AGENT_ID") WHO_VALUE=$(printf '%s' "$BADGE_WITH_WHO" | jq -r '.identities[0].value // empty') [ "$WHO_VALUE" = "$IDENTITY_DID" ] || fail "badge identities[] join missing (got: $WHO_VALUE)" From b07a1ce4f19c93d90a2a0056ffa942df51003bd9 Mon Sep 17 00:00:00 2001 From: Connor Snitker Date: Thu, 11 Jun 2026 18:29:56 -0500 Subject: [PATCH 08/13] =?UTF-8?q?feat(poc):=20ethid=20playground=20CLI=20?= =?UTF-8?q?=E2=80=94=20eth=20identity=20kinds=20against=20real=20infrastru?= =?UTF-8?q?cture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standalone proof-of-concept CLI (own Go module under scripts/poc/ — go-ethereum and miekg/dns never touch the production go.mod; not built by make) answering whether the future eth identity kinds are implementable from plain Go with no funded wallet and no paid APIs: - did:ethr: offline proof-of-control (EIP-191 personal_sign -> ecrecover -> address compare — zero RPC for the bare form) plus full ERC-1056 resolution: the changed() linked-list event walk, delegate/attribute document construction matching the reference resolver, validTo/revocation semantics. - ENS from first principles: namehash, registry parent-walk, ENSIP-10 wildcard, and the EIP-3668 CCIP-Read gateway loop — resolves offchain names (cb.id, base.eth subnames) that no maintained Go ENS library can. - ENSIP-25: bidirectional ENS <-> ERC-8004 verification against the official IdentityRegistry deployments, with ERC-7930 key encode/decode and probing of the CAIP-style key forms observed in the wild (spec-vs-wild drift surfaced explicitly). - ENSIP-26: agent-context / agent-endpoint discovery with real-DNS checks (A/AAAA/HTTPS/TXT + DNSSEC AD bit) and well-known document probes. Validated live against mainnet (enswhois.eth — a real ERC-8004 agent, jesse.base.eth via Coinbase's production CCIP gateway) and Sepolia. The README maps each finding onto the controlVerifier seam these kinds will eventually plug into. Signed-off-by: Connor Snitker --- scripts/poc/ethid/.gitignore | 1 + scripts/poc/ethid/README.md | 212 +++++++++++ scripts/poc/ethid/ccip.go | 260 ++++++++++++++ scripts/poc/ethid/chains.go | 114 ++++++ scripts/poc/ethid/didethr.go | 641 ++++++++++++++++++++++++++++++++++ scripts/poc/ethid/dnscheck.go | 151 ++++++++ scripts/poc/ethid/ens.go | 417 ++++++++++++++++++++++ scripts/poc/ethid/ensip25.go | 462 ++++++++++++++++++++++++ scripts/poc/ethid/ensip26.go | 249 +++++++++++++ scripts/poc/ethid/go.mod | 39 +++ scripts/poc/ethid/go.sum | 208 +++++++++++ scripts/poc/ethid/keys.go | 302 ++++++++++++++++ scripts/poc/ethid/main.go | 124 +++++++ scripts/poc/ethid/out.go | 46 +++ 14 files changed, 3226 insertions(+) create mode 100644 scripts/poc/ethid/.gitignore create mode 100644 scripts/poc/ethid/README.md create mode 100644 scripts/poc/ethid/ccip.go create mode 100644 scripts/poc/ethid/chains.go create mode 100644 scripts/poc/ethid/didethr.go create mode 100644 scripts/poc/ethid/dnscheck.go create mode 100644 scripts/poc/ethid/ens.go create mode 100644 scripts/poc/ethid/ensip25.go create mode 100644 scripts/poc/ethid/ensip26.go create mode 100644 scripts/poc/ethid/go.mod create mode 100644 scripts/poc/ethid/go.sum create mode 100644 scripts/poc/ethid/keys.go create mode 100644 scripts/poc/ethid/main.go create mode 100644 scripts/poc/ethid/out.go diff --git a/scripts/poc/ethid/.gitignore b/scripts/poc/ethid/.gitignore new file mode 100644 index 0000000..8a66881 --- /dev/null +++ b/scripts/poc/ethid/.gitignore @@ -0,0 +1 @@ +/ethid diff --git a/scripts/poc/ethid/README.md b/scripts/poc/ethid/README.md new file mode 100644 index 0000000..54f7b37 --- /dev/null +++ b/scripts/poc/ethid/README.md @@ -0,0 +1,212 @@ +# ethid — Ethereum-based identity playground (PoC) + +A standalone CLI for playing with the Ethereum-side identifier kinds +before they become `ans` verified-identity kinds: **did:ethr** +(ERC-1056), **ENSIP-25** (ENS ↔ ERC-8004 agent binding), and +**ENSIP-26** (ENS agent discovery records). Everything reads REAL +infrastructure: live Ethereum JSON-RPC, the production ENS registry +(including ENSIP-10 wildcard and EIP-3668 CCIP-Read offchain names), +the official ERC-8004 IdentityRegistry deployments, and the public +DNS. + +**This is not the identity implementation.** It is its own Go module +(go-ethereum and miekg/dns never touch the production `go.mod`), it +is not built by `make build`, and nothing here registers a route or +seals an event. When a kind graduates, it becomes a `controlVerifier` +registration in `internal/ra/service/identitykinds.go` with the same +noop/real adapter split as did:web — this tool exists to settle the +resolution and proof mechanics empirically first. + +## No wallet, no paid API + +Two questions this PoC answers by construction: + +- **"Do I need a wallet I own?"** No. A "wallet address" is just a + secp256k1 keypair; `ethid keygen` mints one locally in + milliseconds and the result is already a valid `did:ethr`. Nothing + needs funding until you want to WRITE to a chain (and then Sepolia + faucet ETH suffices). +- **"Do I need a paid API?"** No. Every read goes to free keyless + public RPC endpoints (publicnode.com by default; `-rpc`/ + `ETHID_RPC_URL` to override). Verification workloads are pure + `eth_call`/`eth_getLogs` reads. + +## Build + +```sh +cd scripts/poc/ethid +go build -o ethid . +``` + +## Recipes + +### 1. did:ethr proof-of-control — fully offline, zero RPC + +The core property that makes did:ethr cheap to support: a bare +`did:ethr:0x
` has an *implicit* DID document, and proof-of- +control is ECDSA public-key recovery — no document fetch at all. + +```sh +./ethid keygen # mint a keypair; note the did + private key +./ethid sign -key 0x 'the-ra-issued-nonce' +./ethid verify -did did:ethr:0x -sig 0x 'the-ra-issued-nonce' +# ✓ control PROVEN — recovered signer is the DID subject +``` + +The signature is EIP-191 `personal_sign`, byte-compatible with what +MetaMask produces — a real registrant could sign the RA's challenge +with their wallet and never export a key. + +### 2. did:ethr resolution — the implicit document and the registry walk + +```sh +# Implicit document (zero registry writes): +./ethid did resolve did:ethr:0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 + +# A document with real on-chain history (keys added via ERC-1056): +./ethid did resolve did:ethr:sepolia:0xd8ede1cd1394a287bb30fd7b320096376d54b7d8 +``` + +The second prints the `changed()` linked-list walk (one single-block +`eth_getLogs` per change) and the document the events build. + +To create history of your own on Sepolia (needs faucet ETH): + +```sh +cast send 0x03d5003bf0e79c5f5223588f347eba39afbc3818 \ + "setAttribute(address,bytes32,bytes,uint256)" \ + $(cast --format-bytes32-string "did/svc/AgentService") \ + $(cast --from-utf8 "https://agent.example.com/mcp") 86400 \ + --rpc-url https://ethereum-sepolia-rpc.publicnode.com --private-key $PK +./ethid did resolve did:ethr:sepolia: # service appears; expires in a day +``` + +### 3. ENS resolution — including offchain names no Go library can do + +```sh +./ethid ens resolve vitalik.eth # classic onchain resolver +./ethid ens resolve jesse.base.eth # ENSIP-10 wildcard + CCIP-Read via + # Coinbase's production gateway +./ethid ens text uniswap.eth url com.twitter # arbitrary text records +``` + +`jesse.base.eth` exercises the full EIP-3668 loop: the resolver +reverts `OffchainLookup`, ethid fetches the gateway, replays the +signed response through the on-chain callback, and reports which +gateway took part. This is the piece missing from wealdtech/go-ens +(issue #38) — it fits in ~150 lines (`ccip.go`). + +### 4. ENSIP-26 agent discovery — live mainnet agent + real DNS + +```sh +./ethid ensip26 discover -dns -fetch enswhois.eth +``` + +`enswhois.eth` is a real registered agent: `agent-context`, +`agent-endpoint[mcp]`, `agent-endpoint[web]`. The `-dns` flag +resolves each endpoint host against real DNS (with the resolver's +DNSSEC AD bit, mirroring the RA's `lookup` verifier); `-fetch` +probes the well-known documents (`agent-registration.json` from +ERC-8004; `agent.json` from the unmerged ENSIP-27 draft — expect +404s today). + +### 5. ENSIP-25 verification — ENS name ↔ ERC-8004 registry entry + +```sh +# Bidirectional, deriving the claimed name from the registry side: +./ethid ensip25 verify -agent-id 34339 + +# Or pin the name yourself: +./ethid ensip25 verify -agent-id 34339 enswhois.eth + +# Inspect / decode the record key grammar: +./ethid ensip25 key -agent-id 42 +./ethid ensip25 key -decode 0x000100000101148004a169fb4a3325136eb29fa0ceb6d2e539a432 +``` + +The verifier reads the agent NFT (`ownerOf` + `tokenURI`) from the +official IdentityRegistry, fetches the registration file (https / +ipfs / data URIs), extracts the ENS claim, and checks the +`agent-registration[…][…]` text record on the name. + +**Wire-format drift, observed live:** ENSIP-25 specifies the record +key's registry segment as an ERC-7930 binary hex string, but the +real mainnet record on enswhois.eth uses a CAIP-style +`eip155:1:0x` form instead — and the ENS claim lives +in a `binding{type:"ens"}` field rather than the EIP's +`services[{name:"ENS"}]` entry. ethid probes all observed forms and +reports which one matched. This is exactly the kind of drift the ans +pre-implementation shape-diff exists to catch; any future +`ens-ensip25` kind must decide which forms it accepts **before** the +sealed event shape freezes. + +To register a throwaway agent of your own on the official Sepolia +registry (faucet ETH only): + +```sh +cast send 0x8004A818BFB912233c491871b3d84c89A494BD9e "register(string)" \ + "data:application/json;base64,$(base64 <<<'{"name":"my-test-agent","services":[{"name":"ENS","endpoint":"mytest.eth"}]}')" \ + --rpc-url https://ethereum-sepolia-rpc.publicnode.com --private-key $PK +# agentId = topic1 of the Registered event in the receipt +./ethid ensip25 verify -chain sepolia -agent-id mytest.eth +``` + +### 6. Raw DNS checks + +```sh +./ethid dns mcp.enswhois.com # A / AAAA / HTTPS / TXT + DNSSEC AD bit +./ethid dns -resolver 1.1.1.1 example.com +``` + +### 7. Zero-cost writes: Anvil mainnet fork + +To test record writes without owning anything, fork mainnet locally +and impersonate any name's owner: + +```sh +anvil --fork-url https://ethereum-rpc.publicnode.com # local fork on :8545 +cast rpc anvil_impersonateAccount +cast send "setText(bytes32,string,string)" \ + 'agent-endpoint[mcp]' 'https://my.agent/mcp' \ + --from --unlocked +./ethid ensip26 discover -rpc http://localhost:8545 +``` + +Real contracts, real resolution logic, zero cost, works in CI. + +## What a real `eth` identity kind would need (notes for the RA design) + +- **ES256K is not in Go's stdlib** — eth-native proofs are EIP-191 + `personal_sign` (or EIP-712) over secp256k1, verified by recovery. + `ProofSubmission` would grow an additive member for an eth + signature rather than forcing a compact JWS; geth's `crypto` + package supplies `Ecrecover`/`SigToPub`. +- **Bare did:ethr is the did:key of this family**: zero I/O + verification (recover → derive address → compare). Registry state + only matters after `changeOwner`/`addDelegate`/`setAttribute`, and + ERC-1271 contract wallets would add an `eth_call` leg. +- **Verbatim sealing maps cleanly**: for did:ethr the sealed + verification method is the implicit/registry-built VM; for the + ENSIP kinds the verbatim artifact is the raw text-record key/value + pair (+ the registry entry coordinates), exactly as served. +- **The noop/real split falls out naturally**: noop = pure recovery + semantics (which IS the real semantics for bare did:ethr); real = + RPC reads behind a port whose dev default is a localhost Anvil. +- **Key-form drift must be settled first** (see recipe 5) — accept + spec-form, wild-form, or both, and record the decision in the + spec/ contracts before anything is sealed. + +## Known PoC limits (deliberate) + +- ENS name normalization covers the ASCII subset only (full + ENSIP-15/UTS-46 is out of scope; non-ASCII names are refused, not + mis-hashed). +- did:ethr document building covers owner changes, delegates, and + the common `did/pub/*` / `did/svc/*` attribute grammars; exotic + encodings surface in the raw history table instead of being + guessed into the document. +- Reverse ENS discovery (name → all registries) needs an offchain + indexer — text-record keys are not enumerable on chain; ethid + probes known key shapes instead. +- ipfs:// registration files go through the public ipfs.io gateway, + which can be slow; data: and https URIs are fetched directly. diff --git a/scripts/poc/ethid/ccip.go b/scripts/poc/ethid/ccip.go new file mode 100644 index 0000000..4f53fef --- /dev/null +++ b/scripts/poc/ethid/ccip.go @@ -0,0 +1,260 @@ +package main + +import ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/rpc" +) + +// EIP-3668 CCIP-Read client loop. When a resolver's answer lives off +// chain (Coinbase cb.id names, base.eth subnames, most L2 names), the +// eth_call reverts with: +// +// error OffchainLookup(address sender, string[] urls, bytes callData, +// bytes4 callbackFunction, bytes extraData) +// +// The client must fetch callData from one of the gateway urls over +// HTTPS, then re-call sender with callbackFunction(response, extraData) +// — the contract verifies the gateway's signed response on chain, so +// the gateway is untrusted. This loop is the piece missing from every +// Go ENS library today; it is ~150 lines. +// +// Limits below mirror the spec's recommendations: bounded recursion, +// bounded gateway attempts, HTTPS only, bounded response size. + +const ( + maxCCIPDepth = 4 + maxGatewayAttempts = 5 + maxGatewayBodyBytes = 1 << 20 // 1 MiB +) + +// offchainLookupSelector = bytes4(keccak("OffchainLookup(address,string[],bytes,bytes4,bytes)")). +var offchainLookupSelector = [4]byte{0x55, 0x6f, 0x18, 0x30} + +var offchainLookupArgs = abi.Arguments{ + {Type: mustType("address")}, + {Type: mustType("string[]")}, + {Type: mustType("bytes")}, + {Type: mustType("bytes4")}, + {Type: mustType("bytes")}, +} + +var ccipCallbackArgs = abi.Arguments{ + {Type: mustType("bytes")}, + {Type: mustType("bytes")}, +} + +func mustType(t string) abi.Type { + parsed, err := abi.NewType(t, "", nil) + if err != nil { + panic("static ABI type failed to parse: " + err.Error()) + } + return parsed +} + +// logGateway records a consulted gateway once, preserving order — +// several record reads through the same gateway are one fact. +func (e *ensClient) logGateway(url string) { + for _, seen := range e.gatewayLog { + if seen == url { + return + } + } + e.gatewayLog = append(e.gatewayLog, url) +} + +// gatewayClientError marks an HTTP 4xx from a CCIP gateway — +// permanent per EIP-3668, so the client must not retry other URLs. +type gatewayClientError struct { + status int +} + +func (e *gatewayClientError) Error() string { + return fmt.Sprintf("gateway returned HTTP %d (permanent per EIP-3668)", e.status) +} + +type offchainLookup struct { + Sender common.Address + URLs []string + CallData []byte + CallbackFunction [4]byte + ExtraData []byte +} + +// ccipCall is eth_call with the OffchainLookup loop applied. +func (e *ensClient) ccipCall(ctx context.Context, to common.Address, data []byte, depth int) ([]byte, error) { + ret, err := e.ethCall(ctx, to, data) + if err == nil { + return ret, nil + } + + revert, ok := revertData(err) + if !ok || len(revert) < 4 || !bytes.Equal(revert[:4], offchainLookupSelector[:]) { + return nil, err + } + if depth >= maxCCIPDepth { + return nil, fmt.Errorf("CCIP-Read recursion limit (%d) exceeded", maxCCIPDepth) + } + + lookup, err := decodeOffchainLookup(revert[4:]) + if err != nil { + return nil, fmt.Errorf("decode OffchainLookup revert: %w", err) + } + // EIP-3668: only honor lookups that name the contract we called — + // anything else could be a nested contract trying to proxy us. + if lookup.Sender != to { + return nil, fmt.Errorf("OffchainLookup sender %s != called contract %s — refusing per EIP-3668", lookup.Sender.Hex(), to.Hex()) + } + + var lastErr error + attempts := 0 + for _, gatewayURL := range lookup.URLs { + if attempts >= maxGatewayAttempts { + break + } + attempts++ + response, err := e.fetchGateway(ctx, gatewayURL, lookup.Sender, lookup.CallData) + if err != nil { + // EIP-3668: 4xx is a permanent failure — stop, don't + // shop the same request to other gateways; 5xx and + // transport errors are transient — try the next URL. + var permanent *gatewayClientError + if errors.As(err, &permanent) { + return nil, fmt.Errorf("gateway %s: %w", gatewayURL, err) + } + lastErr = fmt.Errorf("gateway %s: %w", gatewayURL, err) + continue + } + e.logGateway(gatewayURL) + + callback, err := ccipCallbackArgs.Pack(response, lookup.ExtraData) + if err != nil { + return nil, fmt.Errorf("pack CCIP callback: %w", err) + } + callbackData := append(lookup.CallbackFunction[:], callback...) + return e.ccipCall(ctx, lookup.Sender, callbackData, depth+1) + } + if lastErr == nil { + lastErr = errors.New("OffchainLookup carried no gateway urls") + } + return nil, fmt.Errorf("all CCIP-Read gateways failed: %w", lastErr) +} + +func decodeOffchainLookup(encoded []byte) (*offchainLookup, error) { + values, err := offchainLookupArgs.Unpack(encoded) + if err != nil { + return nil, err + } + lookup := &offchainLookup{} + var ok bool + if lookup.Sender, ok = values[0].(common.Address); !ok { + return nil, errors.New("OffchainLookup: sender is not an address") + } + if lookup.URLs, ok = values[1].([]string); !ok { + return nil, errors.New("OffchainLookup: urls is not string[]") + } + if lookup.CallData, ok = values[2].([]byte); !ok { + return nil, errors.New("OffchainLookup: callData is not bytes") + } + if lookup.CallbackFunction, ok = values[3].([4]byte); !ok { + return nil, errors.New("OffchainLookup: callbackFunction is not bytes4") + } + if lookup.ExtraData, ok = values[4].([]byte); !ok { + return nil, errors.New("OffchainLookup: extraData is not bytes") + } + return lookup, nil +} + +// fetchGateway implements the EIP-3668 gateway HTTP protocol: GET +// with {sender}/{data} template substitution when the URL contains +// {data}, POST {"data","sender"} JSON otherwise. The response is +// always {"data": "0x…"}. +func (e *ensClient) fetchGateway(ctx context.Context, gatewayURL string, sender common.Address, callData []byte) ([]byte, error) { + senderHex := strings.ToLower(sender.Hex()) + dataHex := "0x" + hex.EncodeToString(callData) + + var req *http.Request + var err error + if strings.Contains(gatewayURL, "{data}") { + expanded := strings.NewReplacer("{sender}", senderHex, "{data}", dataHex).Replace(gatewayURL) + req, err = http.NewRequestWithContext(ctx, http.MethodGet, expanded, nil) + } else { + expanded := strings.ReplaceAll(gatewayURL, "{sender}", senderHex) + body, merr := json.Marshal(map[string]string{"data": dataHex, "sender": senderHex}) + if merr != nil { + return nil, merr + } + req, err = http.NewRequestWithContext(ctx, http.MethodPost, expanded, bytes.NewReader(body)) + if req != nil { + req.Header.Set("Content-Type", "application/json") + } + } + if err != nil { + return nil, err + } + if req.URL.Scheme != "https" { + return nil, fmt.Errorf("gateway url %q is not https", gatewayURL) + } + + httpCtx, cancel := e.opts.callCtx(ctx) + defer cancel() + resp, err := http.DefaultClient.Do(req.WithContext(httpCtx)) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode >= 400 && resp.StatusCode < 500 { + return nil, &gatewayClientError{status: resp.StatusCode} + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("gateway returned HTTP %d", resp.StatusCode) + } + + raw, err := io.ReadAll(io.LimitReader(resp.Body, maxGatewayBodyBytes+1)) + if err != nil { + return nil, err + } + if len(raw) > maxGatewayBodyBytes { + return nil, fmt.Errorf("gateway response exceeds %d bytes", maxGatewayBodyBytes) + } + var parsed struct { + Data string `json:"data"` + } + if err := json.Unmarshal(raw, &parsed); err != nil { + return nil, fmt.Errorf("gateway response is not {\"data\": …} JSON: %w", err) + } + out, err := hexBytes(parsed.Data) + if err != nil { + return nil, fmt.Errorf("gateway data field is not hex: %w", err) + } + return out, nil +} + +// revertData digs the raw revert bytes out of an eth_call error. +// geth's RPC client surfaces them via the rpc.DataError interface as +// a 0x-prefixed hex string. +func revertData(err error) ([]byte, bool) { + var dataErr rpc.DataError + if !errors.As(err, &dataErr) { + return nil, false + } + hexStr, ok := dataErr.ErrorData().(string) + if !ok || !strings.HasPrefix(hexStr, "0x") { + return nil, false + } + raw, decErr := hex.DecodeString(hexStr[2:]) + if decErr != nil { + return nil, false + } + return raw, true +} diff --git a/scripts/poc/ethid/chains.go b/scripts/poc/ethid/chains.go new file mode 100644 index 0000000..d8ac837 --- /dev/null +++ b/scripts/poc/ethid/chains.go @@ -0,0 +1,114 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" +) + +// chainPreset carries everything chain-specific the commands need: +// a default free public RPC endpoint plus the well-known registry +// deployments. Every address here is a public, audited deployment — +// nothing is ours and nothing requires an account to read. +type chainPreset struct { + Name string + ChainID uint64 + DefaultRPC string + + // ENSRegistry is the ENS registry proxy — the same address on + // mainnet, Sepolia, and Holesky. + ENSRegistry common.Address + + // ERC1056Registry is the EthereumDIDRegistry behind did:ethr. + ERC1056Registry common.Address + + // ERC8004IdentityRegistry is the trustless-agents IdentityRegistry + // referenced by ENSIP-25 attestation records. + ERC8004IdentityRegistry common.Address +} + +var presets = map[string]chainPreset{ + "mainnet": { + Name: "mainnet", + ChainID: 1, + DefaultRPC: "https://ethereum-rpc.publicnode.com", + ENSRegistry: common.HexToAddress("0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e"), + ERC1056Registry: common.HexToAddress("0xdca7ef03e98e0dc2b855be647c39abe984fcf21b"), + ERC8004IdentityRegistry: common.HexToAddress("0x8004a169FB4a3325136EB29fA0CEB6D2E539a432"), + }, + "sepolia": { + Name: "sepolia", + ChainID: 11155111, + DefaultRPC: "https://ethereum-sepolia-rpc.publicnode.com", + ENSRegistry: common.HexToAddress("0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e"), + ERC1056Registry: common.HexToAddress("0x03d5003bf0e79c5f5223588f347eba39afbc3818"), + // The OFFICIAL erc-8004/erc-8004-contracts testnet deployment + // (same vanity address on Base/Arbitrum/OP Sepolia + Amoy). + // It differs from the mainnet vanity address — 0x8004A169… + // has no code on Sepolia. + ERC8004IdentityRegistry: common.HexToAddress("0x8004A818BFB912233c491871b3d84c89A494BD9e"), + }, +} + +// netOpts are the flags shared by every command that talks to the +// network. Zero-value friendly: addNetFlags wires them onto a FlagSet. +type netOpts struct { + chain string + rpc string + timeout time.Duration + jsonOut bool +} + +func addNetFlags(fs *flag.FlagSet, o *netOpts) { + fs.StringVar(&o.chain, "chain", "mainnet", "chain preset: mainnet | sepolia") + fs.StringVar(&o.rpc, "rpc", os.Getenv("ETHID_RPC_URL"), "JSON-RPC endpoint override") + fs.DurationVar(&o.timeout, "timeout", 15*time.Second, "per network call timeout") + fs.BoolVar(&o.jsonOut, "json", false, "machine-readable JSON output") +} + +// dial connects to the chosen RPC endpoint and sanity-checks that the +// endpoint actually serves the chain the preset claims, so a -chain +// mainnet run against a Sepolia URL fails loudly instead of reading +// garbage from the wrong registries. +func (o *netOpts) dial(ctx context.Context) (*ethclient.Client, chainPreset, error) { + preset, ok := presets[o.chain] + if !ok { + return nil, chainPreset{}, fmt.Errorf("unknown chain preset %q (have: mainnet, sepolia)", o.chain) + } + rpcURL := o.rpc + if rpcURL == "" { + rpcURL = preset.DefaultRPC + } + + dialCtx, cancel := context.WithTimeout(ctx, o.timeout) + defer cancel() + client, err := ethclient.DialContext(dialCtx, rpcURL) + if err != nil { + return nil, chainPreset{}, fmt.Errorf("dial %s: %w", rpcURL, err) + } + + idCtx, cancel2 := context.WithTimeout(ctx, o.timeout) + defer cancel2() + chainID, err := client.ChainID(idCtx) + if err != nil { + client.Close() + return nil, chainPreset{}, fmt.Errorf("eth_chainId via %s: %w", rpcURL, err) + } + if chainID.Uint64() != preset.ChainID { + client.Close() + return nil, chainPreset{}, fmt.Errorf( + "endpoint %s serves chain id %d but preset %q expects %d — pass the matching -chain", + rpcURL, chainID.Uint64(), preset.Name, preset.ChainID) + } + return client, preset, nil +} + +// callCtx derives a per-RPC-call timeout context. +func (o *netOpts) callCtx(ctx context.Context) (context.Context, context.CancelFunc) { + return context.WithTimeout(ctx, o.timeout) +} diff --git a/scripts/poc/ethid/didethr.go b/scripts/poc/ethid/didethr.go new file mode 100644 index 0000000..bb4ef4b --- /dev/null +++ b/scripts/poc/ethid/didethr.go @@ -0,0 +1,641 @@ +package main + +import ( + "context" + "encoding/base64" + "encoding/hex" + "flag" + "fmt" + "math" + "math/big" + "strings" + "time" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" +) + +// did:ethr resolution against the real ERC-1056 EthereumDIDRegistry. +// +// The crucial property: a bare did:ethr:0x
needs ZERO chain +// reads — its DID document is implicit (the address is the controller, +// expressed as an EcdsaSecp256k1RecoveryMethod2020 verification +// method). The registry only matters once someone has called +// changeOwner / addDelegate / setAttribute. Resolution then walks a +// linked list the contract maintains: +// +// changed(identity) → block N of the most recent change +// logs at block N carry previousChange → block M of the one before +// … until previousChange == 0 +// +// Each event mutates the document: DIDOwnerChanged rotates the +// controller, DIDDelegateChanged adds signing keys by address, +// DIDAttributeChanged adds off-registry public keys and service +// endpoints. validTo timestamps expire delegates/attributes; writing +// validTo=0 revokes. + +const erc1056ABIJSON = `[ + {"name":"identityOwner","type":"function","stateMutability":"view", + "inputs":[{"name":"identity","type":"address"}], + "outputs":[{"name":"","type":"address"}]}, + {"name":"changed","type":"function","stateMutability":"view", + "inputs":[{"name":"","type":"address"}], + "outputs":[{"name":"","type":"uint256"}]}, + {"name":"DIDOwnerChanged","type":"event","anonymous":false,"inputs":[ + {"name":"identity","type":"address","indexed":true}, + {"name":"owner","type":"address","indexed":false}, + {"name":"previousChange","type":"uint256","indexed":false}]}, + {"name":"DIDDelegateChanged","type":"event","anonymous":false,"inputs":[ + {"name":"identity","type":"address","indexed":true}, + {"name":"delegateType","type":"bytes32","indexed":false}, + {"name":"delegate","type":"address","indexed":false}, + {"name":"validTo","type":"uint256","indexed":false}, + {"name":"previousChange","type":"uint256","indexed":false}]}, + {"name":"DIDAttributeChanged","type":"event","anonymous":false,"inputs":[ + {"name":"identity","type":"address","indexed":true}, + {"name":"name","type":"bytes32","indexed":false}, + {"name":"value","type":"bytes","indexed":false}, + {"name":"validTo","type":"uint256","indexed":false}, + {"name":"previousChange","type":"uint256","indexed":false}]} +]` + +var erc1056ABI = mustABI(erc1056ABIJSON) + +// registryEvent is one decoded ERC-1056 event in the identity's +// change history. +type registryEvent struct { + Kind string // owner | delegate | attribute + Block uint64 + TxHash common.Hash + Owner common.Address // owner events + DelegateType string // delegate events (bytes32 ascii) + Delegate common.Address + AttributeName string // attribute events (bytes32 ascii) + AttributeValue []byte + ValidTo uint64 + PreviousChange uint64 +} + +func cmdDIDResolve(ctx context.Context, args []string) error { + fs := flag.NewFlagSet("did resolve", flag.ContinueOnError) + var o netOpts + addNetFlags(fs, &o) + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() != 1 { + return fmt.Errorf("did resolve: exactly one DID argument required") + } + did, err := parseDIDEthr(fs.Arg(0)) + if err != nil { + return err + } + + // The DID's network wins over -chain so a sepolia DID can't be + // silently resolved against mainnet state. + switch did.ChainID { + case 1: + o.chain = "mainnet" + case 11155111: + o.chain = "sepolia" + default: + return fmt.Errorf("no preset for did:ethr network %q (chain id %d) — this PoC ships mainnet + sepolia", did.Network, did.ChainID) + } + + client, preset, err := o.dial(ctx) + if err != nil { + return err + } + defer client.Close() + + owner, changedBlock, err := readRegistryHead(ctx, &o, client, preset, did.Address) + if err != nil { + return err + } + events, err := walkChangeLog(ctx, &o, client, preset, did.Address, changedBlock) + if err != nil { + return err + } + doc := buildDIDDocument(did, preset, owner, events) + + if o.jsonOut { + return printJSON(map[string]any{ + "didDocument": doc, + "owner": owner.Hex(), + "changedBlock": changedBlock, + "events": summarizeEvents(events), + }) + } + + section(fmt.Sprintf("did:ethr resolution (%s, registry %s)", preset.Name, preset.ERC1056Registry.Hex())) + kv("did", did.DID) + kv("identity address", did.Address.Hex()) + kv("current owner", owner.Hex()) + if owner == (common.Address{}) { + failLine("DEACTIVATED — owner is the zero address") + } else if owner == did.Address { + noteLine("owner unchanged — the implicit document applies") + } else { + noteLine("owner ROTATED on chain — controller differs from the identity address") + } + if changedBlock == 0 { + noteLine("no registry writes — document is fully implicit (zero history)") + } else { + kv("latest change at block", fmt.Sprintf("%d", changedBlock)) + section("on-chain change history (oldest first)") + for _, ev := range events { + switch ev.Kind { + case "owner": + noteLine(fmt.Sprintf("block %-9d DIDOwnerChanged owner=%s", ev.Block, ev.Owner.Hex())) + case "delegate": + noteLine(fmt.Sprintf("block %-9d DIDDelegateChanged type=%s delegate=%s validTo=%s", + ev.Block, ev.DelegateType, ev.Delegate.Hex(), formatValidTo(ev.ValidTo))) + case "attribute": + noteLine(fmt.Sprintf("block %-9d DIDAttributeChanged name=%s validTo=%s value=%s", + ev.Block, ev.AttributeName, formatValidTo(ev.ValidTo), previewBytes(ev.AttributeValue))) + } + } + } + section("DID document") + return printJSON(doc) +} + +func readRegistryHead(ctx context.Context, o *netOpts, client *ethclient.Client, preset chainPreset, identity common.Address) (common.Address, uint64, error) { + ownerData, err := erc1056ABI.Pack("identityOwner", identity) + if err != nil { + return common.Address{}, 0, err + } + callCtx, cancel := o.callCtx(ctx) + ret, err := client.CallContract(callCtx, ethereum.CallMsg{To: &preset.ERC1056Registry, Data: ownerData}, nil) + cancel() + if err != nil { + return common.Address{}, 0, fmt.Errorf("identityOwner(): %w", err) + } + var owner common.Address + if err := erc1056ABI.UnpackIntoInterface(&owner, "identityOwner", ret); err != nil { + return common.Address{}, 0, err + } + + changedData, err := erc1056ABI.Pack("changed", identity) + if err != nil { + return common.Address{}, 0, err + } + callCtx2, cancel2 := o.callCtx(ctx) + ret, err = client.CallContract(callCtx2, ethereum.CallMsg{To: &preset.ERC1056Registry, Data: changedData}, nil) + cancel2() + if err != nil { + return common.Address{}, 0, fmt.Errorf("changed(): %w", err) + } + var changedBlock *big.Int + if err := erc1056ABI.UnpackIntoInterface(&changedBlock, "changed", ret); err != nil { + return common.Address{}, 0, err + } + return owner, changedBlock.Uint64(), nil +} + +// walkChangeLog follows previousChange pointers backwards, returning +// events oldest-first. Each hop is a single-block eth_getLogs filtered +// to the registry address and the identity topic — cheap enough for +// any public endpoint. +func walkChangeLog(ctx context.Context, o *netOpts, client *ethclient.Client, preset chainPreset, identity common.Address, head uint64) ([]registryEvent, error) { + identityTopic := common.BytesToHash(common.LeftPadBytes(identity.Bytes(), 32)) + eventTopics := []common.Hash{ + erc1056ABI.Events["DIDOwnerChanged"].ID, + erc1056ABI.Events["DIDDelegateChanged"].ID, + erc1056ABI.Events["DIDAttributeChanged"].ID, + } + + var events []registryEvent + const maxHops = 200 // runaway-history guard for a PoC + block, hops := head, 0 + for ; block != 0 && hops < maxHops; hops++ { + blockBig := new(big.Int).SetUint64(block) + callCtx, cancel := o.callCtx(ctx) + logs, err := client.FilterLogs(callCtx, ethereum.FilterQuery{ + FromBlock: blockBig, + ToBlock: blockBig, + Addresses: []common.Address{preset.ERC1056Registry}, + Topics: [][]common.Hash{eventTopics, {identityTopic}}, + }) + cancel() + if err != nil { + return nil, fmt.Errorf("eth_getLogs at block %d: %w", block, err) + } + if len(logs) == 0 { + return nil, fmt.Errorf("registry pointed at block %d but no events found there — wrong registry address for this chain?", block) + } + + next := uint64(0) + // Logs arrive oldest-first within the block; append newest- + // first so the single whole-slice flip below yields history + // that is chronological both across AND within blocks (an + // add+revoke pair in one block must apply in true order). + for i := len(logs) - 1; i >= 0; i-- { + ev, err := decodeRegistryEvent(logs[i]) + if err != nil { + return nil, err + } + events = append(events, ev) + // Several writes can land in one block; the earliest + // previousChange among them points strictly backwards. + if next == 0 || ev.PreviousChange < next { + next = ev.PreviousChange + } + } + if next >= block { + break // defensive: refuse to loop forward + } + block = next + } + if block != 0 && hops >= maxHops { + // Silently dropping the OLDEST events would present a partial + // document as complete — fail closed instead. + return nil, fmt.Errorf("change history exceeds %d hops (next previousChange block %d) — refusing to build a partial DID document", maxHops, block) + } + + // Walked newest → oldest; flip to apply chronologically. + for i, j := 0, len(events)-1; i < j; i, j = i+1, j-1 { + events[i], events[j] = events[j], events[i] + } + return events, nil +} + +func decodeRegistryEvent(lg types.Log) (registryEvent, error) { + ev := registryEvent{Block: lg.BlockNumber, TxHash: lg.TxHash} + if len(lg.Topics) == 0 { + return ev, fmt.Errorf("log at block %d has no topics — endpoint returned a malformed log", lg.BlockNumber) + } + switch lg.Topics[0] { + case erc1056ABI.Events["DIDOwnerChanged"].ID: + ev.Kind = "owner" + var out struct { + Owner common.Address + PreviousChange *big.Int + } + if err := erc1056ABI.UnpackIntoInterface(&out, "DIDOwnerChanged", lg.Data); err != nil { + return ev, fmt.Errorf("decode DIDOwnerChanged: %w", err) + } + ev.Owner = out.Owner + ev.PreviousChange = out.PreviousChange.Uint64() + case erc1056ABI.Events["DIDDelegateChanged"].ID: + ev.Kind = "delegate" + var out struct { + DelegateType [32]byte + Delegate common.Address + ValidTo *big.Int + PreviousChange *big.Int + } + if err := erc1056ABI.UnpackIntoInterface(&out, "DIDDelegateChanged", lg.Data); err != nil { + return ev, fmt.Errorf("decode DIDDelegateChanged: %w", err) + } + ev.DelegateType = bytes32ToString(out.DelegateType) + ev.Delegate = out.Delegate + ev.ValidTo = clampUint64(out.ValidTo) + ev.PreviousChange = out.PreviousChange.Uint64() + case erc1056ABI.Events["DIDAttributeChanged"].ID: + ev.Kind = "attribute" + var out struct { + Name [32]byte + Value []byte + ValidTo *big.Int + PreviousChange *big.Int + } + if err := erc1056ABI.UnpackIntoInterface(&out, "DIDAttributeChanged", lg.Data); err != nil { + return ev, fmt.Errorf("decode DIDAttributeChanged: %w", err) + } + ev.AttributeName = bytes32ToString(out.Name) + ev.AttributeValue = out.Value + ev.ValidTo = clampUint64(out.ValidTo) + ev.PreviousChange = out.PreviousChange.Uint64() + default: + return ev, fmt.Errorf("unexpected event topic %s", lg.Topics[0].Hex()) + } + return ev, nil +} + +// ---- DID document construction ---- + +// didDocument is the W3C-shaped output. Field order mirrors common +// resolver output for easy diffing against the TS reference resolver. +type didDocument struct { + Context []string `json:"@context"` + ID string `json:"id"` + Controller string `json:"controller,omitempty"` + VerificationMethod []verificationMethod `json:"verificationMethod"` + Authentication []string `json:"authentication"` + AssertionMethod []string `json:"assertionMethod"` + KeyAgreement []string `json:"keyAgreement,omitempty"` + Service []didService `json:"service,omitempty"` + Deactivated bool `json:"deactivated,omitempty"` +} + +type verificationMethod struct { + ID string `json:"id"` + Type string `json:"type"` + Controller string `json:"controller"` + BlockchainAccountID string `json:"blockchainAccountId,omitempty"` + PublicKeyHex string `json:"publicKeyHex,omitempty"` + PublicKeyBase64 string `json:"publicKeyBase64,omitempty"` + PublicKeyBase58 string `json:"publicKeyBase58,omitempty"` + PublicKeyPem string `json:"publicKeyPem,omitempty"` +} + +type didService struct { + ID string `json:"id"` + Type string `json:"type"` + ServiceEndpoint string `json:"serviceEndpoint"` +} + +func buildDIDDocument(did *didEthr, preset chainPreset, owner common.Address, events []registryEvent) *didDocument { + doc := &didDocument{ + Context: []string{ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/security/suites/secp256k1recovery-2020/v2", + }, + ID: did.DID, + Authentication: []string{}, + AssertionMethod: []string{}, + VerificationMethod: []verificationMethod{}, + } + if owner == (common.Address{}) { + doc.Deactivated = true + return doc + } + + caip10 := fmt.Sprintf("eip155:%d:%s", did.ChainID, owner.Hex()) + controllerVM := did.DID + "#controller" + doc.VerificationMethod = append(doc.VerificationMethod, verificationMethod{ + ID: controllerVM, + Type: "EcdsaSecp256k1RecoveryMethod2020", + Controller: did.DID, + BlockchainAccountID: caip10, + }) + doc.Authentication = append(doc.Authentication, controllerVM) + doc.AssertionMethod = append(doc.AssertionMethod, controllerVM) + + // Public-key-form DIDs additionally expose the key itself — but + // only while the on-chain owner is still the derived address. + if did.PublicKey != nil && owner == did.Address { + keyVM := did.DID + "#controllerKey" + doc.VerificationMethod = append(doc.VerificationMethod, verificationMethod{ + ID: keyVM, + Type: "EcdsaSecp256k1VerificationKey2019", + Controller: did.DID, + PublicKeyHex: hex.EncodeToString(did.PublicKey), + }) + doc.Authentication = append(doc.Authentication, keyVM) + doc.AssertionMethod = append(doc.AssertionMethod, keyVM) + } + + // Apply events chronologically, mirroring the reference resolver: + // an event with validTo >= now adds/replaces the record for its + // logical key; validTo < now (revocations emit 0 or the revoke + // timestamp) removes it. The #delegate-N / #service-N fragment + // counters increment on EVERY relevant event — including + // revocations — so fragment ids stay stable across revocations, + // matching ethr-did-resolver's numbering. + type docEntry struct { + vm verificationMethod + svc didService + purpose string // delegate purposes or "service" + } + now := uint64(time.Now().Unix()) + entries := map[string]docEntry{} + var order []string + delegateIdx := 0 + serviceIdx := 0 + record := func(key string, entry docEntry, active bool) { + if !active { + delete(entries, key) + return + } + // Re-adds after a revocation must not leave a stale slot in + // order — that would emit the entry twice. Delete-then-append + // also mirrors the reference resolver's insertion semantics + // (a re-added entry moves to the end). + for i, existing := range order { + if existing == key { + order = append(order[:i], order[i+1:]...) + break + } + } + order = append(order, key) + entries[key] = entry + } + for _, ev := range events { + active := ev.ValidTo >= now + switch ev.Kind { + case "delegate": + delegateIdx++ + key := "delegate|" + ev.DelegateType + "|" + ev.Delegate.Hex() + vm := verificationMethod{ + ID: fmt.Sprintf("%s#delegate-%d", did.DID, delegateIdx), + Type: "EcdsaSecp256k1RecoveryMethod2020", + Controller: did.DID, + BlockchainAccountID: fmt.Sprintf("eip155:%d:%s", did.ChainID, ev.Delegate.Hex()), + } + record(key, docEntry{vm: vm, purpose: ev.DelegateType}, active) + case "attribute": + key := "attribute|" + ev.AttributeName + "|" + hex.EncodeToString(ev.AttributeValue) + if strings.HasPrefix(ev.AttributeName, "did/pub/") { + delegateIdx++ + if vm, purpose, ok := attributeToVM(did, delegateIdx, ev); ok { + record(key, docEntry{vm: vm, purpose: purpose}, active) + } + } else if strings.HasPrefix(ev.AttributeName, "did/svc/") { + serviceIdx++ + if svc, ok := attributeToService(did, serviceIdx, ev); ok { + record(key, docEntry{svc: svc, purpose: "service"}, active) + } + } + // Unknown attribute grammars are surfaced in the history + // table but deliberately not guessed into the document. + } + } + + for _, key := range order { + entry, live := entries[key] + if !live { + continue + } + if entry.purpose == "service" { + doc.Service = append(doc.Service, entry.svc) + continue + } + doc.VerificationMethod = append(doc.VerificationMethod, entry.vm) + switch entry.purpose { + case "sigAuth": + // The reference resolver lists sigAuth keys under BOTH + // authentication and assertionMethod. + doc.Authentication = append(doc.Authentication, entry.vm.ID) + doc.AssertionMethod = append(doc.AssertionMethod, entry.vm.ID) + case "enc": + doc.KeyAgreement = append(doc.KeyAgreement, entry.vm.ID) + default: // veriKey and unknown delegate types + doc.AssertionMethod = append(doc.AssertionMethod, entry.vm.ID) + } + } + return doc +} + +// attributeToVM maps a did/pub/// attribute +// into a verification method per the did:ethr method spec. +func attributeToVM(did *didEthr, idx int, ev registryEvent) (verificationMethod, string, bool) { + parts := strings.Split(ev.AttributeName, "/") + if len(parts) < 4 || parts[0] != "did" || parts[1] != "pub" { + return verificationMethod{}, "", false + } + alg, purpose := parts[2], parts[3] + encoding := "hex" + if len(parts) >= 5 { + encoding = parts[4] + } + + vmType := map[string]string{ + "Secp256k1": "EcdsaSecp256k1VerificationKey2019", + "Ed25519": "Ed25519VerificationKey2018", + "RSA": "RSAVerificationKey2018", + "X25519": "X25519KeyAgreementKey2019", + }[alg] + if vmType == "" { + return verificationMethod{}, "", false + } + + vm := verificationMethod{ + ID: fmt.Sprintf("%s#delegate-%d", did.DID, idx), + Type: vmType, + Controller: did.DID, + } + switch encoding { + case "hex": + vm.PublicKeyHex = hex.EncodeToString(ev.AttributeValue) + case "base64": + vm.PublicKeyBase64 = base64.StdEncoding.EncodeToString(ev.AttributeValue) + case "base58": + vm.PublicKeyBase58 = base58Encode(ev.AttributeValue) + case "pem": + vm.PublicKeyPem = string(ev.AttributeValue) + default: + return verificationMethod{}, "", false + } + return vm, purpose, true +} + +// attributeToService maps a did/svc/ attribute into a service +// entry; the value is the endpoint string. +func attributeToService(did *didEthr, idx int, ev registryEvent) (didService, bool) { + parts := strings.Split(ev.AttributeName, "/") + if len(parts) < 3 || parts[0] != "did" || parts[1] != "svc" { + return didService{}, false + } + return didService{ + ID: fmt.Sprintf("%s#service-%d", did.DID, idx), + Type: parts[2], + ServiceEndpoint: strings.TrimSpace(string(ev.AttributeValue)), + }, true +} + +// base58Encode is the Bitcoin alphabet used by publicKeyBase58. +func base58Encode(input []byte) string { + const alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + x := new(big.Int).SetBytes(input) + radix := big.NewInt(58) + rem := new(big.Int) + var out []byte + for x.Sign() > 0 { + x.DivMod(x, radix, rem) + out = append(out, alphabet[rem.Int64()]) + } + for _, b := range input { + if b != 0 { + break + } + out = append(out, alphabet[0]) + } + for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 { + out[i], out[j] = out[j], out[i] + } + return string(out) +} + +func bytes32ToString(raw [32]byte) string { + return string(bytesTrimRightZero(raw[:])) +} + +func bytesTrimRightZero(raw []byte) []byte { + end := len(raw) + for end > 0 && raw[end-1] == 0 { + end-- + } + return raw[:end] +} + +// clampUint64 saturates a uint256 to uint64. ERC-1056 validTo is a +// uint256 and "never expires" values >= 2^64 exist in the wild; +// truncating would flip a live entry to "revoked". Anything past +// 2^64 seconds is effectively forever, so saturation preserves the +// reference resolver's full-width comparison for every input. +func clampUint64(x *big.Int) uint64 { + if !x.IsUint64() { + return math.MaxUint64 + } + return x.Uint64() +} + +func formatValidTo(validTo uint64) string { + if validTo == 0 { + return "0 (revoked)" + } + if validTo > math.MaxInt64 { + return fmt.Sprintf("%d (effectively never expires)", validTo) + } + t := time.Unix(int64(validTo), 0).UTC() + if t.Before(time.Now()) { + return t.Format(time.RFC3339) + " (expired)" + } + return t.Format(time.RFC3339) +} + +func previewBytes(raw []byte) string { + const max = 48 + printable := true + for _, b := range raw { + if b < 0x20 || b > 0x7e { + printable = false + break + } + } + s := "" + if printable { + s = string(raw) + } else { + s = "0x" + hex.EncodeToString(raw) + } + if len(s) > max { + return s[:max] + "…" + } + return s +} + +func summarizeEvents(events []registryEvent) []map[string]any { + out := make([]map[string]any, 0, len(events)) + for _, ev := range events { + entry := map[string]any{"kind": ev.Kind, "block": ev.Block, "tx": ev.TxHash.Hex()} + switch ev.Kind { + case "owner": + entry["owner"] = ev.Owner.Hex() + case "delegate": + entry["delegateType"] = ev.DelegateType + entry["delegate"] = ev.Delegate.Hex() + entry["validTo"] = ev.ValidTo + case "attribute": + entry["name"] = ev.AttributeName + entry["value"] = previewBytes(ev.AttributeValue) + entry["validTo"] = ev.ValidTo + } + out = append(out, entry) + } + return out +} diff --git a/scripts/poc/ethid/dnscheck.go b/scripts/poc/ethid/dnscheck.go new file mode 100644 index 0000000..15da973 --- /dev/null +++ b/scripts/poc/ethid/dnscheck.go @@ -0,0 +1,151 @@ +package main + +import ( + "context" + "flag" + "fmt" + "net" + "strings" + "time" + + "github.com/miekg/dns" +) + +// Real DNS reads via miekg/dns — the same library behind the repo's +// `lookup` DNS verifier. Queries carry the DO bit so a validating +// resolver returns AD (Authenticated Data) when the answer chain is +// DNSSEC-signed; that mirrors how the RA threads dnssecVerified into +// TL attestations today. + +type dnsAnswer struct { + Type string `json:"type"` + Records []string `json:"records"` + AD bool `json:"dnssecValidated"` +} + +type dnsQuerier struct { + server string + timeout time.Duration +} + +func newDNSQuerier(server string, timeout time.Duration) *dnsQuerier { + if server == "" { + server = systemResolver() + } + if !strings.Contains(server, ":") { + server += ":53" + } + return &dnsQuerier{server: server, timeout: timeout} +} + +// systemResolver picks the host's first configured nameserver, +// falling back to a public one. +func systemResolver() string { + conf, err := dns.ClientConfigFromFile("/etc/resolv.conf") + if err == nil && len(conf.Servers) > 0 { + return net.JoinHostPort(conf.Servers[0], conf.Port) + } + return "1.1.1.1:53" +} + +func (q *dnsQuerier) query(ctx context.Context, host string, qtype uint16) (dnsAnswer, error) { + typeName := dns.TypeToString[qtype] + msg := new(dns.Msg) + msg.SetQuestion(dns.Fqdn(host), qtype) + msg.SetEdns0(4096, true) // DO bit: ask the resolver to validate + msg.RecursionDesired = true + + client := &dns.Client{Timeout: q.timeout} + resp, _, err := client.ExchangeContext(ctx, msg, q.server) + if err == nil && resp.Truncated { + // Standard UDP-then-TCP fallback: a TC answer is partial + // (large TXT sets) and must never be reported as complete. + tcpClient := &dns.Client{Net: "tcp", Timeout: q.timeout} + resp, _, err = tcpClient.ExchangeContext(ctx, msg, q.server) + } + if err != nil { + return dnsAnswer{Type: typeName}, fmt.Errorf("%s query for %s via %s: %w", typeName, host, q.server, err) + } + if resp.Truncated { + return dnsAnswer{Type: typeName}, fmt.Errorf("%s answer for %s truncated even over TCP", typeName, host) + } + if resp.Rcode != dns.RcodeSuccess && resp.Rcode != dns.RcodeNameError { + return dnsAnswer{Type: typeName}, fmt.Errorf("%s query for %s: rcode %s", typeName, host, dns.RcodeToString[resp.Rcode]) + } + + answer := dnsAnswer{Type: typeName, AD: resp.AuthenticatedData} + for _, rr := range resp.Answer { + if rr.Header().Rrtype != qtype { + continue // skip CNAMEs in the chain + } + // Strip the header for display: "name ttl class type rdata" → rdata. + full := rr.String() + if idx := strings.Index(full, "\t"+typeName+"\t"); idx >= 0 { + answer.Records = append(answer.Records, full[idx+len(typeName)+2:]) + } else { + answer.Records = append(answer.Records, full) + } + } + return answer, nil +} + +// checkHost runs the standard record set for an endpoint host. +func (q *dnsQuerier) checkHost(ctx context.Context, host string) ([]dnsAnswer, error) { + var out []dnsAnswer + for _, qtype := range []uint16{dns.TypeA, dns.TypeAAAA, dns.TypeHTTPS, dns.TypeTXT} { + ans, err := q.query(ctx, host, qtype) + if err != nil { + return nil, err + } + out = append(out, ans) + } + return out, nil +} + +func cmdDNS(ctx context.Context, args []string) error { + fs := flag.NewFlagSet("dns", flag.ContinueOnError) + server := fs.String("resolver", "", "DNS server host[:port] (default: system resolver)") + timeout := fs.Duration("timeout", 5*time.Second, "query timeout") + jsonOut := fs.Bool("json", false, "machine-readable JSON output") + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() != 1 { + return fmt.Errorf("dns: exactly one HOST argument required") + } + host := strings.TrimSuffix(strings.ToLower(fs.Arg(0)), ".") + + q := newDNSQuerier(*server, *timeout) + answers, err := q.checkHost(ctx, host) + if err != nil { + return err + } + + if *jsonOut { + return printJSON(map[string]any{"host": host, "resolver": q.server, "answers": answers}) + } + section(fmt.Sprintf("DNS: %s (via %s)", host, q.server)) + printDNSAnswers(answers) + return nil +} + +func printDNSAnswers(answers []dnsAnswer) { + for _, ans := range answers { + suffix := "" + if ans.AD { + suffix = " [DNSSEC AD]" + } + if len(ans.Records) == 0 { + kv(ans.Type, "(none)"+suffix) + continue + } + for i, rec := range ans.Records { + label := ans.Type + if i > 0 { + label = "" + } + kv(label, rec+suffix) + suffix = "" + } + } +} diff --git a/scripts/poc/ethid/ens.go b/scripts/poc/ethid/ens.go new file mode 100644 index 0000000..ddd4b13 --- /dev/null +++ b/scripts/poc/ethid/ens.go @@ -0,0 +1,417 @@ +package main + +import ( + "context" + "flag" + "fmt" + "strings" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/ethclient" +) + +// ENS resolution from first principles — deliberately NOT using an +// ENS library, to prove the surface ans would actually need is small: +// +// 1. node = namehash(name) (pure keccak) +// 2. walk name → parent → … until registry.resolver(namehash(frag)) +// is non-zero; finding it on an ancestor means ENSIP-10 wildcard +// 3. plain resolver → call text(node,key) / addr(node) directly +// ENSIP-10 resolver → call resolve(dnsEncode(name), innerCall) +// 4. either call may revert with EIP-3668 OffchainLookup — the +// CCIP-Read gateway loop in ccip.go handles that, which is what +// makes offchain/L2 names (cb.id, base.eth subnames) resolve +// +// This is the resolution algorithm ensjs/viem implement; the Go +// ecosystem gap is steps 2–4, and they fit in this file plus ccip.go. + +const ( + // ensip10InterfaceID is the ERC-165 id of the ENSIP-10 wildcard + // IExtendedResolver: bytes4(keccak("resolve(bytes,bytes)")). + ensip10InterfaceID = "0x9061b923" + + maxLabelLen = 63 +) + +const ensRegistryABIJSON = `[ + {"name":"resolver","type":"function","stateMutability":"view", + "inputs":[{"name":"node","type":"bytes32"}], + "outputs":[{"name":"","type":"address"}]} +]` + +const ensResolverABIJSON = `[ + {"name":"addr","type":"function","stateMutability":"view", + "inputs":[{"name":"node","type":"bytes32"}], + "outputs":[{"name":"","type":"address"}]}, + {"name":"text","type":"function","stateMutability":"view", + "inputs":[{"name":"node","type":"bytes32"},{"name":"key","type":"string"}], + "outputs":[{"name":"","type":"string"}]}, + {"name":"supportsInterface","type":"function","stateMutability":"view", + "inputs":[{"name":"interfaceID","type":"bytes4"}], + "outputs":[{"name":"","type":"bool"}]}, + {"name":"resolve","type":"function","stateMutability":"view", + "inputs":[{"name":"name","type":"bytes"},{"name":"data","type":"bytes"}], + "outputs":[{"name":"","type":"bytes"}]} +]` + +var ( + ensRegistryABI = mustABI(ensRegistryABIJSON) + ensResolverABI = mustABI(ensResolverABIJSON) +) + +func mustABI(j string) abi.ABI { + parsed, err := abi.JSON(strings.NewReader(j)) + if err != nil { + panic("static ABI failed to parse: " + err.Error()) + } + return parsed +} + +// namehash implements the ENS node derivation (EIP-137). The name +// must already be normalized — see normalizeENSName. +func namehash(name string) common.Hash { + node := common.Hash{} + if name == "" { + return node + } + labels := strings.Split(name, ".") + for i := len(labels) - 1; i >= 0; i-- { + labelHash := crypto.Keccak256([]byte(labels[i])) + node = common.BytesToHash(crypto.Keccak256(node.Bytes(), labelHash)) + } + return node +} + +// normalizeENSName lowercases and validates a name. Full ENS +// normalization is ENSIP-15 (UTS-46 + emoji sequences); this PoC +// supports the ASCII subset and refuses anything else rather than +// hashing a wrong form silently. +func normalizeENSName(name string) (string, error) { + name = strings.TrimSuffix(strings.TrimSpace(name), ".") + if name == "" { + return "", fmt.Errorf("empty ENS name") + } + for _, r := range name { + if r > 0x7f { + return "", fmt.Errorf("name %q contains non-ASCII characters — full ENSIP-15 normalization is out of scope for this PoC", name) + } + } + name = strings.ToLower(name) + for _, label := range strings.Split(name, ".") { + if label == "" { + return "", fmt.Errorf("name %q has an empty label", name) + } + if len(label) > maxLabelLen { + return "", fmt.Errorf("label %q exceeds 63 bytes", label) + } + } + return name, nil +} + +// dnsEncode packs a name into DNS wire format (length-prefixed +// labels, zero terminator) — the form ENSIP-10 resolve() takes. +func dnsEncode(name string) ([]byte, error) { + var out []byte + for _, label := range strings.Split(name, ".") { + if len(label) > maxLabelLen { + return nil, fmt.Errorf("label %q exceeds 63 bytes", label) + } + out = append(out, byte(len(label))) + out = append(out, label...) + } + return append(out, 0x00), nil +} + +// ensClient performs resolution against one chain. gatewayLog records +// every CCIP-Read gateway consulted so commands can surface exactly +// which offchain services took part in an answer. +type ensClient struct { + client *ethclient.Client + preset chainPreset + opts *netOpts + gatewayLog []string +} + +// resolverInfo is the outcome of the registry walk. +type resolverInfo struct { + Resolver common.Address + FoundAt string // the name fragment whose registry entry held the resolver + Wildcard bool // resolver came from an ancestor (ENSIP-10 required) + Extended bool // resolver advertises ENSIP-10 IExtendedResolver +} + +func (e *ensClient) findResolver(ctx context.Context, name string) (*resolverInfo, error) { + for frag := name; frag != ""; frag = parentName(frag) { + data, err := ensRegistryABI.Pack("resolver", namehash(frag)) + if err != nil { + return nil, fmt.Errorf("pack registry.resolver: %w", err) + } + ret, err := e.ethCall(ctx, e.preset.ENSRegistry, data) + if err != nil { + return nil, fmt.Errorf("registry.resolver(%s): %w", frag, err) + } + var addr common.Address + if err := ensRegistryABI.UnpackIntoInterface(&addr, "resolver", ret); err != nil { + return nil, fmt.Errorf("decode registry.resolver(%s): %w", frag, err) + } + if addr == (common.Address{}) { + continue + } + + info := &resolverInfo{Resolver: addr, FoundAt: frag, Wildcard: frag != name} + extended, err := e.supportsENSIP10(ctx, addr) + if err != nil { + // Pre-ERC-165 resolvers exist; treat probe failure as "not extended". + extended = false + } + info.Extended = extended + if info.Wildcard && !info.Extended { + return nil, fmt.Errorf( + "name %q has no resolver; nearest ancestor %q has one at %s but it does not implement ENSIP-10 wildcard resolution", + name, frag, addr.Hex()) + } + return info, nil + } + return nil, fmt.Errorf("no resolver found for %q (or any ancestor) in the ENS registry %s", name, e.preset.ENSRegistry.Hex()) +} + +func (e *ensClient) supportsENSIP10(ctx context.Context, resolver common.Address) (bool, error) { + var iface [4]byte + copy(iface[:], common.FromHex(ensip10InterfaceID)) + data, err := ensResolverABI.Pack("supportsInterface", iface) + if err != nil { + return false, err + } + ret, err := e.ethCall(ctx, resolver, data) + if err != nil { + return false, err + } + var ok bool + if err := ensResolverABI.UnpackIntoInterface(&ok, "supportsInterface", ret); err != nil { + return false, err + } + return ok, nil +} + +// resolverCall runs an inner resolver call (e.g. text(node,key)) for +// name, routing through ENSIP-10 resolve() when the resolver is +// extended, and through the CCIP-Read loop in either case. +func (e *ensClient) resolverCall(ctx context.Context, name string, info *resolverInfo, inner []byte) ([]byte, error) { + if !info.Extended { + return e.ccipCall(ctx, info.Resolver, inner, 0) + } + wire, err := dnsEncode(name) + if err != nil { + return nil, err + } + data, err := ensResolverABI.Pack("resolve", wire, inner) + if err != nil { + return nil, fmt.Errorf("pack resolve(): %w", err) + } + ret, err := e.ccipCall(ctx, info.Resolver, data, 0) + if err != nil { + return nil, err + } + var innerRet []byte + if err := ensResolverABI.UnpackIntoInterface(&innerRet, "resolve", ret); err != nil { + return nil, fmt.Errorf("decode resolve() envelope: %w", err) + } + return innerRet, nil +} + +// Text resolves a single text record; empty string means "not set". +func (e *ensClient) Text(ctx context.Context, name string, info *resolverInfo, key string) (string, error) { + inner, err := ensResolverABI.Pack("text", namehash(name), key) + if err != nil { + return "", fmt.Errorf("pack text(): %w", err) + } + ret, err := e.resolverCall(ctx, name, info, inner) + if err != nil { + return "", err + } + if len(ret) == 0 { + return "", nil + } + var value string + if err := ensResolverABI.UnpackIntoInterface(&value, "text", ret); err != nil { + return "", fmt.Errorf("decode text(%q): %w", key, err) + } + return value, nil +} + +// Addr resolves the ETH address record; zero address means "not set". +func (e *ensClient) Addr(ctx context.Context, name string, info *resolverInfo) (common.Address, error) { + inner, err := ensResolverABI.Pack("addr", namehash(name)) + if err != nil { + return common.Address{}, fmt.Errorf("pack addr(): %w", err) + } + ret, err := e.resolverCall(ctx, name, info, inner) + if err != nil { + return common.Address{}, err + } + if len(ret) == 0 { + return common.Address{}, nil + } + var addr common.Address + if err := ensResolverABI.UnpackIntoInterface(&addr, "addr", ret); err != nil { + return common.Address{}, fmt.Errorf("decode addr(): %w", err) + } + return addr, nil +} + +// ethCall is a bare eth_call with the per-call timeout applied. +func (e *ensClient) ethCall(ctx context.Context, to common.Address, data []byte) ([]byte, error) { + callCtx, cancel := e.opts.callCtx(ctx) + defer cancel() + return e.client.CallContract(callCtx, ethereum.CallMsg{To: &to, Data: data}, nil) +} + +func parentName(name string) string { + if i := strings.IndexByte(name, '.'); i >= 0 { + return name[i+1:] + } + return "" +} + +// ---- commands ---- + +func cmdENSResolve(ctx context.Context, args []string) error { + fs := flag.NewFlagSet("ens resolve", flag.ContinueOnError) + var o netOpts + addNetFlags(fs, &o) + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() != 1 { + return fmt.Errorf("ens resolve: exactly one NAME argument required") + } + name, err := normalizeENSName(fs.Arg(0)) + if err != nil { + return err + } + + client, preset, err := o.dial(ctx) + if err != nil { + return err + } + defer client.Close() + e := &ensClient{client: client, preset: preset, opts: &o} + + info, err := e.findResolver(ctx, name) + if err != nil { + return err + } + addr, err := e.Addr(ctx, name, info) + if err != nil { + return err + } + + textKeys := []string{"url", "avatar", "com.twitter", "description"} + texts := map[string]string{} + for _, key := range textKeys { + v, err := e.Text(ctx, name, info, key) + if err != nil { + return fmt.Errorf("text(%q): %w", key, err) + } + if v != "" { + texts[key] = v + } + } + + if o.jsonOut { + return printJSON(map[string]any{ + "name": name, + "chain": preset.Name, + "namehash": namehash(name).Hex(), + "resolver": info.Resolver.Hex(), + "resolverFoundAt": info.FoundAt, + "wildcard": info.Wildcard, + "extended": info.Extended, + "address": addr.Hex(), + "texts": texts, + "ccipGateways": e.gatewayLog, + }) + } + + section(fmt.Sprintf("ENS resolution: %s (%s)", name, preset.Name)) + kv("namehash", namehash(name).Hex()) + kv("resolver", info.Resolver.Hex()) + if info.Wildcard { + noteLine(fmt.Sprintf("wildcard: resolver lives on ancestor %q (ENSIP-10)", info.FoundAt)) + } + if info.Extended { + noteLine("resolver implements ENSIP-10 IExtendedResolver") + } + for _, gw := range e.gatewayLog { + noteLine("offchain answer via CCIP-Read gateway: " + gw) + } + if addr != (common.Address{}) { + kv("address", addr.Hex()) + } else { + kv("address", "(not set)") + } + for _, key := range textKeys { + if v, ok := texts[key]; ok { + kv("text:"+key, v) + } + } + return nil +} + +func cmdENSText(ctx context.Context, args []string) error { + fs := flag.NewFlagSet("ens text", flag.ContinueOnError) + var o netOpts + addNetFlags(fs, &o) + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() < 2 { + return fmt.Errorf("ens text: NAME and at least one KEY required") + } + name, err := normalizeENSName(fs.Arg(0)) + if err != nil { + return err + } + keys := fs.Args()[1:] + + client, preset, err := o.dial(ctx) + if err != nil { + return err + } + defer client.Close() + e := &ensClient{client: client, preset: preset, opts: &o} + + info, err := e.findResolver(ctx, name) + if err != nil { + return err + } + values := map[string]string{} + for _, key := range keys { + v, err := e.Text(ctx, name, info, key) + if err != nil { + return fmt.Errorf("text(%q): %w", key, err) + } + values[key] = v + } + + if o.jsonOut { + return printJSON(map[string]any{ + "name": name, "chain": preset.Name, "records": values, "ccipGateways": e.gatewayLog, + }) + } + section(fmt.Sprintf("ENS text records: %s (%s)", name, preset.Name)) + for _, gw := range e.gatewayLog { + noteLine("offchain answer via CCIP-Read gateway: " + gw) + } + for _, key := range keys { + if values[key] == "" { + kv(key, "(not set)") + } else { + kv(key, values[key]) + } + } + return nil +} diff --git a/scripts/poc/ethid/ensip25.go b/scripts/poc/ethid/ensip25.go new file mode 100644 index 0000000..82415c3 --- /dev/null +++ b/scripts/poc/ethid/ensip25.go @@ -0,0 +1,462 @@ +package main + +import ( + "context" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "io" + "math/big" + "net/http" + "net/url" + "strings" + + "github.com/ethereum/go-ethereum/common" +) + +// ENSIP-25 ("AI Agent Registry ENS Name Verification", draft) binds +// an ENS name to an entry in an on-chain agent registry — ERC-8004's +// IdentityRegistry being the motivating one. The binding is a text +// record on the name: +// +// key = agent-registration[][] +// value = any non-empty string (SHOULD be "1") +// +// Normative direction (registry → name): read the agent's claimed ENS +// name from its registration, construct the key, resolve the text +// record; non-empty ⇒ the name owner attests the association. +// Reverse direction (name → registry): ENSIP-25 leaves it to the +// registry; for ERC-8004 the claimed name lives in the agent's +// OFF-CHAIN registration file (tokenURI → JSON), as the services[] +// entry {"name":"ENS","endpoint":""}. +// +// Reality check baked in below: the spec key uses the ERC-7930 binary +// hex form, but live mainnet records (e.g. enswhois.eth) have been +// observed using a CAIP-style text form `eip155:1:0x` +// instead. The verifier probes both families and reports which one +// matched — exactly the wire-format drift ans cares about catching. + +// erc8004ABIJSON is the read surface of the official IdentityRegistry +// (ERC-721 + URIStorage; agentId == tokenId, tokenURI == agentURI). +const erc8004ABIJSON = `[ + {"name":"ownerOf","type":"function","stateMutability":"view", + "inputs":[{"name":"tokenId","type":"uint256"}], + "outputs":[{"name":"","type":"address"}]}, + {"name":"tokenURI","type":"function","stateMutability":"view", + "inputs":[{"name":"tokenId","type":"uint256"}], + "outputs":[{"name":"","type":"string"}]}, + {"name":"getAgentWallet","type":"function","stateMutability":"view", + "inputs":[{"name":"agentId","type":"uint256"}], + "outputs":[{"name":"","type":"address"}]} +]` + +var erc8004ABI = mustABI(erc8004ABIJSON) + +// erc7930Address renders (chainId, address) as the ERC-7930 v1 +// interoperable-address hex string used inside the record key: +// version 0x0001 ‖ chainType 0x0000 (eip155) ‖ chainRefLen ‖ +// chainRef (minimal big-endian chainId) ‖ addrLen 0x14 ‖ address. +func erc7930Address(chainID uint64, addr common.Address) string { + chainRef := minimalBigEndian(chainID) + buf := make([]byte, 0, 6+len(chainRef)+20) + buf = append(buf, 0x00, 0x01) // version 1 + buf = append(buf, 0x00, 0x00) // chain type: eip155 + buf = append(buf, byte(len(chainRef))) + buf = append(buf, chainRef...) + buf = append(buf, 0x14) // 20-byte address + buf = append(buf, addr.Bytes()...) + return "0x" + hex.EncodeToString(buf) +} + +func minimalBigEndian(v uint64) []byte { + if v == 0 { + return []byte{0x00} + } + var full [8]byte + binary.BigEndian.PutUint64(full[:], v) + i := 0 + for i < 7 && full[i] == 0 { + i++ + } + return full[i:] +} + +// decodeERC7930 parses the binary form back into its fields. +func decodeERC7930(s string) (version uint16, chainType uint16, chainID *big.Int, addr []byte, err error) { + raw, err := hexBytes(s) + if err != nil { + return 0, 0, nil, nil, fmt.Errorf("not hex: %w", err) + } + if len(raw) < 6 { + return 0, 0, nil, nil, fmt.Errorf("too short (%d bytes)", len(raw)) + } + version = binary.BigEndian.Uint16(raw[0:2]) + chainType = binary.BigEndian.Uint16(raw[2:4]) + refLen := int(raw[4]) + if len(raw) < 5+refLen+1 { + return 0, 0, nil, nil, fmt.Errorf("truncated chain reference") + } + chainID = new(big.Int).SetBytes(raw[5 : 5+refLen]) + addrLen := int(raw[5+refLen]) + rest := raw[5+refLen+1:] + if len(rest) != addrLen { + return 0, 0, nil, nil, fmt.Errorf("address length %d but %d bytes remain", addrLen, len(rest)) + } + return version, chainType, chainID, rest, nil +} + +// registrationKeyCandidates returns the record keys to probe, spec +// form first, then the CAIP-style forms observed in the wild. +func registrationKeyCandidates(chainID uint64, registry common.Address, agentID string) []string { + caip := fmt.Sprintf("eip155:%d:%s", chainID, registry.Hex()) + return []string{ + fmt.Sprintf("agent-registration[%s][%s]", erc7930Address(chainID, registry), agentID), + fmt.Sprintf("agent-registration[%s][%s]", caip, agentID), + fmt.Sprintf("agent-registration[%s][%s]", strings.ToLower(caip), agentID), + } +} + +// agentRegistration is the ERC-8004 registration file (the JSON the +// agentURI points at). Only the fields the verification needs. Two +// claimed-name shapes exist in the wild: +// +// - the EIP-8004 shape: services[] entry {"name":"ENS", +// "endpoint":""} +// - the ens8004.xyz manifest shape (schemaVersion 0.1): +// top-level binding {"type":"ens","name":""} +type agentRegistration struct { + Type string `json:"type"` + Name string `json:"name"` + Services []struct { + Name string `json:"name"` + Endpoint string `json:"endpoint"` + Version string `json:"version"` + } `json:"services"` + Binding struct { + Type string `json:"type"` + Name string `json:"name"` + } `json:"binding"` + Registrations []struct { + AgentID json.Number `json:"agentId"` + AgentRegistry string `json:"agentRegistry"` + } `json:"registrations"` +} + +// claimedENSName extracts the registry-side ENS claim and reports +// which shape carried it. +func (r *agentRegistration) claimedENSName() (name, source string) { + for _, svc := range r.Services { + if strings.EqualFold(svc.Name, "ENS") { + return strings.TrimSpace(svc.Endpoint), `services[name=="ENS"].endpoint (EIP-8004 shape)` + } + } + if strings.EqualFold(r.Binding.Type, "ens") && r.Binding.Name != "" { + return strings.TrimSpace(r.Binding.Name), `binding{type:"ens"}.name (ens8004.xyz manifest shape)` + } + return "", "" +} + +func cmdENSIP25Key(_ context.Context, args []string) error { + fs := flag.NewFlagSet("ensip25 key", flag.ContinueOnError) + var o netOpts + addNetFlags(fs, &o) + agentID := fs.String("agent-id", "", "agent identifier (ERC-8004 tokenId)") + registry := fs.String("registry", "", "registry address override (default: chain preset's ERC-8004 IdentityRegistry)") + decode := fs.String("decode", "", "decode an ERC-7930 hex string instead of encoding") + if err := fs.Parse(args); err != nil { + return err + } + + if *decode != "" { + version, chainType, chainID, addr, err := decodeERC7930(*decode) + if err != nil { + return fmt.Errorf("decode ERC-7930: %w", err) + } + section("ERC-7930 interoperable address") + kv("version", fmt.Sprintf("%d", version)) + kv("chain type", fmt.Sprintf("0x%04x (0x0000 = eip155)", chainType)) + kv("chain id", chainID.String()) + kv("address", common.BytesToAddress(addr).Hex()) + return nil + } + + preset, ok := presets[o.chain] + if !ok { + return fmt.Errorf("unknown chain preset %q", o.chain) + } + if *agentID == "" { + return fmt.Errorf("ensip25 key: -agent-id required (or use -decode)") + } + if strings.ContainsAny(*agentID, "[]") { + return fmt.Errorf("agentId must not contain '[' or ']' (ENSIP-25)") + } + registryAddr := preset.ERC8004IdentityRegistry + if *registry != "" { + registryAddr = common.HexToAddress(*registry) + } + + keys := registrationKeyCandidates(preset.ChainID, registryAddr, *agentID) + if o.jsonOut { + return printJSON(map[string]any{ + "chain": preset.Name, "registry": registryAddr.Hex(), "agentId": *agentID, + "specKey": keys[0], "observedWildForms": keys[1:], + }) + } + section("ENSIP-25 record key") + kv("registry", registryAddr.Hex()) + kv("agentId", *agentID) + kv("key (spec, ERC-7930)", keys[0]) + noteLine("wild forms also seen on mainnet:") + for _, key := range keys[1:] { + kv("", key) + } + return nil +} + +func cmdENSIP25Verify(ctx context.Context, args []string) error { + fs := flag.NewFlagSet("ensip25 verify", flag.ContinueOnError) + var o netOpts + addNetFlags(fs, &o) + agentID := fs.String("agent-id", "", "agent identifier (ERC-8004 tokenId)") + registry := fs.String("registry", "", "registry address override (default: chain preset's ERC-8004 IdentityRegistry)") + if err := fs.Parse(args); err != nil { + return err + } + if *agentID == "" { + return fmt.Errorf("ensip25 verify: -agent-id required") + } + if strings.ContainsAny(*agentID, "[]") { + return fmt.Errorf("agentId must not contain '[' or ']' (ENSIP-25)") + } + var name string + var err error + if fs.NArg() == 1 { + name, err = normalizeENSName(fs.Arg(0)) + if err != nil { + return err + } + } else if fs.NArg() > 1 { + return fmt.Errorf("ensip25 verify: at most one NAME argument") + } + + client, preset, err := o.dial(ctx) + if err != nil { + return err + } + defer client.Close() + e := &ensClient{client: client, preset: preset, opts: &o} + registryAddr := preset.ERC8004IdentityRegistry + if *registry != "" { + registryAddr = common.HexToAddress(*registry) + } + human := !o.jsonOut // -json must emit ONLY the JSON document + + if human { + section(fmt.Sprintf("ERC-8004 registry entry (agent %s @ %s, %s)", *agentID, registryAddr.Hex(), preset.Name)) + } + tokenID, ok := new(big.Int).SetString(*agentID, 10) + if !ok { + return fmt.Errorf("agentId %q is not a decimal tokenId", *agentID) + } + owner, agentURI, err := readERC8004Agent(ctx, e, registryAddr, tokenID) + if err != nil { + return err + } + if human { + kv("owner", owner.Hex()) + kv("agentURI", agentURI) + } + + var reg *agentRegistration + claimedName, claimSource, registrationError := "", "", "" + if agentURI != "" { + raw, fetchErr := fetchAgentURI(ctx, &o, agentURI) + switch { + case fetchErr != nil: + registrationError = "registration file fetch failed: " + fetchErr.Error() + case json.Unmarshal(raw, ®) != nil: + registrationError = "registration file is not JSON" + default: + claimedName, claimSource = reg.claimedENSName() + } + if human { + switch { + case registrationError != "": + failLine(registrationError) + case claimedName == "": + kv("registration name", reg.Name) + noteLine(`registration file carries no ENS claim (neither services[name=="ENS"] nor binding{type:"ens"})`) + default: + kv("registration name", reg.Name) + kv("claimed ENS name", claimedName) + noteLine("claim carried by " + claimSource) + } + } + } + + // Decide which name to verify the record on. + target := name + if target == "" { + if claimedName == "" { + return fmt.Errorf("no NAME argument and the registration file claims no ENS name — nothing to verify") + } + target, err = normalizeENSName(claimedName) + if err != nil { + return fmt.Errorf("claimed ENS name %q: %w", claimedName, err) + } + } + + if human { + section(fmt.Sprintf("ENSIP-25 record check on %s", target)) + } + info, err := e.findResolver(ctx, target) + if err != nil { + return err + } + matchedKey, matchedValue := "", "" + for _, key := range registrationKeyCandidates(preset.ChainID, registryAddr, *agentID) { + value, err := e.Text(ctx, target, info, key) + if err != nil { + return fmt.Errorf("text(%q): %w", key, err) + } + if human { + marker := "(not set)" + if value != "" { + marker = fmt.Sprintf("%q", value) + } + kv(key, marker) + } + if value != "" && matchedKey == "" { + matchedKey, matchedValue = key, value + } + } + + recordOK := matchedKey != "" + nameAgrees := claimedName == "" || name == "" || strings.EqualFold(claimedName, name) + bidirectional := recordOK && claimedName != "" && nameAgrees + + if o.jsonOut { + return printJSON(map[string]any{ + "chain": preset.Name, "registry": registryAddr.Hex(), "agentId": *agentID, + "owner": owner.Hex(), "agentURI": agentURI, + "registrationError": registrationError, + "claimedENSName": claimedName, "claimSource": claimSource, + "verifiedName": target, "matchedKey": matchedKey, "matchedValue": matchedValue, + "ensAttestationPresent": recordOK, "nameAgrees": nameAgrees, "bidirectional": bidirectional, + }) + } + + section("verdict") + if recordOK { + okLine("ENS-side attestation present (registry → name verifies per ENSIP-25)") + if strings.HasPrefix(matchedKey, "agent-registration[0x") { + noteLine("matched the spec's ERC-7930 key form") + } else { + noteLine("matched a CAIP-style key form — spec-vs-wild drift, record it") + } + } else { + failLine("no non-empty agent-registration record — verification MUST fail per ENSIP-25") + } + if claimedName != "" { + if nameAgrees { + okLine(fmt.Sprintf("registry-side claim agrees (registration file names %q)", claimedName)) + } else { + failLine(fmt.Sprintf("registry-side claim DISAGREES: file names %q, you asked about %q", claimedName, name)) + } + } + if bidirectional { + okLine("BIDIRECTIONAL: registry entry and ENS name attest to each other") + } + return nil +} + +func readERC8004Agent(ctx context.Context, e *ensClient, registry common.Address, tokenID *big.Int) (common.Address, string, error) { + ownerData, err := erc8004ABI.Pack("ownerOf", tokenID) + if err != nil { + return common.Address{}, "", err + } + ret, err := e.ethCall(ctx, registry, ownerData) + if err != nil { + return common.Address{}, "", fmt.Errorf("ownerOf(%s) — does agent %s exist on this chain's registry? %w", tokenID, tokenID, err) + } + var owner common.Address + if err := erc8004ABI.UnpackIntoInterface(&owner, "ownerOf", ret); err != nil { + return common.Address{}, "", err + } + + uriData, err := erc8004ABI.Pack("tokenURI", tokenID) + if err != nil { + return common.Address{}, "", err + } + ret, err = e.ethCall(ctx, registry, uriData) + if err != nil { + return common.Address{}, "", fmt.Errorf("tokenURI(%s): %w", tokenID, err) + } + var agentURI string + if err := erc8004ABI.UnpackIntoInterface(&agentURI, "tokenURI", ret); err != nil { + return common.Address{}, "", err + } + return owner, agentURI, nil +} + +// fetchAgentURI retrieves a registration file from the URI schemes +// the ENSIP-25 reference demo supports: https, data:, and ipfs:// +// (via the public ipfs.io gateway). Size-capped; PoC trust model — +// the file is a claim to cross-check, never an authority. +func fetchAgentURI(ctx context.Context, o *netOpts, uri string) ([]byte, error) { + const maxBody = 1 << 20 + + switch { + case strings.HasPrefix(uri, "data:"): + payload := strings.TrimPrefix(uri, "data:") + meta, data, found := strings.Cut(payload, ",") + if !found { + return nil, fmt.Errorf("malformed data: URI") + } + if strings.HasSuffix(meta, ";base64") { + return base64.StdEncoding.DecodeString(data) + } + // RFC 2397 data URIs are percent-encoded; PathUnescape keeps + // literal '+' intact (QueryUnescape would corrupt it to ' '). + decoded, err := url.PathUnescape(data) + if err != nil { + return nil, err + } + return []byte(decoded), nil + + case strings.HasPrefix(uri, "ipfs://"): + uri = "https://ipfs.io/ipfs/" + strings.TrimPrefix(uri, "ipfs://") + fallthrough + + case strings.HasPrefix(uri, "https://"): + req, err := http.NewRequestWithContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + httpCtx, cancel := o.callCtx(ctx) + defer cancel() + resp, err := http.DefaultClient.Do(req.WithContext(httpCtx)) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d from %s", resp.StatusCode, uri) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, maxBody+1)) + if err != nil { + return nil, err + } + if len(body) > maxBody { + return nil, fmt.Errorf("registration file exceeds %d bytes", maxBody) + } + return body, nil + + default: + return nil, fmt.Errorf("unsupported agentURI scheme in %q (supported: https, ipfs, data)", uri) + } +} diff --git a/scripts/poc/ethid/ensip26.go b/scripts/poc/ethid/ensip26.go new file mode 100644 index 0000000..8ab2889 --- /dev/null +++ b/scripts/poc/ethid/ensip26.go @@ -0,0 +1,249 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +// ENSIP-26 ("Agent Text Records", merged draft 2026-05) defines +// exactly two text record keys: +// +// agent-context — free-form entry point ("index.html +// for agents"): plain text, Markdown, +// YAML, JSON — anything goes +// agent-endpoint[] — one URL per protocol; the spec +// enumerates mcp, a2a, web and leaves +// the tag set open +// +// There is no verification beyond ENS ownership — whoever controls +// the resolver controls the records. Discovery is: read context, +// then read whichever endpoints you speak. +// +// The -dns flag resolves each endpoint host against real DNS +// (A/AAAA/HTTPS/TXT + the resolver's DNSSEC AD bit), and -fetch +// probes two well-known documents behind each endpoint origin: +// /.well-known/agent-registration.json (ERC-8004's endpoint-domain +// proof — in the final EIP) and /.well-known/agent.json (the +// ENSIP-27 agent-card draft, NOT merged — probe may 404). + +// ensip26Protocols are the spec-enumerated tags plus oasf, which was +// floated in the proposal discussion and is permitted by the open +// tag registry. +var ensip26Protocols = []string{"mcp", "a2a", "web", "oasf"} + +type stringList []string + +func (s *stringList) String() string { return strings.Join(*s, ",") } +func (s *stringList) Set(v string) error { + *s = append(*s, v) + return nil +} + +func cmdENSIP26Discover(ctx context.Context, args []string) error { + fs := flag.NewFlagSet("ensip26 discover", flag.ContinueOnError) + var o netOpts + addNetFlags(fs, &o) + var extraKeys stringList + fs.Var(&extraKeys, "key", "additional text record key to probe (repeatable)") + dnsCheck := fs.Bool("dns", false, "resolve each endpoint host against real DNS") + fetch := fs.Bool("fetch", false, "probe well-known agent documents behind each endpoint") + resolver := fs.String("resolver", "", "DNS server host[:port] for -dns (default: system resolver)") + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() != 1 { + return fmt.Errorf("ensip26 discover: exactly one NAME argument required") + } + name, err := normalizeENSName(fs.Arg(0)) + if err != nil { + return err + } + + client, preset, err := o.dial(ctx) + if err != nil { + return err + } + defer client.Close() + e := &ensClient{client: client, preset: preset, opts: &o} + + info, err := e.findResolver(ctx, name) + if err != nil { + return err + } + + // agent-context first — the spec's entry point. + agentContext, err := e.Text(ctx, name, info, "agent-context") + if err != nil { + return fmt.Errorf("text(agent-context): %w", err) + } + + endpoints := map[string]string{} + var endpointOrder []string + probe := make([]string, 0, len(ensip26Protocols)+len(extraKeys)) + for _, proto := range ensip26Protocols { + probe = append(probe, "agent-endpoint["+proto+"]") + } + probe = append(probe, extraKeys...) + for _, key := range probe { + value, err := e.Text(ctx, name, info, key) + if err != nil { + return fmt.Errorf("text(%q): %w", key, err) + } + if value != "" { + endpoints[key] = value + endpointOrder = append(endpointOrder, key) + } + } + + if o.jsonOut { + out := map[string]any{ + "name": name, "chain": preset.Name, "resolver": info.Resolver.Hex(), + "agentContext": agentContext, "endpoints": endpoints, "ccipGateways": e.gatewayLog, + } + if *dnsCheck || *fetch { + out["checks"] = runEndpointChecks(ctx, &o, *resolver, endpointOrder, endpoints, *dnsCheck, *fetch, true) + } + return printJSON(out) + } + + section(fmt.Sprintf("ENSIP-26 discovery: %s (%s)", name, preset.Name)) + kv("resolver", info.Resolver.Hex()) + for _, gw := range e.gatewayLog { + noteLine("offchain answer via CCIP-Read gateway: " + gw) + } + if agentContext == "" { + failLine("no agent-context record — per ENSIP-26, no agent context is available for this name") + } else { + okLine("agent-context present:") + printIndented(agentContext, 600) + } + if len(endpointOrder) == 0 { + noteLine("no agent-endpoint[*] records among: " + strings.Join(probe, ", ")) + } + for _, key := range endpointOrder { + kv(key, endpoints[key]) + } + + if *dnsCheck || *fetch { + runEndpointChecks(ctx, &o, *resolver, endpointOrder, endpoints, *dnsCheck, *fetch, false) + } + return nil +} + +// runEndpointChecks performs the real-world legs: DNS resolution of +// each endpoint host and well-known document probes. Returns the +// machine-readable form when jsonMode is set; prints otherwise. +func runEndpointChecks(ctx context.Context, o *netOpts, resolver string, order []string, endpoints map[string]string, dnsCheck, fetch, jsonMode bool) []map[string]any { + var results []map[string]any + seenHosts := map[string]bool{} + for _, key := range order { + endpoint := endpoints[key] + parsed, err := url.Parse(endpoint) + if err != nil || parsed.Host == "" { + if !jsonMode { + section("endpoint " + endpoint) + noteLine("not a host-bearing URL — skipping checks") + } + continue + } + host := parsed.Hostname() + entry := map[string]any{"endpoint": endpoint, "host": host} + + if dnsCheck && !seenHosts[host] { + seenHosts[host] = true + q := newDNSQuerier(resolver, o.timeout) + answers, err := q.checkHost(ctx, host) + if jsonMode { + if err != nil { + entry["dnsError"] = err.Error() + } else { + entry["dns"] = answers + } + } else { + section(fmt.Sprintf("DNS: %s (via %s)", host, q.server)) + if err != nil { + failLine(err.Error()) + } else { + printDNSAnswers(answers) + } + } + } + + if fetch && parsed.Scheme == "https" { + origin := "https://" + parsed.Host + wellKnown := map[string]string{ + "agent-registration.json (ERC-8004)": origin + "/.well-known/agent-registration.json", + "agent.json (ENSIP-27 draft, may 404)": origin + "/.well-known/agent.json", + } + probes := map[string]any{} + if !jsonMode { + section("well-known probes: " + origin) + } + for label, probeURL := range wellKnown { + status, body := probeJSON(ctx, o, probeURL) + probes[probeURL] = status + if jsonMode { + continue + } + if status == "200 (json)" { + okLine(label + " → 200, JSON document:") + printIndented(body, 400) + } else { + noteLine(label + " → " + status) + } + } + entry["wellKnown"] = probes + } + results = append(results, entry) + } + return results +} + +// probeJSON GETs a URL and reports a compact status plus pretty JSON +// when the answer parses. +func probeJSON(ctx context.Context, o *netOpts, probeURL string) (status, body string) { + const maxBody = 256 << 10 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, probeURL, nil) + if err != nil { + return "bad url: " + err.Error(), "" + } + req.Header.Set("Accept", "application/json") + httpCtx, cancel := o.callCtx(ctx) + defer cancel() + resp, err := http.DefaultClient.Do(req.WithContext(httpCtx)) + if err != nil { + return "unreachable: " + err.Error(), "" + } + defer func() { _ = resp.Body.Close() }() + raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBody)) + if err != nil { + return fmt.Sprintf("%d (body read failed)", resp.StatusCode), "" + } + if resp.StatusCode != http.StatusOK { + return fmt.Sprintf("%d", resp.StatusCode), "" + } + var pretty map[string]any + if json.Unmarshal(raw, &pretty) != nil { + return "200 (not json)", "" + } + formatted, err := json.MarshalIndent(pretty, "", " ") + if err != nil { + return "200 (json)", string(raw) + } + return "200 (json)", string(formatted) +} + +func printIndented(text string, limit int) { + if len(text) > limit { + text = text[:limit] + "\n… (truncated; use -json for the full value)" + } + for _, line := range strings.Split(text, "\n") { + fmt.Printf(" %s\n", sanitizeCtrl(line)) + } +} diff --git a/scripts/poc/ethid/go.mod b/scripts/poc/ethid/go.mod new file mode 100644 index 0000000..6fb9fdf --- /dev/null +++ b/scripts/poc/ethid/go.mod @@ -0,0 +1,39 @@ +module github.com/godaddy/ans/scripts/poc/ethid + +go 1.26.2 + +require ( + github.com/ethereum/go-ethereum v1.17.3 + github.com/miekg/dns v1.1.72 +) + +require ( + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect + github.com/StackExchange/wmi v1.2.1 // indirect + github.com/bits-and-blooms/bitset v1.20.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/consensys/gnark-crypto v0.18.1 // indirect + github.com/crate-crypto/go-eth-kzg v1.5.0 // indirect + github.com/deckarep/golang-set/v2 v2.6.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect + github.com/ethereum/c-kzg-4844/v2 v2.1.6 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/gorilla/websocket v1.4.2 // indirect + github.com/holiman/uint256 v1.3.2 // indirect + github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible // indirect + github.com/supranational/blst v0.3.16 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect + golang.org/x/mod v0.31.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/tools v0.40.0 // indirect +) diff --git a/scripts/poc/ethid/go.sum b/scripts/poc/ethid/go.sum new file mode 100644 index 0000000..bcf7cf2 --- /dev/null +++ b/scripts/poc/ethid/go.sum @@ -0,0 +1,208 @@ +github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ= +github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 h1:1zYrtlhrZ6/b6SAjLSfKzWtdgqK0U+HtH/VcBWh1BaU= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= +github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA= +github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8= +github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0= +github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bits-and-blooms/bitset v1.20.0 h1:2F+rfL86jE2d/bmw7OhqUg2Sj/1rURkBn3MdfoPyRVU= +github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cockroachdb/errors v1.11.3 h1:5bA+k2Y6r+oz/6Z/RFlNeVCesGARKuC6YymtcDrbC/I= +github.com/cockroachdb/errors v1.11.3/go.mod h1:m4UIW4CDjx+R5cybPsNrRbreomiFqt8o1h1wUVazSd8= +github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce h1:giXvy4KSc/6g/esnpM7Geqxka4WSqI1SZc7sMJFd3y4= +github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce/go.mod h1:9/y3cnZ5GKakj/H4y9r9GTjCvAFta7KLgSHPJJYc52M= +github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZeQy818SGhaone5OnYfxFR/+AzdY3sf5aE= +github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs= +github.com/cockroachdb/pebble v1.1.5 h1:5AAWCBWbat0uE0blr8qzufZP5tBjkRyy/jWe1QWLnvw= +github.com/cockroachdb/pebble v1.1.5/go.mod h1:17wO9el1YEigxkP/YtV8NtCivQDgoCyBg5c4VR/eOWo= +github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwPJ30= +github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg= +github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 h1:zuQyyAKVxetITBuuhv3BI9cMrmStnpT18zmgmTxunpo= +github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ= +github.com/consensys/gnark-crypto v0.18.1 h1:RyLV6UhPRoYYzaFnPQA4qK3DyuDgkTgskDdoGqFt3fI= +github.com/consensys/gnark-crypto v0.18.1/go.mod h1:L3mXGFTe1ZN+RSJ+CLjUt9x7PNdx8ubaYfDROyp2Z8c= +github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc= +github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/crate-crypto/go-eth-kzg v1.5.0 h1:FYRiJMJG2iv+2Dy3fi14SVGjcPteZ5HAAUe4YWlJygc= +github.com/crate-crypto/go-eth-kzg v1.5.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dchest/siphash v1.2.3 h1:QXwFc8cFOR2dSa/gE6o/HokBMWtLUaNDVd+22aKHeEA= +github.com/dchest/siphash v1.2.3/go.mod h1:0NvQU092bT0ipiFN++/rXm69QG9tVxLAlQHIXMPAkHc= +github.com/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80NsVHagjM= +github.com/deckarep/golang-set/v2 v2.6.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= +github.com/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0= +github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= +github.com/emicklei/dot v1.6.2 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A= +github.com/emicklei/dot v1.6.2/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s= +github.com/ethereum/c-kzg-4844/v2 v2.1.6 h1:xQymkKCT5E2Jiaoqf3v4wsNgjZLY0lRSkZn27fRjSls= +github.com/ethereum/c-kzg-4844/v2 v2.1.6/go.mod h1:8HMkUZ5JRv4hpw/XUrYWSQNAUzhHMg2UDb/U+5m+XNw= +github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab h1:rvv6MJhy07IMfEKuARQ9TKojGqLVNxQajaXEp/BoqSk= +github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab/go.mod h1:IuLm4IsPipXKF7CW5Lzf68PIbZ5yl7FFd74l/E0o9A8= +github.com/ethereum/go-ethereum v1.17.3 h1:Ev/sQHH+UdKZHWjuVzhu2pxhi/sXaPZl23Q+Q5LDd4Q= +github.com/ethereum/go-ethereum v1.17.3/go.mod h1:f2EhRwqewIZkGoQekywI2Y2RZAMTSavLNkD9qItFy1A= +github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY= +github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg= +github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps= +github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= +github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grafana/pyroscope-go v1.2.7 h1:VWBBlqxjyR0Cwk2W6UrE8CdcdD80GOFNutj0Kb1T8ac= +github.com/grafana/pyroscope-go v1.2.7/go.mod h1:o/bpSLiJYYP6HQtvcoVKiE9s5RiNgjYTj1DhiddP2Pc= +github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og= +github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU= +github.com/hashicorp/go-bexpr v0.1.10 h1:9kuI5PFotCboP3dkDYFr/wi0gg0QVbSNz5oFRpxn4uE= +github.com/hashicorp/go-bexpr v0.1.10/go.mod h1:oxlubA2vC/gFVfX1A6JGp7ls7uCDlfJn732ehYYg+g0= +github.com/holiman/billy v0.0.0-20250707135307-f2f9b9aae7db h1:IZUYC/xb3giYwBLMnr8d0TGTzPKFGNTCGgGLoyeX330= +github.com/holiman/billy v0.0.0-20250707135307-f2f9b9aae7db/go.mod h1:xTEYN9KCHxuYHs+NmrmzFcnvHMzLLNiGFafCb1n3Mfg= +github.com/holiman/bloomfilter/v2 v2.0.3 h1:73e0e/V0tCydx14a0SCYS/EWCxgwLZ18CZcZKVu0fao= +github.com/holiman/bloomfilter/v2 v2.0.3/go.mod h1:zpoh+gs7qcpqrHr3dB55AMiJwo0iURXE7ZOP9L9hSkA= +github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA= +github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E= +github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= +github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= +github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= +github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= +github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU= +github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4= +github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= +github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= +github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= +github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g= +github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM= +github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag= +github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/pointerstructure v1.2.0 h1:O+i9nHnXS3l/9Wu7r4NrEdwA2VFTicjUEN1uBnDo34A= +github.com/mitchellh/pointerstructure v1.2.0/go.mod h1:BRAsLI5zgXmw97Lf6s25bs8ohIXc3tViBH44KcwB2g4= +github.com/pion/dtls/v2 v2.2.7 h1:cSUBsETxepsCSFSxC3mc/aDo14qQLMSL+O6IjG28yV8= +github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s= +github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY= +github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= +github.com/pion/stun/v2 v2.0.0 h1:A5+wXKLAypxQri59+tmQKVs7+l6mMM+3d+eER9ifRU0= +github.com/pion/stun/v2 v2.0.0/go.mod h1:22qRSh08fSEttYUmJZGlriq9+03jtVmXNODgLccj8GQ= +github.com/pion/transport/v2 v2.2.1 h1:7qYnCBlpgSJNYMbLCKuSY9KbQdBFoETvPNETv0y4N7c= +github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g= +github.com/pion/transport/v3 v3.0.1 h1:gDTlPJwROfSfz6QfSi0ZmeCSkFcnWWiiR9ES0ouANiM= +github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.15.0 h1:5fCgGYogn0hFdhyhLbw7hEsWxufKtY9klyvdNfFlFhM= +github.com/prometheus/client_golang v1.15.0/go.mod h1:e9yaBhRPU2pPNsZwE+JdQl0KEt1N9XgF6zxWmaC0xOk= +github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4= +github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w= +github.com/prometheus/common v0.42.0 h1:EKsfXEYo4JpWMHH5cg+KOUWeuJSov1Id8zGR8eeI1YM= +github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc= +github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI= +github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik= +github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU= +github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/supranational/blst v0.3.16 h1:bTDadT+3fK497EvLdWRQEjiGnUtzJ7jjIUMF0jqwYhE= +github.com/supranational/blst v0.3.16/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw= +github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY= +github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w= +github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df h1:UA2aFVmmsIlefxMk29Dp2juaUSth8Pyn3Tq5Y5mJGME= +golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= +golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/scripts/poc/ethid/keys.go b/scripts/poc/ethid/keys.go new file mode 100644 index 0000000..787b7f8 --- /dev/null +++ b/scripts/poc/ethid/keys.go @@ -0,0 +1,302 @@ +package main + +import ( + "crypto/ecdsa" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "os" + "strconv" + "strings" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +// This file is the registrant side of a did:ethr proof-of-control — +// the eth-native analog of scripts/demo/signproof. Everything here is +// pure local computation: no RPC, no wallet software, no funded +// account. The "wallet address" IS the keypair; minting one is free. +// +// The signature scheme is EIP-191 personal_sign (the thing MetaMask's +// personal_sign produces), so a proof minted here is byte-compatible +// with what a real wallet user would hand the RA: +// +// digest = keccak256("\x19Ethereum Signed Message:\n" + len(msg) + msg) +// sig = secp256k1_sign(digest) → 65 bytes r||s||v, v ∈ {27,28} +// +// Verification recovers the public key from the signature (ECDSA +// public-key recovery — this is why no DID document fetch is needed +// for a bare did:ethr) and compares the derived address to the DID +// subject. + +func cmdKeygen(args []string) error { + fs := flag.NewFlagSet("keygen", flag.ContinueOnError) + out := fs.String("out", "", "also write the private key hex to FILE (0600)") + jsonOut := fs.Bool("json", false, "machine-readable JSON output") + if err := fs.Parse(args); err != nil { + return err + } + + key, err := crypto.GenerateKey() + if err != nil { + return fmt.Errorf("generate secp256k1 key: %w", err) + } + privHex := hex.EncodeToString(crypto.FromECDSA(key)) + addr := crypto.PubkeyToAddress(key.PublicKey) + compressed := hex.EncodeToString(crypto.CompressPubkey(&key.PublicKey)) + + if *out != "" { + if err := os.WriteFile(*out, []byte("0x"+privHex+"\n"), 0o600); err != nil { + return fmt.Errorf("write key file: %w", err) + } + } + + if *jsonOut { + return printJSON(map[string]string{ + "address": addr.Hex(), + "didEthr": "did:ethr:" + addr.Hex(), + "didEthrSepolia": "did:ethr:sepolia:" + addr.Hex(), + "didEthrPublicKey": "did:ethr:0x" + compressed, + "privateKey": "0x" + privHex, + "publicKey": "0x" + compressed, + }) + } + + section("keypair (local only — nothing touched the network)") + kv("address", addr.Hex()) + kv("did:ethr (mainnet)", "did:ethr:"+addr.Hex()) + kv("did:ethr (sepolia)", "did:ethr:sepolia:"+addr.Hex()) + kv("did:ethr (pubkey form)", "did:ethr:0x"+compressed) + kv("public key (compressed)", "0x"+compressed) + kv("private key", "0x"+privHex) + if *out != "" { + kv("key file", *out) + } + return nil +} + +func cmdSign(args []string) error { + fs := flag.NewFlagSet("sign", flag.ContinueOnError) + keyArg := fs.String("key", os.Getenv("ETHID_KEY"), "private key hex, or @FILE to read it (env ETHID_KEY)") + jsonOut := fs.Bool("json", false, "machine-readable JSON output") + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() != 1 { + return fmt.Errorf("sign: exactly one MESSAGE argument required") + } + message := fs.Arg(0) + + key, err := loadPrivateKey(*keyArg) + if err != nil { + return err + } + + digest := personalSignDigest([]byte(message)) + sig, err := crypto.Sign(digest, key) + if err != nil { + return fmt.Errorf("sign: %w", err) + } + // geth emits recovery id 0/1; wallets emit 27/28. Normalize to the + // wallet convention so the output is MetaMask-compatible. + sig[64] += 27 + + addr := crypto.PubkeyToAddress(key.PublicKey) + if *jsonOut { + return printJSON(map[string]string{ + "address": addr.Hex(), + "didEthr": "did:ethr:" + addr.Hex(), + "message": message, + "signature": "0x" + hex.EncodeToString(sig), + }) + } + section("EIP-191 personal_sign proof") + kv("signer", addr.Hex()) + kv("did:ethr", "did:ethr:"+addr.Hex()) + kv("message", message) + kv("signature", "0x"+hex.EncodeToString(sig)) + return nil +} + +func cmdVerify(args []string) error { + fs := flag.NewFlagSet("verify", flag.ContinueOnError) + didArg := fs.String("did", "", "did:ethr DID the proof must control") + sigArg := fs.String("sig", "", "65-byte signature hex from `ethid sign` (or any personal_sign wallet)") + jsonOut := fs.Bool("json", false, "machine-readable JSON output") + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() != 1 || *didArg == "" || *sigArg == "" { + return fmt.Errorf("verify: need -did, -sig, and exactly one MESSAGE argument") + } + message := fs.Arg(0) + + subject, err := parseDIDEthr(*didArg) + if err != nil { + return err + } + sig, err := hexBytes(*sigArg) + if err != nil { + return fmt.Errorf("decode signature: %w", err) + } + if len(sig) != 65 { + return fmt.Errorf("signature must be 65 bytes r||s||v, got %d", len(sig)) + } + // Accept both recovery-id conventions. + if sig[64] >= 27 { + sig[64] -= 27 + } + + digest := personalSignDigest([]byte(message)) + pub, err := crypto.SigToPub(digest, sig) + if err != nil { + return fmt.Errorf("recover public key: %w", err) + } + recovered := crypto.PubkeyToAddress(*pub) + + match := subject.matches(pub, recovered) + if *jsonOut { + if err := printJSON(map[string]any{ + "did": subject.DID, + "recoveredAddress": recovered.Hex(), + "controlProven": match, + }); err != nil { + return err + } + } else { + section("did:ethr proof-of-control (offline — zero RPC)") + kv("did", subject.DID) + kv("message", message) + kv("recovered address", recovered.Hex()) + if match { + okLine("control PROVEN — recovered signer is the DID subject") + } else { + failLine("control NOT proven — recovered signer does not match the DID subject") + } + } + if !match { + os.Exit(1) + } + return nil +} + +// didEthr is a parsed did:ethr identifier: +// +// did:ethr:0x<20-byte address> +// did:ethr::0x<20-byte address> +// did:ethr:[:]0x<33-byte compressed secp256k1 public key> +// +// network is a preset name (mainnet, sepolia) or a hex chain id. +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 +} + +func parseDIDEthr(s string) (*didEthr, error) { + const prefix = "did:ethr:" + if !strings.HasPrefix(s, prefix) { + return nil, fmt.Errorf("%q is not a did:ethr DID", s) + } + rest := strings.TrimPrefix(s, prefix) + parts := strings.Split(rest, ":") + + d := &didEthr{DID: s, Network: "mainnet", ChainID: 1} + var subject string + switch len(parts) { + case 1: + subject = parts[0] + case 2: + d.Network = parts[0] + subject = parts[1] + switch { + case parts[0] == "mainnet": + d.ChainID = 1 + case parts[0] == "sepolia": + d.ChainID = 11155111 + case strings.HasPrefix(parts[0], "0x"): + id, err := strconv.ParseUint(parts[0][2:], 16, 64) + if err != nil { + return nil, fmt.Errorf("bad chain id %q in DID", parts[0]) + } + d.ChainID = id + default: + return nil, fmt.Errorf("unknown did:ethr network %q (supported: mainnet, sepolia, 0x)", parts[0]) + } + default: + return nil, fmt.Errorf("malformed did:ethr DID %q", s) + } + + raw, err := hexBytes(subject) + if err != nil { + return nil, fmt.Errorf("bad did:ethr subject %q: %w", subject, err) + } + switch len(raw) { + case 20: + d.Address = common.BytesToAddress(raw) + case 33: + pub, err := crypto.DecompressPubkey(raw) + if err != nil { + return nil, fmt.Errorf("bad compressed public key in DID: %w", err) + } + d.PublicKey = raw + d.Address = crypto.PubkeyToAddress(*pub) + default: + return nil, fmt.Errorf("did:ethr subject must be a 20-byte address or 33-byte compressed public key, got %d bytes", len(raw)) + } + return d, nil +} + +// matches reports whether the recovered signer is this DID's subject. +// For the public-key form the comparison is on the key itself; for +// the address form it is on the derived address. +func (d *didEthr) matches(pub *ecdsa.PublicKey, recovered common.Address) bool { + if d.PublicKey != nil { + return hex.EncodeToString(crypto.CompressPubkey(pub)) == hex.EncodeToString(d.PublicKey) + } + return recovered == d.Address +} + +// personalSignDigest implements EIP-191 version 0x45 (personal_sign): +// keccak256("\x19Ethereum Signed Message:\n" || len(message) || message). +func personalSignDigest(message []byte) []byte { + prefixed := fmt.Sprintf("\x19Ethereum Signed Message:\n%d%s", len(message), message) + return crypto.Keccak256([]byte(prefixed)) +} + +func loadPrivateKey(arg string) (*ecdsa.PrivateKey, error) { + if arg == "" { + return nil, fmt.Errorf("no key: pass -key 0x, -key @FILE, or set ETHID_KEY") + } + if strings.HasPrefix(arg, "@") { + raw, err := os.ReadFile(strings.TrimPrefix(arg, "@")) + if err != nil { + return nil, fmt.Errorf("read key file: %w", err) + } + arg = strings.TrimSpace(string(raw)) + } + raw, err := hexBytes(arg) + if err != nil { + return nil, fmt.Errorf("decode private key: %w", err) + } + key, err := crypto.ToECDSA(raw) + if err != nil { + return nil, fmt.Errorf("parse private key: %w", err) + } + return key, nil +} + +func hexBytes(s string) ([]byte, error) { + return hex.DecodeString(strings.TrimPrefix(strings.TrimSpace(s), "0x")) +} + +func printJSON(v any) error { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(v) +} diff --git a/scripts/poc/ethid/main.go b/scripts/poc/ethid/main.go new file mode 100644 index 0000000..9383e4b --- /dev/null +++ b/scripts/poc/ethid/main.go @@ -0,0 +1,124 @@ +// Command ethid is a proof-of-concept playground for Ethereum-based +// identity verification against REAL infrastructure: the live ENS +// registry and resolvers (including ENSIP-10 wildcard and EIP-3668 +// CCIP-Read offchain names), the ERC-1056 registry behind did:ethr, +// the ERC-8004 trustless-agents IdentityRegistry behind ENSIP-25, +// and the public DNS. +// +// It exists to answer "can ans verify these identifier kinds from +// plain Go, with no paid services and no funded wallet?" empirically. +// It is NOT the identity implementation: it lives in its own Go +// module, is never built by `make build`, and shares no code with +// the RA/TL. When these kinds graduate into the RA they become +// controlVerifier registrations (internal/ra/service/identitykinds.go) +// with a noop/real adapter split at the port boundary, exactly like +// did:web. +package main + +import ( + "context" + "fmt" + "os" +) + +const usageText = `ethid — Ethereum-based identity playground (PoC; reads real chain + real DNS) + +Usage: + ethid keygen [-out FILE] mint a secp256k1 keypair (address, did:ethr, keys) + ethid sign -key KEY MESSAGE EIP-191 personal_sign over MESSAGE + ethid verify -did DID -sig SIG MESSAGE recover the signer; compare to the did:ethr subject + ethid did resolve DID build the DID document (implicit + ERC-1056 history) + ethid ens resolve NAME resolve NAME: addr + agent records (wildcard + CCIP-Read) + ethid ens text NAME KEY [KEY...] read specific ENS text record(s) + ethid ensip25 verify -agent-id N [flags] NAME verify the ENS name <-> ERC-8004 agent binding + ethid ensip25 key -agent-id N [flags] print/decode the agent-registration record key + ethid ensip26 discover [-dns] [-fetch] NAME discover agent-context / agent-endpoint records + ethid dns HOST A/AAAA/HTTPS/TXT lookups against a real resolver + +Common flags (per subcommand): + -chain NAME chain preset: mainnet | sepolia (default mainnet) + -rpc URL JSON-RPC endpoint override (env ETHID_RPC_URL) + -timeout DUR per network call timeout (default 15s) + -json machine-readable output + +Keys are throwaway local secp256k1 pairs — no funded wallet, no paid +API. Every read goes to free public JSON-RPC endpoints by default. + +Examples: + ethid keygen + ethid sign -key 0xPRIV 'nonce-from-the-ra' + ethid verify -did did:ethr:0xADDR -sig 0xSIG 'nonce-from-the-ra' + ethid did resolve did:ethr:0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 + ethid ens resolve vitalik.eth + ethid ens text uniswap.eth url com.twitter + ethid ensip26 discover -dns -fetch agentdomain.eth + ethid ensip25 verify -agent-id 42 myagent.eth + ethid dns mcp.example.com +` + +func usage() { + fmt.Fprint(os.Stderr, usageText) +} + +func main() { + if len(os.Args) < 2 { + usage() + os.Exit(2) + } + + ctx := context.Background() + var err error + switch os.Args[1] { + case "keygen": + err = cmdKeygen(os.Args[2:]) + case "sign": + err = cmdSign(os.Args[2:]) + case "verify": + err = cmdVerify(os.Args[2:]) + case "did": + err = dispatch(ctx, os.Args[2:], map[string]subcommand{ + "resolve": cmdDIDResolve, + }) + case "ens": + err = dispatch(ctx, os.Args[2:], map[string]subcommand{ + "resolve": cmdENSResolve, + "text": cmdENSText, + }) + case "ensip25": + err = dispatch(ctx, os.Args[2:], map[string]subcommand{ + "verify": cmdENSIP25Verify, + "key": cmdENSIP25Key, + }) + case "ensip26": + err = dispatch(ctx, os.Args[2:], map[string]subcommand{ + "discover": cmdENSIP26Discover, + }) + case "dns": + err = cmdDNS(ctx, os.Args[2:]) + case "help", "-h", "--help": + usage() + default: + fmt.Fprintf(os.Stderr, "ethid: unknown command %q\n\n", os.Args[1]) + usage() + os.Exit(2) + } + if err != nil { + fmt.Fprintf(os.Stderr, "ethid: %v\n", err) + os.Exit(1) + } +} + +type subcommand func(ctx context.Context, args []string) error + +func dispatch(ctx context.Context, args []string, cmds map[string]subcommand) error { + if len(args) == 0 { + usage() + return fmt.Errorf("missing subcommand") + } + cmd, ok := cmds[args[0]] + if !ok { + usage() + return fmt.Errorf("unknown subcommand %q", args[0]) + } + return cmd(ctx, args[1:]) +} diff --git a/scripts/poc/ethid/out.go b/scripts/poc/ethid/out.go new file mode 100644 index 0000000..1eab4e7 --- /dev/null +++ b/scripts/poc/ethid/out.go @@ -0,0 +1,46 @@ +package main + +import ( + "fmt" + "os" + "strings" +) + +// Tiny human-output helpers. Plain text, no color dependencies — +// the -json flag is the machine interface. +// +// Everything printed here may contain REMOTE strings (text records, +// gateway URLs, registration files), so control characters are +// stripped at this single choke point: a malicious resolver must not +// be able to smuggle ANSI cursor sequences or carriage returns that +// repaint the terminal into a forged "✓ verified" line. The JSON +// path is safe by construction (encoding/json escapes). + +func sanitizeCtrl(s string) string { + return strings.Map(func(r rune) rune { + if r == 0x7f || (r < 0x20 && r != '\t') { + return '�' + } + return r + }, s) +} + +func section(title string) { + fmt.Fprintf(os.Stdout, "\n== %s ==\n", sanitizeCtrl(title)) +} + +func kv(key, value string) { + fmt.Fprintf(os.Stdout, " %-26s %s\n", sanitizeCtrl(key), sanitizeCtrl(value)) +} + +func okLine(msg string) { + fmt.Fprintf(os.Stdout, " ✓ %s\n", sanitizeCtrl(msg)) +} + +func failLine(msg string) { + fmt.Fprintf(os.Stdout, " ✗ %s\n", sanitizeCtrl(msg)) +} + +func noteLine(msg string) { + fmt.Fprintf(os.Stdout, " · %s\n", sanitizeCtrl(msg)) +} From 964ca27d7c25bd94dfb65629a193779e7a5adb9c Mon Sep 17 00:00:00 2001 From: Connor Snitker Date: Thu, 11 Jun 2026 18:29:56 -0500 Subject: [PATCH 09/13] docs: implementation plan for the 2026-06-11 review-pass deltas The delta plan against the as-built branch: what the three 2026-06-11 design-review passes changed, which items were already implemented, the eight workstreams, the resolved doc inconsistencies (revoked identities stay visible on still-linked agents; the agent lane's return-before-seal flagged as a bug to fix separately), and the as-built notes on the race guards the adversarial review added (read-side terminal revocation, conditional commits, the seal claim). Signed-off-by: Connor Snitker --- docs/PLAN-identity-review-pass-2026-06-11.md | 404 +++++++++++++++++++ 1 file changed, 404 insertions(+) create mode 100644 docs/PLAN-identity-review-pass-2026-06-11.md diff --git a/docs/PLAN-identity-review-pass-2026-06-11.md b/docs/PLAN-identity-review-pass-2026-06-11.md new file mode 100644 index 0000000..4d29308 --- /dev/null +++ b/docs/PLAN-identity-review-pass-2026-06-11.md @@ -0,0 +1,404 @@ +# Implementation plan — 2026-06-11 design-review deltas + +| | | +|---|---| +| **Date** | 2026-06-11 | +| **Input** | `docs/DESIGN-multi-identity-anchors.md` — the three review passes dated 2026-06-11 (Keith's AI review triage, the second-reviewer pass, and the computed-views-carry-keys decision), layered onto the rev-4 design that `feat/verified-identities` (ans#41) implements | +| **Baseline** | The as-built state on `feat/verified-identities` (commits `bde6484`/`97c84b0`/`40378cd`/`c673548`) | +| **Method** | Every doc delta was checked against the code before this plan was written; "already done" items are listed so reviewers don't re-derive them | + +--- + +## 0. What today's doc updates actually change + +The 2026-06-11 passes add **ten normative deltas** against the as-built +branch. Verified against the code, they split three ways: + +**Already implemented (no work — recorded so nobody re-litigates):** + +| Doc item | Where it already lives | +|---|---| +| did:web colon→slash path mapping, `/.well-known` only for the root form (§3.6) | `internal/domain/identity.go` (`canonicalizeDIDWeb`, `DIDWebResolutionURL`) — audit item W7 below tightens the per-segment rejection list to the doc's exact grammar | +| `IDENTITY_UPDATED` carries `previousValue` (§5.5) | `internal/tl/event/identity/event.go:142`, populated in `identity.go:372` | +| `linkLogId` on the badge join (§5.6.3) | `internal/tl/service/identitybadge.go:47` | +| Hard `agentIds[]` cardinality cap (§4.3) | `maxLinkBatch = 256`, `internal/ra/service/identity.go:35` | +| No standalone RA agent-side routes; current = `AgentDetails.identities[]` (§5.2) | Only the 8 identity/link routes are registered (`cmd/ans-ra/main.go:258-265`); the RA per-agent view is the computed `identities[]` on AgentDetails | +| Unlink retains the `identity_links` row as `UNLINKED` (§5.7/§10.6) | `internal/adapter/store/sqlite/identity.go` link store — confirmed intentional by the second reviewer | +| lei flow changes (§3.6/§10.7), `subject_aid`/`presentation_status` columns (§5.7) | **Deferred with the lei slice** (postponed 2026-06-09). Columns arrive via a future `ALTER TABLE` migration when Slice 4 resumes — migrations are append-only, nothing is lost by waiting | + +**Real deltas (the work — Workstreams 1–8 below):** + +1. **Seal-before-success** (§5.6.1 item 6; §7 flags the code) — the big one. +2. **Computed views carry the keys** (§5.6.3) — `provenKeyIds` → verbatim `keys[]` + `keysLogId`. +3. **One effectiveness predicate, every view** + revoked entries drop out + the agent-liveness conjunct (§5.6.3). +4. **`identitiesUnavailable: true`** on join failure (§5.6.3). +5. **Link liveness gate** — `AGENT_NOT_LINKABLE` (§4.3). +6. **Link-route per-owner rate limit** (§4.3). +7. **JWS `crit` rejection** in proof verification (§5.5). +8. **Pagination inheritance** — RA list cursor envelope; TL reverse join + per-agent identities route; badge cap + total (§5.6.1). + +**Cross-cutting:** did:web segment-validation audit (W7), log-PII sweep + +seal-failure alerting hooks (W9), spec/docsui updates (W10), demo updates +(W11). + +--- + +## 1. Doc inconsistencies — RESOLVED 2026-06-11 (Connor) and folded into the doc + +1. **§10.6 vs §5.6.3 — revoked identities in `identities[]`: §10.6 wins.** + Connor's call: a revoked identity **remains visible** on still-linked + agents' views with `identityStatus: REVOKED` (a verifier must see the + who was revoked, not have it vanish); `keys[]`/`keysLogId` are omitted — + the keys are no longer attested. §5.6.3 is updated accordingly: the + predicate splits into **visibility** (link `LINKED` ∧ agent live) and + **attestation** (identity `VERIFIED` gates `keys[]`). This matches the + as-built behavior, shrinking W3 to "add the agent-liveness conjunct". +2. **Agent lane seal-before-success — flagged as a BUG in §5.6.1.** The + as-built agent lane also returns success before its seal; correct + behavior is wait-for-confirmation on **both** lanes. This plan fixes the + identity lane; the agent-lane fix is its own tracked change (it touches + the registration path this design otherwise leaves untouched). +3. *(as-built note, 2026-06-11)* — the adversarial review found the race + surface wider than §1.3 below: a revoke racing an in-flight + verify/link could land its leaf BEFORE the loser's, and + latest-event-wins would then resurrect the revoked identity on the + public badge. Implemented guards: (a) read-side terminality — the TL + derives REVOKED from any revocation leaf on the stream; (b) + conditional commits — `MarkRevoked` (status='REVOKED' iff still + VERIFIED), Link's in-tx identity re-read, and `StageChallenge` + (challenge persists conditional on load-time status+nonce+no live + claim, never writing status) replace every blind save the race could + clobber. Race-pinning tests simulate the concurrent commit inside the + seal round trip via a sealer hook. +4. **Sealed-loser race under seal-before-success** (not addressed by the + doc): with the seal moved before the row commit, two concurrent + verify-controls — or two owners racing the global proven-uniqueness + index — can both seal before one loses the row transaction. W1 closes + the same-identity race with a nonce **claim**; for the cross-owner + uniqueness race it keeps the advisory pre-check and documents the + residual: the loser's sealed event asserts a true fact (control WAS + proven), the RA row never flips, and the loser's identity never becomes + linkable. This matches the doc's "divergence is bounded" posture but + deserves explicit sign-off. + +--- + +## 2. Workstreams + +Ordered so each lands green on its own; W1 first because W2–W5 touch the +same read/write paths and rebase painfully across it. + +### W1 — Seal-before-success for the IDENTITY lane (§5.6.1 item 6, §7) + +**As-built:** `verify-control`, revoke, link, and unlink run +`uow.Run(tx { row mutations; enqueueIdentityEvent })` and return success when +the tx commits; the outbox worker delivers to the TL asynchronously +(`internal/ra/service/identity.go:374,401,535,557,621-656`). The RA row is +ahead of the log until delivery. + +**Target:** no identity operation reports success before the TL acknowledges +the seal; the row transition (and nonce consumption, and link-row writes) +commits **with** that acknowledgment; TL-down → retryable failure, nonce +unconsumed, prior row state stands. + +**Design — direct synchronous submission, no outbox rows for identities:** + +The outbox exists to guarantee eventual delivery with byte-replay on retry. +Seal-before-success inverts the requirement: delivery must precede success, +and a failed delivery must surface as a failed operation — there is nothing +for a background worker to retry (retrying would seal an event whose row +transition never happened). So the IDENTITY lane stops writing outbox rows +entirely and calls the TL inline: + +1. **Phase A — validate + claim.** All existing validation (ownership, + state, proofs verified against the resolved keys, payload-equality, + nonce fresh). Then a new small tx **claims** the nonce: + `UPDATE identities SET challenge_claimed_at_ms = ? WHERE identity_id = ? + AND challenge_consumed_at_ms IS NULL AND (challenge_claimed_at_ms IS NULL + OR challenge_claimed_at_ms < ?)` — a short-TTL (~30 s) provisional claim, + NOT consumption. This closes the TOCTOU that seal-first opens (two + concurrent verifies both sealing); a failed operation **releases** the + claim (`challenge_claimed_at_ms = NULL`) so §3.2's + failed-attempts-don't-consume rule still holds. Link/unlink/revoke have + no nonce; their claim is the row's optimistic concurrency (re-read + + compare in Phase C). +2. **Phase B — build, sign once, submit inline.** `buildIdentityEvent` + + JCS + producer signature exactly as today (the sign-once discipline is + unchanged — one canonicalization, one signature, used for exactly one + submission). Submit via the existing TL client (the same + `tlclient` routing `IDENTITY` → `POST /v1/internal/identities/event`), + under a new config knob `identity.sealTimeout` (default 5 s, parity with + the fetch budget). No mid-transaction network call: Phase A's tx is + committed before Phase B dials. +3. **Phase C — commit with the ack.** On TL 2xx: one tx applies the row + transition + `ConsumeChallenge` (the existing conditional UPDATE) + link + row writes. Then return success. +4. **Failure path.** TL unreachable / 5xx / timeout → release the claim, + return **503** with new code `TL_UNAVAILABLE` (RFC 7807; `detail` says + the operation is retryable and nothing was consumed). 4xx from the TL + (schema rejection — a bug, not weather) → 502 `TL_REJECTED_EVENT`, same + no-side-effects guarantee, and an error-level log (this should page — + W9). +5. **Crash window (B succeeded, C never ran):** the event is sealed, the + row unflipped. The client retries the whole operation → fresh proof → + second sealed event → row flips. The duplicate event is benign + (append-only stream; both assert true facts) and divergence is bounded + to the crash window — this is the §5.7 "sealed event tail wins" posture. + Document it in the service comment; no janitor needed because no state + is left behind (no outbox row exists). + +**Touches:** +- `internal/ra/service/identity.go` — restructure the four sealing ops + (`VerifyControl`, `Revoke`, `Link`, `Unlink`) into A/B/C phases; delete + `enqueueIdentityEvent`'s outbox write in favor of a `sealIdentityEvent` + that signs + submits + returns the ack. +- `internal/port/` — the service needs a TL-submission port + (`port.EventSealer`: `Seal(ctx, lane, eventType, subject, payload) error`) + so tests stay hermetic; default adapter wraps the existing + `internal/adapter/tlclient`. The outbox enqueuer dependency drops from + `IdentityService`. +- `internal/adapter/store/sqlite/identity.go` + migration `008_identity_claim.sql` — + add `challenge_claimed_at_ms` (single ALTER; the `IDENTITY` value in the + outbox CHECK from 007 stays — harmless, and removing it would force a + table rebuild for nothing). +- `internal/adapter/tlclient` / `outbox.go` — the worker no longer ever + sees IDENTITY rows (none are written); remove the IDENTITY-lane arm from + the **worker** only if it is now dead code, keep the route mapping in the + client (W1 uses it). +- `cmd/ans-ra/main.go`, `internal/config` — wire the sealer + `identity.sealTimeout`. + +**Tests:** happy paths re-pass; TL-down → 503, nonce unconsumed, row +unchanged, re-verify succeeds when TL returns; concurrent verify-control — +one wins, one gets the claim rejection; link idempotency (zero newly-linked +→ no TL call at all, success); crash-window simulation (sealer succeeds, +commit fn injected to fail → assert row unchanged and a retry completes). +The existing `identity_test.go` fail-closed matrix moves to the new +structure unchanged. + +**Consequence for demos (W11):** `poll_tl_identity_audit` becomes +unnecessary — reads immediately after a 200 are consistent by construction. +The demo should *assert* that (read the audit with zero polling). + +### W2 — Computed views carry the keys (§5.6.3) + +**As-built:** `LinkedIdentityView` carries `ProvenKeyIDs []string` (method +ids only) + `IdentityLogID` (the **latest** stream entry — which may be a +link event with no keys); the TL identity detail (`Get`) returns the latest +sealed event + computed status only +(`internal/tl/service/identitybadge.go:39,51,83,202`). + +**Target shape (paste-diff per the CLAUDE.md discipline):** + +```jsonc +// badge identities[] entry — BEFORE (as-built) // AFTER (§5.6.3) +{ { + "identityId": "01HXKQ…", "identityId": "01HXKQ…", + "kind": "did:web", "kind": "did:web", + "value": "did:web:identity.acme-corp.com", "value": "did:web:identity.acme-corp.com", + "identityStatus": "VERIFIED", "identityStatus": "VERIFIED", + "provenKeyIds": ["did:web:…#key-0"], // ─→ "keys": [ { /* verificationMethod VERBATIM from the + "identityLogId": "", // ─→ // latest sealed PROOF event — id/type/controller/ + "linkedAt": "…", // publicKeyJwk|publicKeyMultibase, member-for-member; + "linkLogId": "" // signedProof NOT included */ } ], +} "keysLogId": "", + "linkedAt": "…", + "linkLogId": "" + } +``` + +- `keys[]` quotes the `verificationMethod` members of the latest + `IDENTITY_VERIFIED`/`IDENTITY_UPDATED` event's sealed `keys[]` — + **verbatim `json.RawMessage`, never re-encoded** (the seal-verbatim rule + extends to the quote); `signedProof` stays in the seal, one hop away via + `keysLogId`. +- `GET /v1/identities/{identityId}` gains computed `{status, keys[], + keysLogId}` alongside the latest sealed event it already returns. When + the identity is `REVOKED`, `keys[]`/`keysLogId` are omitted. +- `IdentityLogID` is **removed** (replaced by `keysLogId`, which has the + semantics verifiers actually need — the doc's badge shape carries exactly + `keysLogId` + `linkLogId`). +- The RA `AgentDetails.identities[]` stays the **common-core subset** + (identityId, kind, value, identityStatus, linkedAt) — no keys on the + management plane (§5.4). Verify the RA DTO matches this list exactly. +- kind-uniformity note for the future lei slice: the views quote whatever + the kind sealed (lei will quote AID + thumbprint). No code needed now; + the quote helper must not assume `publicKeyJwk` exists. + +**Touches:** `internal/tl/service/identitybadge.go` (view struct, the +`LatestProofByIdentity` read already exists in `log.go` — thread its +`LogID` through), `internal/tl/handler/handler.go` (identity detail +response), spec files (W10), tests (`handler_identity_test.go` staged +asserts, `identityevents_test.go`). + +### W3 — One effectiveness predicate, every view (§5.6.3) + +**As-built:** views derive `identityStatus` from the latest event and +**include REVOKED entries** (`identitybadge.go:193`); the agent-liveness +conjunct is not applied (the reverse join decorates `AgentStatus` but +filters nothing); the RA-side `LinkedIdentitiesForAgent` copies the +identity status through (REVOKED included); `Detail`'s linked-agent list +counts live link rows regardless of identity/agent state. + +**Target (revised per Connor 2026-06-11):** the predicate splits in two — +**visibility** = link `LINKED` ∧ agent live (`ACTIVE` | `DEPRECATED`); +**attestation** = identity `VERIFIED` (gates `keys[]`/`keysLogId` and the +`VERIFIED` status value). Revoked identities stay visible with +`identityStatus: REVOKED` and no keys — as the code already does. So the +real delta is the **agent-liveness conjunct**, applied once per side: + +| View | Side | Change | +|---|---|---| +| badge `identities[]` | TL | drop links whose agent is terminal; REVOKED identities stay, keys omitted (W2) | +| `GET /v1/agents/{id}/identities` | TL | same filter (it must equal the badge list) | +| `GET /v1/identities/{id}/agents` | TL | filter by agent liveness (identity status is the subject, reported not filtered) | +| `AgentDetails.identities[]` | RA | agent-liveness filter — the RA knows agent status locally | +| identity detail linked-agent count | RA | count only visibility-passing links | + +Implementation: a single `linkEffective(linkState, identityStatus, +agentStatus) bool` helper in `internal/tl/service` and its RA twin in +`internal/ra/service` (two sides, one definition each — the doc's "no view +restates its own variant"). The TL side already computes agent badge status +for the reverse join; the badge join needs the agent's own status threaded +in (the badge handler has it — pass it down rather than re-deriving). + +Note: `GET /v1/identities/{id}` (the identity's own detail) is **not** a +link view — it serves REVOKED identities with `status: REVOKED` (keys +omitted, W2). History routes are untouched (they are history, not +"current"). + +### W4 — `identitiesUnavailable` on join failure (§5.6.3) + +**As-built:** `GetBadge` fails the **entire badge** with `writeError` when +the join errors (`internal/tl/handler/handler.go:264-269`). + +**Target:** the badge succeeds; `identities` is omitted; +`identitiesUnavailable: true` is set; the join error is logged (warn). An +empty array means "no effective links", never "join failed". Add the field +to the `TransparencyLog` DTO (`omitempty`); the standalone +`/v1/agents/{id}/identities` route keeps failing loudly (it has no other +payload to protect). Spec + tests (inject a failing identity store; assert +the badge still serves the agent material). + +### W5 — Link liveness gate (§4.3) + +**As-built:** `Link` checks ownership + existence only +(`identity.go:497-509`); a terminal agent links successfully today. + +**Target:** every `agentIds[]` entry must be `ACTIVE` or `DEPRECATED`; +terminal (`REVOKED`/`EXPIRED`/`FAILED`) → **422 `AGENT_NOT_LINKABLE`** +(name the offending agentId in `detail`); pre-activation agents +(`PENDING_*`) are also rejected with the same code — they have no TL +presence yet, so a link event naming them could not be joined (the doc: +"structurally unlinkable"). One status check inside the existing per-agent +ownership loop. New code in the spec error table. Tests: each terminal +state rejected, DEPRECATED accepted, mixed batch rejected atomically +(all-or-nothing — consistent with the one-event batch semantics). + +### W6 — Link-route rate limit (§4.3) + +**As-built:** `ownerLimiter` guards Register + Rotate only +(`identity.go:190,245`). + +**Target:** apply a per-owner limit to `Link` (and `Unlink` — the doc names +"the link route"; unlink shares the abuse surface and the step-up-auth +posture, flag the inclusion in the PR for confirmation). Separate config +knob `identity.linkRateLimit` (default e.g. 60/min — links are cheap but +seal synchronously after W1, so the limiter also protects the TL). 429 on +trip, same shape as the register limiter. + +### W7 — did:web segment validation — close the gap to the doc grammar (§3.6) + +**As-built:** colon→slash mapping + empty-segment rejection exist +(`internal/domain/identity.go:177-268`). The doc's full rejection list per +segment: empty, `.`, `..`, literal **or percent-encoded** `/`, `@`, `:`, +NUL — with v1 rejecting **any** percent-encoded segment outright, and +port/userinfo-bearing DIDs rejected. + +**Work:** audit `canonicalizeDIDWeb` against that exact list and add what's +missing (likely `.`/`..` and the percent-encoding posture; ports/`@` are +already rejected per the existing grammar — verify with tests for each +listed rejection). Confirm the parse-once rule: the same parsed segments +feed `DIDWebResolutionURL` and the §3.7 dialer (no re-parsing in the web +resolver). Domain tests keep `internal/domain` at 100%. + +### W8 — Pagination inheritance (§5.6.1) + +| Route | As-built | Target | +|---|---|---| +| RA `GET /v2/ans/identities` | unpaginated | `limit` + opaque-cursor envelope per `api-spec-v2.yaml`'s existing convention (reuse the agent-list envelope helpers) | +| TL `GET /v1/identities/{id}/agents` | unpaginated (the genuinely unbounded read) | TL `limit`/`offset` per `api-spec-tl-v2.yaml` convention (the audit family already does this — `parsePagination`) | +| TL `GET /v1/agents/{id}/identities` | unpaginated | same TL convention (it is the documented overflow target for the badge cap) | +| badge `identities[]` | uncapped | safety cap (e.g. 25) + `identitiesTotal` count; overflow → the standalone route | + +### W9 — Alerting hooks + log-PII sweep (§4.6) + +- Sweep every identity code path for logging of key material, proofs, or + (future) ACDC bodies — assert none; add a test-adjacent lint note in the + service doc comment. (The §5.5 PII rule now extends to application logs.) +- Seal failures (`TL_UNAVAILABLE`/`TL_REJECTED_EVENT` after W1) log at + error level with a stable message shape — the "TL-unavailability bursts + SHOULD alert" hook. The sealed-vs-live drift alert is AIM's job (not this + repo); note only. + +### W10 — Spec + docsui + +`spec/api-spec-v2.yaml` + `spec/api-spec-tl-v2.yaml` (+ `make docs-sync`): +- badge/TL identity entry: `keys[]` (verbatim verification-method objects), + `keysLogId`; **remove `provenKeyIds`** and `identityLogId`. +- TL identity detail: computed `{status, keys[], keysLogId}`; REVOKED omits keys. +- `identitiesUnavailable` on the badge; `identitiesTotal` + cap semantics. +- Error codes: `AGENT_NOT_LINKABLE`, `TL_UNAVAILABLE`, `TL_REJECTED_EVENT`, link-route 429. +- Pagination params on the three routes (W8) + RA list cursor envelope. +- Route descriptions: seal-before-success semantics on verify-control / + revoke / links (success ⇒ sealed); did:web path grammar + rejection list; + the third-party verification recipe note (byte-recompute + `crit` + rejection) on the sealed-event schema description. + +### W11 — Demos + lifecycle docs + +`scripts/demo/identity-lifecycle.sh`: +- Remove TL polling after sealing ops (W1 makes reads-after-200 consistent) + and **assert** immediate audit visibility. +- Replace `provenKeyIds` assertions with `keys[]` verbatim-equality asserts + (compare badge `keys[]` member-for-member against the served DID doc / + decoded did:key) + `keysLogId` → fetch the seal → assert `signedProof` + present there. +- Step 20 rework: after revocation the did:web entry **disappears** from + both agents' `identities[]` (assert absence + `identitiesTotal` math), + while `GET /v1/identities/{id}` shows `REVOKED` with no keys and the + history route still shows the link events. +- New negative step: revoke agent-2 first, then attempt to link → assert + 422 `AGENT_NOT_LINKABLE`; link to the DEPRECATED state still succeeds if + cheaply demonstrable (deprecate → link → assert join present). +- `scripts/demo/run-lifecycle.sh` steps 16–19: same polling removal + keys[] assert. +- `scripts/demo/common.sh`: retire `poll_tl_identity_audit` (or keep as a + no-op-deprecated helper if other scripts reference it). + +--- + +## 3. Sequencing, risks, gate + +**Order:** W1 → W2+W3+W4 (one PR-internal milestone — they all rewrite the +view layer) → W5+W6 (service guards) → W7 (domain audit) → W8 (pagination) +→ W9 → W10+W11 last (specs/demos assert the final shape). Single PR on top +of `feat/verified-identities` (or stacked PR if review prefers; ans#41 is +still a draft so amending the branch is also viable — **ask before +committing/pushing**, per standing instruction). + +**Risks / decisions already taken in this plan (flag in PR description):** +1. IDENTITY lane leaves the outbox entirely (W1) — the outbox-replay + invariant in CLAUDE.md remains true for the agent lanes; the identity + lane's new invariant is "sign once, submit once, success ⇒ sealed". + CLAUDE.md gets a paragraph for the identity-lane rule. +2. The agent lane still returns before its seal — doc inconsistency #2 + above; explicitly out of scope here. +3. Sealed-loser duplicate events under crash/race — documented-benign + posture (inconsistency #3); needs team sign-off. +4. `IdentityLogID` field removal is a wire change to an **unsealed, + computed** response on a draft PR — allowed now, impossible later; + that's exactly why this lands before ans#41 merges. + +**Quality gate (unchanged bar):** `make check` green; `internal/domain` +stays 100%; overall ≥90%; race-clean; both demos run end-to-end against the +noop resolver; spec/docsui synced; no TODOs; conventional commits with DCO +sign-off; no AI co-author trailers; **no commit/push without asking first**. From 2383afb7d796325f85617b645eac0e1e5baaa2b7 Mon Sep 17 00:00:00 2001 From: James Hateley Date: Thu, 11 Jun 2026 17:39:54 +1000 Subject: [PATCH 10/13] feat: lei verification Signed-off-by: James Hateley --- cmd/ans-ra/main.go | 27 +- config/ra-local.yaml | 21 + internal/adapter/docsui/openapi/ra.yaml | 77 +- internal/adapter/docsui/openapi/tl.yaml | 27 +- .../adapter/leiverifier/leiverifier_test.go | 358 ++++++ internal/adapter/leiverifier/noop.go | 138 +++ internal/adapter/leiverifier/verifier.go | 447 +++++++ internal/adapter/store/sqlite/identity.go | 13 +- .../migrations/008_identity_subject_aid.sql | 13 + internal/config/config.go | 46 + internal/config/config_test.go | 50 + internal/config/defaults.go | 1 + internal/domain/identity.go | 19 + internal/domain/identity_test.go | 26 + internal/port/leiverifier.go | 66 ++ internal/ra/handler/identity.go | 62 +- internal/ra/handler/identity_handler_test.go | 3 + internal/ra/service/identity.go | 62 +- internal/ra/service/identity_lei_test.go | 226 ++++ internal/ra/service/identity_test.go | 19 +- internal/ra/service/identitykinds.go | 48 +- internal/ra/service/leiverifier.go | 164 +++ scripts/demo/start.sh | 13 + scripts/demo/vlei/README.md | 262 +++++ scripts/demo/vlei/build-chain.sh | 54 + scripts/demo/vlei/docker-compose.yml | 206 ++++ scripts/demo/vlei/down.sh | 32 + scripts/demo/vlei/keria-docker.json | 15 + scripts/demo/vlei/run-vlei.sh | 80 ++ .../vlei/signify/out/ecr-presentation.json | 5 + .../demo/vlei/signify/out/tier1-outputs.json | 8 + .../vlei/signify/scripts_ts/build-chain.ts | 459 ++++++++ .../vlei/signify/scripts_ts/sign-proof.ts | 62 + scripts/demo/vlei/signify/scripts_ts/utils.ts | 1045 +++++++++++++++++ scripts/demo/vlei/up.sh | 74 ++ scripts/demo/vlei/verify-control-demo.sh | 259 ++++ .../demo/vlei/witness-config/main/wan.json | 8 + .../demo/vlei/witness-config/main/wes.json | 8 + .../demo/vlei/witness-config/main/wil.json | 8 + .../demo/vlei/witness-config/main/wit.json | 8 + .../demo/vlei/witness-config/main/wub.json | 8 + .../demo/vlei/witness-config/main/wyz.json | 8 + spec/api-spec-tl-v2.yaml | 27 +- spec/api-spec-v2.yaml | 77 +- 44 files changed, 4554 insertions(+), 85 deletions(-) create mode 100644 internal/adapter/leiverifier/leiverifier_test.go create mode 100644 internal/adapter/leiverifier/noop.go create mode 100644 internal/adapter/leiverifier/verifier.go create mode 100644 internal/adapter/store/sqlite/migrations/008_identity_subject_aid.sql create mode 100644 internal/port/leiverifier.go create mode 100644 internal/ra/service/identity_lei_test.go create mode 100644 internal/ra/service/leiverifier.go create mode 100644 scripts/demo/vlei/README.md create mode 100755 scripts/demo/vlei/build-chain.sh create mode 100644 scripts/demo/vlei/docker-compose.yml create mode 100755 scripts/demo/vlei/down.sh create mode 100755 scripts/demo/vlei/keria-docker.json create mode 100755 scripts/demo/vlei/run-vlei.sh create mode 100644 scripts/demo/vlei/signify/out/ecr-presentation.json create mode 100644 scripts/demo/vlei/signify/out/tier1-outputs.json create mode 100644 scripts/demo/vlei/signify/scripts_ts/build-chain.ts create mode 100644 scripts/demo/vlei/signify/scripts_ts/sign-proof.ts create mode 100644 scripts/demo/vlei/signify/scripts_ts/utils.ts create mode 100755 scripts/demo/vlei/up.sh create mode 100755 scripts/demo/vlei/verify-control-demo.sh create mode 100755 scripts/demo/vlei/witness-config/main/wan.json create mode 100755 scripts/demo/vlei/witness-config/main/wes.json create mode 100755 scripts/demo/vlei/witness-config/main/wil.json create mode 100755 scripts/demo/vlei/witness-config/main/wit.json create mode 100755 scripts/demo/vlei/witness-config/main/wub.json create mode 100755 scripts/demo/vlei/witness-config/main/wyz.json diff --git a/cmd/ans-ra/main.go b/cmd/ans-ra/main.go index 1464706..0dbf6c5 100644 --- a/cmd/ans-ra/main.go +++ b/cmd/ans-ra/main.go @@ -32,6 +32,7 @@ import ( "github.com/godaddy/ans/internal/adapter/docsui" "github.com/godaddy/ans/internal/adapter/eventbus" "github.com/godaddy/ans/internal/adapter/keymanager" + "github.com/godaddy/ans/internal/adapter/leiverifier" "github.com/godaddy/ans/internal/adapter/store/sqlite" "github.com/godaddy/ans/internal/adapter/tlclient" "github.com/godaddy/ans/internal/config" @@ -155,8 +156,13 @@ func run(cfgPath string) error { // did:web resolver — noop (quickstart) or hardened web fetch, // the identity surface's analog of the DNS verifier selection. didResolver := selectDIDResolver(cfg) + // vLEI control verifier — noop (quickstart) or HTTP client for an internal vlei-verifier, + // the lei kind's analog of + // the did:web resolver selection. + leiVerifier := selectLEIVerifier(cfg) logger.Info(). Str("resolver", cfg.Identity.Resolver.Type). + Str("vleiVerifier", cfg.VLEI.Type). Dur("challengeTTL", cfg.Identity.ChallengeTTL). Msg("verified-identity surface configured") @@ -199,7 +205,7 @@ func run(cfgPath string) error { logger.Warn().Msg("TL client disabled — identity verify/revoke/link operations will fail with TL_UNAVAILABLE (seal-before-success)") } identitySvc := service.NewIdentityService( - identityStore, identityLinks, agents, didResolver, identitySealer, db, + identityStore, identityLinks, agents, didResolver, identitySealer, leiVerifier, db, ).WithSigner(service.EventSigner{ KeyManager: km, KeyID: signerKeyID, @@ -483,3 +489,22 @@ func selectDIDResolver(cfg *config.RAConfig) port.DIDResolver { return didresolver.NewNoopResolver() } } + +// selectLEIVerifier returns the configured vLEI control verifier — the +// lei kind's analog of selectDIDResolver. "verifier" is the hardened +// HTTP client for an internal vlei-verifier (config-validated base +// URL); the default "noop" runs real Ed25519 crypto over the signing +// input but waives the GLEIF authorization binding, for self-contained +// local development. +func selectLEIVerifier(cfg *config.RAConfig) port.LEIControlVerifier { + switch cfg.VLEI.Type { + case "verifier": + var opts []leiverifier.VerifierOption + if cfg.VLEI.PresentTimeout > 0 { + opts = append(opts, leiverifier.WithTimeout(cfg.VLEI.PresentTimeout)) + } + return leiverifier.NewVerifier(cfg.VLEI.BaseURL, opts...) + default: + return leiverifier.NewNoop() + } +} diff --git a/config/ra-local.yaml b/config/ra-local.yaml index cef22ed..4d518f2 100644 --- a/config/ra-local.yaml +++ b/config/ra-local.yaml @@ -48,6 +48,27 @@ identity: # minute (each can trigger an outbound did:web fetch). register-rate-limit: 10 +vlei: + # The lei (vLEI) control verifier behind the `lei` identifier kind, + # mirroring the DNS verifier's and did:web resolver's noop/real split: + # "noop" — no I/O; runs REAL Ed25519 crypto over the served + # signingInput, but waives the GLEIF/vlei-verifier + # authorization binding (anyone can present any LEI). + # Zero-infra quickstart only — NOT for production. + # "verifier" — a hardened HTTP client for an internal GLEIF + # vlei-verifier (present / authorize / verify). The RA + # never parses KERI key state itself; the verifier is + # the authoritative key-state oracle. REQUIRED for the + # real end-to-end flow that scripts/demo/vlei/up.sh + # brings up (keria, vlei-server, vlei-verifier, + # witnesses, signify). + type: noop + # base-url: required when type is "verifier" — the internal + # vlei-verifier service URL. + # base-url: "http://localhost:7676" + # present-timeout bounds each verifier HTTP request (default 5s). + # present-timeout: 5s + keys: type: file file: diff --git a/internal/adapter/docsui/openapi/ra.yaml b/internal/adapter/docsui/openapi/ra.yaml index 88eef90..adb3821 100644 --- a/internal/adapter/docsui/openapi/ra.yaml +++ b/internal/adapter/docsui/openapi/ra.yaml @@ -927,9 +927,12 @@ paths: is the idempotent re-add: the same `identityId` returns with a fresh nonce (the prior nonce is superseded). A value already verified — by this owner or any other — returns 409 - `IDENTIFIER_DUPLICATE`. Kinds without an enabled control - verifier (`lei`, postponed) return 422 - `IDENTIFIER_KIND_UNSUPPORTED`. + `IDENTIFIER_DUPLICATE`. A recognized value whose kind has no + enabled control verifier (e.g. `did:plc`, `did:ion`, until + their verifiers ship) returns 422 + `IDENTIFIER_KIND_UNSUPPORTED`. The `lei` kind additionally + requires a `vleiPresentation` at register time; omitting it + returns 422 `IDENTIFIER_PRESENTATION_REQUIRED`. operationId: registerIdentity requestBody: required: true @@ -2063,16 +2066,38 @@ components: description: | Registers (POST) or rotates (PUT) an identifier. The kind is inferred from the value's lexical form — `did:web:` prefix, - `did:key:` prefix, or a 20-character LEI (recognized but - postponed) — never caller-asserted. + `did:key:` prefix, or a 20-character LEI — never + caller-asserted. properties: value: type: string description: The identifier to prove control of example: did:web:identity.acme-corp.com + vleiPresentation: + $ref: '#/components/schemas/VLEIPresentation' required: - value + VLEIPresentation: + type: object + description: | + The lei (vLEI) register-time credential presentation. REQUIRED + for the `lei` kind and omitted for the JWS kinds (`did:web`, + `did:key`). The RA submits the CESR to its configured + vlei-verifier, which derives and pins the subject AID; the + 202's `presentationStatus` reports the verifier's advisory + authorization decision. + properties: + cesr: + type: string + description: | + The full-chain CESR export of the vLEI credential and its + supporting KELs/ACDCs (the `credentials().get(said, true)` + shape). The RA never parses KERI key state itself — the + verifier is the authoritative key-state oracle. + required: + - cesr + IdentityChallengeResponse: type: object description: | @@ -2093,6 +2118,15 @@ components: description: The canonical identifier this round proves status: $ref: '#/components/schemas/IdentityLifecycleStatus' + presentationStatus: + type: string + description: | + The lei register-time advisory authorization status from + the vlei-verifier (`AUTHORIZED` | `PENDING`). Omitted for + kinds with no register-time presentation (`did:web`, + `did:key`). Advisory only — control is finally established + by a LIVE re-authorization at verify-control. + enum: ['AUTHORIZED', 'PENDING'] nonce: type: string description: Base64url 32-byte single-use anti-replay nonce @@ -2135,11 +2169,20 @@ components: VerifyControlRequest: type: object description: | - One compact JWS per proven key. Supported algorithms match - what the verifier implements: EdDSA (Ed25519), ES256 - (ECDSA P-256), and RS256 (RSA >= 2048). Key-agreement keys - (X25519) and curves without a verifier (secp256k1, - P-384/521) are rejected with a precise error. + The control proof. Its members are additive per identifier + kind, and EXACTLY ONE family is set per request: + + - JWS kinds (`did:web`, `did:key`) submit `signedProofs` — + one compact JWS per proven key. + - The `lei` kind submits `cesrSignature` — a single CESR + signature over the served signingInput by the subject + AID's current key. + + Supported JWS algorithms match what the verifier implements: + EdDSA (Ed25519), ES256 (ECDSA P-256), and RS256 (RSA >= + 2048). Key-agreement keys (X25519) and curves without a + verifier (secp256k1, P-384/521) are rejected with a precise + error. properties: signedProofs: type: array @@ -2153,8 +2196,18 @@ components: and MAY carry `jwk` (the signer's public key — required by the quickstart noop resolver, ignored by the web resolver, which always uses the resolved document). - required: - - signedProofs + cesrSignature: + type: string + description: | + The lei proof: a single CESR signature over the served + signingInput by the subject AID's current key. Set only + for the `lei` kind. The RA forwards it to the + vlei-verifier, which resolves the AID's key state from its + KEL and checks the signature over the exact signingInput + bytes (the same payload the JWS kinds sign). + oneOf: + - required: [signedProofs] + - required: [cesrSignature] IdentityLifecycleStatus: type: string diff --git a/internal/adapter/docsui/openapi/tl.yaml b/internal/adapter/docsui/openapi/tl.yaml index 4664a78..78cdcf7 100644 --- a/internal/adapter/docsui/openapi/tl.yaml +++ b/internal/adapter/docsui/openapi/tl.yaml @@ -1203,17 +1203,25 @@ components: conveniences (anyone can derive RFC 7638 from the sealed source) and are never part of the sealed contract. - The postponed `lei` kind is the one deliberate exception: - it will seal the subject AID + a key thumbprint only — there - is no document to quote, the ACDC is PII, and KERI's KEL is - already the authoritative key history. Seal verbatim what - has no other tamper-evident home; commit minimally where one - exists. + The `lei` kind is the one deliberate exception: it seals the + subject AID + a key thumbprint only (`{id: , type: + "vLEI-KERI-AID", thumbprint: base64url(SHA-256(AID))}`) — + there is no document to quote, the ACDC is PII, and KERI's + KEL is already the authoritative key history. Seal verbatim + what has no other tamper-evident home; commit minimally where + one exists. A `lei` seal is therefore NOT offline + re-verifiable from the seal alone: the KEL-backed key state + lives at the vlei-verifier (the documented lei trust + boundary), whereas a JWS seal carries the key material and so + the sealed proof re-verifies offline. required: [verificationMethod, signedProof] properties: verificationMethod: type: object - description: The DID document's verification-method object, verbatim + description: | + For JWS kinds, the DID document's verification-method + object, verbatim. For `lei`, the synthesized subject-AID + object (`id`, `type: vLEI-KERI-AID`, `thumbprint`). example: id: did:web:identity.acme-corp.com#key-1 type: JsonWebKey2020 @@ -1221,7 +1229,10 @@ components: publicKeyJwk: { kty: OKP, crv: Ed25519, x: "0-e2i2_..." } signedProof: type: string - description: The compact JWS over the served IdentityProofInput + description: | + The proof over the served IdentityProofInput — a compact + JWS for the JWS kinds, or the CESR signature by the + subject AID's current key for `lei`. LinkedIdentityView: type: object diff --git a/internal/adapter/leiverifier/leiverifier_test.go b/internal/adapter/leiverifier/leiverifier_test.go new file mode 100644 index 0000000..23b4a9d --- /dev/null +++ b/internal/adapter/leiverifier/leiverifier_test.go @@ -0,0 +1,358 @@ +package leiverifier + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/godaddy/ans/internal/domain" +) + +// b64 is the unpadded base64url encoding the noop wire shape uses. +func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } + +// noopFixture mints an Ed25519 keypair and the matching noop +// presentation cesr + subject AID for the public key. +func noopFixture(t *testing.T, lei string) (ed25519.PrivateKey, string, string) { + t.Helper() + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + aid := b64(pub) + raw, err := json.Marshal(noopPresentation{PublicKey: aid, LEI: lei}) + if err != nil { + t.Fatal(err) + } + return priv, aid, b64(raw) +} + +func TestNoopPresentAndVerify(t *testing.T) { + ctx := context.Background() + n := NewNoop() + priv, aid, cesr := noopFixture(t, "5493001KJTIIGC8Y1R17") + + // Present recovers the AID + echoes the LEI + always AUTHORIZED. + res, err := n.Present(ctx, cesr) + if err != nil { + t.Fatalf("Present: %v", err) + } + if res.SubjectAID != aid || res.LEI != "5493001KJTIIGC8Y1R17" || res.Status != "AUTHORIZED" { + t.Fatalf("present result: %+v", res) + } + + // Authorization authorizes a well-formed AID, asserts no LEI binding. + auth, err := n.Authorization(ctx, aid) + if err != nil { + t.Fatalf("Authorization: %v", err) + } + if !auth.Authorized || auth.LEI != "" { + t.Fatalf("authorization: %+v", auth) + } + + // VerifySignature: a real Ed25519 signature over the signing input. + const signingInput = "the-served-signing-input" + sig := ed25519.Sign(priv, []byte(signingInput)) + ok, err := n.VerifySignature(ctx, aid, signingInput, b64(sig)) + if err != nil || !ok { + t.Fatalf("verify good sig: ok=%v err=%v", ok, err) + } + // Tampered payload does not verify. + if ok, _ := n.VerifySignature(ctx, aid, "other-input", b64(sig)); ok { + t.Fatal("tampered payload should not verify") + } +} + +func TestNoopPresentFailures(t *testing.T) { + ctx := context.Background() + n := NewNoop() + _, validAID, _ := noopFixture(t, "X") + + noLEI, _ := json.Marshal(noopPresentation{PublicKey: validAID}) + badPub, _ := json.Marshal(noopPresentation{PublicKey: "not-base64url-key", LEI: "X"}) + + cases := []struct { + name string + cesr string + }{ + {"not base64url", "!!!not-base64!!!"}, + {"not a json object", b64([]byte("not json"))}, + {"bad public key", b64(badPub)}, + {"missing lei", b64(noLEI)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, err := n.Present(ctx, tc.cesr); !isCode(err, "LEI_PRESENTATION_INVALID") { + t.Fatalf("want LEI_PRESENTATION_INVALID, got %v", err) + } + }) + } + + // A malformed AID to Authorization is an error (not a silent allow). + if _, err := n.Authorization(ctx, "bogus-aid"); err == nil { + t.Fatal("malformed AID should error") + } + + // VerifySignature treats malformed AID / signature as a non-verifying + // false, never an I/O error. + if ok, err := n.VerifySignature(ctx, "bogus-aid", "in", b64([]byte("sig"))); ok || err != nil { + t.Fatalf("bad aid: ok=%v err=%v", ok, err) + } + if ok, err := n.VerifySignature(ctx, validAID, "in", "!!!"); ok || err != nil { + t.Fatalf("bad sig encoding: ok=%v err=%v", ok, err) + } + if ok, err := n.VerifySignature(ctx, validAID, "in", b64([]byte("too-short"))); ok || err != nil { + t.Fatalf("wrong-length sig: ok=%v err=%v", ok, err) + } +} + +// vleiServer is a programmable stand-in for the vlei-verifier service. +type vleiServer struct { + presentStatus int + presentBody string + authStatus int + authBody string + verifyStatus int +} + +func (s *vleiServer) handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/presentations/", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(s.presentStatus) + _, _ = w.Write([]byte(s.presentBody)) + }) + mux.HandleFunc("/authorizations/", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(s.authStatus) + _, _ = w.Write([]byte(s.authBody)) + }) + mux.HandleFunc("/signature/verify", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(s.verifyStatus) + }) + return mux +} + +// validCESR is a minimal full-chain export: a leading ACDC frame whose +// self-addressing `d` is the presented credential SAID. +const validCESR = `{"v":"ACDC10JSON00011c_","d":"ECredSAID123","i":"EHolderAID","s":"ESchema","a":{"LEI":"5493001KJTIIGC8Y1R17"}}-CESR-attachments` + +func newVerifierFor(t *testing.T, s *vleiServer) *Verifier { + t.Helper() + srv := httptest.NewServer(s.handler()) + t.Cleanup(srv.Close) + return NewVerifier(srv.URL+"/", WithHTTPClient(srv.Client()), WithTimeout(2*time.Second), WithMaxBodyBytes(1<<16)) +} + +func TestVerifierPresentHappy(t *testing.T) { + s := &vleiServer{ + presentStatus: http.StatusAccepted, + presentBody: `{"aid":"EHolderAID","said":"ECredSAID123"}`, + authStatus: http.StatusOK, + authBody: `{"aid":"EHolderAID","lei":"5493001KJTIIGC8Y1R17","role":"OOR"}`, + } + v := newVerifierFor(t, s) + res, err := v.Present(context.Background(), validCESR) + if err != nil { + t.Fatalf("Present: %v", err) + } + if res.SubjectAID != "EHolderAID" || res.LEI != "5493001KJTIIGC8Y1R17" || res.Status != "AUTHORIZED" { + t.Fatalf("present result: %+v", res) + } +} + +func TestVerifierPresentPendingAuthorization(t *testing.T) { + // Presentation accepted but authorization still processing → PENDING. + s := &vleiServer{ + presentStatus: http.StatusOK, + presentBody: `{"aid":"EHolderAID","said":"ECredSAID123"}`, + authStatus: http.StatusNotFound, + } + v := newVerifierFor(t, s) + res, err := v.Present(context.Background(), validCESR) + if err != nil { + t.Fatalf("Present: %v", err) + } + if res.Status != "PENDING" || res.SubjectAID != "EHolderAID" { + t.Fatalf("present result: %+v", res) + } +} + +func TestVerifierPresentFailures(t *testing.T) { + t.Run("no ACDC frame", func(t *testing.T) { + v := newVerifierFor(t, &vleiServer{}) + if _, err := v.Present(context.Background(), "no acdc here"); !isCode(err, "LEI_PRESENTATION_INVALID") { + t.Fatalf("want LEI_PRESENTATION_INVALID, got %v", err) + } + }) + t.Run("verifier rejects 4xx", func(t *testing.T) { + v := newVerifierFor(t, &vleiServer{presentStatus: http.StatusBadRequest}) + if _, err := v.Present(context.Background(), validCESR); !isCode(err, "LEI_PRESENTATION_INVALID") { + t.Fatalf("want LEI_PRESENTATION_INVALID, got %v", err) + } + }) + t.Run("path-injecting SAID rejected before dial", func(t *testing.T) { + // A leaf SAID carrying path/query characters must be rejected by the + // qb64 guard — never interpolated into the request path. + v := newVerifierFor(t, &vleiServer{presentStatus: http.StatusOK, presentBody: `{"aid":"EAID"}`}) + injecting := `{"v":"ACDC10JSON_","d":"../authorizations/EVIL?x=1"}` + if _, err := v.Present(context.Background(), injecting); !isCode(err, "LEI_PRESENTATION_INVALID") { + t.Fatalf("want LEI_PRESENTATION_INVALID, got %v", err) + } + }) + t.Run("verifier 5xx unavailable", func(t *testing.T) { + v := newVerifierFor(t, &vleiServer{presentStatus: http.StatusInternalServerError}) + if _, err := v.Present(context.Background(), validCESR); !isCode(err, "LEI_VERIFIER_UNAVAILABLE") { + t.Fatalf("want LEI_VERIFIER_UNAVAILABLE, got %v", err) + } + }) + t.Run("empty aid unavailable", func(t *testing.T) { + v := newVerifierFor(t, &vleiServer{presentStatus: http.StatusOK, presentBody: `{"said":"x"}`}) + if _, err := v.Present(context.Background(), validCESR); !isCode(err, "LEI_VERIFIER_UNAVAILABLE") { + t.Fatalf("want LEI_VERIFIER_UNAVAILABLE, got %v", err) + } + }) +} + +func TestVerifierAuthorization(t *testing.T) { + ctx := context.Background() + t.Run("authorized", func(t *testing.T) { + v := newVerifierFor(t, &vleiServer{authStatus: http.StatusOK, authBody: `{"lei":"L1"}`}) + auth, err := v.Authorization(ctx, "EAID") + if err != nil || !auth.Authorized || auth.LEI != "L1" { + t.Fatalf("auth=%+v err=%v", auth, err) + } + }) + for _, code := range []int{http.StatusUnauthorized, http.StatusNotFound} { + v := newVerifierFor(t, &vleiServer{authStatus: code}) + auth, err := v.Authorization(ctx, "EAID") + if err != nil || auth.Authorized { + t.Fatalf("status %d: auth=%+v err=%v", code, auth, err) + } + } + t.Run("5xx unavailable", func(t *testing.T) { + v := newVerifierFor(t, &vleiServer{authStatus: http.StatusBadGateway}) + if _, err := v.Authorization(ctx, "EAID"); !isCode(err, "LEI_VERIFIER_UNAVAILABLE") { + t.Fatalf("want LEI_VERIFIER_UNAVAILABLE, got %v", err) + } + }) + t.Run("non-qb64 AID rejected before dial", func(t *testing.T) { + v := newVerifierFor(t, &vleiServer{authStatus: http.StatusOK, authBody: `{"lei":"L1"}`}) + if _, err := v.Authorization(ctx, "../signature/verify"); !isCode(err, "LEI_SUBJECT_AID_INVALID") { + t.Fatalf("want LEI_SUBJECT_AID_INVALID, got %v", err) + } + }) +} + +func TestVerifierVerifySignature(t *testing.T) { + ctx := context.Background() + t.Run("verifies", func(t *testing.T) { + v := newVerifierFor(t, &vleiServer{verifyStatus: http.StatusAccepted}) + ok, err := v.VerifySignature(ctx, "EAID", "input", "0Bsig") + if err != nil || !ok { + t.Fatalf("ok=%v err=%v", ok, err) + } + }) + // 401: bad signature; 400: malformed input; 404: AID unknown to the + // verifier's KEL — all non-verifying signatures, never outages. + for _, code := range []int{http.StatusUnauthorized, http.StatusBadRequest, http.StatusNotFound} { + v := newVerifierFor(t, &vleiServer{verifyStatus: code}) + ok, err := v.VerifySignature(ctx, "EAID", "input", "0Bsig") + if err != nil || ok { + t.Fatalf("status %d: ok=%v err=%v", code, ok, err) + } + } + t.Run("5xx unavailable", func(t *testing.T) { + v := newVerifierFor(t, &vleiServer{verifyStatus: http.StatusInternalServerError}) + if _, err := v.VerifySignature(ctx, "EAID", "input", "0Bsig"); !isCode(err, "LEI_VERIFIER_UNAVAILABLE") { + t.Fatalf("want LEI_VERIFIER_UNAVAILABLE, got %v", err) + } + }) +} + +func TestVerifierUnreachable(t *testing.T) { + // A base URL pointing nowhere → the client.Do error path → unavailable. + v := NewVerifier("http://127.0.0.1:1", WithTimeout(500*time.Millisecond)) + if _, err := v.Authorization(context.Background(), "EAID"); !isCode(err, "LEI_VERIFIER_UNAVAILABLE") { + t.Fatalf("want LEI_VERIFIER_UNAVAILABLE, got %v", err) + } +} + +func TestVerifierMalformedSuccessBodyFailsClosed(t *testing.T) { + // A 200 carrying malformed JSON must surface as LEI_VERIFIER_UNAVAILABLE + // instead of silently leaving out zero-valued — otherwise an authorized + // response with junk JSON would degrade to the noop waiver (auth.LEI == "") + // and the service's AID↔LEI binding check would be skipped. + t.Run("present", func(t *testing.T) { + v := newVerifierFor(t, &vleiServer{presentStatus: http.StatusOK, presentBody: `{not-json`}) + if _, err := v.Present(context.Background(), validCESR); !isCode(err, "LEI_VERIFIER_UNAVAILABLE") { + t.Fatalf("want LEI_VERIFIER_UNAVAILABLE, got %v", err) + } + }) + t.Run("authorize", func(t *testing.T) { + v := newVerifierFor(t, &vleiServer{authStatus: http.StatusOK, authBody: `{not-json`}) + if _, err := v.Authorization(context.Background(), "EAID"); !isCode(err, "LEI_VERIFIER_UNAVAILABLE") { + t.Fatalf("want LEI_VERIFIER_UNAVAILABLE, got %v", err) + } + }) +} + +func TestVerifierBodyCapExceeded(t *testing.T) { + // A response larger than the configured cap is treated as a protocol + // failure (unavailable), never decoded — the response-size control. + s := &vleiServer{authStatus: http.StatusOK, authBody: `{"lei":"a-body-far-larger-than-the-cap"}`} + srv := httptest.NewServer(s.handler()) + t.Cleanup(srv.Close) + v := NewVerifier(srv.URL, WithHTTPClient(srv.Client()), WithMaxBodyBytes(8)) + if _, err := v.Authorization(context.Background(), "EAID"); !isCode(err, "LEI_VERIFIER_UNAVAILABLE") { + t.Fatalf("want LEI_VERIFIER_UNAVAILABLE, got %v", err) + } +} + +func TestPresentedCredentialSAID(t *testing.T) { + cases := []struct { + name, in, want string + }{ + {"single credential", validCESR, "ECredSAID123"}, + {"no marker", "plain text", ""}, + {"escaped strings before d", `{"v":"ACDC10JSON_","note":"a \"quoted\" }brace","d":"EReal"}`, "EReal"}, + {"unterminated object", `{"v":"ACDC10JSON_","d":"x"`, ""}, + {"marker but no d, then a real one", `{"v":"ACDC10JSON_","x":1}{"v":"ACDC10JSON_","d":"ESecond"}`, "ESecond"}, + // Full-chain exports: the leaf is the credential no other + // credential's edge `n` references — independent of frame order. + // KERIA emits issuer-first (leaf last); we must not pick the first. + {"chain leaf serialized last", + `{"v":"ACDC10JSON_","d":"EQVI","a":{"LEI":"L"}}` + + `{"v":"ACDC10JSON_","d":"ELE","e":{"d":"Eedge1","qvi":{"n":"EQVI","s":"S"}}}` + + `{"v":"ACDC10JSON_","d":"EECR","e":{"d":"Eedge2","le":{"n":"ELE","s":"S"}}}`, + "EECR"}, + {"chain leaf serialized first", + `{"v":"ACDC10JSON_","d":"EECR","e":{"d":"Eedge2","le":{"n":"ELE","s":"S"}}}` + + `{"v":"ACDC10JSON_","d":"ELE","e":{"d":"Eedge1","qvi":{"n":"EQVI","s":"S"}}}` + + `{"v":"ACDC10JSON_","d":"EQVI","a":{"LEI":"L"}}`, + "EECR"}, + // An edge group may be an ARRAY of edges (multi-source), and a + // non-string `n` is ignored — both edge-walk branches exercised. + {"edge group as array", + `{"v":"ACDC10JSON_","d":"EROOT","a":{"LEI":"L"}}` + + `{"v":"ACDC10JSON_","d":"ELEAF","e":{"d":"Eb","grp":[{"n":"EROOT","s":"S"},{"n":123}]}}`, + "ELEAF"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := presentedCredentialSAID(tc.in); got != tc.want { + t.Fatalf("presentedCredentialSAID = %q, want %q", got, tc.want) + } + }) + } +} + +func isCode(err error, code string) bool { + var de *domain.Error + return errors.As(err, &de) && de.Code == code +} diff --git a/internal/adapter/leiverifier/noop.go b/internal/adapter/leiverifier/noop.go new file mode 100644 index 0000000..18150c5 --- /dev/null +++ b/internal/adapter/leiverifier/noop.go @@ -0,0 +1,138 @@ +// Package leiverifier provides the two port.LEIControlVerifier +// adapters behind the lei (vLEI) identifier kind: +// +// - Noop — zero-infra quickstart verifier; runs REAL Ed25519 +// crypto over the signing input but waives the GLEIF/vlei-verifier +// authorization binding. +// - Verifier — the real thing: a hardened HTTP client for an +// internal vlei-verifier service (present / authorize / verify). +// +// Selected by `vlei.type` ("noop" | "verifier") in the RA config — +// the same pattern as the DNS verifier's `dns.type` ("noop" | +// "lookup") and the did:web resolver's `identity.resolver.type` +// ("noop" | "web"). +package leiverifier + +import ( + "context" + "crypto/ed25519" + "encoding/base64" + "encoding/json" + + "github.com/godaddy/ans/internal/domain" + "github.com/godaddy/ans/internal/port" +) + +// Noop is the quickstart vLEI verifier. It never dials anywhere. +// +// What this preserves and what it waives, stated precisely (the noop +// DNS / noop did:web precedent — real crypto, waived external-world +// binding): +// +// - PRESERVED: VerifySignature runs a genuine Ed25519 verification +// of the registrant's signature over the served signingInput, so +// the sealed proof is not a rubber stamp. The subject AID encodes +// the registrant's public key, so the verify key is recoverable +// from the AID alone — no KEL, no state. +// - WAIVED: "is this CESR really an authorized vLEI credential, and +// is the AID↔LEI binding real?" Anyone can mint a keypair and +// present any LEI. Authorization therefore returns Authorized +// with an empty LEI (no binding asserted), and the service skips +// the LEI-equality check accordingly. +// +// In noop mode the presentation `cesr` is a base64url (unpadded) +// encoding of a small JSON object {"publicKey": "", "lei": ""}, and a signature is the +// base64url (unpadded) Ed25519 signature over the exact signingInput +// bytes. Strictly for local development and tests. NOT for production. +type Noop struct{} + +// NewNoop returns the quickstart verifier. +func NewNoop() *Noop { return &Noop{} } + +// noopPresentation is the noop's stand-in for a full-chain CESR export. +type noopPresentation struct { + PublicKey string `json:"publicKey"` + LEI string `json:"lei"` +} + +// Present decodes the noop presentation, recovers the registrant's +// Ed25519 public key, and reports a subject AID that IS the base64url +// encoding of that key (so VerifySignature can recover it). The LEI is +// echoed verbatim and the status is always AUTHORIZED. +func (n *Noop) Present(_ context.Context, cesr string) (port.PresentationResult, error) { + pres, pub, err := decodeNoopPresentation(cesr) + if err != nil { + return port.PresentationResult{}, err + } + return port.PresentationResult{ + SubjectAID: base64.RawURLEncoding.EncodeToString(pub), + LEI: pres.LEI, + Status: "AUTHORIZED", + }, nil +} + +// Authorization always authorizes a well-formed AID and asserts NO +// LEI binding (the waived check) — the service treats the empty LEI +// as "verifier does not constrain the LEI". +func (n *Noop) Authorization(_ context.Context, subjectAID string) (port.AuthorizationResult, error) { + if _, err := decodeSubjectAID(subjectAID); err != nil { + return port.AuthorizationResult{}, err + } + return port.AuthorizationResult{Authorized: true, LEI: ""}, nil +} + +// VerifySignature recovers the public key from the subject AID and +// runs a real Ed25519 verification of the signature over the +// signingInput bytes. A malformed AID or signature is a false (not an +// error) — a non-verifying proof, not an I/O failure. +func (n *Noop) VerifySignature(_ context.Context, subjectAID, signingInput, signature string) (bool, error) { + pub, err := decodeSubjectAID(subjectAID) + if err != nil { + return false, nil //nolint:nilerr // malformed AID is a non-verifying proof (false), not an I/O failure + } + sig, err := base64.RawURLEncoding.DecodeString(signature) + if err != nil || len(sig) != ed25519.SignatureSize { + return false, nil //nolint:nilerr // malformed signature is a non-verifying proof (false), not an I/O failure + } + return ed25519.Verify(pub, []byte(signingInput), sig), nil +} + +// decodeNoopPresentation parses the base64url-wrapped JSON presentation +// and validates the embedded Ed25519 public key. +func decodeNoopPresentation(cesr string) (noopPresentation, ed25519.PublicKey, error) { + raw, err := base64.RawURLEncoding.DecodeString(cesr) + if err != nil { + return noopPresentation{}, nil, domain.NewValidationError("LEI_PRESENTATION_INVALID", + "vlei presentation is not valid base64url") + } + var pres noopPresentation + if err := json.Unmarshal(raw, &pres); err != nil { + return noopPresentation{}, nil, domain.NewValidationError("LEI_PRESENTATION_INVALID", + "vlei presentation is not a valid noop presentation object") + } + pub, err := decodeSubjectAID(pres.PublicKey) + if err != nil { + return noopPresentation{}, nil, domain.NewValidationError("LEI_PRESENTATION_INVALID", + "vlei presentation publicKey is not a base64url Ed25519 public key") + } + if pres.LEI == "" { + return noopPresentation{}, nil, domain.NewValidationError("LEI_PRESENTATION_INVALID", + "vlei presentation carries no lei") + } + return pres, pub, nil +} + +// decodeSubjectAID recovers the Ed25519 public key a noop subject AID +// encodes. +func decodeSubjectAID(aid string) (ed25519.PublicKey, error) { + b, err := base64.RawURLEncoding.DecodeString(aid) + if err != nil || len(b) != ed25519.PublicKeySize { + return nil, domain.NewValidationError("LEI_PRESENTATION_INVALID", + "subject AID is not a base64url Ed25519 public key") + } + return ed25519.PublicKey(b), nil +} + +// compile-time conformance. +var _ port.LEIControlVerifier = (*Noop)(nil) diff --git a/internal/adapter/leiverifier/verifier.go b/internal/adapter/leiverifier/verifier.go new file mode 100644 index 0000000..9f35421 --- /dev/null +++ b/internal/adapter/leiverifier/verifier.go @@ -0,0 +1,447 @@ +package leiverifier + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/godaddy/ans/internal/domain" + "github.com/godaddy/ans/internal/port" +) + +// Verifier is the production vLEI control verifier: a hardened HTTP +// client for an internal GLEIF vlei-verifier service. The RA is the +// single touchpoint for the verifier — it never parses KERI key state +// itself; the verifier is the authoritative key-state oracle. +// +// It speaks three vlei-verifier endpoints (the real reference API): +// +// - PUT /presentations/{said} (application/json+cesr) — submit the +// full-chain CESR export; the verifier validates it cryptographically +// and reports the holder AID + credential SAID. The {said} is read +// out of the (registrant-supplied) CESR, so it is qb64-validated +// before it is interpolated into the path (see isQB64); the verifier +// then re-derives and re-validates it, and the SUBJECT AID we pin +// comes from the verifier's response, never from the caller. +// - GET /authorizations/{aid} — the LIVE authorization for the AID: +// 200 with {aid, said, lei, role} while authorized, 401 when not, +// 404 before the presentation has been processed. +// - POST /signature/verify — verify a CESR signature: the +// verifier resolves the AID's current key from its KEL and checks +// the signature over the supplied bytes verbatim. +// +// The base URL is operator-configured (a trusted internal service), so +// the host can never be attacker-chosen the way the did:web resolver's +// can; the controls are a hard timeout, a response-size cap, error +// details that never echo the configured host, and qb64 validation of +// every identifier interpolated into a request path (the {said} read +// from registrant-supplied CESR, the subject AID) so it cannot re-target +// the path or inject a query against that host. +type Verifier struct { + baseURL string + client *http.Client + maxBodyBytes int64 +} + +// VerifierOption customizes the Verifier. +type VerifierOption func(*Verifier) + +// WithTimeout overrides the per-request HTTP timeout (default 5s). +func WithTimeout(d time.Duration) VerifierOption { + return func(v *Verifier) { + if d > 0 { + v.client.Timeout = d + } + } +} + +// WithHTTPClient injects an HTTP client (tests). Its Timeout is +// preserved unless WithTimeout follows. +func WithHTTPClient(c *http.Client) VerifierOption { + return func(v *Verifier) { + if c != nil { + v.client = c + } + } +} + +// WithMaxBodyBytes overrides the response-size cap (default 1 MiB). +func WithMaxBodyBytes(n int64) VerifierOption { + return func(v *Verifier) { + if n > 0 { + v.maxBodyBytes = n + } + } +} + +// NewVerifier constructs the production verifier against baseURL (e.g. +// "http://vlei-verifier:7676"), with the trailing slash trimmed. +func NewVerifier(baseURL string, opts ...VerifierOption) *Verifier { + v := &Verifier{ + baseURL: strings.TrimRight(baseURL, "/"), + client: &http.Client{Timeout: 5 * time.Second}, + maxBodyBytes: 1 << 20, + } + for _, opt := range opts { + opt(v) + } + return v +} + +// presentationResponse is the PUT /presentations/{said} body. +type presentationResponse struct { + AID string `json:"aid"` + SAID string `json:"said"` + Msg string `json:"msg"` +} + +// authorizationResponse is the GET /authorizations/{aid} body. +type authorizationResponse struct { + AID string `json:"aid"` + SAID string `json:"said"` + LEI string `json:"lei"` + Role string `json:"role"` + Msg string `json:"msg"` +} + +// Present submits the full-chain CESR export and returns the verifier's +// view of the holder. The subject AID is read from the verifier's +// response — never extracted by us, never caller-asserted. The {said} +// path segment is the only thing we read out of the CESR (a content +// address the verifier re-derives), routing the submission to the +// presented credential. +func (v *Verifier) Present(ctx context.Context, cesr string) (port.PresentationResult, error) { + said := presentedCredentialSAID(cesr) + if said == "" { + return port.PresentationResult{}, domain.NewValidationError("LEI_PRESENTATION_INVALID", + "vlei presentation carries no ACDC credential") + } + // said is read from registrant-supplied CESR and interpolated into the + // path below — reject anything outside the qb64 alphabet so it cannot + // re-target the path ('/', '..') or inject a query ('?', '#', '%'). + if !isQB64(said) { + return port.PresentationResult{}, domain.NewValidationError("LEI_PRESENTATION_INVALID", + "the presented credential SAID is not a valid qb64 identifier") + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPut, + v.baseURL+"/presentations/"+said, strings.NewReader(cesr)) + if err != nil { + return port.PresentationResult{}, v.unavailable("present") + } + req.Header.Set("Content-Type", "application/json+cesr") + + var pres presentationResponse + status, err := v.do(req, &pres) + if err != nil { + return port.PresentationResult{}, err + } + switch { + case status == http.StatusAccepted || status == http.StatusOK: + // processed below + case status >= 400 && status < 500: + return port.PresentationResult{}, domain.NewValidationError("LEI_PRESENTATION_INVALID", + "the vlei verifier rejected the presented credential") + default: + return port.PresentationResult{}, v.unavailable("present") + } + if pres.AID == "" { + return port.PresentationResult{}, v.unavailable("present") + } + + // The presentation is accepted; authorization is processed + // asynchronously, so the holder may still be PENDING. A live + // authorization check resolves the status + LEI. + auth, err := v.Authorization(ctx, pres.AID) + if err != nil { + return port.PresentationResult{}, err + } + status0 := "PENDING" + if auth.Authorized { + status0 = "AUTHORIZED" + } + return port.PresentationResult{ + SubjectAID: pres.AID, + LEI: auth.LEI, + Status: status0, + }, nil +} + +// Authorization reports the verifier's live authorization for the AID. +func (v *Verifier) Authorization(ctx context.Context, subjectAID string) (port.AuthorizationResult, error) { + // On the real path subjectAID is verifier-derived, but it is + // interpolated into the path below all the same — qb64-validate it for + // the same reason as the {said} in Present (defense in depth). + if !isQB64(subjectAID) { + return port.AuthorizationResult{}, domain.NewValidationError("LEI_SUBJECT_AID_INVALID", + "subject AID is not a valid qb64 identifier") + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, + v.baseURL+"/authorizations/"+subjectAID, nil) + if err != nil { + return port.AuthorizationResult{}, v.unavailable("authorize") + } + var auth authorizationResponse + status, err := v.do(req, &auth) + if err != nil { + return port.AuthorizationResult{}, err + } + switch status { + case http.StatusOK: + return port.AuthorizationResult{Authorized: true, LEI: auth.LEI}, nil + case http.StatusUnauthorized, http.StatusNotFound: + // 401: presented but not authorized; 404: not yet processed. + return port.AuthorizationResult{Authorized: false}, nil + default: + return port.AuthorizationResult{}, v.unavailable("authorize") + } +} + +// signatureVerifyRequest is the POST /signature/verify body. The +// verifier resolves the AID's current key from its KEL and checks the +// signature over the UTF-8 bytes of non_prefixed_digest verbatim — so +// non_prefixed_digest carries the served signingInput, the exact bytes +// the registrant signed (the same payload the JWS kinds sign). +type signatureVerifyRequest struct { + SignerAID string `json:"signer_aid"` + Signature string `json:"signature"` + NonPrefixedDigest string `json:"non_prefixed_digest"` +} + +// VerifySignature checks the CESR signature over the signing input via +// the verifier's KEL-backed key state. A well-formed but non-verifying +// signature is a false; an I/O or protocol failure reaching the +// verifier is an error. +func (v *Verifier) VerifySignature(ctx context.Context, subjectAID, signingInput, signature string) (bool, error) { + body, err := json.Marshal(signatureVerifyRequest{ + SignerAID: subjectAID, + Signature: signature, + NonPrefixedDigest: signingInput, + }) + if err != nil { + return false, v.unavailable("verify-signature") + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + v.baseURL+"/signature/verify", bytes.NewReader(body)) + if err != nil { + return false, v.unavailable("verify-signature") + } + req.Header.Set("Content-Type", "application/json") + + status, err := v.do(req, nil) + if err != nil { + return false, err + } + switch status { + case http.StatusAccepted, http.StatusOK: + return true, nil + case http.StatusUnauthorized, http.StatusBadRequest, http.StatusNotFound: + // 401: signature does not verify against the AID's current key. + // 400: malformed input (a non-verifying signature, not an outage). + // 404: AID is unknown to the verifier's KEL — also a non-verifying + // proof from the registrant's perspective, not a verifier outage. + return false, nil + default: + return false, v.unavailable("verify-signature") + } +} + +// do executes the request, enforces the response-size cap, and (when +// out is non-nil and the status carries a JSON body) decodes it. +// Returns the status code so callers map it to domain semantics. +func (v *Verifier) do(req *http.Request, out any) (int, error) { + // The request URL is built only from the operator-configured baseURL + // (a trusted internal vlei-verifier) plus verifier-controlled path + // segments — never from registrant input — so the SSRF posture the + // did:web resolver needs does not apply here. (See the type doc.) + resp, err := v.client.Do(req) //nolint:gosec // baseURL is operator-configured; no caller-controlled host + + if err != nil { + return 0, v.unavailable(req.Method) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(io.LimitReader(resp.Body, v.maxBodyBytes+1)) + if err != nil { + return resp.StatusCode, v.unavailable(req.Method) + } + if int64(len(body)) > v.maxBodyBytes { + return resp.StatusCode, v.unavailable(req.Method) + } + // Decode strictly on a 2xx success — the contract callers rely on. + // A 200 with malformed JSON would otherwise leave `out` zero-valued + // (e.g. auth.LEI == ""), and the service treats an empty LEI as the + // noop waiver of the AID↔LEI binding — silently degrading the + // production verifier to noop semantics. Fail-closed instead. + // Non-2xx bodies are caller-mapped status text (plain or JSON) and + // are not consumed by callers, so we leave them undecoded. + if out != nil && len(body) > 0 && resp.StatusCode >= 200 && resp.StatusCode < 300 { + if err := json.Unmarshal(body, out); err != nil { + return resp.StatusCode, v.unavailable("decode") + } + } + return resp.StatusCode, nil +} + +// unavailable builds the operator-facing error for a verifier I/O or +// protocol failure. The detail names the operation but never the +// configured host (no internal-topology leak). +func (v *Verifier) unavailable(op string) error { + return domain.NewInternalError("LEI_VERIFIER_UNAVAILABLE", + fmt.Sprintf("the vlei verifier is unavailable (%s)", op), nil) +} + +// isQB64 reports whether s is a non-empty CESR qb64 token — the +// base64url alphabet ([A-Za-z0-9_-]) and nothing else. KERI SAIDs and +// AIDs are qb64, so this is the guard that lets us safely interpolate a +// verifier SAID / subject AID into a request path: it rejects '/', '.', +// '?', '#', '%', and every other character that could re-target the path +// or inject a query against the operator-configured verifier host. The +// fixed baseURL bounds the host; this bounds the path. +func isQB64(s string) bool { + if s == "" { + return false + } + for i := range len(s) { + c := s[i] + switch { + case c >= 'A' && c <= 'Z', + c >= 'a' && c <= 'z', + c >= '0' && c <= '9', + c == '-', c == '_': + default: + return false + } + } + return true +} + +// presentedCredentialSAID extracts the SAID of the *presented* (leaf) +// credential from a full-chain CESR export — the minimal, targeted read +// the real verifier path needs to route PUT /presentations/{said}. It is +// NOT a CESR codec: KERI/ACDC serializations are version-string-first, so +// an ACDC credential message is always a JSON object whose first member +// is `"v":"ACDC…"`; we locate those frames by brace-balancing (respecting +// JSON string escaping), read each frame's self-addressing `d`, and +// collect the edge node SAIDs (the `n` of each edge) from each frame's +// `e` block. +// +// The presented credential is the most-derived one — the ECR/role +// credential at the bottom of the ECR→LE→QVI chain — identified +// structurally as the lone credential whose SAID is NOT referenced by any +// other credential's edge. This is position-independent: KERIA's +// `credentials().get(said, true)` exporter emits the chain in topological +// (issuer-first) order, so the leaf is serialized LAST, but we never rely +// on frame order. A single-credential export (no chain) has no references, +// so its one frame is the leaf. The end-to-end demo (scripts/demo/vlei) +// exercises this against the live verifier. +func presentedCredentialSAID(cesr string) string { + const marker = `{"v":"ACDC` + type acdcFrame struct { + D string `json:"d"` + E json.RawMessage `json:"e"` + } + var saids []string + referenced := make(map[string]struct{}) + offset := 0 + for { + rel := strings.Index(cesr[offset:], marker) + if rel < 0 { + break + } + start := offset + rel + obj, end := balancedJSONObject(cesr, start) + if obj == "" { + offset = start + 1 + continue + } + offset = end + var frame acdcFrame + if err := json.Unmarshal([]byte(obj), &frame); err != nil || frame.D == "" { + continue + } + saids = append(saids, frame.D) + if len(frame.E) > 0 { + collectEdgeNodes(frame.E, referenced) + } + } + // The leaf is the credential no other credential chains to. A + // well-formed linear chain has exactly one such SAID. + for _, d := range saids { + if _, ok := referenced[d]; !ok { + return d + } + } + return "" +} + +// collectEdgeNodes walks an ACDC `e` (edge) block and records every edge +// node SAID — the `n` field of each edge — into seen. Edge group names +// are arbitrary (le, qvi, auth, …) and the block may nest, so it +// recurses; the edge block's own `d` (a SAID of the edge block itself, +// not a referenced credential) is ignored because only `n` values are +// collected. +func collectEdgeNodes(raw json.RawMessage, seen map[string]struct{}) { + var doc any + if err := json.Unmarshal(raw, &doc); err != nil { + return + } + var walk func(v any) + walk = func(v any) { + switch t := v.(type) { + case map[string]any: + for k, val := range t { + if k == "n" { + if s, ok := val.(string); ok && s != "" { + seen[s] = struct{}{} + } + } + walk(val) + } + case []any: + for _, el := range t { + walk(el) + } + } + } + walk(doc) +} + +// balancedJSONObject returns the JSON object beginning at start +// (cesr[start] must be '{') and the index just past it, balancing +// braces while respecting strings and escapes. Returns "" if the object +// does not close. +func balancedJSONObject(s string, start int) (string, int) { + depth := 0 + inStr := false + escaped := false + for i := start; i < len(s); i++ { + c := s[i] + switch { + case escaped: + escaped = false + case c == '\\' && inStr: + escaped = true + case c == '"': + inStr = !inStr + case inStr: + // skip + case c == '{': + depth++ + case c == '}': + depth-- + if depth == 0 { + return s[start : i+1], i + 1 + } + } + } + return "", len(s) +} + +// compile-time conformance. +var _ port.LEIControlVerifier = (*Verifier)(nil) diff --git a/internal/adapter/store/sqlite/identity.go b/internal/adapter/store/sqlite/identity.go index ea35bc4..35a10a6 100644 --- a/internal/adapter/store/sqlite/identity.go +++ b/internal/adapter/store/sqlite/identity.go @@ -25,6 +25,7 @@ type identityRow struct { Status string `db:"status"` ProofMethod string `db:"proof_method"` PendingValue string `db:"pending_value"` + SubjectAID sql.NullString `db:"subject_aid"` ChallengeNonce sql.NullString `db:"challenge_nonce"` ChallengeExpiresAtMs sql.NullInt64 `db:"challenge_expires_at_ms"` ChallengeConsumedAtMs sql.NullInt64 `db:"challenge_consumed_at_ms"` @@ -34,7 +35,7 @@ type identityRow struct { } const identityCols = `identity_id, provider_id, kind, value, status, proof_method, - pending_value, challenge_nonce, challenge_expires_at_ms, + pending_value, subject_aid, challenge_nonce, challenge_expires_at_ms, challenge_consumed_at_ms, verified_at_ms, created_at_ms, updated_at_ms` func (r identityRow) toDomain() *domain.VerifiedIdentity { @@ -46,6 +47,7 @@ func (r identityRow) toDomain() *domain.VerifiedIdentity { Status: domain.IdentityStatus(r.Status), ProofMethod: r.ProofMethod, PendingValue: r.PendingValue, + SubjectAID: r.SubjectAID.String, CreatedAt: msToTime(r.CreatedAtMs), UpdatedAt: msToTime(r.UpdatedAtMs), } @@ -85,14 +87,19 @@ func (s *IdentityStore) Save(ctx context.Context, v *domain.VerifiedIdentity) er if !v.VerifiedAt.IsZero() { verifiedAt = sql.NullInt64{Int64: v.VerifiedAt.UnixMilli(), Valid: true} } + var subjectAID sql.NullString + if v.SubjectAID != "" { + subjectAID = sql.NullString{String: v.SubjectAID, Valid: true} + } const q = ` INSERT INTO identities (` + identityCols + `) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(identity_id) DO UPDATE SET value = excluded.value, status = excluded.status, proof_method = excluded.proof_method, pending_value = excluded.pending_value, + subject_aid = excluded.subject_aid, challenge_nonce = excluded.challenge_nonce, challenge_expires_at_ms = excluded.challenge_expires_at_ms, challenge_consumed_at_ms = excluded.challenge_consumed_at_ms, @@ -106,7 +113,7 @@ func (s *IdentityStore) Save(ctx context.Context, v *domain.VerifiedIdentity) er updated_at_ms = excluded.updated_at_ms` _, err := s.db.extx(ctx).ExecContext(ctx, q, v.IdentityID, v.ProviderID, string(v.Kind), v.Value, string(v.Status), - v.ProofMethod, v.PendingValue, + v.ProofMethod, v.PendingValue, subjectAID, nonce, expiresAt, consumedAt, verifiedAt, v.CreatedAt.UnixMilli(), v.UpdatedAt.UnixMilli(), ) diff --git a/internal/adapter/store/sqlite/migrations/008_identity_subject_aid.sql b/internal/adapter/store/sqlite/migrations/008_identity_subject_aid.sql new file mode 100644 index 0000000..4cd32da --- /dev/null +++ b/internal/adapter/store/sqlite/migrations/008_identity_subject_aid.sql @@ -0,0 +1,13 @@ +-- lei (vLEI) subject AID pinning. +-- +-- The lei kind carries its credential presentation at REGISTER time; +-- the verifier extracts the holder AID from that presentation and the +-- RA pins it on the aggregate (the §3.6 pinning rule — the caller +-- never re-supplies the signer at verify-control). The AID must +-- therefore persist between register and verify-control. +-- +-- Nullable: every other kind (did:web, did:key, and future DID +-- methods) carries no register-time presentation and leaves this NULL. +-- No CHECK and no index — the column is read only on the verify-control +-- path for the row already loaded by identity_id. +ALTER TABLE identities ADD COLUMN subject_aid TEXT; diff --git a/internal/config/config.go b/internal/config/config.go index 0f025e2..4cb88ab 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -140,6 +140,25 @@ type IdentityResolver struct { Type string `koanf:"type"` // "noop" | "web" } +// VLEI selects the vLEI (lei kind) control verifier — the GLEIF / +// vlei-verifier interaction behind the lei identifier kind. Top-level, +// matching the DNS verifier's placement (not nested under identity), +// because it is a distinct outbound dependency with its own service +// endpoint. +// +// "noop" runs real Ed25519 crypto over the signing input but waives +// the GLEIF authorization binding (quickstart — NOT for production); +// "verifier" is a hardened HTTP client for an internal vlei-verifier +// service. +type VLEI struct { + Type string `koanf:"type"` // "noop" | "verifier" + // BaseURL is the internal vlei-verifier service URL, required when + // type is "verifier" (e.g. "http://vlei-verifier:7676"). + BaseURL string `koanf:"base-url"` + // PresentTimeout bounds each verifier HTTP request (default 5s). + PresentTimeout time.Duration `koanf:"present-timeout"` +} + // Keys holds key-manager configuration. type Keys struct { Type string `koanf:"type"` // "file" @@ -227,6 +246,7 @@ type RAConfig struct { CA CA `koanf:"ca"` DNS DNS `koanf:"dns"` Identity Identity `koanf:"identity"` + VLEI VLEI `koanf:"vlei"` Keys Keys `koanf:"keys"` Store Store `koanf:"store"` TLClient TLClient `koanf:"tl-client"` @@ -382,6 +402,9 @@ func (c *RAConfig) Validate() error { if c.Identity.RegisterRateLimit < 0 { return errors.New("identity.register-rate-limit must not be negative") } + if err := validateVLEI(&c.VLEI); err != nil { + return err + } if err := validateKeys(&c.Keys); err != nil { return err } @@ -429,6 +452,29 @@ func (c *TLConfig) Validate() error { return nil } +// validateVLEI checks the vLEI control-verifier selection: "noop" +// needs nothing, "verifier" needs a valid http(s) base URL, and the +// per-request timeout may not be negative. +func validateVLEI(v *VLEI) error { + switch v.Type { + case "noop": + case "verifier": + if v.BaseURL == "" { + return errors.New("vlei.base-url is required when vlei.type is 'verifier'") + } + u, err := url.Parse(v.BaseURL) + if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") { + return fmt.Errorf("vlei.base-url must be a valid http(s) URL, got %q", v.BaseURL) + } + default: + return fmt.Errorf("vlei.type %q not supported (expected 'noop' or 'verifier')", v.Type) + } + if v.PresentTimeout < 0 { + return errors.New("vlei.present-timeout must not be negative") + } + return nil +} + func validateAuth(a *Auth) error { switch a.Type { case "static": diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5face48..3cfc6b4 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -153,6 +153,56 @@ tl-client: {base-url: ""} } } +// ----- VLEI validation ----- + +func TestRAConfig_ValidateVLEI(t *testing.T) { + cases := []struct { + name string + mutate func(*RAConfig) + wantErr string // substring; "" means must pass + }{ + {"noop default", func(*RAConfig) {}, ""}, + {"verifier with valid url", func(c *RAConfig) { + c.VLEI = VLEI{Type: "verifier", BaseURL: "http://vlei-verifier:7676"} + }, ""}, + {"verifier https", func(c *RAConfig) { + c.VLEI = VLEI{Type: "verifier", BaseURL: "https://vlei.internal"} + }, ""}, + {"verifier missing base-url", func(c *RAConfig) { + c.VLEI = VLEI{Type: "verifier"} + }, "vlei.base-url is required"}, + {"verifier non-http scheme", func(c *RAConfig) { + c.VLEI = VLEI{Type: "verifier", BaseURL: "ftp://host"} + }, "valid http(s) URL"}, + {"verifier no host", func(c *RAConfig) { + c.VLEI = VLEI{Type: "verifier", BaseURL: "http:///path"} + }, "valid http(s) URL"}, + {"unsupported type", func(c *RAConfig) { + c.VLEI = VLEI{Type: "bogus"} + }, "vlei.type"}, + {"negative present-timeout", func(c *RAConfig) { + c.VLEI = VLEI{Type: "noop", PresentTimeout: -time.Second} + }, "vlei.present-timeout must not be negative"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cfg := defaultRAConfig() + cfg.Auth.Static.APIKey = "test-key" // satisfy the auth check ahead of vlei + tc.mutate(cfg) + err := cfg.Validate() + if tc.wantErr == "" { + if err != nil { + t.Fatalf("expected pass, got %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("want error containing %q, got %v", tc.wantErr, err) + } + }) + } +} + // ----- LoadTL ----- func TestLoadTL_MissingFile(t *testing.T) { diff --git a/internal/config/defaults.go b/internal/config/defaults.go index f12e9e7..c325798 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -27,6 +27,7 @@ func defaultRAConfig() *RAConfig { LinkRateLimit: 60, SealTimeout: 5 * time.Second, }, + VLEI: VLEI{Type: "noop"}, Keys: Keys{ Type: "file", File: &KeysFile{Path: "./data/ra/keys"}, diff --git a/internal/domain/identity.go b/internal/domain/identity.go index 85e0dc0..5caff07 100644 --- a/internal/domain/identity.go +++ b/internal/domain/identity.go @@ -92,6 +92,13 @@ type VerifiedIdentity struct { // (§4.2): set by StageRotation, applied by CompleteVerification. // While staged, the previously sealed state stands. PendingValue string + // SubjectAID is the lei (vLEI) holder AID the verifier extracted + // from the presentation at register time and the RA pinned on the + // aggregate — the signer the verify-control proof is checked + // against (§3.6 pinning rule; the caller never re-supplies it). + // Empty for kinds with no register-time presentation (did:web, + // did:key). + SubjectAID string // Challenge is the live anti-replay nonce, if any. Challenge *IdentityChallenge VerifiedAt time.Time // zero until first proof @@ -385,6 +392,18 @@ func (v *VerifiedIdentity) StageRotation(rawValue string, now time.Time) error { return nil } +// SetSubjectAID pins the lei holder AID the verifier derived from the +// presentation (§3.6). Rejects an empty AID — pinning a blank signer +// would let any key satisfy verify-control. +func (v *VerifiedIdentity) SetSubjectAID(aid string, now time.Time) error { + if aid == "" { + return NewValidationError("LEI_PRESENTATION_INVALID", "subject AID is required") + } + v.SubjectAID = aid + v.UpdatedAt = now.UTC() + return nil +} + // CompleteVerification applies a successful control proof: // // - PENDING_CONTROL → VERIFIED (first proof; seals IDENTITY_VERIFIED) diff --git a/internal/domain/identity_test.go b/internal/domain/identity_test.go index 88000f9..a401ed9 100644 --- a/internal/domain/identity_test.go +++ b/internal/domain/identity_test.go @@ -308,3 +308,29 @@ func TestInferIdentifierKind_DIDWebSegmentRejections(t *testing.T) { t.Fatalf("multi-segment: %v %v %v", kind, canonical, err) } } + +func TestSetSubjectAID(t *testing.T) { + v := newPendingIdentity(t, "5493001KJTIIGC8Y1R17") + + // Empty AID is rejected and mutates nothing. + err := v.SetSubjectAID("", idNow) + var domainErr *Error + if !errors.As(err, &domainErr) || domainErr.Code != "LEI_PRESENTATION_INVALID" { + t.Fatalf("want LEI_PRESENTATION_INVALID, got %v", err) + } + if v.SubjectAID != "" { + t.Fatalf("subject AID should remain unset, got %q", v.SubjectAID) + } + + // A non-empty AID pins the field and stamps UpdatedAt. + later := idNow.Add(time.Minute) + if err := v.SetSubjectAID("EAID123", later); err != nil { + t.Fatalf("SetSubjectAID: %v", err) + } + if v.SubjectAID != "EAID123" { + t.Fatalf("subject AID = %q, want EAID123", v.SubjectAID) + } + if !v.UpdatedAt.Equal(later.UTC()) { + t.Fatalf("UpdatedAt = %v, want %v", v.UpdatedAt, later.UTC()) + } +} diff --git a/internal/port/leiverifier.go b/internal/port/leiverifier.go new file mode 100644 index 0000000..73fe7b9 --- /dev/null +++ b/internal/port/leiverifier.go @@ -0,0 +1,66 @@ +package port + +import "context" + +// PresentationResult is what a vLEI presentation yields once the +// verifier has parsed the full-chain CESR export: the holder's subject +// AID (derived FROM the presentation, never caller-asserted — the +// §3.6 pinning rule), the credential's authorized LEI, and the +// verifier's register-time authorization status. +// +// Status is advisory: it records what the verifier saw at present +// time. The authoritative gate is the LIVE Authorization re-check at +// verify-control — a credential may age out of authorization (the +// vlei-verifier's TimeoutAuth window) between register and prove. +type PresentationResult struct { + // SubjectAID is the holder AID the verifier extracted from the + // presentation. The RA pins it on the identity aggregate; the + // caller never supplies it. + SubjectAID string + // LEI is the LEI the presented credential authorizes the AID for. + LEI string + // Status is "AUTHORIZED" or "PENDING". + Status string +} + +// AuthorizationResult is the verifier's LIVE view of an AID's vLEI +// authorization at verify-control time. +type AuthorizationResult struct { + // Authorized reports whether the AID currently holds a valid, + // unexpired vLEI authorization. + Authorized bool + // LEI is the authorized LEI, when the verifier asserts one. The + // noop adapter waives the AID↔LEI binding and returns "" — the + // service then skips the LEI-equality assertion (the documented + // noop waiver, mirroring noop-DNS waiving the zone binding). + LEI string +} + +// LEIControlVerifier is the outbound port for the GLEIF / vlei-verifier +// interaction behind the lei (vLEI) identifier kind. lei is NOT a DID +// method, so it does not ride port.DIDResolver; it gets its own port, +// following the DNS/DID precedent — a noop adapter for the quickstart +// and a real adapter selected by config (`vlei.type: noop | verifier`). +// +// The RA never parses CESR/KERI: the verifier is the authoritative +// key-state oracle. Present reports the subject AID, Authorization +// re-checks live authorization, and VerifySignature owns the KEL/key +// state used to check the registrant's signature. +type LEIControlVerifier interface { + // Present submits the full-chain CESR export to the verifier and + // returns the parsed subject AID + authorized LEI + presentation + // status. The subject AID is derived from the presentation, never + // caller-asserted. + Present(ctx context.Context, cesr string) (PresentationResult, error) + + // Authorization reports the verifier's LIVE authorization for the + // AID (re-checked on every verify-control; the register-time + // status is advisory). + Authorization(ctx context.Context, subjectAID string) (AuthorizationResult, error) + + // VerifySignature checks that `signature` over `signingInput` was + // produced by the AID's current signing key (the verifier owns the + // KEL/key state); an error signals an + // I/O or protocol failure reaching the verifier. + VerifySignature(ctx context.Context, subjectAID, signingInput, signature string) (bool, error) +} diff --git a/internal/ra/handler/identity.go b/internal/ra/handler/identity.go index 7dc1d8f..83b3333 100644 --- a/internal/ra/handler/identity.go +++ b/internal/ra/handler/identity.go @@ -31,6 +31,15 @@ func NewIdentityHandler(svc *service.IdentityService) *IdentityHandler { // — never caller-asserted. type identityRegisterRequest struct { Value string `json:"value"` + // VLEIPresentation carries the lei full-chain CESR export at + // register time (the credential + KELs). Set only for lei; the + // JWS kinds omit it. + VLEIPresentation *vleiPresentationDTO `json:"vleiPresentation,omitempty"` +} + +// vleiPresentationDTO is the lei register-time credential presentation. +type vleiPresentationDTO struct { + CESR string `json:"cesr"` } // identityChallengeDTO is one entry of the 202 challenge list. @@ -42,13 +51,17 @@ type identityChallengeDTO struct { // identityChallengeResponse is the 202 body returned by register and // rotate: the identity's id plus the challenge round to sign. type identityChallengeResponse struct { - IdentityID string `json:"identityId"` - Kind string `json:"kind"` - Value string `json:"value"` - Status string `json:"status"` - Nonce string `json:"nonce"` - ExpiresAt string `json:"expiresAt"` - Challenges []identityChallengeDTO `json:"challenges"` + IdentityID string `json:"identityId"` + Kind string `json:"kind"` + Value string `json:"value"` + Status string `json:"status"` + // PresentationStatus is the lei register-time advisory status + // ("AUTHORIZED" | "PENDING"); omitted for kinds with no + // register-time presentation (did:web, did:key). + PresentationStatus string `json:"presentationStatus,omitempty"` + Nonce string `json:"nonce"` + ExpiresAt string `json:"expiresAt"` + Challenges []identityChallengeDTO `json:"challenges"` } // verifyControlRequest is the POST .../verify-control body. Members @@ -61,6 +74,10 @@ type verifyControlRequest struct { // SignedProofs — one compact JWS per proven key, every payload // equal to the served signingInput verbatim. SignedProofs []string `json:"signedProofs"` + // CESRSignature — the lei proof: one CESR signature over the + // served signingInput by the subject AID's current key. Set only + // for lei. + CESRSignature string `json:"cesrSignature"` } // identityDetailResponse is the identity object echoed by @@ -107,7 +124,7 @@ func (h *IdentityHandler) Register(w http.ResponseWriter, r *http.Request) { WriteError(w, domain.NewValidationError("INVALID_IDENTIFIER", "value is required")) return } - res, err := h.svc.Register(r.Context(), providerID, req.Value) + res, err := h.svc.Register(r.Context(), providerID, req.Value, req.registerOptions()) if err != nil { WriteError(w, err) return @@ -115,6 +132,16 @@ func (h *IdentityHandler) Register(w http.ResponseWriter, r *http.Request) { WriteJSON(w, http.StatusAccepted, toChallengeResponse(res)) } +// registerOptions maps the kind-specific request members to the +// service's additive RegisterOptions. Empty for non-lei kinds. +func (req identityRegisterRequest) registerOptions() service.RegisterOptions { + var opt service.RegisterOptions + if req.VLEIPresentation != nil { + opt.VLEIPresentation = req.VLEIPresentation.CESR + } + return opt +} + // Rotate handles PUT /v2/ans/identities/{identityId} → 202 + fresh // challenges over the staged replacement. func (h *IdentityHandler) Rotate(w http.ResponseWriter, r *http.Request) { @@ -131,7 +158,7 @@ func (h *IdentityHandler) Rotate(w http.ResponseWriter, r *http.Request) { WriteError(w, domain.NewValidationError("INVALID_IDENTIFIER", "value is required")) return } - res, err := h.svc.Rotate(r.Context(), providerID, chi.URLParam(r, "identityId"), req.Value) + res, err := h.svc.Rotate(r.Context(), providerID, chi.URLParam(r, "identityId"), req.Value, req.registerOptions()) if err != nil { WriteError(w, err) return @@ -153,7 +180,7 @@ func (h *IdentityHandler) VerifyControl(w http.ResponseWriter, r *http.Request) return } identity, err := h.svc.VerifyControl(r.Context(), providerID, chi.URLParam(r, "identityId"), - service.ProofSubmission{SignedProofs: req.SignedProofs}) + service.ProofSubmission{SignedProofs: req.SignedProofs, CESRSignature: req.CESRSignature}) if err != nil { WriteError(w, err) return @@ -280,13 +307,14 @@ func toChallengeResponse(res *service.IdentityChallengeResponse) identityChallen challenges = append(challenges, identityChallengeDTO{Kid: c.Kid, SigningInput: c.SigningInput}) } return identityChallengeResponse{ - IdentityID: res.Identity.IdentityID, - Kind: string(res.Identity.Kind), - Value: res.Identity.EffectiveValue(), - Status: string(res.Identity.Status), - Nonce: res.Nonce, - ExpiresAt: res.ExpiresAt.UTC().Format(time.RFC3339), - Challenges: challenges, + IdentityID: res.Identity.IdentityID, + Kind: string(res.Identity.Kind), + Value: res.Identity.EffectiveValue(), + Status: string(res.Identity.Status), + PresentationStatus: res.PresentationStatus, + Nonce: res.Nonce, + ExpiresAt: res.ExpiresAt.UTC().Format(time.RFC3339), + Challenges: challenges, } } diff --git a/internal/ra/handler/identity_handler_test.go b/internal/ra/handler/identity_handler_test.go index a4f241e..5d3629a 100644 --- a/internal/ra/handler/identity_handler_test.go +++ b/internal/ra/handler/identity_handler_test.go @@ -26,6 +26,7 @@ import ( "github.com/godaddy/ans/internal/adapter/cert" "github.com/godaddy/ans/internal/adapter/didresolver" "github.com/godaddy/ans/internal/adapter/eventbus" + "github.com/godaddy/ans/internal/adapter/leiverifier" "github.com/godaddy/ans/internal/adapter/store/sqlite" anscrypto "github.com/godaddy/ans/internal/crypto" "github.com/godaddy/ans/internal/domain" @@ -87,6 +88,8 @@ func newIdentityHTTPFixture(t *testing.T) *identityHTTPFixture { agents, didresolver.NewNoopResolver(), okSealer{}, + leiverifier.NewNoop(), + outbox, db, ).WithChallengeTTL(30 * time.Minute) diff --git a/internal/ra/service/identity.go b/internal/ra/service/identity.go index f9c38a3..48ae02b 100644 --- a/internal/ra/service/identity.go +++ b/internal/ra/service/identity.go @@ -71,6 +71,11 @@ type IdentityChallengeResponse struct { Nonce string ExpiresAt time.Time Challenges []ProofChallenge + // PresentationStatus is the advisory register-time status a kind + // with a register-time presentation reports ("AUTHORIZED" | + // "PENDING"); empty for kinds without one (did:web, did:key). The + // handler emits it on the 202 only when set. + PresentationStatus string } // IdentityService owns the Verified Identity lifecycle: register → @@ -109,13 +114,14 @@ func NewIdentityService( agents port.AgentStore, resolver port.DIDResolver, sealer IdentityEventSealer, + leiCtl port.LEIControlVerifier, uow port.UnitOfWork, ) *IdentityService { return &IdentityService{ identities: identities, links: links, agents: agents, - verifiers: newControlVerifiers(resolver), + verifiers: newControlVerifiers(resolver, leiCtl), sealer: sealer, uow: uow, challengeTTL: time.Hour, @@ -223,7 +229,8 @@ func (s *IdentityService) verifierFor(kind domain.IdentifierKind) (controlVerifi // superseded). IDENTIFIER_DUPLICATE is reserved for genuine // conflicts: already VERIFIED by this owner (rotate instead), or // proven by another owner. -func (s *IdentityService) Register(ctx context.Context, providerID, rawValue string) (*IdentityChallengeResponse, error) { +func (s *IdentityService) Register(ctx context.Context, providerID, rawValue string, opts ...RegisterOptions) (*IdentityChallengeResponse, error) { + opt := firstRegisterOption(opts) if providerID == "" { return nil, domain.NewValidationError("INVALID_PROVIDER_ID", "authenticated owner is required") } @@ -249,7 +256,7 @@ func (s *IdentityService) Register(ctx context.Context, providerID, rawValue str "identifier is already verified by this owner; rotate it with PUT instead") case err == nil: // PENDING_CONTROL → idempotent re-challenge on the same row. - return s.challenge(ctx, existing, now, false) + return s.challenge(ctx, existing, now, false, opt) case errors.Is(err, domain.ErrNotFound): // fall through to creation default: @@ -274,14 +281,25 @@ func (s *IdentityService) Register(ctx context.Context, providerID, rawValue str if err != nil { return nil, err } - return s.challenge(ctx, identity, now, true) + return s.challenge(ctx, identity, now, true, opt) +} + +// firstRegisterOption collapses the variadic options to a single +// value: the additive register material is at most one struct, so a +// caller passes zero (non-lei kinds, all current callers) or one. +func firstRegisterOption(opts []RegisterOptions) RegisterOptions { + if len(opts) > 0 { + return opts[0] + } + return RegisterOptions{} } // Rotate stages a same-kind replacement (§4.2 PUT) and returns fresh // challenges over it. The previously sealed state stands until the // new proof lands; a replacement that never verifies expires with its // nonce. -func (s *IdentityService) Rotate(ctx context.Context, providerID, identityID, rawValue string) (*IdentityChallengeResponse, error) { +func (s *IdentityService) Rotate(ctx context.Context, providerID, identityID, rawValue string, opts ...RegisterOptions) (*IdentityChallengeResponse, error) { + opt := firstRegisterOption(opts) if !s.limiter.Allow(providerID, s.clock()) { return nil, domain.NewValidationError("RATE_LIMITED", "too many identity register/rotate calls; retry later") @@ -294,7 +312,7 @@ func (s *IdentityService) Rotate(ctx context.Context, providerID, identityID, ra if err := identity.StageRotation(rawValue, now); err != nil { return nil, err } - return s.challenge(ctx, identity, now, false) + return s.challenge(ctx, identity, now, true, opt) } // challenge mints a fresh nonce on the identity, runs the kind's @@ -307,7 +325,24 @@ func (s *IdentityService) Rotate(ctx context.Context, providerID, identityID, ra // load and persist spans seconds, and a blind upsert here could // clobber a verify or revoke that committed in that window (status // regression = a different owner could then take the identifier). -func (s *IdentityService) challenge(ctx context.Context, identity *domain.VerifiedIdentity, now time.Time, isNew bool) (*IdentityChallengeResponse, error) { +func (s *IdentityService) challenge(ctx context.Context, identity *domain.VerifiedIdentity, now time.Time, isNew bool, opt RegisterOptions) (*IdentityChallengeResponse, error) { + verifier, err := s.verifierFor(identity.Kind) + if err != nil { + return nil, err + } + + // Kinds carrying a register-time presentation (lei) submit it to + // their verifier here, pinning the verifier-derived subject AID on + // the aggregate before the challenge enumerates it. Discovered by + // capability — non-presentation kinds skip this entirely. + var presentationStatus string + if pr, ok := verifier.(presentationRegistrar); ok { + presentationStatus, err = pr.RegisterPresentation(ctx, identity, opt, now) + if err != nil { + return nil, err + } + } + // Load-time snapshot for the conditional persist. expectedStatus := identity.Status expectedNonce := "" @@ -336,10 +371,6 @@ func (s *IdentityService) challenge(ctx context.Context, identity *domain.Verifi return nil, domain.NewInternalError("CHALLENGE_GENERATION", "could not build signing input", err) } - verifier, err := s.verifierFor(identity.Kind) - if err != nil { - return nil, err - } challenges, err := verifier.Challenges(ctx, identity, signingInput) if err != nil { return nil, err @@ -355,10 +386,11 @@ func (s *IdentityService) challenge(ctx context.Context, identity *domain.Verifi } } return &IdentityChallengeResponse{ - Identity: identity, - Nonce: nonce, - ExpiresAt: identity.Challenge.ExpiresAt, - Challenges: challenges, + Identity: identity, + PresentationStatus: presentationStatus, + Nonce: nonce, + ExpiresAt: identity.Challenge.ExpiresAt, + Challenges: challenges, }, nil } diff --git a/internal/ra/service/identity_lei_test.go b/internal/ra/service/identity_lei_test.go new file mode 100644 index 0000000..7cd03af --- /dev/null +++ b/internal/ra/service/identity_lei_test.go @@ -0,0 +1,226 @@ +package service_test + +// lei (vLEI) lane tests for IdentityService: the register-time +// presentation (subject-AID pinning + LEI reconciliation), the single +// CESR-signature control proof, the live re-authorization at +// verify-control, and the AID+thumbprint seal. A programmable fake +// LEIControlVerifier stands in for the noop/real adapters so every +// failure code is reachable deterministically. + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "testing" + + "github.com/godaddy/ans/internal/domain" + "github.com/godaddy/ans/internal/port" + "github.com/godaddy/ans/internal/ra/service" + identityevent "github.com/godaddy/ans/internal/tl/event/identity" +) + +// the LEI the lei tests register; a valid 20-char LEI so the kind infers. +const testLEI = "5493001KJTIIGC8Y1R17" + +// fakeLEI is a programmable port.LEIControlVerifier. +type fakeLEI struct { + present port.PresentationResult + presentErr error + auth port.AuthorizationResult + authErr error + verifyOK bool + verifyErr error +} + +func (f *fakeLEI) Present(context.Context, string) (port.PresentationResult, error) { + return f.present, f.presentErr +} + +func (f *fakeLEI) Authorization(context.Context, string) (port.AuthorizationResult, error) { + return f.auth, f.authErr +} + +func (f *fakeLEI) VerifySignature(context.Context, string, string, string) (bool, error) { + return f.verifyOK, f.verifyErr +} + +// authorizedFakeLEI is the all-clear fake: presents the testLEI for AID +// "EHolderAID", authorizes it, and verifies any signature. +func authorizedFakeLEI() *fakeLEI { + return &fakeLEI{ + present: port.PresentationResult{SubjectAID: "EHolderAID", LEI: testLEI, Status: "AUTHORIZED"}, + auth: port.AuthorizationResult{Authorized: true, LEI: testLEI}, + verifyOK: true, + } +} + +func leiCode(t *testing.T, err error, want string) { + t.Helper() + var de *domain.Error + if !errors.As(err, &de) || de.Code != want { + t.Fatalf("want %s, got %v", want, err) + } +} + +func TestIdentityLifecycle_LEIHappy(t *testing.T) { + t.Parallel() + fx := newIdentityFixtureWithLEI(t, nil, authorizedFakeLEI()) + ctx := context.Background() + + // Register with the presentation → 202 with the advisory status and + // the single AID-kid challenge. + res, err := fx.svc.Register(ctx, fx.providerID, testLEI, + service.RegisterOptions{VLEIPresentation: "full-chain-cesr-blob"}) + if err != nil { + t.Fatalf("register: %v", err) + } + if res.Identity.Kind != domain.KindLEI { + t.Fatalf("kind = %s, want lei", res.Identity.Kind) + } + if res.PresentationStatus != "AUTHORIZED" { + t.Fatalf("presentation status = %q", res.PresentationStatus) + } + if len(res.Challenges) != 1 || res.Challenges[0].Kid != "EHolderAID" || res.Challenges[0].SigningInput == "" { + t.Fatalf("challenge shape: %+v", res.Challenges) + } + + // verify-control with the CESR signature → VERIFIED, seal emitted. + identity, err := fx.svc.VerifyControl(ctx, fx.providerID, res.Identity.IdentityID, + service.ProofSubmission{CESRSignature: "0Bcesr-signature-bytes"}) + if err != nil { + t.Fatalf("verify-control: %v", err) + } + if identity.Status != domain.IdentityVerified || identity.ProofMethod != "lei-vlei-acdc" { + t.Fatalf("verified state: %+v", identity) + } + + rows := fx.drainOutbox(t) + if len(rows) != 1 { + t.Fatalf("outbox rows: %d", len(rows)) + } + inner := fx.decodeOutboxEvent(t, rows[0]) + if inner.EventType != identityevent.TypeIdentityVerified || len(inner.Keys) != 1 { + t.Fatalf("sealed event: %+v", inner) + } + key := inner.Keys[0] + // The lei seal commits the subject AID as the id, a vLEI-KERI-AID + // type, and a base64url(SHA-256(AID)) thumbprint — no JWK, no doc. + if key.ID() != "EHolderAID" || key.SignedProof != "0Bcesr-signature-bytes" { + t.Fatalf("sealed key: id=%q proof=%q", key.ID(), key.SignedProof) + } + var vm struct { + ID string `json:"id"` + Type string `json:"type"` + Thumbprint string `json:"thumbprint"` + } + if err := json.Unmarshal(key.VerificationMethod, &vm); err != nil { + t.Fatalf("verification method not an object: %v", err) + } + wantThumb := base64.RawURLEncoding.EncodeToString(sha256Sum("EHolderAID")) + if vm.ID != "EHolderAID" || vm.Type != "vLEI-KERI-AID" || vm.Thumbprint != wantThumb { + t.Fatalf("sealed verification method: %+v", vm) + } +} + +func sha256Sum(s string) []byte { + sum := sha256.Sum256([]byte(s)) + return sum[:] +} + +func TestIdentityRegister_LEIFailures(t *testing.T) { + t.Parallel() + ctx := context.Background() + + t.Run("presentation required", func(t *testing.T) { + fx := newIdentityFixtureWithLEI(t, nil, authorizedFakeLEI()) + _, err := fx.svc.Register(ctx, fx.providerID, testLEI) + leiCode(t, err, "IDENTIFIER_PRESENTATION_REQUIRED") + }) + + t.Run("present error propagates", func(t *testing.T) { + fake := &fakeLEI{presentErr: domain.NewInternalError("LEI_VERIFIER_UNAVAILABLE", "down", nil)} + fx := newIdentityFixtureWithLEI(t, nil, fake) + _, err := fx.svc.Register(ctx, fx.providerID, testLEI, service.RegisterOptions{VLEIPresentation: "x"}) + leiCode(t, err, "LEI_VERIFIER_UNAVAILABLE") + }) + + t.Run("no subject AID", func(t *testing.T) { + fake := &fakeLEI{present: port.PresentationResult{LEI: testLEI, Status: "AUTHORIZED"}} + fx := newIdentityFixtureWithLEI(t, nil, fake) + _, err := fx.svc.Register(ctx, fx.providerID, testLEI, service.RegisterOptions{VLEIPresentation: "x"}) + leiCode(t, err, "LEI_PRESENTATION_INVALID") + }) + + t.Run("lei mismatch at presentation", func(t *testing.T) { + fake := &fakeLEI{present: port.PresentationResult{SubjectAID: "EAID", LEI: "OTHERLEI000000000000", Status: "AUTHORIZED"}} + fx := newIdentityFixtureWithLEI(t, nil, fake) + _, err := fx.svc.Register(ctx, fx.providerID, testLEI, service.RegisterOptions{VLEIPresentation: "x"}) + leiCode(t, err, "LEI_MISMATCH") + }) +} + +// registerLEI drives a lei identity to PENDING_CONTROL and returns the +// challenge response, for the verify-control failure cases. +func registerLEI(t *testing.T, fx *identityFixture) *service.IdentityChallengeResponse { + t.Helper() + res, err := fx.svc.Register(context.Background(), fx.providerID, testLEI, + service.RegisterOptions{VLEIPresentation: "x"}) + if err != nil { + t.Fatalf("register: %v", err) + } + return res +} + +func TestIdentityVerifyControl_LEIFailures(t *testing.T) { + t.Parallel() + ctx := context.Background() + + t.Run("missing cesr signature", func(t *testing.T) { + fx := newIdentityFixtureWithLEI(t, nil, authorizedFakeLEI()) + res := registerLEI(t, fx) + _, err := fx.svc.VerifyControl(ctx, fx.providerID, res.Identity.IdentityID, service.ProofSubmission{}) + leiCode(t, err, "IDENTIFIER_PROOF_INVALID") + }) + + t.Run("not authorized", func(t *testing.T) { + fake := authorizedFakeLEI() + fake.auth = port.AuthorizationResult{Authorized: false} + fx := newIdentityFixtureWithLEI(t, nil, fake) + res := registerLEI(t, fx) + _, err := fx.svc.VerifyControl(ctx, fx.providerID, res.Identity.IdentityID, + service.ProofSubmission{CESRSignature: "0Bsig"}) + leiCode(t, err, "LEI_NOT_AUTHORIZED") + }) + + t.Run("authorization lei mismatch", func(t *testing.T) { + fake := authorizedFakeLEI() + fake.auth = port.AuthorizationResult{Authorized: true, LEI: "OTHERLEI000000000000"} + fx := newIdentityFixtureWithLEI(t, nil, fake) + res := registerLEI(t, fx) + _, err := fx.svc.VerifyControl(ctx, fx.providerID, res.Identity.IdentityID, + service.ProofSubmission{CESRSignature: "0Bsig"}) + leiCode(t, err, "LEI_MISMATCH") + }) + + t.Run("signature does not verify", func(t *testing.T) { + fake := authorizedFakeLEI() + fake.verifyOK = false + fx := newIdentityFixtureWithLEI(t, nil, fake) + res := registerLEI(t, fx) + _, err := fx.svc.VerifyControl(ctx, fx.providerID, res.Identity.IdentityID, + service.ProofSubmission{CESRSignature: "0Bsig"}) + leiCode(t, err, "PRICC_SIGNATURE_INVALID") + }) + + t.Run("verify error propagates", func(t *testing.T) { + fake := authorizedFakeLEI() + fake.verifyErr = domain.NewInternalError("LEI_VERIFIER_UNAVAILABLE", "down", nil) + fx := newIdentityFixtureWithLEI(t, nil, fake) + res := registerLEI(t, fx) + _, err := fx.svc.VerifyControl(ctx, fx.providerID, res.Identity.IdentityID, + service.ProofSubmission{CESRSignature: "0Bsig"}) + leiCode(t, err, "LEI_VERIFIER_UNAVAILABLE") + }) +} diff --git a/internal/ra/service/identity_test.go b/internal/ra/service/identity_test.go index bdc2f10..691283b 100644 --- a/internal/ra/service/identity_test.go +++ b/internal/ra/service/identity_test.go @@ -25,6 +25,7 @@ import ( "github.com/godaddy/ans/internal/adapter/didresolver" "github.com/godaddy/ans/internal/adapter/keymanager" + "github.com/godaddy/ans/internal/adapter/leiverifier" "github.com/godaddy/ans/internal/adapter/store/sqlite" anscrypto "github.com/godaddy/ans/internal/crypto" "github.com/godaddy/ans/internal/domain" @@ -103,8 +104,15 @@ type fakeClock struct{ now time.Time } func (c *fakeClock) Now() time.Time { return c.now } // newIdentityFixture wires the service against real SQLite + the -// given resolver (nil → noop). +// given resolver (nil → noop), with the noop lei verifier. func newIdentityFixture(t *testing.T, resolver port.DIDResolver) *identityFixture { + return newIdentityFixtureWithLEI(t, resolver, leiverifier.NewNoop()) +} + +// newIdentityFixtureWithLEI is newIdentityFixture with an injectable lei +// control verifier — the lei lane tests drive a programmable fake to +// reach every failure code deterministically. +func newIdentityFixtureWithLEI(t *testing.T, resolver port.DIDResolver, lei port.LEIControlVerifier) *identityFixture { t.Helper() db, err := sqlite.Open(context.Background(), ":memory:") if err != nil { @@ -135,6 +143,7 @@ func newIdentityFixture(t *testing.T, resolver port.DIDResolver) *identityFixtur sqlite.NewAgentStore(db), resolver, sealer, + lei, db, ).WithSigner(service.EventSigner{ KeyManager: km, @@ -323,10 +332,11 @@ func TestIdentityRegister_Rejections(t *testing.T) { !strings.Contains(err.Error(), "IDENTIFIER_KIND_UNSUPPORTED") { t.Errorf("bogus value: %v", err) } - // lei is recognized but postponed. + // lei is now enabled: a register with no presentation fails on the + // missing CESR, not on the kind being unsupported. if _, err := fx.svc.Register(ctx, fx.providerID, "5493001KJTIIGC8Y1R17"); err == nil || - !strings.Contains(err.Error(), "IDENTIFIER_KIND_UNSUPPORTED") { - t.Errorf("lei: %v", err) + !strings.Contains(err.Error(), "IDENTIFIER_PRESENTATION_REQUIRED") { + t.Errorf("lei without presentation: %v", err) } } @@ -1420,6 +1430,7 @@ func TestNilSealerFailsClosed(t *testing.T) { sqlite.NewAgentStore(db), didresolver.NewNoopResolver(), nil, // no sealer + leiverifier.NewNoop(), db, ) ctx := context.Background() diff --git a/internal/ra/service/identitykinds.go b/internal/ra/service/identitykinds.go index 19f7bad..45de40c 100644 --- a/internal/ra/service/identitykinds.go +++ b/internal/ra/service/identitykinds.go @@ -47,6 +47,7 @@ import ( "encoding/json" "fmt" "strings" + "time" anscrypto "github.com/godaddy/ans/internal/crypto" "github.com/godaddy/ans/internal/domain" @@ -62,6 +63,41 @@ type ProofSubmission struct { // and the future did:plc / did:ion): one compact JWS per proven // key, every payload equal to the served signingInput verbatim. SignedProofs []string + // CESRSignature is the lei (vLEI) proof: one CESR signature over + // the served signingInput, produced by the subject AID's current + // signing key. Set only for lei; the JWS kinds ignore it. + CESRSignature string +} + +// RegisterOptions carries the additive, per-kind material a register +// (or rotate) call may need beyond the identifier value. It is empty +// for kinds with no register-time presentation (did:web, did:key); +// lei populates VLEIPresentation. Additive by design — a new kind adds +// a member here, existing callers pass the zero value. +type RegisterOptions struct { + // VLEIPresentation is the lei full-chain CESR export submitted to + // the vlei-verifier at register time (the credential + KELs). The + // verifier derives the subject AID from it; the caller never + // asserts the AID. + VLEIPresentation string +} + +// presentationRegistrar is the optional capability a kind implements +// when it carries credential material at REGISTER time (lei's vLEI +// presentation). The service discovers it by type-assertion on the +// kind's controlVerifier — the same discover-by-capability pattern the +// 202 response uses for presentationStatus — so kinds with no +// register-time presentation (did:web, did:key) never grow a dead +// method. RegisterPresentation runs inside the shared challenge path, +// so an idempotent re-add (and a rotation) re-presents and refreshes +// the verifier's authorization window for free. +type presentationRegistrar interface { + // RegisterPresentation submits the kind's register-time credential + // material to its verifier, pins the derived subject identifier on + // the aggregate, reconciles it against the requested value, and + // returns the advisory presentation status ("AUTHORIZED" | + // "PENDING") for the 202 body. + RegisterPresentation(ctx context.Context, identity *domain.VerifiedIdentity, opts RegisterOptions, now time.Time) (string, error) } // controlVerifier is the per-kind control-proof gate — the design's @@ -86,14 +122,16 @@ type controlVerifier interface { // did:ion, and did:ethr slot in here when their verifiers are real; // until then domain.InferIdentifierKind may recognize a value's form // but the missing registry entry yields IDENTIFIER_KIND_UNSUPPORTED. -func newControlVerifiers(resolver port.DIDResolver) map[domain.IdentifierKind]controlVerifier { +func newControlVerifiers(resolver port.DIDResolver, leiCtl port.LEIControlVerifier) map[domain.IdentifierKind]controlVerifier { // NOTE: deliberately NOT exhaustive over IdentifierKind — a - // recognized-but-absent kind (lei, until its vlei-verifier - // integration ships) MUST fail with IDENTIFIER_KIND_UNSUPPORTED - // rather than register a stub. The 404-is-the-signal rule. - return map[domain.IdentifierKind]controlVerifier{ //nolint:exhaustive // absence == kind not enabled, by design + // recognized-but-absent kind (did:plc, did:ion, did:ethr, until + // their verifiers ship) MUST fail with IDENTIFIER_KIND_UNSUPPORTED + // rather than register a stub. The 404-is-the-signal rule. lei + // now registers — its noop adapter is the zero-infra quickstart. + return map[domain.IdentifierKind]controlVerifier{ domain.KindDIDWeb: &didWebVerifier{resolver: resolver}, domain.KindDIDKey: &didKeyVerifier{}, + domain.KindLEI: &leiVerifier{v: leiCtl}, } } diff --git a/internal/ra/service/leiverifier.go b/internal/ra/service/leiverifier.go new file mode 100644 index 0000000..f7b2ffb --- /dev/null +++ b/internal/ra/service/leiverifier.go @@ -0,0 +1,164 @@ +package service + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/godaddy/ans/internal/domain" + "github.com/godaddy/ans/internal/port" + identityevent "github.com/godaddy/ans/internal/tl/event/identity" +) + +// leiVerifier is the lei (vLEI) controlVerifier — the per-kind gate +// behind the lei identifier kind. It owns no key state itself: every +// CESR/KERI question routes to the injected port.LEIControlVerifier +// (the noop quickstart adapter, or the real vlei-verifier HTTP client). +// +// lei differs from the JWS kinds in two structural ways, both absorbed +// behind the seam: +// +// - The credential presentation arrives at REGISTER time, so lei +// also implements presentationRegistrar: the shared challenge path +// submits the CESR, pins the verifier-derived subject AID on the +// aggregate, and reports the advisory presentation status. A +// re-add (or rotation) re-presents and refreshes the verifier's +// authorization window for free. +// - The proof is a single CESR signature over the served +// signingInput (not a JWS array), and the seal commits the subject +// AID + a thumbprint only — no JWK, no document. The KEL is the +// authoritative key history; the ACDC is PII. (event.go §lei seal +// exception.) +type leiVerifier struct { + v port.LEIControlVerifier +} + +// RegisterPresentation submits the register-time CESR to the verifier, +// pins the derived subject AID, and reconciles the credential's LEI +// against the requested value. Returns the advisory presentation +// status for the 202. +func (lv *leiVerifier) RegisterPresentation( + ctx context.Context, + identity *domain.VerifiedIdentity, + opts RegisterOptions, + now time.Time, +) (string, error) { + if opts.VLEIPresentation == "" { + return "", domain.NewValidationError("IDENTIFIER_PRESENTATION_REQUIRED", + "lei registration requires vleiPresentation.cesr") + } + res, err := lv.v.Present(ctx, opts.VLEIPresentation) + if err != nil { + return "", err + } + if res.SubjectAID == "" { + return "", domain.NewValidationError("LEI_PRESENTATION_INVALID", + "the vlei verifier returned no subject AID for the presentation") + } + // Reconcile the presented LEI against the registered value. The + // noop adapter waives the AID↔LEI binding and returns an empty LEI + // (the documented quickstart waiver, mirroring noop-DNS); skip the + // equality check in that case. + if res.LEI != "" && !strings.EqualFold(res.LEI, identity.EffectiveValue()) { + return "", domain.NewValidationError("LEI_MISMATCH", + fmt.Sprintf("presented credential authorizes LEI %q, not %q", res.LEI, identity.EffectiveValue())) + } + if err := identity.SetSubjectAID(res.SubjectAID, now); err != nil { + return "", err + } + return res.Status, nil +} + +// Challenges returns the single lei challenge entry: the pinned +// subject AID as the kid, the served signingInput as the payload to +// sign. RegisterPresentation has already run (same challenge path), so +// the subject AID is pinned on the aggregate. +func (lv *leiVerifier) Challenges( + _ context.Context, + identity *domain.VerifiedIdentity, + signingInput string, +) ([]ProofChallenge, error) { + if identity.SubjectAID == "" { + return nil, domain.NewInternalError("LEI_SUBJECT_AID_MISSING", + "subject AID was not pinned before challenge", nil) + } + return []ProofChallenge{{Kid: identity.SubjectAID, SigningInput: signingInput}}, nil +} + +// VerifyProofs runs the lei control proof: a LIVE authorization +// re-check against the verifier (the register-time status is +// advisory), then a CESR signature verification over the served +// signingInput by the pinned subject AID's current key. Seals one +// ProvenKey = subject AID + thumbprint (no JWK, no document). +func (lv *leiVerifier) VerifyProofs( + ctx context.Context, + identity *domain.VerifiedIdentity, + sub ProofSubmission, + signingInput string, +) ([]identityevent.ProvenKey, error) { + if sub.CESRSignature == "" { + return nil, domain.NewValidationError("IDENTIFIER_PROOF_INVALID", "cesrSignature is required") + } + aid := identity.SubjectAID + if aid == "" { + return nil, domain.NewInvalidStateError("LEI_SUBJECT_AID_MISSING", + "no subject AID is pinned; re-register the identifier with its presentation") + } + + auth, err := lv.v.Authorization(ctx, aid) + if err != nil { + return nil, err + } + if !auth.Authorized { + return nil, domain.NewValidationError("LEI_NOT_AUTHORIZED", + "the vlei verifier does not currently authorize this AID") + } + if auth.LEI != "" && !strings.EqualFold(auth.LEI, identity.EffectiveValue()) { + return nil, domain.NewValidationError("LEI_MISMATCH", + fmt.Sprintf("the AID is authorized for LEI %q, not %q", auth.LEI, identity.EffectiveValue())) + } + + ok, err := lv.v.VerifySignature(ctx, aid, signingInput, sub.CESRSignature) + if err != nil { + return nil, err + } + if !ok { + return nil, domain.NewValidationError("PRICC_SIGNATURE_INVALID", + "the CESR signature does not verify against the subject AID's current key") + } + + vm, err := json.Marshal(map[string]string{ + "id": aid, + "type": "vLEI-KERI-AID", + "thumbprint": aidThumbprint(aid), + }) + if err != nil { + return nil, domain.NewInternalError("PROOF_SEAL", "could not build lei verification method", err) + } + return []identityevent.ProvenKey{{ + VerificationMethod: vm, + SignedProof: sub.CESRSignature, + }}, nil +} + +// aidThumbprint is the sealed key fingerprint for a lei proof: +// base64url(SHA-256(subjectAID)). The subject AID is itself a KERI +// self-addressing identifier (a digest of the holder's inception key +// state), so a hash over it is a stable, content-bound fingerprint — +// the AID+thumbprint pair is what the seal commits in lieu of a JWK +// (the KEL is the authoritative key history; see event.go §lei seal). +func aidThumbprint(aid string) string { + sum := sha256.Sum256([]byte(aid)) + return base64.RawURLEncoding.EncodeToString(sum[:]) +} + +// compile-time conformance: lei implements both the control gate and +// the register-time presentation capability. +var ( + _ controlVerifier = (*leiVerifier)(nil) + _ presentationRegistrar = (*leiVerifier)(nil) +) diff --git a/scripts/demo/start.sh b/scripts/demo/start.sh index 979a3e9..b809ecf 100755 --- a/scripts/demo/start.sh +++ b/scripts/demo/start.sh @@ -129,6 +129,19 @@ identity: type: ${ANS_IDENTITY_RESOLVER:-noop} challenge-ttl: 1h +vlei: + # The lei (vLEI) control verifier behind the "lei" identifier kind. + # "noop" runs real Ed25519 crypto but waives the GLEIF authorization + # binding; "verifier" routes CESR/KERI questions to a real + # vlei-verifier (scripts/demo/vlei brings one up on :7676). The + # base-url can't be set via ANS_RA_VLEI__BASE_URL — koanf maps a + # single underscore literally, so it would target vlei.base_url, not + # vlei.base-url — hence it is composed into the file here: + # ANS_VLEI_TYPE=verifier ANS_VLEI_BASE_URL=http://localhost:7676 \ + # scripts/demo/start.sh + type: ${ANS_VLEI_TYPE:-noop} + base-url: "${ANS_VLEI_BASE_URL:-}" + keys: type: file file: diff --git a/scripts/demo/vlei/README.md b/scripts/demo/vlei/README.md new file mode 100644 index 0000000..7f2a5c5 --- /dev/null +++ b/scripts/demo/vlei/README.md @@ -0,0 +1,262 @@ +# vLEI ecosystem + identifier / `verify-control` demo + +This directory stands up a **local, self-contained** GLEIF/KERI stack so +a genuine AID holding a (self-issued) vLEI can be registered with the RA +on the identity-scoped routes — `POST /v2/ans/identities` (carrying the +full-chain CESR) + `.../verify-control` — and the RA can present it to, +and verify the AID-signed `signingInput` against, a **real +`vlei-verifier`**, seal the `IDENTITY_VERIFIED` event on the TL, and link +the verified `lei` identity to an agent. + +Proves the *RA integration* against the stock +GLEIF verifier, including the credential-chain / authorized-LEI check. + +## No genuine LEI/vLEI required + +The entire ecosystem is self-issued against a **synthetic local GLEIF +root** registered via `POST /root_of_trust/{aid}` (the verifier runs +with `VERIFIER_ENV=development`, `VERIFY_ROOT_OF_TRUST=True`). The LEI is +any well-formed 20-char string you choose — the verifier checks the +chain to *your* local root, not `api.gleif.org`. The RA's `ValidateLEI` +checks ISO-17442 **format only** (20-char `[A-Z0-9]`), so any well-formed +string works; `build-chain.ts` uses LEI `875500ELOZEL05BVXV37`. + +A genuine vLEI is needed *only* if you point the verifier at the real +GLEIF production root — out of scope here. + +## Notes +- **Everything is version-pinned for reproducibility.** GLEIF's + KERI/KERIA/vLEI images and the `vlei-verifier` config schema move + between releases, so the compose file pins every image to a known-good + tag (`weboftrust/keri:1.2.0-rc4`, `gleif/vlei:1.0.0`, + `gleif/keria:0.3.0`) and builds the verifier from the `0.1.5` source. + The verifier uses its own bundled `verifier-config-docker-local.json` + (keri-style `iurls`/`durls` pointing at `witness-demo` + `vlei-server`), + so there is no local config file to keep in sync. Bumping any pin means + re-confirming flags/endpoints and the config shape against that release. +- **The RA is the single touchpoint for the verifier.** The holder hands + the RA their full-chain credential CESR via the `vleiPresentation` of + `POST /v2/ans/identities`; the RA reads the leaf credential SAID out of + it (the only thing it parses — never KERI key state), presents the chain + to the verifier (`PUT /presentations/{said}`), reads the verifier-reported + subject AID, and pins it on the identity. The holder never calls the + verifier directly. The one bootstrap the RA can't do is registering the + *synthetic local* GLEIF root of trust — that is a one-time admin step + against the verifier (`build-chain.ts` does it). + +## Components (`docker-compose.yml`) + +| Service | Image | Ports | Role | +|---|---|---|---| +| `witnesses` | `weboftrust/keri:1.2.0-rc4` | 5642-5644 | KERI witness network (key-event logs) | +| `vlei-server` | `gleif/vlei:1.0.0` | 7723 | ACDC schema + OOBI server (LE/OOR/ECR/QVI) | +| `keria` | `gleif/keria:0.3.0` | 3901-3903 | KERIA edge agent for the holder (signify-ts) | +| `vlei-verifier` | built from source @ `0.1.5` | **7676** | the service `ans-ra` calls | +| `signify` | `denoland/deno:alpine-2.8.2` | — | runs `build-chain.ts` (build chain, present, export) and `sign-proof.ts` | + +> `vlei-verifier` is not published to any registry, so the compose file +> builds it from the pinned [`GLEIF-IT/vlei-verifier`](https://github.com/GLEIF-IT/vlei-verifier) +> `0.1.5` tag via a git build context. The first `up.sh` therefore runs a +> `docker build` (git clone + `pip install`) that takes a few minutes; later +> runs reuse the cached `ans-vlei-verifier:0.1.5` image. The verifier loads +> its **bundled** `verifier-config-docker-local.json` (selected via +> `VERIFIER_CONFIG_FILE`), which already points at this stack's `witness-demo` +> and `vlei-server` hostnames — there is no local config file to edit. + +> **The SignifyTS runner is self-contained.** The `signify` service is the +> stock `denoland/deno` image — no custom build. It bind-mounts +> [`signify/`](signify/) in this directory, which holds the demo's +> [`scripts_ts/`](signify/scripts_ts/): `build-chain.ts` (the trust-chain build, +> converted from the old `ans-vlei-verifier.ipynb`), `sign-proof.ts` (the +> standalone proof signer), and `utils.ts` (the SignifyTS helpers, vendored from +> [GLEIF-IT/vlei-trainings](https://github.com/GLEIF-IT/vlei-trainings)). The +> container is on the same Docker network as the rest of the stack and reaches +> `keria`, `vlei-server`, `witness-demo`, and `vlei-verifier` by service name — +> no external network / `docker network connect` required. On start it pre-caches +> the npm deps (`signify-ts`, `libsodium`) so the first `deno run` is fast. The +> exported artifacts land in [`signify/out/`](signify/out/) via the bind mount. +> When re-vendoring, re-pull `utils.ts` from the upstream repo. + +## End-to-end sequence + +### Prerequisites (the RA — once) + +The vLEI stack is self-contained, but the RA owns its own lifecycle, so two +RA-side steps happen first: + +1. **Enable the verifier wiring.** Set the `vlei:` block in + `config/ra-local.yaml` to the real verifier (it ships as `type: noop`): + ```yaml + vlei: + type: verifier + base-url: "http://localhost:7676" + ``` + The identity routes (`POST /v2/ans/identities`, `.../verify-control`, + `.../links`) are always registered — `did:web`/`did:key` use them too. + `vlei.type` only selects which control verifier backs the `lei` kind: + `noop` runs real Ed25519 crypto but waives the GLEIF authorization + binding (zero-infra quickstart), while `verifier` routes every + CESR/KERI question to this stack's `vlei-verifier`. The real end-to-end + flow below requires `type: verifier`. +2. **Start the RA and register an agent.** `scripts/demo/start.sh` starts + `ans-ra`; `scripts/demo/run-lifecycle.sh` registers an agent and writes its + id to `data/demo/vlei/last-agent-id`. That id is the `AGENT_ID` below. + +### Commands + +```bash +ANS_VLEI_TYPE=verifier ANS_VLEI_BASE_URL=http://localhost:7676 ./scripts/demo/start.sh +AGENT_ID=$(scripts/demo/register.sh --v2) +scripts/demo/vlei/run-vlei.sh +``` + +`run-vlei.sh` checks the RA is reachable, then chains the three steps below +with **no manual paste**: + +1. **`up.sh`** — build + start the whole stack (witnesses, vlei-server, KERIA, + vlei-verifier, signify Deno runner) on one Docker network; wait for the + verifier's `/health` (`:7676`) and for the `signify` container to finish + pre-caching its deps. The first run includes a `docker build` of the + verifier image and the signify container's npm-dep download, which together + take a few minutes; later runs reuse the cached image and deps. +2. **`build-chain.sh`** — run [`build-chain.ts`](signify/scripts_ts/build-chain.ts) + via `deno run` in the `signify` container. It builds the synthetic + `GLEIF → QVI(delegated) → LE → ECR` chain, issues the ECR to the `role` + holder AID (LEI `875500ELOZEL05BVXV37`), presents it via IPEX, **registers + the local `gleif` root of trust** at the verifier, and exports two files into + the bind-mounted [`signify/out/`](signify/out/) dir: + - `ecr-presentation.json` — `{cesr, lei, aid}` the shell hands to the RA; + - `tier1-outputs.json` — carries the holder's `roleBran` for the signer. +3. **`verify-control-demo.sh`** (invoked with `AUTO_SIGN=1` and `DATA` pointed + at `build-chain.ts`'s exports) — the RA-mediated register + verify-control flow: + - `POST /v2/ans/identities { value: , vleiPresentation:{ cesr } }` — the + RA reads the leaf SAID, presents the chain to the verifier, pins the + verifier-reported subject AID, and returns the challenge round (nonce + + `signingInput`) plus the advisory `presentationStatus`; + - **auto-sign** — runs [`sign-proof.ts`](signify/scripts_ts/sign-proof.ts) + inside the `signify` container, reconstructing `roleClient` from the + exported `roleBran` and signing the served `signingInput` with the `role` + AID's KERIA-held keys; + - **re-present** — re-POST the same body while `PENDING_CONTROL` to refresh + the verifier's authorization window (same `identityId`, fresh nonce); + - `POST .../verify-control { cesrSignature }` — **no aid in the body**; the + RA pins the signer AID to the identity → expects `status: VERIFIED`, + then polls the TL for the sealed `IDENTITY_VERIFIED`; + - `POST .../links { agentIds: [ ] }` then + `GET /v2/ans/agents/` → the computed `identities[]` badge + carries the verified `lei` identity. + +Add `--down` to tear the stack down after a successful run. + +### Step by step / fallback + +Each stage is runnable on its own: + +```bash +scripts/demo/vlei/up.sh # stack +scripts/demo/vlei/build-chain.sh # headless chain build + export +AGENT_ID= DATA=scripts/demo/vlei/signify/out \ + AUTO_SIGN=1 scripts/demo/vlei/verify-control-demo.sh # present + verify +scripts/demo/vlei/down.sh # tear down +``` + +**Manual signing fallback.** To sign by hand, run `verify-control-demo.sh` +**without** `AUTO_SIGN`: it prints the served `signingInput`, then prints the +exact `deno run … sign-proof.ts ` command to sign it +in the `signify` container. Run that, copy the printed signature (indexed Siger +qb64, e.g. `AAB…`), and paste it back at the prompt. You can also pass a +signature non-interactively with `SIGNED_PROOF=`. + +The `lei` control proof is uniform with the JWS kinds: every kind signs the +served `signingInput` (the base64url of the JCS-canonical `IdentityProofInput`, +which binds the nonce, the identity id, the identifier, and the proof purpose). +The RA forwards the signature to the verifier as `non_prefixed_digest = signingInput`. + +**The 10-minute authorization window.** The `vlei-verifier` ages credential +authorizations off after `TimeoutAuth = 600s`. `verify-control` re-checks +authorization **live** on every call, so a slow manual signing step can lapse +the window and surface as `LEI_NOT_AUTHORIZED` even with a valid signature. The +script re-presents the chain (the idempotent re-add) immediately before the +verify to reset the window; if you still hit it, just re-run. + +**Registering the root of trust by hand.** `build-chain.sh` registers it for +you; if the verifier was restarted (its DB is in-container and ephemeral) and +you need to re-register without re-running the chain build, the raw call is: +```bash +curl -X POST http://localhost:7676/root_of_trust/{gleifRootAID} \ + -H 'Content-Type: application/json' \ + -d '{"vlei":"","oobi":""}' +``` + +## Troubleshooting + +### Step 1 (QVI Credential) fails with `unknown AID` + +Symptom — the IPEX grant in *Step 1* dies with: + +``` +HTTP POST /identifiers/gleif/ipex/grant - 400 Bad Request +{"description": "attempt to send to unknown AID="} +``` + +and the keria container loops forever on: + +``` +ERROR eventing .processEscrowDelegables Kevery unescrow failed: No delegation seal found for event. +keri.kering.MissingDelegableApprovalError: No delegation seal found for event. +INFO routing .acceptReply Revery: escrowing without key state for signer on reply ... +``` + +**Cause.** A *previous* run approved the QVI delegation incorrectly — e.g. +older setup code using `identifiers().interact()` (which only anchors +the seal) instead of `delegations().approve()` (which also ingests the +QVI's `dip`). That leaves a QVI `dip` that can never be promoted, churning +in gleif's `delegables` escrow. Because `gleif` uses a **fixed bran** (so +the same gleif agent is reused on every run) and the compose file mounts +**no data volume** for keria (agent DBs live in the container's ephemeral +writable layer), this poison survives across runs — and a plain +`docker compose restart keria` does **not** clear it (same container, same +writable layer). + +**Fix.** Recreate the keria container to wipe all agent state, then re-run +the chain build: + +```bash +docker compose -f scripts/demo/vlei/docker-compose.yml \ + up -d --force-recreate keria +``` + +gleif comes back with the **same prefix** (it is deterministic from the +fixed bran), so any root-of-trust registration still matches. Then just +re-run `scripts/demo/vlei/build-chain.sh`. `build-chain.ts` already uses +`delegations().approve()` in a retry loop, so on a clean keria the +delegation completes and the QVI credential grants/admits cleanly. + +### Benign noise + +`Unverified loc scheme reply URL=… SAID=…` (a `/loc/scheme` reply, often +with a `dt` of `2024-12-31`) is **not** an error you need to chase — it is +transient witness-resolution churn that self-resolves once the witness KEL +loads. Only delegation/escrow errors (`MissingDelegableApprovalError`, +`escrowing without key state`) indicate the poisoned-agent state above. + +## Scope + +This implements the **RA-mediated register-with-presentation + +verify-control** flow for the `lei` identifier kind on the identity-scoped +`/v2/ans/identities` routes: + +- The RA is the single touchpoint for the verifier: `POST /v2/ans/identities` + carries the holder's full-chain CESR in `vleiPresentation`, the RA presents + it and pins the verifier-reported subject AID on the identity; + `.../verify-control` is a CESR-signature proof over the served `signingInput` + that **pins the signer AID to the identity** — never a request-body value. +- On a clean verify the RA seals an `IDENTITY_VERIFIED` event on the identity's + TL stream (the demo polls the TL identity audit for it), and `.../links` + binds the verified `lei` identity to an agent so it surfaces in the agent's + computed `identities[]` badge. +- The seal commits the subject AID + a thumbprint only (no JWK, no document): + the KEL-backed key state lives at the verifier, so a `lei` seal is **not** + offline-re-verifiable from the seal alone — the documented `lei` trust + boundary (`ans-verify` enforces the AID+thumbprint shape, not an offline + signature re-check). diff --git a/scripts/demo/vlei/build-chain.sh b/scripts/demo/vlei/build-chain.sh new file mode 100755 index 0000000..14443f3 --- /dev/null +++ b/scripts/demo/vlei/build-chain.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# +# Build the vLEI trust chain headless, inside the signify (Deno) container. +# +# Runs build-chain.ts: the trust-chain build, ECR issuance, IPEX present, local +# root-of-trust registration, and the holder-state export. No notebook/kernel — +# it is plain SignifyTS run by Deno. +# +# On success build-chain.ts has written, into the bind-mounted out dir +# (host: scripts/demo/vlei/signify/out/): +# - ecr-presentation.json {cesr, lei, aid} consumed by verify-control-demo.sh +# - tier1-outputs.json {roleBran, ...} consumed by the nonce signer +# +# Usage: +# scripts/demo/vlei/build-chain.sh +# +# Env overrides: +# COMPOSE docker compose command (default: "docker compose") +# SCRIPT build script path inside the container (default: scripts_ts/build-chain.ts) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../common.sh +. "$SCRIPT_DIR/../common.sh" + +COMPOSE="${COMPOSE:-docker compose}" +COMPOSE_FILE="$SCRIPT_DIR/docker-compose.yml" +SCRIPT="${SCRIPT:-scripts_ts/build-chain.ts}" +OUT_DIR="$SCRIPT_DIR/signify/out" + +require_cmd docker + +header "Building the vLEI trust chain headless — $SCRIPT" + +# Run the build script in the signify container. -A grants the file/net +# permissions the SignifyTS flow needs; the container is on the stack network so +# keria / vlei-server / witness-demo / vlei-verifier resolve by service name. +# shellcheck disable=SC2086 # COMPOSE may be a multi-word command +$COMPOSE -f "$COMPOSE_FILE" exec -T signify deno run -A "$SCRIPT" + +ok "build script executed" + +# Assert the exported artifacts landed on the host via the bind mount. +for f in ecr-presentation.json tier1-outputs.json; do + if [ ! -s "$OUT_DIR/$f" ]; then + fail "expected $OUT_DIR/$f to exist and be non-empty after the run — check the output above" + fi + ok "wrote $f" +done + +header "Chain build complete" +note "ecr-presentation.json + tier1-outputs.json are in $OUT_DIR" +note "next: $SCRIPT_DIR/verify-control-demo.sh (or run-vlei.sh for the full flow)" diff --git a/scripts/demo/vlei/docker-compose.yml b/scripts/demo/vlei/docker-compose.yml new file mode 100644 index 0000000..dc842bc --- /dev/null +++ b/scripts/demo/vlei/docker-compose.yml @@ -0,0 +1,206 @@ +# vLEI ecosystem for the ans verify-control demo. +# +# Brings up a self-contained, LOCAL GLEIF/KERI stack so a genuine AID +# holding a (self-issued) vLEI can sign the RA's nonce and the RA's +# verify-control endpoint can verify it against a real `vlei-verifier`. +# +# NO genuine LEI/vLEI is required: the whole chain is self-issued +# against a synthetic local GLEIF root registered via +# POST /root_of_trust/{aid} (VERIFIER_ENV=development). The LEI is any +# well-formed 20-char string; the verifier checks the chain to YOUR +# local root, not api.gleif.org. +# +# Image pinning: the sibling images are pinned to known-good tags for +# reproducibility (GLEIF's KERI/KERIA/vLEI images move fast and +# flag/endpoint shapes vary by release). The vlei-verifier image is NOT +# published to any registry — upstream builds it locally — so we build +# it from the pinned `0.1.5` source via a git build context and let it +# use its own bundled `verifier-config-docker-local.json`. See README.md. + +services: + # KERI witness network — irreducible: chain-walk needs the key-event + # logs. Exposes three demo witnesses (wan/wil/wes) on 5642-5644. + witnesses: + # Pinned to the same keripy the verifier is built on + # (verifier.dockerfile: FROM weboftrust/keri:1.2.0-rc4). `kli witness + # demo` mints the canonical wan/wil/wes demo AIDs the verifier's + # bundled config resolves at boot. + image: weboftrust/keri:1.2.0-rc4 + container_name: ans-vlei-witnesses + # The verifier's bundled verifier-config-docker-local.json resolves + # witness OOBIs at host `witness-demo`; alias this service to match. + networks: + default: + aliases: + - witness-demo + environment: + - PYTHONUNBUFFERED=1 + - PYTHONIOENCODING=UTF-8 + # `kli witness demo` reads each witness's config from + # scripts/keri/cf/main/.json (Configer headDirPath="scripts", + # WORKDIR /keripy). Without these files the witnesses advertise no + # reachable `curls`, so KERIA — in its own container — resolves their + # KELs but has "no http endpoint" to request receipts from, and AID + # inception hangs. These configs point each witness's curls at the + # `witness-demo` network alias so KERIA can reach them. + # Mounted read-write: keripy's Configer opens the config directory + # read-write (lock files), and a read-only mount makes it silently + # fall back to an empty ~/.keri/cf/main, leaving the witnesses with + # no curls again. + volumes: + - ./witness-config/main:/keripy/scripts/keri/cf/main + # weboftrust/keri's ENTRYPOINT is already `kli`; pass only its args. + command: + - witness + - demo + ports: + - "5642:5642" + - "5643:5643" + - "5644:5644" + # weboftrust/keri ships no curl; use the bundled python3 to probe the + # witness HTTP endpoint (127.0.0.1 to avoid IPv6-localhost refusal). + healthcheck: + test: ["CMD", "python3", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:5642/oobi',timeout=3).status==200 else 1)"] + interval: 5s + timeout: 3s + retries: 30 + + # vLEI schema server — serves the ACDC schemas (LE/OOR/ECR/QVI) that + # the credential chain is typed against, on 7723. + vlei-server: + image: gleif/vlei:1.0.0 + container_name: ans-vlei-server + environment: + - PYTHONUNBUFFERED=1 + # Use the image's default entrypoint command (vLEI-server -s /vLEI/schema + # -c /vLEI/credentials -o /vLEI/oobis). Overriding the paths breaks schema + # OOBI resolution — gleif/vlei:1.0.0 ships schemas under /vLEI/schema, and + # the verifier's durls fail to parse an empty body if they're not served. + ports: + - "7723:7723" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:7723/health"] + interval: 5s + timeout: 3s + retries: 30 + + # KERIA edge agent — hosts the holder's cloud agent so the signify scripts + # (build-chain.ts / sign-proof.ts) can incept the holder AID, resolve OOBIs, + # and receive/present credentials. Admin 3901, boot 3903. + keria: + image: gleif/keria:0.3.0 + container_name: ans-vlei-keria + environment: + - KERIA_CURLS=http://keria:3902/ + - PYTHONUNBUFFERED=1 + # gleif/keria's ENTRYPOINT is already `keria`; pass only its args. + # Mounted read-write: KERIA opens this config via + # configing.Configer(reopen=True), and a read-only mount makes it + # silently fall back to an empty ~/.keri/cf/keria.json — dropping the + # `iurls`. KERIA copies those iurls into every new agent's config at + # creation, which is how each agent bootstraps the witness endpoints. + # Without them, only AIDs whose client explicitly resolves the witness + # OOBIs can be incepted; the rest fail with "unknown witness". + volumes: + - ./keria-docker.json:/keria/config/keri/cf/keria.json + command: + - start + - --config-dir + - /keria/config + - --config-file + - keria + - --name + - agent + - --loglevel + - DEBUG + ports: + - "3901:3901" + - "3902:3902" + - "3903:3903" + depends_on: + witnesses: + condition: service_healthy + vlei-server: + condition: service_healthy + + # GLEIF vlei-verifier — the service ans-ra calls. development env so + # the local GLEIF root can be registered via POST /root_of_trust/{aid}; + # VERIFY_ROOT_OF_TRUST=True enables chain-to-root verification. + vlei-verifier: + # Not published to any registry — built from the pinned 0.1.5 source. + # The entrypoint reads VERIFIER_CONFIG_FILE and loads keri-style config + # from scripts/keri/cf/.json baked into the image; we point it at + # the bundled verifier-config-docker-local.json, which already targets + # our witness-demo + vlei-server hostnames. + build: + context: https://github.com/GLEIF-IT/vlei-verifier.git#0.1.5 + dockerfile: images/verifier.dockerfile + image: ans-vlei-verifier:0.1.5 + container_name: ans-vlei-verifier + environment: + # development env + VERIFY_ROOT_OF_TRUST enable POST /root_of_trust/{aid} + # so the local synthetic GLEIF root can be registered. + - VERIFIER_ENV=development + - VERIFY_ROOT_OF_TRUST=True + - VERIFIER_CONFIG_FILE=verifier-config-docker-local.json + - PYTHONUNBUFFERED=1 + ports: + - "7676:7676" + depends_on: + witnesses: + condition: service_healthy + vlei-server: + condition: service_healthy + # Built on weboftrust/keri (no curl); probe /health with python3. + healthcheck: + test: ["CMD", "python3", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:7676/health',timeout=3).status==200 else 1)"] + interval: 5s + timeout: 3s + retries: 30 + + # Deno / SignifyTS runner — executes build-chain.ts (build the synthetic vLEI + # trust chain, issue the ECR to the holder AID, present it, register the local + # GLEIF root of trust, export the artifacts) and sign-proof.ts (sign the RA's + # verify-control signingInput with the holder AID). No Jupyter/notebook layer — the + # demo logic is plain SignifyTS run by Deno. On the same default network as + # the rest of the stack, so the scripts reach `keria`, `vlei-server`, + # `witness-demo`, and `vlei-verifier` by service name — no external network / + # `docker network connect` required. + # + # Pinned to a known-good Deno (GLEIF's KERI/KERIA/vLEI images move fast and so + # do their SignifyTS-facing shapes); bump deliberately. The container idles on + # `tail -f /dev/null` so build-chain.sh / verify-control-demo.sh can + # `docker compose exec` `deno run` into it; the npm deps are pre-cached at + # start so the first exec is fast and a `/tmp/ready` marker gates up.sh. + signify: + image: denoland/deno:alpine-2.8.2 + container_name: ans-vlei-signify + working_dir: /app + environment: + # Persist Deno's module cache inside the container for the session so the + # second exec (sign-proof.ts) reuses build-chain.ts's downloads. + - DENO_DIR=/deno-dir + # Bind the signify dir so edits are live AND the exported artifacts + # (out/ecr-presentation.json, out/tier1-outputs.json) land on the host for + # the shell demo to consume. + volumes: + - ./signify:/app + # Pre-cache npm deps, drop a readiness marker, then idle so the container + # stays available for `docker compose exec deno run`. + command: + - sh + - -c + - "deno cache scripts_ts/build-chain.ts scripts_ts/sign-proof.ts && touch /tmp/ready && tail -f /dev/null" + depends_on: + # keria has no healthcheck (it gates on witnesses + vlei-server itself); + # build-chain.ts retries against KERIA via signify's own operation waits. + keria: + condition: service_started + vlei-verifier: + condition: service_healthy + healthcheck: + test: ["CMD", "test", "-f", "/tmp/ready"] + interval: 5s + timeout: 3s + retries: 60 + restart: unless-stopped diff --git a/scripts/demo/vlei/down.sh b/scripts/demo/vlei/down.sh new file mode 100755 index 0000000..001f156 --- /dev/null +++ b/scripts/demo/vlei/down.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# +# Tear down the vLEI ecosystem. +# +# Usage: +# scripts/demo/vlei/down.sh # stop + remove containers +# KEEP_VOLUMES=1 scripts/demo/vlei/down.sh # keep named volumes +# +# Env overrides: +# COMPOSE docker compose command (default: "docker compose") + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export DATA="${DATA:-$(cd "$SCRIPT_DIR/../../.." && pwd)/data/demo/vlei}" +# shellcheck source=../../common.sh +. "$SCRIPT_DIR/../common.sh" + +COMPOSE="${COMPOSE:-docker compose}" + +require_cmd docker + +header "vLEI ecosystem — down" + +DOWN_ARGS=(down --remove-orphans) +if [ "${KEEP_VOLUMES:-0}" != "1" ]; then + DOWN_ARGS+=(--volumes) +fi + +# shellcheck disable=SC2086 # COMPOSE may be a multi-word command +$COMPOSE -f "$SCRIPT_DIR/docker-compose.yml" "${DOWN_ARGS[@]}" +ok "stack stopped" diff --git a/scripts/demo/vlei/keria-docker.json b/scripts/demo/vlei/keria-docker.json new file mode 100755 index 0000000..250da36 --- /dev/null +++ b/scripts/demo/vlei/keria-docker.json @@ -0,0 +1,15 @@ +{ + "keria": { + "dt": "2024-12-31T14:06:30.123456+00:00", + "curls": ["http://keria:3902/"] + }, + "dt": "2024-12-31T14:06:30.123456+00:00", + "iurls": [ + "http://witness-demo:5642/oobi/BBilc4-L3tFUnfM_wJr4S4OJanAv_VmF_dJNN6vkf2Ha/controller?name=wan&tag=witness&tag=sample", + "http://witness-demo:5643/oobi/BLskRTInXnMxWaGqcpSyMgo0nYbalW99cGZESrz3zapM/controller?name=wil&tag=witness&tag=sample", + "http://witness-demo:5644/oobi/BIKKuvBwpmDVA4Ds-EpL5bt9OqPzWPja2LigFYZN2YfX/controller?name=wes&tag=witness&tag=sample", + "http://witness-demo:5645/oobi/BM35JN8XeJSEfpxopjn5jr7tAHCE5749f0OobhMLCorE/controller?name=wit&tag=witness&tag=sample", + "http://witness-demo:5646/oobi/BIj15u5V11bkbtAxMA7gcNJZcax-7TgaBMLsQnMHpYHP/controller?name=wub&tag=witness&tag=sample", + "http://witness-demo:5647/oobi/BF2rZTW79z4IXocYRQnjjsOuvFUQv-ptCf8Yltd7PfsM/controller?name=wyz&tag=witness&tag=sample" + ] +} \ No newline at end of file diff --git a/scripts/demo/vlei/run-vlei.sh b/scripts/demo/vlei/run-vlei.sh new file mode 100755 index 0000000..4caffbb --- /dev/null +++ b/scripts/demo/vlei/run-vlei.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# +# One-command vLEI verify-control demo. +# +# Chains the whole self-contained flow: +# 1. up.sh — bring up the stack (witnesses, vlei-server, KERIA, +# vlei-verifier, signify Deno runner) on one network. +# 2. build-chain.sh — run build-chain.ts headless: build the synthetic +# vLEI trust chain, issue the ECR to the holder AID, +# present it, register the local GLEIF root of trust, +# and export ecr-presentation.json + tier1-outputs.json. +# 3. verify-control-demo.sh (AUTO_SIGN=1) — RA-mediated register + +# verify-control on /v2/ans/identities, signing the +# served signingInput in-container with the holder +# (role) AID, then linking the verified lei identity +# to the agent. No manual paste. +# +# The RA is NOT started here — it owns its own lifecycle (config + agent +# registration). This script requires it already running with an agent +# registered; it checks both up front and points you at the right script if not. +# +# Usage: +# AGENT_ID=$(scripts/demo/register.sh --v2) +# scripts/demo/vlei/run-vlei.sh [--down] +# +# Required env: +# AGENT_ID a registered ans agent id (e.g. from scripts/demo/register.sh +# +# Flags: +# --down tear the stack down (down.sh) after a successful run. +# +# Env overrides: +# COMPOSE docker compose command (default: "docker compose") +# RA_URL ans-ra base URL (default: http://localhost:18080) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../common.sh +. "$SCRIPT_DIR/../common.sh" + +TEARDOWN=0 +for arg in "$@"; do + case "$arg" in + --down) TEARDOWN=1 ;; + *) fail "unknown argument: $arg (supported: --down)" ;; + esac +done + +OUT_DIR="$SCRIPT_DIR/signify/out" + +# AGENT_ID: from env +[ -n "${AGENT_ID:-}" ] || fail "set AGENT_ID — register an agent first (scripts/demo/register.sh)" + +header "vLEI verify-control demo — full run" +note "agent: $AGENT_ID RA: $RA_URL" + +# Fail fast if the RA isn't reachable, before standing the stack up. +if ! curl -sSf "$RA_URL/v2/admin/ready" >/dev/null 2>&1; then + fail "ans-ra isn't reachable at $RA_URL — run scripts/demo/start.sh first (and ensure the vlei: block in config/ra-local.yaml is enabled)" +fi +ok "ans-ra ready at $RA_URL" + +# 1. stack up +"$SCRIPT_DIR/up.sh" + +# 2. build the chain (headless): build chain, present, register root, export artifacts +"$SCRIPT_DIR/build-chain.sh" + +# 3. RA-mediated register + verify-control, auto-signing the signingInput +# in-container. DATA points the verify script at build-chain.ts's exported +# artifacts so it finds both ecr-presentation.json and tier1-outputs.json there. +AGENT_ID="$AGENT_ID" DATA="$OUT_DIR" AUTO_SIGN=1 "$SCRIPT_DIR/verify-control-demo.sh" + +if [ "$TEARDOWN" = "1" ]; then + "$SCRIPT_DIR/down.sh" +fi + +header "All done" +note "vLEI control proven end-to-end with no manual steps." diff --git a/scripts/demo/vlei/signify/out/ecr-presentation.json b/scripts/demo/vlei/signify/out/ecr-presentation.json new file mode 100644 index 0000000..27b66f6 --- /dev/null +++ b/scripts/demo/vlei/signify/out/ecr-presentation.json @@ -0,0 +1,5 @@ +{ + "cesr": "{\"v\":\"KERI10JSON0001b7_\",\"t\":\"icp\",\"d\":\"EEc4ZankjRGMnFyu1AZrYtf_jg3VLVylci2n51KgAuXk\",\"i\":\"EEc4ZankjRGMnFyu1AZrYtf_jg3VLVylci2n51KgAuXk\",\"s\":\"0\",\"kt\":\"1\",\"k\":[\"DOt6-9xBMvmyS9PmGVz3useCtbqJpuB3Yn-kMuaVRgH8\"],\"nt\":\"1\",\"n\":[\"EObUbg8u1gHxkwI33ZFIQzD4XFZQYNZQaerBpnvjSrBk\"],\"bt\":\"3\",\"b\":[\"BBilc4-L3tFUnfM_wJr4S4OJanAv_VmF_dJNN6vkf2Ha\",\"BLskRTInXnMxWaGqcpSyMgo0nYbalW99cGZESrz3zapM\",\"BIKKuvBwpmDVA4Ds-EpL5bt9OqPzWPja2LigFYZN2YfX\"],\"c\":[],\"a\":[]}-VBq-AABAACW9PoEPZf9gtkUKUVCjY-3eThjE5YjZDxsSiyoAks_cuXlBXLPNiiodpHfsSgrH3ipkCpnt1Y3CoujtfpQjNQE-BADAAAK0Ci5-Vmb-w6030xkAxaMp5xbGt2Bx8T_BGnApUs3sDOGd0DSlW2GX8oGuKxY3hzLvSJEdcLLBGA-PLJ79WIAABAtpfJUYldswt3-YFDzlVfiBJsX4NMPbzhdLRKABHnlIPJEHj4Fc4pllzQll2XE2OrxhjAlTVCU3fH2fB2isy4AACAA4a3VsrCUGNSAO4qsGI5pobaceOfDJ7-STIrnQHd93NolKoKvNoz0nlmeHnBUBsz6nbDOWWramc9kk8kZgLwK-EAB0AAAAAAAAAAAAAAAAAAAAAAA1AAG2026-06-11T05c26c15d775436p00c00{\"v\":\"KERI10JSON00013a_\",\"t\":\"ixn\",\"d\":\"EAlosSU9P_6SG9rV9UhCf-xZ0TA-Q1xrJc6eFn4PWoEB\",\"i\":\"EEc4ZankjRGMnFyu1AZrYtf_jg3VLVylci2n51KgAuXk\",\"s\":\"1\",\"p\":\"EEc4ZankjRGMnFyu1AZrYtf_jg3VLVylci2n51KgAuXk\",\"a\":[{\"i\":\"EE90lsKcXR7s-nULTURBhtKD6Eyjtgwtn7T_HKAYGovn\",\"s\":\"0\",\"d\":\"EE90lsKcXR7s-nULTURBhtKD6Eyjtgwtn7T_HKAYGovn\"}]}-VBq-AABAADgWppEnWN4KKZG3uxj5yze6h56gLSw9B3q3PrU-oUs4KOybPGSMmwSrgNnW5irQKtcRbyDNryadvrsD0RuvkQN-BADAADogN3qPVG4Wr10tNbzapl7_6u6q30E7HYPR2D9jPMZnWN_0KyIBt3sC1HeicBk7hiUqR0r4_Co3WfhQtIC1yYDABCc3MfcaYrl8L68bJhZuCH86UKsL1Mq_MsradrFMds0bcGC-7Y2698ekZuDH8R_SovKpkI7SdimYq-ffuewKd0LACCnIie-9_bPrDGuo23NfGcpYoT-zyPv_urgvjL7kjDBSC9KqNlfMCRxov8vsWPjkz42WXTrCGlEU4XEtO-1sq0N-EAB0AAAAAAAAAAAAAAAAAAAAAAB1AAG2026-06-11T05c26c15d778006p00c00{\"v\":\"KERI10JSON00013a_\",\"t\":\"ixn\",\"d\":\"ENeuCZc-nwXwgI6-Q8L6RTvwqi7i9yqy3nqin7jH7dpw\",\"i\":\"EEc4ZankjRGMnFyu1AZrYtf_jg3VLVylci2n51KgAuXk\",\"s\":\"2\",\"p\":\"EAlosSU9P_6SG9rV9UhCf-xZ0TA-Q1xrJc6eFn4PWoEB\",\"a\":[{\"i\":\"EH7hyKa78D8BzVzNgXPBon8IFtd6RHPQXp-dGbu-trxH\",\"s\":\"0\",\"d\":\"EH7hyKa78D8BzVzNgXPBon8IFtd6RHPQXp-dGbu-trxH\"}]}-VBq-AABAAB0yD2ShRLE9bVA_sGR-fPw-wtcKuJVDdD6kvGHm9tyWR6HgJbBv_nlogSNQqnNeH0NRQLl18fKWhgxtYCTxoAP-BADAAATdsAgl8nIYBs2MDZvkQczgGjht20YOKgjfItz6BoAgGedMrS8JrZiOwINJ27ClV_g5pm12lxh9cyFv-Lv42YIABA2fr7H6fhaGrT3fvMWCEjpgjXlPi2ueusgDxO5WHftHeDXQAJPu7oWh6arKnD5ssUtYjJzJgJEh9yIraW2ci0AACDbNruEWQwrLOu4hPit65IxOh3UTlZJnieb6_Jn5ASa5to0X2CofwWo6nqlpSRCdSGB5eU51kQkHRnteNtb7iUB-EAB0AAAAAAAAAAAAAAAAAAAAAAC1AAG2026-06-11T05c26c38d319587p00c00{\"v\":\"KERI10JSON00013a_\",\"t\":\"ixn\",\"d\":\"EPpu0lh5fmbgWxrvRsDDNf0FhWXRBq7ckOZ95yGndJWl\",\"i\":\"EEc4ZankjRGMnFyu1AZrYtf_jg3VLVylci2n51KgAuXk\",\"s\":\"3\",\"p\":\"ENeuCZc-nwXwgI6-Q8L6RTvwqi7i9yqy3nqin7jH7dpw\",\"a\":[{\"i\":\"EFfRtRsxpEej-zIHPIcsfUDGrLRwS_2aVgOzOwbL941o\",\"s\":\"0\",\"d\":\"ECTZS9gF3NefkoXtXvL2dpP8OUSMYKQ2ODJtgv1uSedF\"}]}-VBq-AABAAAXbFIZjN6jviJfvO9aSG1XBlN_JTbbQOC4AVL48WALG4sawm3KL7SnQ11cLV1syJ_VFE2j3DdA_ydc6GodKlkE-BADAABjvthGNIZQ9q1oWclwfO3iUDut-CGi-GcHpAcW1JvXmgurbycqdKmBALeHsbbwTj0bK5IAqelk35ydrkFHMZgLABCNEZJ5l_ng1C5O_4-u4asrILdPi2rHcn-a7unX_RCqWc8QAGtrrPMN9CJ72qrv75YFNAF1jiiGLsC3Z42W7dIFACCD7OP5FKI-uD9Z9Rs3I3iLH3hrDXAN49Gu8d58woevKtpnPTt-8qD1DhDTjCL4yrXVaQit-nYo7DlItYrXs18A-EAB0AAAAAAAAAAAAAAAAAAAAAAD1AAG2026-06-11T05c26c38d348177p00c00{\"v\":\"KERI10JSON0001eb_\",\"t\":\"dip\",\"d\":\"EE90lsKcXR7s-nULTURBhtKD6Eyjtgwtn7T_HKAYGovn\",\"i\":\"EE90lsKcXR7s-nULTURBhtKD6Eyjtgwtn7T_HKAYGovn\",\"s\":\"0\",\"kt\":\"1\",\"k\":[\"DAK3yw9yruQOKWaEhJnMhASzNhTigNnpO05bZS6bON94\"],\"nt\":\"1\",\"n\":[\"ED9axlzJGRSShmIi5aT8acvww12urlP9qvL24Us7A1OA\"],\"bt\":\"3\",\"b\":[\"BBilc4-L3tFUnfM_wJr4S4OJanAv_VmF_dJNN6vkf2Ha\",\"BLskRTInXnMxWaGqcpSyMgo0nYbalW99cGZESrz3zapM\",\"BIKKuvBwpmDVA4Ds-EpL5bt9OqPzWPja2LigFYZN2YfX\"],\"c\":[],\"a\":[],\"di\":\"EEc4ZankjRGMnFyu1AZrYtf_jg3VLVylci2n51KgAuXk\"}-VB8-AABAAALRoR4Q2Hdzq2I_KqR7Wq_1gbIMXhGNov4uaqUUr-sfdssr2msqrTQB3PnT16uSPoVzYY0IxHYl3iMSuhGf24E-BADAAC2L3XNeFndkopThWUGQPLghSatuQsLv0625cUlzmol3z42SlyruPJwxlm2RCjWqP6YQXhBLz6y-bT3L7e9n7kGABDRaDmi96BFrH2M508U4V54DNUUFYRqpI8tAjpubZW1umbsy6pe1K3o8GN_8aCv3MislfDZin5bsmqQqrLSWGoFACAjUiD_HUbhv0kkKS1o6IvCGa-csVkr5HvCHdhauJnJstYgObPjYcDqR7ixggGHmiZwadDf0uexIjukSHcS_UIK-GAB0AAAAAAAAAAAAAAAAAAAAAABEAlosSU9P_6SG9rV9UhCf-xZ0TA-Q1xrJc6eFn4PWoEB-EAB0AAAAAAAAAAAAAAAAAAAAAAA1AAG2026-06-11T05c26c15d780613p00c00{\"v\":\"KERI10JSON00013a_\",\"t\":\"ixn\",\"d\":\"EAQ4u3MCJFZQuZmrpbfDkymd4Ow0SZqz9hKssQw7zX4_\",\"i\":\"EE90lsKcXR7s-nULTURBhtKD6Eyjtgwtn7T_HKAYGovn\",\"s\":\"1\",\"p\":\"EE90lsKcXR7s-nULTURBhtKD6Eyjtgwtn7T_HKAYGovn\",\"a\":[{\"i\":\"ELvoz8SYcyons7ytNhhMzOY2GgvM-RO7mhj1GK6D8SFf\",\"s\":\"0\",\"d\":\"ELvoz8SYcyons7ytNhhMzOY2GgvM-RO7mhj1GK6D8SFf\"}]}-VBq-AABAAB-zwO_a7m8TPQt70fZ_k12dL26cgOvB0by7JnyLu9ySizIvSZnTZ2px2WH8ARCKdAvOMHQvbS8Dkym4VlXSG8E-BADAAAnklHaQ9uraB__CqEfoDNliVPGUq5S0Hc2d9Ov8ixxshNVLCfnFlMusDPP_jv-kHCYVnkFUUu9cjiBZZeKYXECABC0wtUMJZx-bHnP-MZ7Y3VEo8AGP6u1CGY5H0aFcgnLPFqtuApz2tYkFRhrVs73XMhe3-8OPKG4FS060cPHP-wJACCB4oF2sMyA0eYrzPj6ukIM-jWDZMIwBtAqQ6mY610OFVCMEEGI71UBxIWJu4oVKT-Wgt4PYy31HnBJJauusPYB-EAB0AAAAAAAAAAAAAAAAAAAAAAB1AAG2026-06-11T05c26c38d413917p00c00{\"v\":\"KERI10JSON00013a_\",\"t\":\"ixn\",\"d\":\"EEO5ecpf-eQIX5kHGkYamO96wWWblQlwVgZnVckBJLGn\",\"i\":\"EE90lsKcXR7s-nULTURBhtKD6Eyjtgwtn7T_HKAYGovn\",\"s\":\"2\",\"p\":\"EAQ4u3MCJFZQuZmrpbfDkymd4Ow0SZqz9hKssQw7zX4_\",\"a\":[{\"i\":\"ED9pqCxYI519oB3fOqq1WLBCq8QwQO8tvlUfiFbksNni\",\"s\":\"0\",\"d\":\"EAaMenfuD8insWq_MqOqntu_L0acrmL4RwHHqTwsClZs\"}]}-VBq-AABAADr7pniHv6hYHPTHl9U5uoNKzrjFuv8mlDr-hC682oooA_vnLAzCiu8r017VY5Ef6BNaLB-_6jZKoErAEQj_d8I-BADAAByP1LwRjvVYm2IHjbo4wyOjZg30ZEca5afzuT8gLbF8czYzl5Skgji-kkpJef3r-8kRghEM_wR-wfN7Z76jgwJABCcRVg8b9GbE4RA4QWnt4P6k_gUzHY9YqNOHhOQfFQlSou2vywzhcP9qTEfg8wTrPtIFvaCZBeX4XuB7xNeyhkAACCw2j_DgzVOpTctykyLrcU5lMWha_gsLn_IUYIKwabdgNK-p3i1vpKUwInE1k1q8_XyvIw43Pks9n0UL_G4fMAK-EAB0AAAAAAAAAAAAAAAAAAAAAAC1AAG2026-06-11T05c26c38d443212p00c00{\"v\":\"KERI10JSON000113_\",\"t\":\"vcp\",\"d\":\"EH7hyKa78D8BzVzNgXPBon8IFtd6RHPQXp-dGbu-trxH\",\"i\":\"EH7hyKa78D8BzVzNgXPBon8IFtd6RHPQXp-dGbu-trxH\",\"ii\":\"EEc4ZankjRGMnFyu1AZrYtf_jg3VLVylci2n51KgAuXk\",\"s\":\"0\",\"c\":[\"NB\"],\"bt\":\"0\",\"b\":[],\"n\":\"AJz_M1jOToVGPDIJBSjeDKA19kDFn9hSnXwwMzb38zFS\"}-VAS-GAB0AAAAAAAAAAAAAAAAAAAAAACENeuCZc-nwXwgI6-Q8L6RTvwqi7i9yqy3nqin7jH7dpw{\"v\":\"KERI10JSON0000ed_\",\"t\":\"iss\",\"d\":\"ECTZS9gF3NefkoXtXvL2dpP8OUSMYKQ2ODJtgv1uSedF\",\"i\":\"EFfRtRsxpEej-zIHPIcsfUDGrLRwS_2aVgOzOwbL941o\",\"s\":\"0\",\"ri\":\"EH7hyKa78D8BzVzNgXPBon8IFtd6RHPQXp-dGbu-trxH\",\"dt\":\"2026-06-11T05:26:18.944000+00:00\"}-VAS-GAB0AAAAAAAAAAAAAAAAAAAAAADEPpu0lh5fmbgWxrvRsDDNf0FhWXRBq7ckOZ95yGndJWl{\"v\":\"ACDC10JSON000197_\",\"d\":\"EFfRtRsxpEej-zIHPIcsfUDGrLRwS_2aVgOzOwbL941o\",\"i\":\"EEc4ZankjRGMnFyu1AZrYtf_jg3VLVylci2n51KgAuXk\",\"ri\":\"EH7hyKa78D8BzVzNgXPBon8IFtd6RHPQXp-dGbu-trxH\",\"s\":\"EBfdlu8R27Fbx-ehrqwImnK-8Cm79sqbAQ4MmvEAYqao\",\"a\":{\"d\":\"EGw2h5vW3OEcki0tOcAnxBRD_8bwZ8qwwawVLXzRuzzb\",\"i\":\"EE90lsKcXR7s-nULTURBhtKD6Eyjtgwtn7T_HKAYGovn\",\"LEI\":\"254900OPPU84GM83MG36\",\"dt\":\"2026-06-11T05:26:18.944000+00:00\"}}-IABEFfRtRsxpEej-zIHPIcsfUDGrLRwS_2aVgOzOwbL941o0AAAAAAAAAAAAAAAAAAAAAAAECTZS9gF3NefkoXtXvL2dpP8OUSMYKQ2ODJtgv1uSedF{\"v\":\"KERI10JSON0001eb_\",\"t\":\"dip\",\"d\":\"EE90lsKcXR7s-nULTURBhtKD6Eyjtgwtn7T_HKAYGovn\",\"i\":\"EE90lsKcXR7s-nULTURBhtKD6Eyjtgwtn7T_HKAYGovn\",\"s\":\"0\",\"kt\":\"1\",\"k\":[\"DAK3yw9yruQOKWaEhJnMhASzNhTigNnpO05bZS6bON94\"],\"nt\":\"1\",\"n\":[\"ED9axlzJGRSShmIi5aT8acvww12urlP9qvL24Us7A1OA\"],\"bt\":\"3\",\"b\":[\"BBilc4-L3tFUnfM_wJr4S4OJanAv_VmF_dJNN6vkf2Ha\",\"BLskRTInXnMxWaGqcpSyMgo0nYbalW99cGZESrz3zapM\",\"BIKKuvBwpmDVA4Ds-EpL5bt9OqPzWPja2LigFYZN2YfX\"],\"c\":[],\"a\":[],\"di\":\"EEc4ZankjRGMnFyu1AZrYtf_jg3VLVylci2n51KgAuXk\"}-VB8-AABAAALRoR4Q2Hdzq2I_KqR7Wq_1gbIMXhGNov4uaqUUr-sfdssr2msqrTQB3PnT16uSPoVzYY0IxHYl3iMSuhGf24E-BADAAC2L3XNeFndkopThWUGQPLghSatuQsLv0625cUlzmol3z42SlyruPJwxlm2RCjWqP6YQXhBLz6y-bT3L7e9n7kGABDRaDmi96BFrH2M508U4V54DNUUFYRqpI8tAjpubZW1umbsy6pe1K3o8GN_8aCv3MislfDZin5bsmqQqrLSWGoFACAjUiD_HUbhv0kkKS1o6IvCGa-csVkr5HvCHdhauJnJstYgObPjYcDqR7ixggGHmiZwadDf0uexIjukSHcS_UIK-GAB0AAAAAAAAAAAAAAAAAAAAAABEAlosSU9P_6SG9rV9UhCf-xZ0TA-Q1xrJc6eFn4PWoEB-EAB0AAAAAAAAAAAAAAAAAAAAAAA1AAG2026-06-11T05c26c15d780613p00c00{\"v\":\"KERI10JSON00013a_\",\"t\":\"ixn\",\"d\":\"EAQ4u3MCJFZQuZmrpbfDkymd4Ow0SZqz9hKssQw7zX4_\",\"i\":\"EE90lsKcXR7s-nULTURBhtKD6Eyjtgwtn7T_HKAYGovn\",\"s\":\"1\",\"p\":\"EE90lsKcXR7s-nULTURBhtKD6Eyjtgwtn7T_HKAYGovn\",\"a\":[{\"i\":\"ELvoz8SYcyons7ytNhhMzOY2GgvM-RO7mhj1GK6D8SFf\",\"s\":\"0\",\"d\":\"ELvoz8SYcyons7ytNhhMzOY2GgvM-RO7mhj1GK6D8SFf\"}]}-VBq-AABAAB-zwO_a7m8TPQt70fZ_k12dL26cgOvB0by7JnyLu9ySizIvSZnTZ2px2WH8ARCKdAvOMHQvbS8Dkym4VlXSG8E-BADAAAnklHaQ9uraB__CqEfoDNliVPGUq5S0Hc2d9Ov8ixxshNVLCfnFlMusDPP_jv-kHCYVnkFUUu9cjiBZZeKYXECABC0wtUMJZx-bHnP-MZ7Y3VEo8AGP6u1CGY5H0aFcgnLPFqtuApz2tYkFRhrVs73XMhe3-8OPKG4FS060cPHP-wJACCB4oF2sMyA0eYrzPj6ukIM-jWDZMIwBtAqQ6mY610OFVCMEEGI71UBxIWJu4oVKT-Wgt4PYy31HnBJJauusPYB-EAB0AAAAAAAAAAAAAAAAAAAAAAB1AAG2026-06-11T05c26c38d413917p00c00{\"v\":\"KERI10JSON00013a_\",\"t\":\"ixn\",\"d\":\"EEO5ecpf-eQIX5kHGkYamO96wWWblQlwVgZnVckBJLGn\",\"i\":\"EE90lsKcXR7s-nULTURBhtKD6Eyjtgwtn7T_HKAYGovn\",\"s\":\"2\",\"p\":\"EAQ4u3MCJFZQuZmrpbfDkymd4Ow0SZqz9hKssQw7zX4_\",\"a\":[{\"i\":\"ED9pqCxYI519oB3fOqq1WLBCq8QwQO8tvlUfiFbksNni\",\"s\":\"0\",\"d\":\"EAaMenfuD8insWq_MqOqntu_L0acrmL4RwHHqTwsClZs\"}]}-VBq-AABAADr7pniHv6hYHPTHl9U5uoNKzrjFuv8mlDr-hC682oooA_vnLAzCiu8r017VY5Ef6BNaLB-_6jZKoErAEQj_d8I-BADAAByP1LwRjvVYm2IHjbo4wyOjZg30ZEca5afzuT8gLbF8czYzl5Skgji-kkpJef3r-8kRghEM_wR-wfN7Z76jgwJABCcRVg8b9GbE4RA4QWnt4P6k_gUzHY9YqNOHhOQfFQlSou2vywzhcP9qTEfg8wTrPtIFvaCZBeX4XuB7xNeyhkAACCw2j_DgzVOpTctykyLrcU5lMWha_gsLn_IUYIKwabdgNK-p3i1vpKUwInE1k1q8_XyvIw43Pks9n0UL_G4fMAK-EAB0AAAAAAAAAAAAAAAAAAAAAAC1AAG2026-06-11T05c26c38d443212p00c00{\"v\":\"KERI10JSON0001b7_\",\"t\":\"icp\",\"d\":\"EASg13cakYl180PhXoDyGaCC_L_Vp7ZhIphZioA1Oed2\",\"i\":\"EASg13cakYl180PhXoDyGaCC_L_Vp7ZhIphZioA1Oed2\",\"s\":\"0\",\"kt\":\"1\",\"k\":[\"DHK3QY22__M-g-r6aenExqCZDkrDDjYAS4D9QNHDPIoS\"],\"nt\":\"1\",\"n\":[\"ENaBPETNOtxW0qFWWuM9ik-EWvkwjdkbBxszCdTAMWOw\"],\"bt\":\"3\",\"b\":[\"BBilc4-L3tFUnfM_wJr4S4OJanAv_VmF_dJNN6vkf2Ha\",\"BLskRTInXnMxWaGqcpSyMgo0nYbalW99cGZESrz3zapM\",\"BIKKuvBwpmDVA4Ds-EpL5bt9OqPzWPja2LigFYZN2YfX\"],\"c\":[],\"a\":[]}-VBq-AABAADN_CZ_3eoMeBeDbUPG5u8mqT5xuGLUxnM-rkXEEv6P8vKygh5SgR4zwCrtSDZP9-DBqt1bXIBuUeKX4A0LzrQG-BADAABkb-TucbxzyCG2WSBXWG1pZdJj7YBAN7VSxuKrJ8eID4rHIivv62F8NjCBPHFR_WkJUJEfpT0jfJoi3TYkr9gKABD7MM1UJsZzDcwF-eCOQsIGhQ6QtrPaFdui7u-woPHeO6MUiYyIEsDRqFesa64RojvndtHgPwCglsDfhBIv3-AFACAHyigAReA-PblfZmNmgoXswdE1IRAJRKXHEe9u_Tm0vKjNaQ9ytBVwSifp-9Q-s38jtgXfvI-waLNiBVXANjgE-EAB0AAAAAAAAAAAAAAAAAAAAAAA1AAG2026-06-11T05c26c15d763350p00c00{\"v\":\"KERI10JSON00013a_\",\"t\":\"ixn\",\"d\":\"EH03qv-5vJj7Aj0xqqAiJtb8lLHdRigqZ0YJml5-6WC5\",\"i\":\"EASg13cakYl180PhXoDyGaCC_L_Vp7ZhIphZioA1Oed2\",\"s\":\"1\",\"p\":\"EASg13cakYl180PhXoDyGaCC_L_Vp7ZhIphZioA1Oed2\",\"a\":[{\"i\":\"EENW45f2OuBe9nOO99KyXw0iGQ8zNRGHCc0dty3vRqjA\",\"s\":\"0\",\"d\":\"EENW45f2OuBe9nOO99KyXw0iGQ8zNRGHCc0dty3vRqjA\"}]}-VBq-AABAABOHeZrhLDPHUoB-vcLhPGVNaSzZpRXaigmVoHEDH7at2JGc9ywO1upEF9vciH_hZNQAfoqeRUziuuZ52rYX0IF-BADAADgcyIB0_J2TjU3ynQWBv6Bf5bAiIRKsDz_3Ceiwxea79EVnZ_42LZro5KsNEmAz3i9xA4BRKBUYoNELDX1AlMKABDTobQWaJhKPowsE03sbZQR2k-vDLwpX_EXdv5-jg-zhoe6Rhvza0O2nFN2Yva4IH7IerVuzfdngeJODHVtqzgEACDIB7f13YZPWBqg94cJ8ti67ils6gXqNeLpUOVAiirvMZCMv7Dm5A4bVzIqKhuvlRhCx1srU7HNDIvx-qZsxxMJ-EAB0AAAAAAAAAAAAAAAAAAAAAAB1AAG2026-06-11T05c26c38d128107p00c00{\"v\":\"KERI10JSON00013a_\",\"t\":\"ixn\",\"d\":\"EPJ3yPRSBaDntd5YhrHzuSdMQtke7tm_rr0MJS0AAozM\",\"i\":\"EASg13cakYl180PhXoDyGaCC_L_Vp7ZhIphZioA1Oed2\",\"s\":\"2\",\"p\":\"EH03qv-5vJj7Aj0xqqAiJtb8lLHdRigqZ0YJml5-6WC5\",\"a\":[{\"i\":\"EHiDXP4s7QaMGMbpHGmm3e1GYET4EtrrGyhMcDEz3Mmo\",\"s\":\"0\",\"d\":\"EBHKMw2yQzRQqZuPa0xahKYhDUNlneTaEYiW176B3j6J\"}]}-VBq-AABAAC9_eSk2dI6Bd2kHTKFEyySUxocBK6KPfdvX-EyVgS-KYYIfAWUcp9bJXkI6yaMzOxi_DRm5MhtbCnKdiz3Rj4F-BADAAC2ONz9D2E_K7QKZyCyMD9yOEz8v3iLiOWNh7PWx7sLOa-3hjog79U3R539XiiMXMXg4KR1WxKi7nHzKwpAW5kPABAYuUlJ_fToLiqCILPVAe8NFsq_4Gpw6KVHIyexr2S5Mi-D-90RVtfGyTjQ3H5z2o8387YWhMr81tCigFjh9DYIACDp1-50CHrZERSwtpOiueHxceipF4bQf9o8oButC4Au_IjIPiOAHkqglbU2eCdIs5dGnIbd5Te5p0fX97-m8FYA-EAB0AAAAAAAAAAAAAAAAAAAAAAC1AAG2026-06-11T05c26c38d160699p00c00{\"v\":\"KERI10JSON000113_\",\"t\":\"vcp\",\"d\":\"ELvoz8SYcyons7ytNhhMzOY2GgvM-RO7mhj1GK6D8SFf\",\"i\":\"ELvoz8SYcyons7ytNhhMzOY2GgvM-RO7mhj1GK6D8SFf\",\"ii\":\"EE90lsKcXR7s-nULTURBhtKD6Eyjtgwtn7T_HKAYGovn\",\"s\":\"0\",\"c\":[\"NB\"],\"bt\":\"0\",\"b\":[],\"n\":\"AJVgZbUEagvvBdKPc-VXOhdKwIc-vYLPnfukzFtZsImS\"}-VAS-GAB0AAAAAAAAAAAAAAAAAAAAAABEAQ4u3MCJFZQuZmrpbfDkymd4Ow0SZqz9hKssQw7zX4_{\"v\":\"KERI10JSON0000ed_\",\"t\":\"iss\",\"d\":\"EAaMenfuD8insWq_MqOqntu_L0acrmL4RwHHqTwsClZs\",\"i\":\"ED9pqCxYI519oB3fOqq1WLBCq8QwQO8tvlUfiFbksNni\",\"s\":\"0\",\"ri\":\"ELvoz8SYcyons7ytNhhMzOY2GgvM-RO7mhj1GK6D8SFf\",\"dt\":\"2026-06-11T05:26:27.161000+00:00\"}-VAS-GAB0AAAAAAAAAAAAAAAAAAAAAACEEO5ecpf-eQIX5kHGkYamO96wWWblQlwVgZnVckBJLGn{\"v\":\"ACDC10JSON0005c8_\",\"d\":\"ED9pqCxYI519oB3fOqq1WLBCq8QwQO8tvlUfiFbksNni\",\"i\":\"EE90lsKcXR7s-nULTURBhtKD6Eyjtgwtn7T_HKAYGovn\",\"ri\":\"ELvoz8SYcyons7ytNhhMzOY2GgvM-RO7mhj1GK6D8SFf\",\"s\":\"ENPXp1vQzRF6JwIuS-mp2U8Uf1MoADoP_GqQ62VsDZWY\",\"a\":{\"d\":\"ECakQY3CY0BAdwD-gCe-2Jurs7WibLea3ilNtZBdBi2X\",\"i\":\"EASg13cakYl180PhXoDyGaCC_L_Vp7ZhIphZioA1Oed2\",\"LEI\":\"875500ELOZEL05BVXV37\",\"dt\":\"2026-06-11T05:26:27.161000+00:00\"},\"e\":{\"d\":\"EFyuG5XkRQkOKfHcubrp9cNTXkKrLdKTPSFky1ntcedI\",\"qvi\":{\"n\":\"EFfRtRsxpEej-zIHPIcsfUDGrLRwS_2aVgOzOwbL941o\",\"s\":\"EBfdlu8R27Fbx-ehrqwImnK-8Cm79sqbAQ4MmvEAYqao\"}},\"r\":{\"d\":\"EGZ97EjPSINR-O-KHDN_uw4fdrTxeuRXrqT5ZHHQJujQ\",\"usageDisclaimer\":{\"l\":\"Usage of a valid, unexpired, and non-revoked vLEI Credential, as defined in the associated Ecosystem Governance Framework, does not assert that the Legal Entity is trustworthy, honest, reputable in its business dealings, safe to do business with, or compliant with any laws or that an implied or expressly intended purpose will be fulfilled.\"},\"issuanceDisclaimer\":{\"l\":\"All information in a valid, unexpired, and non-revoked vLEI Credential, as defined in the associated Ecosystem Governance Framework, is accurate as of the date the validation process was complete. The vLEI Credential has been issued to the legal entity or person named in the vLEI Credential as the subject; and the qualified vLEI Issuer exercised reasonable care to perform the validation process set forth in the vLEI Ecosystem Governance Framework.\"}}}-IABED9pqCxYI519oB3fOqq1WLBCq8QwQO8tvlUfiFbksNni0AAAAAAAAAAAAAAAAAAAAAAAEAaMenfuD8insWq_MqOqntu_L0acrmL4RwHHqTwsClZs{\"v\":\"KERI10JSON0001b7_\",\"t\":\"icp\",\"d\":\"EASg13cakYl180PhXoDyGaCC_L_Vp7ZhIphZioA1Oed2\",\"i\":\"EASg13cakYl180PhXoDyGaCC_L_Vp7ZhIphZioA1Oed2\",\"s\":\"0\",\"kt\":\"1\",\"k\":[\"DHK3QY22__M-g-r6aenExqCZDkrDDjYAS4D9QNHDPIoS\"],\"nt\":\"1\",\"n\":[\"ENaBPETNOtxW0qFWWuM9ik-EWvkwjdkbBxszCdTAMWOw\"],\"bt\":\"3\",\"b\":[\"BBilc4-L3tFUnfM_wJr4S4OJanAv_VmF_dJNN6vkf2Ha\",\"BLskRTInXnMxWaGqcpSyMgo0nYbalW99cGZESrz3zapM\",\"BIKKuvBwpmDVA4Ds-EpL5bt9OqPzWPja2LigFYZN2YfX\"],\"c\":[],\"a\":[]}-VBq-AABAADN_CZ_3eoMeBeDbUPG5u8mqT5xuGLUxnM-rkXEEv6P8vKygh5SgR4zwCrtSDZP9-DBqt1bXIBuUeKX4A0LzrQG-BADAABkb-TucbxzyCG2WSBXWG1pZdJj7YBAN7VSxuKrJ8eID4rHIivv62F8NjCBPHFR_WkJUJEfpT0jfJoi3TYkr9gKABD7MM1UJsZzDcwF-eCOQsIGhQ6QtrPaFdui7u-woPHeO6MUiYyIEsDRqFesa64RojvndtHgPwCglsDfhBIv3-AFACAHyigAReA-PblfZmNmgoXswdE1IRAJRKXHEe9u_Tm0vKjNaQ9ytBVwSifp-9Q-s38jtgXfvI-waLNiBVXANjgE-EAB0AAAAAAAAAAAAAAAAAAAAAAA1AAG2026-06-11T05c26c15d763350p00c00{\"v\":\"KERI10JSON00013a_\",\"t\":\"ixn\",\"d\":\"EH03qv-5vJj7Aj0xqqAiJtb8lLHdRigqZ0YJml5-6WC5\",\"i\":\"EASg13cakYl180PhXoDyGaCC_L_Vp7ZhIphZioA1Oed2\",\"s\":\"1\",\"p\":\"EASg13cakYl180PhXoDyGaCC_L_Vp7ZhIphZioA1Oed2\",\"a\":[{\"i\":\"EENW45f2OuBe9nOO99KyXw0iGQ8zNRGHCc0dty3vRqjA\",\"s\":\"0\",\"d\":\"EENW45f2OuBe9nOO99KyXw0iGQ8zNRGHCc0dty3vRqjA\"}]}-VBq-AABAABOHeZrhLDPHUoB-vcLhPGVNaSzZpRXaigmVoHEDH7at2JGc9ywO1upEF9vciH_hZNQAfoqeRUziuuZ52rYX0IF-BADAADgcyIB0_J2TjU3ynQWBv6Bf5bAiIRKsDz_3Ceiwxea79EVnZ_42LZro5KsNEmAz3i9xA4BRKBUYoNELDX1AlMKABDTobQWaJhKPowsE03sbZQR2k-vDLwpX_EXdv5-jg-zhoe6Rhvza0O2nFN2Yva4IH7IerVuzfdngeJODHVtqzgEACDIB7f13YZPWBqg94cJ8ti67ils6gXqNeLpUOVAiirvMZCMv7Dm5A4bVzIqKhuvlRhCx1srU7HNDIvx-qZsxxMJ-EAB0AAAAAAAAAAAAAAAAAAAAAAB1AAG2026-06-11T05c26c38d128107p00c00{\"v\":\"KERI10JSON00013a_\",\"t\":\"ixn\",\"d\":\"EPJ3yPRSBaDntd5YhrHzuSdMQtke7tm_rr0MJS0AAozM\",\"i\":\"EASg13cakYl180PhXoDyGaCC_L_Vp7ZhIphZioA1Oed2\",\"s\":\"2\",\"p\":\"EH03qv-5vJj7Aj0xqqAiJtb8lLHdRigqZ0YJml5-6WC5\",\"a\":[{\"i\":\"EHiDXP4s7QaMGMbpHGmm3e1GYET4EtrrGyhMcDEz3Mmo\",\"s\":\"0\",\"d\":\"EBHKMw2yQzRQqZuPa0xahKYhDUNlneTaEYiW176B3j6J\"}]}-VBq-AABAAC9_eSk2dI6Bd2kHTKFEyySUxocBK6KPfdvX-EyVgS-KYYIfAWUcp9bJXkI6yaMzOxi_DRm5MhtbCnKdiz3Rj4F-BADAAC2ONz9D2E_K7QKZyCyMD9yOEz8v3iLiOWNh7PWx7sLOa-3hjog79U3R539XiiMXMXg4KR1WxKi7nHzKwpAW5kPABAYuUlJ_fToLiqCILPVAe8NFsq_4Gpw6KVHIyexr2S5Mi-D-90RVtfGyTjQ3H5z2o8387YWhMr81tCigFjh9DYIACDp1-50CHrZERSwtpOiueHxceipF4bQf9o8oButC4Au_IjIPiOAHkqglbU2eCdIs5dGnIbd5Te5p0fX97-m8FYA-EAB0AAAAAAAAAAAAAAAAAAAAAAC1AAG2026-06-11T05c26c38d160699p00c00{\"v\":\"KERI10JSON0001b7_\",\"t\":\"icp\",\"d\":\"EDHSXmNtZy3QDgOv_jB6cJesIcMdqnPYZoBnla-qwIZJ\",\"i\":\"EDHSXmNtZy3QDgOv_jB6cJesIcMdqnPYZoBnla-qwIZJ\",\"s\":\"0\",\"kt\":\"1\",\"k\":[\"DENUslY77aNd5ez5mQGnZLQi9YJSKCt2mer8mPCjHeNx\"],\"nt\":\"1\",\"n\":[\"EIVDLsI2ulIfhIlHlH-kCKqtiuCw_Pc642I0r98_Qo8u\"],\"bt\":\"3\",\"b\":[\"BBilc4-L3tFUnfM_wJr4S4OJanAv_VmF_dJNN6vkf2Ha\",\"BLskRTInXnMxWaGqcpSyMgo0nYbalW99cGZESrz3zapM\",\"BIKKuvBwpmDVA4Ds-EpL5bt9OqPzWPja2LigFYZN2YfX\"],\"c\":[],\"a\":[]}-VBq-AABAABmYlUzVzuGTJXFr3zviRAPWtQHStBe2tLowYqNzjuAzpK5gknf0udEIiHZ-o_RPF2KrnjA5XCwA7zZVe2p57AF-BADAADOS7aLgNlCYnR1dO1Lfldd-MYBYiQHOtg32HDb0PTw7FEx2WEX5POITVnIeJWAHXlM137x9llZEh2t7LGP2GsNABAeSCw0ycKP_VDqkZxOBGM8e-L7Tsup1VJ3E_GzxQRM98YHgN5i1P0VZz7qTNi9NDHx7aVef8KZf2nrV3DdA90LACAfXhTVWETo3mxgdvv0vFtCt4uA7ls-TappbNRYtyC_7sYrlBwOyaUVSJwRy9ni3BGTpJUbHUDwTdxM_FcsSsYO-EAB0AAAAAAAAAAAAAAAAAAAAAAA1AAG2026-06-11T05c26c14d690903p00c00{\"v\":\"KERI10JSON000113_\",\"t\":\"vcp\",\"d\":\"EENW45f2OuBe9nOO99KyXw0iGQ8zNRGHCc0dty3vRqjA\",\"i\":\"EENW45f2OuBe9nOO99KyXw0iGQ8zNRGHCc0dty3vRqjA\",\"ii\":\"EASg13cakYl180PhXoDyGaCC_L_Vp7ZhIphZioA1Oed2\",\"s\":\"0\",\"c\":[\"NB\"],\"bt\":\"0\",\"b\":[],\"n\":\"AMfztkZztZR2U0MFg-ee2Ln_OnMSo_FeLiZKdz_5f--M\"}-VAS-GAB0AAAAAAAAAAAAAAAAAAAAAABEH03qv-5vJj7Aj0xqqAiJtb8lLHdRigqZ0YJml5-6WC5{\"v\":\"KERI10JSON0000ed_\",\"t\":\"iss\",\"d\":\"EBHKMw2yQzRQqZuPa0xahKYhDUNlneTaEYiW176B3j6J\",\"i\":\"EHiDXP4s7QaMGMbpHGmm3e1GYET4EtrrGyhMcDEz3Mmo\",\"s\":\"0\",\"ri\":\"EENW45f2OuBe9nOO99KyXw0iGQ8zNRGHCc0dty3vRqjA\",\"dt\":\"2026-06-11T05:26:35.317000+00:00\"}-VAS-GAB0AAAAAAAAAAAAAAAAAAAAAACEPJ3yPRSBaDntd5YhrHzuSdMQtke7tm_rr0MJS0AAozM{\"v\":\"ACDC10JSON0007dc_\",\"d\":\"EHiDXP4s7QaMGMbpHGmm3e1GYET4EtrrGyhMcDEz3Mmo\",\"u\":\"0ABn4IzwUDKKhmE7QsLHRaP4\",\"i\":\"EASg13cakYl180PhXoDyGaCC_L_Vp7ZhIphZioA1Oed2\",\"ri\":\"EENW45f2OuBe9nOO99KyXw0iGQ8zNRGHCc0dty3vRqjA\",\"s\":\"EEy9PkikFcANV1l7EHukCeXqrzT1hNZjGlUk7wuMO5jw\",\"a\":{\"d\":\"EMQbEw2G0VSZN9DJuH5nyXRGMq4Ry6BesBykVDxGJPWt\",\"i\":\"EDHSXmNtZy3QDgOv_jB6cJesIcMdqnPYZoBnla-qwIZJ\",\"LEI\":\"875500ELOZEL05BVXV37\",\"personLegalName\":\"John Doe\",\"engagementContextRole\":\"Managing Director\",\"dt\":\"2026-06-11T05:26:35.317000+00:00\"},\"e\":{\"d\":\"EN-tndHWF0jJITEJYL2-iyif-yk-spZ_PZcfBvsv6NEk\",\"le\":{\"n\":\"ED9pqCxYI519oB3fOqq1WLBCq8QwQO8tvlUfiFbksNni\",\"s\":\"ENPXp1vQzRF6JwIuS-mp2U8Uf1MoADoP_GqQ62VsDZWY\"}},\"r\":{\"d\":\"EIfq_m1DI2IQ1MgHhUl9sq3IQ_PJP9WQ1LhbMscngDCB\",\"usageDisclaimer\":{\"l\":\"Usage of a valid, unexpired, and non-revoked vLEI Credential, as defined in the associated Ecosystem Governance Framework, does not assert that the Legal Entity is trustworthy, honest, reputable in its business dealings, safe to do business with, or compliant with any laws or that an implied or expressly intended purpose will be fulfilled.\"},\"issuanceDisclaimer\":{\"l\":\"All information in a valid, unexpired, and non-revoked vLEI Credential, as defined in the associated Ecosystem Governance Framework, is accurate as of the date the validation process was complete. The vLEI Credential has been issued to the legal entity or person named in the vLEI Credential as the subject; and the qualified vLEI Issuer exercised reasonable care to perform the validation process set forth in the vLEI Ecosystem Governance Framework.\"},\"privacyDisclaimer\":{\"l\":\"It is the sole responsibility of Holders as Issuees of an ECR vLEI Credential to present that Credential in a privacy-preserving manner using the mechanisms provided in the Issuance and Presentation Exchange (IPEX) protocol specification and the Authentic Chained Data Container (ACDC) specification. https://github.com/WebOfTrust/IETF-IPEX and https://github.com/trustoverip/tswg-acdc-specification.\"}}}-IABEHiDXP4s7QaMGMbpHGmm3e1GYET4EtrrGyhMcDEz3Mmo0AAAAAAAAAAAAAAAAAAAAAAAEBHKMw2yQzRQqZuPa0xahKYhDUNlneTaEYiW176B3j6J", + "lei": "875500ELOZEL05BVXV37", + "aid": "EDHSXmNtZy3QDgOv_jB6cJesIcMdqnPYZoBnla-qwIZJ" +} \ No newline at end of file diff --git a/scripts/demo/vlei/signify/out/tier1-outputs.json b/scripts/demo/vlei/signify/out/tier1-outputs.json new file mode 100644 index 0000000..1f32957 --- /dev/null +++ b/scripts/demo/vlei/signify/out/tier1-outputs.json @@ -0,0 +1,8 @@ +{ + "gleifPrefix": "EEc4ZankjRGMnFyu1AZrYtf_jg3VLVylci2n51KgAuXk", + "rolePrefix": "EDHSXmNtZy3QDgOv_jB6cJesIcMdqnPYZoBnla-qwIZJ", + "roleBran": "DWvi_zlnWH8sKWYLEsSh1", + "LEI": "875500ELOZEL05BVXV37", + "ecrCredentialSaid": "EHiDXP4s7QaMGMbpHGmm3e1GYET4EtrrGyhMcDEz3Mmo", + "gleifOOBI": "http://keria:3902/oobi/EEc4ZankjRGMnFyu1AZrYtf_jg3VLVylci2n51KgAuXk/agent/ENvos4PmBAa28ZTPKVrSgtXZ3bRhgRP_zpRk0Q914d2L" +} \ No newline at end of file diff --git a/scripts/demo/vlei/signify/scripts_ts/build-chain.ts b/scripts/demo/vlei/signify/scripts_ts/build-chain.ts new file mode 100644 index 0000000..56631bf --- /dev/null +++ b/scripts/demo/vlei/signify/scripts_ts/build-chain.ts @@ -0,0 +1,459 @@ +// ============================================================================ +// ANS demo. +// +// Builds the synthetic GLEIF → QVI(delegated) → LE → ECR vLEI trust chain, +// issues the ECR to the holder (role) AID, presents it via IPEX, registers the +// LOCAL synthetic GLEIF root of trust at the vlei-verifier, and exports the two +// artifacts the shell demo consumes: +// - out/ecr-presentation.json {cesr, lei, aid} → verify-control-demo.sh +// - out/tier1-outputs.json {roleBran, ...} → the nonce signer +// +// This is the headless equivalent of the old Jupyter notebook — same SignifyTS +// logic, no notebook/kernel layer. The interactive sign cell is gone entirely; +// sign-proof.ts is the standalone signer (run by verify-control-demo.sh). +// +// USAGE (inside the signify container, on the stack's docker network): +// deno run -A scripts_ts/build-chain.ts +// +// Service hostnames (keria, vlei-server, witness-demo, vlei-verifier) resolve +// because the container shares the compose default network — see utils.ts. +// ============================================================================ + +import "npm:libsodium-wrappers-sumo@0.7.15"; +import { randomPasscode, Saider } from "npm:signify-ts@^0.3.0-rc1"; +import { + initializeSignify, + initializeAndConnectClient, + createNewAID, + addEndRoleForAID, + generateOOBI, + resolveOOBI, + createCredentialRegistry, + issueCredential, + ipexGrantCredential, + waitForAndGetNotification, + ipexAdmitGrant, + markNotificationRead, + DEFAULT_IDENTIFIER_ARGS, + ROLE_AGENT, + IPEX_GRANT_ROUTE, + IPEX_ADMIT_ROUTE, + SCHEMA_SERVER_HOST, + prTitle, + prMessage, + prContinue, + sleep, +} from "./utils.ts"; + +const OUT_DIR = "out"; +const VERIFIER_URL = "http://vlei-verifier:7676"; + +const WITNESS_OOBIS = [ + { url: "http://witness-demo:5642/oobi/BBilc4-L3tFUnfM_wJr4S4OJanAv_VmF_dJNN6vkf2Ha/controller?name=wan&tag=witness", alias: "wan" }, + { url: "http://witness-demo:5643/oobi/BLskRTInXnMxWaGqcpSyMgo0nYbalW99cGZESrz3zapM/controller?name=wil&tag=witness", alias: "wil" }, + { url: "http://witness-demo:5644/oobi/BIKKuvBwpmDVA4Ds-EpL5bt9OqPzWPja2LigFYZN2YfX/controller?name=wes&tag=witness", alias: "wes" }, +]; + +async function resolveWitnesses(client, who: string) { + for (const w of WITNESS_OOBIS) { + await resolveOOBI(client, w.url, `${who}-${w.alias}`); + } + prMessage(`Resolved witnesses for ${who}`); +} + +// Cross-step state. Each issuance step is wrapped in its own block so its local +// consts (credentialSaid, grantResponse, …) don't collide across steps; the +// credentials and the LE claim flow forward through these module-scope `let`s. +let qviCredential; +let leCredential; +let ecrCredential; +let leData; + +// --------------------------------------------------------------------------- +// Setup Phase — create the four actor clients/AIDs, OOBIs, and registries. +// --------------------------------------------------------------------------- +await initializeSignify(); + +prTitle("Creating clients setup"); + +// Fixed Bran to keep a consistent root of trust (DO NOT MODIFY or else +// validation with the verifier will break — the registered root AID is +// deterministic from this bran). +const gleifBran = "Dm8Tmz05CF6_JLX9sVlFe"; +const gleifAlias = "gleif"; +const { client: gleifClient } = await initializeAndConnectClient(gleifBran); +await resolveWitnesses(gleifClient, "gleif"); +let gleifPrefix; + +// GLEIF GEDA setup. GLEIF is the root, so it stays a plain (non-delegated) +// AID — the root of trust we register with the verifier. +try { + const gleifAid = await gleifClient.identifiers().get(gleifAlias); + gleifPrefix = gleifAid.prefix; +} catch { + prMessage("Creating GLEIF AID"); + const { aid: newAid } = await createNewAID(gleifClient, gleifAlias, DEFAULT_IDENTIFIER_ARGS); + await addEndRoleForAID(gleifClient, gleifAlias, ROLE_AGENT); + gleifPrefix = newAid.i; +} +const gleifOOBI = await generateOOBI(gleifClient, gleifAlias, ROLE_AGENT); +prMessage(`GLEIF Prefix: ${gleifPrefix}`); + +// QVI — DELEGATED by GLEIF. The vlei-verifier requires the QVI AID to be a +// delegated identifier whose delegator is the registered Root Of Trust (gleif). +const qviBran = randomPasscode(); +const qviAlias = "qvi"; +const { client: qviClient } = await initializeAndConnectClient(qviBran); +await resolveWitnesses(qviClient, "qvi"); // qvi's agent must know the witnesses +await resolveOOBI(qviClient, gleifOOBI, gleifAlias); // ...and the delegator's (gleif) KEL + +// 1. qvi initiates a delegated inception (delpre = gleif). The operation stays +// pending until the delegator anchors its approval. +const qviIcpResult = await qviClient.identifiers().create(qviAlias, { + delpre: gleifPrefix, + toad: DEFAULT_IDENTIFIER_ARGS.toad, + wits: DEFAULT_IDENTIFIER_ARGS.wits, +}); +const qviPrefix = qviIcpResult.serder.pre; +const qviIcpOp = await qviIcpResult.op(); +prMessage(`QVI delegated inception pending gleif approval: ${qviPrefix}`); + +// 2. gleif (the delegator) APPROVES the delegation. This MUST use +// delegations().approve() (POST /identifiers/gleif/delegation), NOT +// identifiers().interact(). Only the /delegation endpoint runs KERIA's +// approveDelegation: it promotes qvi's dip out of gleif's `delegables` +// escrow into gleif's kevers. A plain interact anchors the seal but never +// ingests qvi's KEL, so gleif later rejects qvi's dip as "unknown AID". +// For an inception event the seal digest equals the prefix. Retry: qvi's +// post may not have reached gleif's escrow on the first approve; the seal +// anchor is idempotent, so re-approving just re-runs the promotion. +const delegationSeal = { i: qviPrefix, s: "0", d: qviPrefix }; +let approved = false; +for (let attempt = 1; attempt <= 10 && !approved; attempt++) { + try { + const gleifApproval = await gleifClient.delegations().approve(gleifAlias, delegationSeal); + await gleifClient.operations().wait(await gleifApproval.op(), AbortSignal.timeout(30000)); + approved = true; + } catch (_e) { + prMessage(`Waiting for qvi's dip to reach gleif's delegation escrow (attempt ${attempt})...`); + await sleep(2000); + } +} +if (!approved) throw new Error("gleif failed to approve qvi's delegated inception"); + +// 3. qvi pulls gleif's anchoring event, then its delegated inception completes. +const qviQueryOp = await qviClient.keyStates().query(gleifPrefix); +await qviClient.operations().wait(qviQueryOp, AbortSignal.timeout(60000)); +await qviClient.operations().wait(qviIcpOp, AbortSignal.timeout(60000)); +await qviClient.operations().delete(qviIcpOp.name); + +await addEndRoleForAID(qviClient, qviAlias, ROLE_AGENT); +const qviOOBI = await generateOOBI(qviClient, qviAlias, ROLE_AGENT); +prMessage(`QVI Prefix (delegated by gleif): ${qviPrefix}`); + +// LE +const leBran = randomPasscode(); +const leAlias = "le"; +const { client: leClient } = await initializeAndConnectClient(leBran); +await resolveWitnesses(leClient, "le"); +const { aid: leAid } = await createNewAID(leClient, leAlias, DEFAULT_IDENTIFIER_ARGS); +await addEndRoleForAID(leClient, leAlias, ROLE_AGENT); +const leOOBI = await generateOOBI(leClient, leAlias, ROLE_AGENT); +const lePrefix = leAid.i; +prMessage(`LE Prefix: ${lePrefix}`); + +// Role Holder +const roleBran = randomPasscode(); +const roleAlias = "role"; +const { client: roleClient } = await initializeAndConnectClient(roleBran); +await resolveWitnesses(roleClient, "role"); +const { aid: roleAid } = await createNewAID(roleClient, roleAlias, DEFAULT_IDENTIFIER_ARGS); +await addEndRoleForAID(roleClient, roleAlias, ROLE_AGENT); +const roleOOBI = await generateOOBI(roleClient, roleAlias, ROLE_AGENT); +const rolePrefix = roleAid.i; +prMessage(`ROLE Prefix: ${rolePrefix}`); + +// Client OOBI resolution (create contacts) +prTitle("Resolving OOBIs"); +await Promise.all([ + resolveOOBI(gleifClient, qviOOBI, qviAlias), + resolveOOBI(qviClient, gleifOOBI, gleifAlias), + resolveOOBI(qviClient, leOOBI, leAlias), + resolveOOBI(qviClient, roleOOBI, roleAlias), + resolveOOBI(leClient, gleifOOBI, gleifAlias), + resolveOOBI(leClient, qviOOBI, qviAlias), + resolveOOBI(leClient, roleOOBI, roleAlias), + resolveOOBI(roleClient, gleifOOBI, gleifAlias), + resolveOOBI(roleClient, leOOBI, leAlias), + resolveOOBI(roleClient, qviOOBI, qviAlias), +]); + +// Create credential registries +prTitle("Creating Credential Registries"); + +let gleifRegistrySaid; +try { + const registries = await gleifClient.registries().list(gleifAlias); + gleifRegistrySaid = registries[0].regk; +} catch { + prMessage("Creating GLEIF Registry"); + const { registrySaid: newRegistrySaid } = await createCredentialRegistry(gleifClient, gleifAlias, "gleifRegistry"); + gleifRegistrySaid = newRegistrySaid; +} +const { registrySaid: qviRegistrySaid } = await createCredentialRegistry(qviClient, qviAlias, "qviRegistry"); +const { registrySaid: leRegistrySaid } = await createCredentialRegistry(leClient, leAlias, "leRegistry"); +prContinue(); + +// --------------------------------------------------------------------------- +// Schemas — well-known, preloaded into the local schema server. +// --------------------------------------------------------------------------- +const QVI_SCHEMA_SAID = "EBfdlu8R27Fbx-ehrqwImnK-8Cm79sqbAQ4MmvEAYqao"; +const LE_SCHEMA_SAID = "ENPXp1vQzRF6JwIuS-mp2U8Uf1MoADoP_GqQ62VsDZWY"; +const ECR_AUTH_SCHEMA_SAID = "EH6ekLjSr8V32WyFbGe1zXjTzFs9PkTYmupJ9H65O14g"; +const ECR_SCHEMA_SAID = "EEy9PkikFcANV1l7EHukCeXqrzT1hNZjGlUk7wuMO5jw"; +const OOR_AUTH_SCHEMA_SAID = "EKA57bKBKxr_kN7iN5i7lMUxpMG-s19dRcmov1iDxz-E"; +const OOR_SCHEMA_SAID = "EBNaNu-M9P5cgrnfl2Fvymy4E_jvxxyjb70PRtiANlJy"; + +const QVI_SCHEMA_URL = `${SCHEMA_SERVER_HOST}/oobi/${QVI_SCHEMA_SAID}`; +const LE_SCHEMA_URL = `${SCHEMA_SERVER_HOST}/oobi/${LE_SCHEMA_SAID}`; +const ECR_AUTH_SCHEMA_URL = `${SCHEMA_SERVER_HOST}/oobi/${ECR_AUTH_SCHEMA_SAID}`; +const ECR_SCHEMA_URL = `${SCHEMA_SERVER_HOST}/oobi/${ECR_SCHEMA_SAID}`; +const OOR_AUTH_SCHEMA_URL = `${SCHEMA_SERVER_HOST}/oobi/${OOR_AUTH_SCHEMA_SAID}`; +const OOR_SCHEMA_URL = `${SCHEMA_SERVER_HOST}/oobi/${OOR_SCHEMA_SAID}`; + +prTitle("Resolving Schemas"); +await Promise.all([ + resolveOOBI(gleifClient, QVI_SCHEMA_URL), + + resolveOOBI(qviClient, QVI_SCHEMA_URL), + resolveOOBI(qviClient, LE_SCHEMA_URL), + resolveOOBI(qviClient, ECR_AUTH_SCHEMA_URL), + resolveOOBI(qviClient, ECR_SCHEMA_URL), + resolveOOBI(qviClient, OOR_AUTH_SCHEMA_URL), + resolveOOBI(qviClient, OOR_SCHEMA_URL), + + resolveOOBI(leClient, QVI_SCHEMA_URL), + resolveOOBI(leClient, LE_SCHEMA_URL), + resolveOOBI(leClient, ECR_AUTH_SCHEMA_URL), + resolveOOBI(leClient, ECR_SCHEMA_URL), + resolveOOBI(leClient, OOR_AUTH_SCHEMA_URL), + resolveOOBI(leClient, OOR_SCHEMA_URL), + + resolveOOBI(roleClient, QVI_SCHEMA_URL), + resolveOOBI(roleClient, LE_SCHEMA_URL), + resolveOOBI(roleClient, ECR_AUTH_SCHEMA_URL), + resolveOOBI(roleClient, ECR_SCHEMA_URL), + resolveOOBI(roleClient, OOR_AUTH_SCHEMA_URL), + resolveOOBI(roleClient, OOR_SCHEMA_URL), +]); +prContinue(); + +// --------------------------------------------------------------------------- +// Step 1: QVI Credential — GLEIF issues a Qualified vLEI Issuer credential to +// the QVI. First link in the chain, so no edge block. +// --------------------------------------------------------------------------- +{ + const qviData = { + LEI: "254900OPPU84GM83MG36", // QVI LEI (arbitrary value) + }; + + prTitle("Issuing Credential"); + const { credentialSaid } = await issueCredential( + gleifClient, gleifAlias, gleifRegistrySaid, + QVI_SCHEMA_SAID, + qviPrefix, + qviData, + ); + + qviCredential = await gleifClient.credentials().get(credentialSaid); + + prTitle("Granting Credential"); + await ipexGrantCredential(gleifClient, gleifAlias, qviPrefix, qviCredential); + + const grantNotifications = await waitForAndGetNotification(qviClient, IPEX_GRANT_ROUTE); + const grantNotification = grantNotifications[0]; + + prTitle("Admitting Grant"); + await ipexAdmitGrant(qviClient, qviAlias, gleifPrefix, grantNotification.a.d); + await markNotificationRead(qviClient, grantNotification.i); + + const admitNotifications = await waitForAndGetNotification(gleifClient, IPEX_ADMIT_ROUTE); + await markNotificationRead(gleifClient, admitNotifications[0].i); + + prContinue(); +} + +// --------------------------------------------------------------------------- +// Step 2: LE Credential — QVI issues a Legal Entity credential to the LE, +// chained back to the QVI credential via the leEdge `n` pointer. +// --------------------------------------------------------------------------- +{ + leData = { + LEI: "875500ELOZEL05BVXV37", + }; + + const leEdge = Saider.saidify({ + d: "", + qvi: { + n: qviCredential.sad.d, + s: qviCredential.sad.s, + }, + })[1]; + + const leRules = Saider.saidify({ + d: "", + usageDisclaimer: { + l: "Usage of a valid, unexpired, and non-revoked vLEI Credential, as defined in the associated Ecosystem Governance Framework, does not assert that the Legal Entity is trustworthy, honest, reputable in its business dealings, safe to do business with, or compliant with any laws or that an implied or expressly intended purpose will be fulfilled.", + }, + issuanceDisclaimer: { + l: "All information in a valid, unexpired, and non-revoked vLEI Credential, as defined in the associated Ecosystem Governance Framework, is accurate as of the date the validation process was complete. The vLEI Credential has been issued to the legal entity or person named in the vLEI Credential as the subject; and the qualified vLEI Issuer exercised reasonable care to perform the validation process set forth in the vLEI Ecosystem Governance Framework.", + }, + })[1]; + + prTitle("Issuing Credential"); + const { credentialSaid } = await issueCredential( + qviClient, qviAlias, qviRegistrySaid, + LE_SCHEMA_SAID, + lePrefix, + leData, leEdge, leRules, + ); + + prTitle("Granting Credential"); + leCredential = await qviClient.credentials().get(credentialSaid); + + await ipexGrantCredential(qviClient, qviAlias, lePrefix, leCredential); + + const grantNotifications = await waitForAndGetNotification(leClient, IPEX_GRANT_ROUTE); + const grantNotification = grantNotifications[0]; + + prTitle("Admitting Grant"); + await ipexAdmitGrant(leClient, leAlias, qviPrefix, grantNotification.a.d); + await markNotificationRead(leClient, grantNotification.i); + + const admitNotifications = await waitForAndGetNotification(qviClient, IPEX_ADMIT_ROUTE); + await markNotificationRead(qviClient, admitNotifications[0].i); + + prContinue(); +} + +// --------------------------------------------------------------------------- +// Step 6 (Path 1): ECR Credential — LE directly issues an Engagement Context +// Role credential to the Role holder, chained to the LE's own vLEI credential. +// --------------------------------------------------------------------------- +{ + const ecrData = { + LEI: leData.LEI, + personLegalName: "John Doe", + engagementContextRole: "Managing Director", + }; + + const ecrEdge = Saider.saidify({ + d: "", + le: { + n: leCredential.sad.d, + s: leCredential.sad.s, + }, + })[1]; + + const ecrRules = Saider.saidify({ + d: "", + usageDisclaimer: { + l: "Usage of a valid, unexpired, and non-revoked vLEI Credential, as defined in the associated Ecosystem Governance Framework, does not assert that the Legal Entity is trustworthy, honest, reputable in its business dealings, safe to do business with, or compliant with any laws or that an implied or expressly intended purpose will be fulfilled.", + }, + issuanceDisclaimer: { + l: "All information in a valid, unexpired, and non-revoked vLEI Credential, as defined in the associated Ecosystem Governance Framework, is accurate as of the date the validation process was complete. The vLEI Credential has been issued to the legal entity or person named in the vLEI Credential as the subject; and the qualified vLEI Issuer exercised reasonable care to perform the validation process set forth in the vLEI Ecosystem Governance Framework.", + }, + privacyDisclaimer: { + l: "It is the sole responsibility of Holders as Issuees of an ECR vLEI Credential to present that Credential in a privacy-preserving manner using the mechanisms provided in the Issuance and Presentation Exchange (IPEX) protocol specification and the Authentic Chained Data Container (ACDC) specification. https://github.com/WebOfTrust/IETF-IPEX and https://github.com/trustoverip/tswg-acdc-specification.", + }, + })[1]; + + prTitle("Issuing Credential"); + const { credentialSaid } = await issueCredential( + leClient, leAlias, leRegistrySaid, + ECR_SCHEMA_SAID, + rolePrefix, + ecrData, ecrEdge, ecrRules, + true, + ); + + ecrCredential = await leClient.credentials().get(credentialSaid); + + prTitle("Granting Credential"); + await ipexGrantCredential(leClient, leAlias, rolePrefix, ecrCredential); + + const grantNotifications = await waitForAndGetNotification(roleClient, IPEX_GRANT_ROUTE); + const grantNotification = grantNotifications[0]; + + prTitle("Admitting Grant"); + await ipexAdmitGrant(roleClient, roleAlias, lePrefix, grantNotification.a.d); + await markNotificationRead(roleClient, grantNotification.i); + + const admitNotifications = await waitForAndGetNotification(leClient, IPEX_ADMIT_ROUTE); + await markNotificationRead(leClient, admitNotifications[0].i); + + prContinue(); +} + +// --------------------------------------------------------------------------- +// Register the local GLEIF root of trust + export the holder's full-chain ECR +// vLEI for the RA to present. +// +// The RA is the single touchpoint for the verifier, so this does NOT PUT the +// presentation or poll /authorizations — ans-ra does that itself inside POST +// /v2/ans/identities (the vleiPresentation). This only does the two things the +// RA cannot do: register the synthetic local GLEIF root of trust (an admin +// bootstrap of the verifier), and export the holder's full-chain CESR + LEI. +// --------------------------------------------------------------------------- +{ + const ecrSaid = ecrCredential.sad.d; + const ecrLEI = ecrCredential.sad.a.LEI; + + // Export the ECR credential as a self-contained CESR stream. KERIA's exporter + // walks the full edge chain (ECR -> LE -> QVI) and emits every issuer/subject + // KEL and TEL — including the local GLEIF root's KEL and the holder's (role) + // KEL — so this single blob is everything the verifier needs to verify the + // chain to root. The RA parses the leaf SAID + subject AID out of this blob. + const ecrCesr = await roleClient.credentials().get(ecrSaid, true); + + // Register the LOCAL `gleif` AID as a root of trust. The verifier ships + // trusting the real GLEIF external root; our synthetic chain roots at this + // `gleif` AID, so the verifier must be told to trust it or the presented + // chain will not verify. The chain CESR already contains gleif's KEL (it is + // the root issuer), so it doubles as the `vlei` payload. + const rotResp = await fetch(`${VERIFIER_URL}/root_of_trust/${gleifPrefix}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ vlei: ecrCesr, oobi: gleifOOBI }), + }); + console.log("root_of_trust:", rotResp.status, await rotResp.text()); + + // Export {cesr, lei, aid} for the shell demo. The RA parses said+aid out of + // the CESR itself and presents it to the verifier on the holder's behalf. + await Deno.mkdir(OUT_DIR, { recursive: true }); + const presentation = { cesr: ecrCesr, lei: ecrLEI, aid: rolePrefix }; + await Deno.writeTextFile(`${OUT_DIR}/ecr-presentation.json`, JSON.stringify(presentation, null, 2)); + + console.log("\n✅ root of trust registered and full-chain CESR exported."); + console.log(` holder AID : ${rolePrefix}`); + console.log(` LEI : ${ecrLEI}`); + console.log(` leaf SAID : ${ecrSaid}`); + console.log(` wrote ${OUT_DIR}/ecr-presentation.json`); +} + +// --------------------------------------------------------------------------- +// Export the holder state the proof signer needs (roleBran reconnects to the +// holder's KERIA agent so sign-proof.ts can sign with the role AID's keys). +// --------------------------------------------------------------------------- +{ + const outputs = { + gleifPrefix, + rolePrefix, + roleBran, + LEI: leData.LEI, + ecrCredentialSaid: ecrCredential.sad.d, + gleifOOBI, + }; + await Deno.writeTextFile(`${OUT_DIR}/tier1-outputs.json`, JSON.stringify(outputs, null, 2)); + console.log(` wrote ${OUT_DIR}/tier1-outputs.json`); +} diff --git a/scripts/demo/vlei/signify/scripts_ts/sign-proof.ts b/scripts/demo/vlei/signify/scripts_ts/sign-proof.ts new file mode 100644 index 0000000..f2bcec7 --- /dev/null +++ b/scripts/demo/vlei/signify/scripts_ts/sign-proof.ts @@ -0,0 +1,62 @@ +// ============================================================================ +// ANS demo — standalone holder-side proof signer. +// +// Signs the RA's verify-control `signingInput` with the holder (role) AID, +// OUTSIDE the notebook, so the verify-control flow needs no manual copy-paste. +// This is the automated equivalent of the notebook's interactive sign cell +// (tagged `skip-headless`); that cell stays in the notebook as the human +// fallback. +// +// WHY the signingInput and not the bare nonce: the lei control proof is +// uniform with the JWS kinds — every kind signs the served `signingInput` +// (the base64url of the JCS-canonical IdentityProofInput), never a bare +// nonce. The RA forwards the signature to the vlei-verifier as +// `non_prefixed_digest = signingInput`, and the verifier checks it over the +// exact UTF-8 bytes of that string. So we sign those bytes verbatim. +// +// USAGE (run inside the jupyter/signify container, on the stack network): +// deno run -A scripts_ts/sign-proof.ts +// +// Prints ONLY the indexed Siger qb64 signature to stdout (e.g. "AAB…") so a +// shell can capture it directly; all diagnostics go to stderr. +// +// WHY connect, not boot: utils.ts initializeAndConnectClient() calls +// client.boot() first, which fails (and is re-thrown) when the agent already +// exists. The role agent was booted by the notebook run, so here we ONLY +// connect. Reconnecting with the SAME roleBran re-attaches to that same +// persistent KERIA agent — the role AID's salty signing keys live server-side +// and are reachable through the keeper without any OOBI re-resolution. +// +// WHY the role AID and not `kli sign`: the holder is a KERIA/signify +// identifier whose keys live in the cloud agent reachable only via this +// client. A local kli keystore would be a different AID, and the verifier +// pins the signer to the credential's subject AID — so a kli signature is +// rejected as SIGNATURE_INVALID. +// ============================================================================ + +// Pinned to match build-chain.ts so both scripts (and the compose pre-cache) +// resolve the same signify-ts the holder's KERIA agent was created with. +import { ready, SignifyClient, Tier } from "npm:signify-ts@^0.3.0-rc1"; +import { DEFAULT_ADMIN_URL, DEFAULT_BOOT_URL } from "./utils.ts"; + +const ROLE_ALIAS = "role"; + +const [roleBran, signingInput] = Deno.args; +if (!roleBran || !signingInput) { + console.error("usage: deno run -A sign-proof.ts "); + Deno.exit(2); +} + +await ready(); + +// Connect only — do NOT boot an already-existing agent. +const client = new SignifyClient(DEFAULT_ADMIN_URL, roleBran, Tier.low, DEFAULT_BOOT_URL); +await client.connect(); + +const hab = await client.identifiers().get(ROLE_ALIAS); +const keeper = client.manager!.get(hab); +const sigs = await keeper.sign(new TextEncoder().encode(signingInput)); + +// stderr: human-readable context; stdout: the signature only. +console.error(`signerAid : ${hab.prefix}`); +console.log(sigs[0]); diff --git a/scripts/demo/vlei/signify/scripts_ts/utils.ts b/scripts/demo/vlei/signify/scripts_ts/utils.ts new file mode 100644 index 0000000..c0ca782 --- /dev/null +++ b/scripts/demo/vlei/signify/scripts_ts/utils.ts @@ -0,0 +1,1045 @@ +import { + randomPasscode, + ready, + SignifyClient, + Tier, + CreateIdentiferArgs, + State, + Operation, + Contact, + Salter, + Serder +} from 'npm:signify-ts'; + +class Ansi { + // Text Colors + static readonly BLACK = '\x1b[30m'; + static readonly RED = '\x1b[31m'; + static readonly GREEN = '\x1b[32m'; + static readonly YELLOW = '\x1b[33m'; + static readonly BLUE = '\x1b[34m'; + static readonly MAGENTA = '\x1b[35m'; + static readonly CYAN = '\x1b[36m'; + static readonly WHITE = '\x1b[37m'; + + // Bright/Light versions + static readonly BRIGHT_BLACK = '\x1b[90m'; + static readonly BRIGHT_RED = '\x1b[91m'; + static readonly BRIGHT_GREEN = '\x1b[92m'; + static readonly BRIGHT_YELLOW = '\x1b[93m'; + static readonly BRIGHT_BLUE = '\x1b[94m'; + static readonly BRIGHT_MAGENTA = '\x1b[95m'; + static readonly BRIGHT_CYAN = '\x1b[96m'; + static readonly BRIGHT_WHITE = '\x1b[97m'; + + // Background colors + static readonly BG_BLACK = '\x1b[40m'; + static readonly BG_RED = '\x1b[41m'; + static readonly BG_GREEN = '\x1b[42m'; + static readonly BG_YELLOW = '\x1b[43m'; + static readonly BG_BLUE = '\x1b[44m'; + static readonly BG_MAGENTA = '\x1b[45m'; + static readonly BG_CYAN = '\x1b[46m'; + static readonly BG_WHITE = '\x1b[47m'; + + // Bright Background colors + static readonly BG_BRIGHT_BLACK = '\x1b[100m'; + static readonly BG_BRIGHT_RED = '\x1b[101m'; + static readonly BG_BRIGHT_GREEN = '\x1b[102m'; + static readonly BG_BRIGHT_YELLOW = '\x1b[103m'; + static readonly BG_BRIGHT_BLUE = '\x1b[104m'; + static readonly BG_BRIGHT_MAGENTA = '\x1b[105m'; + static readonly BG_BRIGHT_CYAN = '\x1b[106m'; + static readonly BG_BRIGHT_WHITE = '\x1b[107m'; + + // Styles + static readonly BOLD = '\x1b[1m'; + static readonly UNDERLINE = '\x1b[4m'; + static readonly RESET = '\x1b[0m'; +} + +export function prTitle(message: string): void { + console.log(`\n${Ansi.BOLD}${Ansi.UNDERLINE}${Ansi.BG_BLUE}${Ansi.BRIGHT_BLACK} ${message} ${Ansi.RESET}\n`); +} + +export function prMessage(message: string): void { + console.log(`\n${Ansi.BOLD}${Ansi.BRIGHT_BLUE}${message}${Ansi.RESET}\n`); +} + +export function prAlert(message: string): void { + console.log(`\n${Ansi.BOLD}${Ansi.BG_YELLOW}${Ansi.BRIGHT_BLUE}${message}${Ansi.RESET}\n`); +} + +export function prContinue(): void { + const message = " You can continue ✅ "; + console.log(`\n${Ansi.BOLD}${Ansi.BG_GREEN}${Ansi.BRIGHT_BLACK}${message}${Ansi.RESET}\n\n`); +} + +// Helper function for sleeping +export function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +// Function to check the health of a container +export async function isServiceHealthy(healthCheckUrl: string): Promise { + + console.log(`Checking health at: ${healthCheckUrl}`); + + try { + const response = await fetch(healthCheckUrl); + + if (response.ok) { + console.log(`Received status: ${response.status}. Service is healthy.`); + return true; + } else { + console.warn(`Received a non-ok status: ${response.status}. Service is running but may be unhealthy.`); + return false; + } + } catch (error) { + console.error(`Failed to connect to service. It may be down. Error:`, error.message); + return false; + } +} + +// Default KERIA connection parameters (adjust as needed for your environment) +export const DEFAULT_ADMIN_URL = 'http://keria:3901'; +export const DEFAULT_BOOT_URL = 'http://keria:3903'; +export const DEFAULT_TIMEOUT_MS = 30000; // 30 seconds for operations +export const DEFAULT_DELAY_MS = 5000; // 5 seconds for operations +export const DEFAULT_RETRIES = 5; // For retries +export const ROLE_AGENT = 'agent' +export const IPEX_GRANT_ROUTE = '/exn/ipex/grant' +export const IPEX_ADMIT_ROUTE = '/exn/ipex/admit' +export const IPEX_APPLY_ROUTE = '/exn/ipex/apply' +export const IPEX_OFFER_ROUTE = '/exn/ipex/offer' +export const SCHEMA_SERVER_HOST = 'http://vlei-server:7723'; + +export const DEFAULT_IDENTIFIER_ARGS = { + toad: 3, + wits: [ + 'BBilc4-L3tFUnfM_wJr4S4OJanAv_VmF_dJNN6vkf2Ha', + 'BLskRTInXnMxWaGqcpSyMgo0nYbalW99cGZESrz3zapM', + 'BIKKuvBwpmDVA4Ds-EpL5bt9OqPzWPja2LigFYZN2YfX' + ] +}; + +/** + * Initializes the Signify-ts library. + */ +export async function initializeSignify() { + await ready(); + console.log('Signify-ts library initialized.'); +} + +/** + * Creates a new SignifyClient instance, boots it, and connects to the KERIA agent. + * + * @returns {Promise<{ client: SignifyClient; bran: string; clientState: State }>} + * The initialized client, its bran, and state. + */ +export async function initializeAndConnectClient( + bran: string, + adminUrl: string = DEFAULT_ADMIN_URL, + bootUrl: string = DEFAULT_BOOT_URL, + tier: Tier = Tier.low +): Promise<{ client: SignifyClient;clientState: State }> { + + console.log(`Using Passcode (bran): ${bran}`); + + const client = new SignifyClient(adminUrl, bran, tier, bootUrl); + + try { + await client.boot(); + console.log('Client boot process initiated with KERIA agent.'); + + await client.connect(); + const clientState = await client.state(); + + console.log(' Client AID Prefix: ', clientState?.controller?.state?.i); + console.log(' Agent AID Prefix: ', clientState?.agent?.i); + + return { client, clientState }; + } catch (error) { + console.error('Failed to initialize or connect client:', error); + throw error; + } +} + +/** + * Creates a new AID using the provided client. + * + * @param {SignifyClient} client - The initialized SignifyClient. + * @param {string} alias - A human-readable alias for the AID. + * @param {CreateIdentiferArgs} [identifierArgs=DEFAULT_IDENTIFIER_ARGS] - Configuration for the new AID. + * @returns {Promise<{ aid: any; operation: Operation }>} The created AID's inception event and the operation details. + */ +export async function createNewAID( + client: SignifyClient, + alias: string, + identifierArgs: CreateIdentiferArgs = DEFAULT_IDENTIFIER_ARGS +): Promise<{ aid: any; operation: Operation }> { + console.log(`Initiating AID inception for alias: ${alias}`); + try { + const inceptionResult = await client.identifiers().create(alias, identifierArgs as any); + const operationDetails = await inceptionResult.op(); + + const completedOperation = await client + .operations() + .wait(operationDetails, AbortSignal.timeout(DEFAULT_TIMEOUT_MS)); + + if (completedOperation.error) { + throw new Error(`AID creation failed: ${JSON.stringify(completedOperation.error)}`); + } + + const newAidInceptionEvent = completedOperation.response; + console.log(`Successfully created AID with prefix: ${newAidInceptionEvent?.i}`); + + await client.operations().delete(completedOperation.name); + + return { aid: newAidInceptionEvent, operation: completedOperation }; + } catch (error) { + console.error(`Failed to create AID for alias "${alias}":`, error); + throw error; + } +} + +/** + * Assigns an end role for a given AID to the client's KERIA Agent AID. + * + * @returns {Promise<{ operation: Operation }>} The operation details. + */ +export async function addEndRoleForAID( + client: SignifyClient, + aidAlias: string, + role: string +): Promise<{ operation: Operation }> { + if (!client.agent?.pre) { + throw new Error('Client agent prefix is not available.'); + } + const agentAIDPrefix = client.agent.pre; + + console.log(`Assigning '${role}' role to KERIA Agent ${agentAIDPrefix} for AID alias ${aidAlias}`); + try { + const addRoleResult = await client + .identifiers() + .addEndRole(aidAlias, role, agentAIDPrefix); + + const operationDetails = await addRoleResult.op(); + + const completedOperation = await client + .operations() + .wait(operationDetails, AbortSignal.timeout(DEFAULT_TIMEOUT_MS)); + + console.log(`Successfully assigned '${role}' role for AID alias ${aidAlias}.`); + + await client.operations().delete(completedOperation.name); + + return { operation: completedOperation }; + } catch (error) { + console.error(`Failed to add end role for AID alias "${aidAlias}":`, error); + throw error; + } +} + +/** + * Generates an OOBI URL for a given AID and role. + * The arguments for client.oobis().get() are passed directly. + * + * @returns {Promise} The generated OOBI URL. + */ +export async function generateOOBI( + client: SignifyClient, + aidAlias: string, + role: string = 'agent' +): Promise { + console.log(`Generating OOBI for AID alias ${aidAlias} with role ${role}`); + try { + const oobiResult = await client.oobis().get(aidAlias, role); + if (!oobiResult?.oobis?.length) { + throw new Error('No OOBI URL returned from KERIA agent.'); + } + const oobiUrl = oobiResult.oobis[0]; + console.log(`Generated OOBI URL: ${oobiUrl}`); + return oobiUrl; + } catch (error) { + console.error(`Failed to generate OOBI for AID alias "${aidAlias}":`, error); + throw error; + } +} + +/** + * Resolves an OOBI URL + * + * @returns {Promise<{ operation: Operation; contacts?: Contact[] }>} The operation details and the resolved contact. + */ +export async function resolveOOBI( + client: SignifyClient, + oobiUrl: string, + contactAlias?: string +): Promise<{ operation: Operation; contacts?: Contact[] }> { + console.log(`Resolving OOBI URL: ${oobiUrl} with alias ${contactAlias}`); + try { + const resolveOperationDetails = await client.oobis().resolve(oobiUrl, contactAlias); + const completedOperation = await client + .operations() + .wait(resolveOperationDetails, AbortSignal.timeout(DEFAULT_TIMEOUT_MS)); + + if (completedOperation.error) { + throw new Error(`OOBI resolution failed: ${JSON.stringify(completedOperation.error)}`); + } + console.log(`Successfully resolved OOBI URL. Response:`, completedOperation.response ? "OK" : "No response data"); + + const contact = await client.contacts().list(undefined, 'alias', contactAlias); + + if (contact) { + console.log(`Contact "${contactAlias}" added/updated.`); + } else { + console.warn(`Contact "${contactAlias}" not found after OOBI resolution.`); + } + + await client.operations().delete(completedOperation.name); + + return { operation: completedOperation, contact: contact }; + } catch (error) { + console.error(`Failed to resolve OOBI URL "${oobiUrl}":`, error); + throw error; + } +} + +/** + * Generates challenge words for authentication. + * @param {SignifyClient} client - The SignifyClient instance. + * @param {number} [strength=128] - The bit strength for the challenge (e.g., 128, 256). + * @returns {Promise} A promise that resolves to an array of challenge words. + */ +export async function generateChallengeWords( + client: SignifyClient, + strength: number = 128 +): Promise { + console.log(`Generating ${strength}-bit challenge words...`); + try { + const challenge = await client.challenges().generate(strength); + console.log('Generated challenge words:', challenge.words); + return challenge.words; + } catch (error) { + console.error('Failed to generate challenge words:', error); + throw error; + } +} + +/** + * Responds to a challenge by signing the words and sending them to the challenger. + * @param {SignifyClient} client - The SignifyClient instance of the responder. + * @param {string} sourceAidAlias - The alias of the AID that is responding (signing). + * @param {string} recipientAidPrefix - The AID prefix of the challenger (to whom the response is sent). + * @param {string[]} challengeWords - The array of challenge words to sign. + * @returns {Promise} A promise that resolves when the response is sent. + */ +export async function respondToChallenge( + client: SignifyClient, + sourceAidAlias: string, + recipientAidPrefix: string, + challengeWords: string[] +): Promise { + console.log(`AID alias '${sourceAidAlias}' responding to challenge from AID '${recipientAidPrefix}'...`); + try { + await client.challenges().respond(sourceAidAlias, recipientAidPrefix, challengeWords); + console.log('Challenge response sent.'); + } catch (error) { + console.error('Failed to respond to challenge:', error); + throw error; + } +} + +/** + * Verifies a challenge response received from another AID. + * @param {SignifyClient} client - The SignifyClient instance of the verifier. + * @param {string} allegedSenderAidPrefix - The AID prefix of the AID that allegedly sent the response. + * @param {string[]} originalChallengeWords - The original challenge words that were sent. + * @returns {Promise<{ verified: boolean; said?: string; operation?: Operation }>} + * A promise that resolves to an object indicating if verification was successful, + * the SAID of the signed exchange message, and the operation details. + */ +export async function verifyChallengeResponse( + client: SignifyClient, + allegedSenderAidPrefix: string, + originalChallengeWords: string[] +): Promise<{ verified: boolean; said?: string; operation?: Operation }> { + console.log(`Verifying challenge response from AID '${allegedSenderAidPrefix}'...`); + try { + const verifyOperation = await client.challenges().verify(allegedSenderAidPrefix, originalChallengeWords); + const completedOperation = await client + .operations() + .wait(verifyOperation, AbortSignal.timeout(DEFAULT_TIMEOUT_MS)); + + if (completedOperation.error) { + console.error('Challenge verification failed:', completedOperation.error); + await client.operations().delete(completedOperation.name); + return { verified: false, operation: completedOperation }; + } + + const said = completedOperation.response?.exn?.d; + console.log(`Challenge response verified successfully. SAID of exn: ${said}`); + + await client.operations().delete(completedOperation.name); + + return { verified: true, said: said, operation: completedOperation }; + } catch (error) { + console.error('Failed to verify challenge response:', error); + throw error; + } +} + +/** + * Marks a challenge for a contact as authenticated. + * This is done after successful verification of a challenge response. + * @param {SignifyClient} client - The SignifyClient instance. + * @param {string} contactAidPrefix - The AID prefix of the contact to mark as authenticated. + * @param {string} signedChallengeSaid - The SAID of the signed challenge exchange message (exn). + * @returns {Promise} A promise that resolves when the contact is marked. + */ +export async function markChallengeAuthenticated( + client: SignifyClient, + contactAidPrefix: string, + signedChallengeSaid: string +): Promise { + console.log(`Marking challenge for contact AID '${contactAidPrefix}' as authenticated with SAID '${signedChallengeSaid}'...`); + try { + await client.challenges().responded(contactAidPrefix, signedChallengeSaid); + console.log(`Contact AID '${contactAidPrefix}' marked as authenticated.`); + } catch (error) { + console.error(`Failed to mark challenge as authenticated for contact AID '${contactAidPrefix}':`, error); + throw error; + } +} + +export function createTimestamp() { + return new Date().toISOString().replace('Z', '000+00:00'); +} + +/** + * Creates a new credential registry for an AID. + * @param {SignifyClient} client - The SignifyClient instance. + * @param {string} aidAlias - The alias of the AID creating the registry. + * @param {string} registryName - A human-readable name for the registry. + * @returns {Promise<{ registry: any; operation: Operation }>} The created registry details and operation. + */ +export async function createCredentialRegistry( + client: SignifyClient, + aidAlias: string, + registryName: string +): Promise<{ registrySaid: any; operation: Operation }> { + console.log(`Creating credential registry "${registryName}" for AID alias "${aidAlias}"...`); + try { + const createRegistryResult = await client + .registries() + .create({ name: aidAlias, registryName: registryName }); + + const operationDetails = await createRegistryResult.op(); + const completedOperation = await client + .operations() + .wait(operationDetails, AbortSignal.timeout(DEFAULT_TIMEOUT_MS)); + + if (completedOperation.error) { + throw new Error(`Credential registry creation failed: ${JSON.stringify(completedOperation.error)}`); + } + + const registrySaid = completedOperation?.response?.anchor?.i; + console.log(`Successfully created credential registry: ${registrySaid}`); + + await client.operations().delete(completedOperation.name); + return { registrySaid, operation: completedOperation }; + } catch (error) { + console.error(`Failed to create credential registry "${registryName}":`, error); + throw error; + } +} + +/** + * Retrieves a schema by its SAID. + * @param {SignifyClient} client - The SignifyClient instance. + * @param {string} schemaSaid - The SAID of the schema to retrieve. + * @returns {Promise} The schema object. + */ +export async function getSchema( + client: SignifyClient, + schemaSaid: string +): Promise { + console.log(`Retrieving schema with SAID: ${schemaSaid}...`); + try { + const schema = await client.schemas().get(schemaSaid); + console.log(`Successfully retrieved schema: ${schemaSaid}`); + return schema; + } catch (error) { + console.error(`Failed to retrieve schema "${schemaSaid}":`, error); + throw error; + } +} + +/** + * Issues a new credential. + * @param {SignifyClient} client - The SignifyClient instance. + * @param {string} issuerAidAlias - The alias of the issuing AID. + * @param {string} registryIdentifier - The identifier (regk) of the registry. + * @param {string} schemaSaid - The SAID of the credential's schema. + * @param {string} holderAidPrefix - The prefix of the AID to whom the credential will be issued. + * @param {any} credentialClaims - The claims/attributes of the credential. + * @returns {Promise<{ credentialSad: any; credentialSaid: string; operation: Operation }>} The issued credential's SAD, SAID, and operation. + */ +export async function issueCredential( + client: SignifyClient, + issuerAidAlias: string, + registryIdentifier: string, + schemaSaid: string, + holderAidPrefix: string, + credentialClaims: any, + edges?: any, + rules?: any, + salt = false +): Promise<{ credentialSaid: string; operation: Operation }> { + console.log(`Issuing credential from AID "${issuerAidAlias}" to AID "${holderAidPrefix}"...`); + try { + const issueResult = await client + .credentials() + .issue( + issuerAidAlias, + { + ri: registryIdentifier, + s: schemaSaid, + u: salt ? new Salter({}).qb64 : undefined, + a: { + i: holderAidPrefix, + ...credentialClaims + }, + e: edges, + r: rules + }); + + const operationDetails = await issueResult.op; + const completedOperation = await client + .operations() + .wait(operationDetails, AbortSignal.timeout(DEFAULT_TIMEOUT_MS)); + + if (completedOperation.error) { + throw new Error(`Credential issuance failed: ${JSON.stringify(completedOperation.error)}`); + } + console.log(completedOperation) // ************ + const credentialSad = completedOperation.response; // The full Self-Addressing Data (SAD) of the credential + const credentialSaid = credentialSad?.ced?.d; // The SAID of the credential + console.log(`Successfully issued credential with SAID: ${credentialSaid}`); + + await client.operations().delete(completedOperation.name); + return { credentialSaid, operation: completedOperation }; + } catch (error) { + console.error('Failed to issue credential:', error); + throw error; + } +} + +/** + * Submits an IPEX grant for a credential. + * @param {SignifyClient} client - The SignifyClient instance of the issuer. + * @param {string} senderAidAlias - The alias of the AID granting the credential. + * @param {string} recipientAidPrefix - The AID prefix of the recipient (holder). + * @param {any} acdc - The ACDC (credential). + * @returns {Promise<{ operation: Operation }>} The operation details. + */ +export async function ipexGrantCredential( + client: SignifyClient, + senderAidAlias: string, + recipientAidPrefix: string, + acdc: any +): Promise<{ operation: Operation }> { + console.log(`AID "${senderAidAlias}" granting credential to AID "${recipientAidPrefix}" via IPEX...`); + try { + + const [grant, gsigs, gend] = await client.ipex().grant({ + senderName: senderAidAlias, + acdc: new Serder(acdc?.sad), + iss: new Serder(acdc?.iss), + anc: new Serder(acdc?.anc), + ancAttachment: acdc.ancatc, + recipient: recipientAidPrefix, + datetime: createTimestamp(), + }); + + const submitGrantOperationDetails = await client + .ipex() + .submitGrant(senderAidAlias, grant, gsigs, gend, [recipientAidPrefix]); + + const completedOperation = await client + .operations() + .wait(submitGrantOperationDetails, AbortSignal.timeout(DEFAULT_TIMEOUT_MS)); + + if (completedOperation.error) { + throw new Error(`IPEX grant submission failed: ${JSON.stringify(completedOperation.error)}`); + } + + console.log(`Successfully submitted IPEX grant from "${senderAidAlias}" to "${recipientAidPrefix}".`); + await client.operations().delete(completedOperation.name); + return { operation: completedOperation }; + } catch (error) { + console.error('Failed to submit IPEX grant:', error); + throw error; + } +} + +/** + * Retrieves the state of a credential. + * Includes retry logic as this might be called before the information has propagated. + * @param {SignifyClient} client - The SignifyClient instance. + * @param {string} registryIdentifier - The registry identifier (regk). + * @param {string} credentialSaid - The SAID of the credential. + * @param {number} [retries=DEFAULT_RETRIES] - Number of retry attempts. + * @param {number} [delayMs=DEFAULT_DELAY_MS] - Delay between retries in milliseconds. + * @returns {Promise} The credential state. + */ +export async function getCredentialState( + client: SignifyClient, + registryIdentifier: string, + credentialSaid: string, + retries: number = DEFAULT_RETRIES, + delayMs: number = DEFAULT_DELAY_MS +): Promise { + console.log(`Querying credential state for SAID "${credentialSaid}" in registry "${registryIdentifier}"...`); + for (let attempt = 1; attempt <= retries; attempt++) { + try { + const credentialState = await client.credentials().state(registryIdentifier, credentialSaid); + console.log('Successfully retrieved credential state.'); + return credentialState; + } catch (error: any) { + console.warn(`[Attempt ${attempt}/${retries}] Failed to get credential state: ${error.message}`); + if (attempt === retries) { + console.error(`Max retries (${retries}) reached for getting credential state.`); + throw error; + } + await new Promise(resolve => setTimeout(resolve, delayMs)); + } + } + // Should not be reached if retries > 0 + throw new Error('Failed to get credential state after all retries.'); +} + +/** + * Waits for and retrieves a specific notification. + * @param {SignifyClient} client - The SignifyClient instance. + * @param {string} expectedRoute - The expected route in the notification attributes (e.g., IPEX_GRANT_ROUTE). + * @param {number} [retries=DEFAULT_RETRIES] - Number of retry attempts. + * @param {number} [delayMs=DEFAULT_DELAY_MS] - Delay between retries in milliseconds. + * @returns {Promise} The first matching unread notification. + */ +export async function waitForAndGetNotification( + client: SignifyClient, + expectedRoute: string, + retries: number = DEFAULT_RETRIES, + delayMs: number = DEFAULT_DELAY_MS +): Promise { + console.log(`Waiting for notification with route "${expectedRoute}"...`); + + let notifications; + + // Retry loop to fetch notifications. + for (let attempt = 1; attempt <= DEFAULT_RETRIES ; attempt++) { + try{ + // List notifications, filtering for unread IPEX_GRANT_ROUTE messages. + let allNotifications = await client.notifications().list() + notifications = allNotifications.notes.filter( + (n) => n.a.r === expectedRoute && n.r === false // n.r is 'read' status + ); + if(notifications.length === 0){ + throw new Error("Notification not found yet."); // Throw error to trigger retry + } + return notifications; + } + catch (error){ + console.log(`[Retry] Grant notification not found on attempt #${attempt} of ${DEFAULT_RETRIES}`); + if (attempt === DEFAULT_RETRIES) { + console.error(`[Retry] Max retries (${DEFAULT_RETRIES}) reached for grant notification.`); + throw error; + } + console.log(`[Retry] Waiting ${DEFAULT_DELAY_MS}ms before next attempt...`); + await new Promise(resolve => setTimeout(resolve, DEFAULT_DELAY_MS)); + } + } +} + +/** + * Submits an IPEX admit (accepts a grant). + * @param {SignifyClient} client - The SignifyClient instance of the holder. + * @param {string} senderAidAlias - The alias of the AID admitting the grant. + * @param {string} recipientAidPrefix - The AID prefix of the original grantor. + * @param {string} grantSaid - The SAID of the grant being admitted. + * @param {string} [message=''] - Optional message for the admit. + * @returns {Promise<{ operation: Operation }>} The operation details. + */ +export async function ipexAdmitGrant( + client: SignifyClient, + senderAidAlias: string, + recipientAidPrefix: string, + grantSaid: string, + message: string = '' +): Promise<{ operation: Operation }> { + console.log(`AID "${senderAidAlias}" admitting IPEX grant "${grantSaid}" from AID "${recipientAidPrefix}"...`); + try { + const [admit, sigs, aend] = await client.ipex().admit({ + senderName: senderAidAlias, + message: message, + grantSaid: grantSaid, + recipient: recipientAidPrefix, + datetime: createTimestamp(), + }); + + const admitOperationDetails = await client + .ipex() + .submitAdmit(senderAidAlias, admit, sigs, aend, [recipientAidPrefix]); + + const completedOperation = await client + .operations() + .wait(admitOperationDetails, AbortSignal.timeout(DEFAULT_TIMEOUT_MS)); + + if (completedOperation.error) { + throw new Error(`IPEX admit submission failed: ${JSON.stringify(completedOperation.error)}`); + } + console.log(`Successfully submitted IPEX admit for grant "${grantSaid}".`); + await client.operations().delete(completedOperation.name); + return { operation: completedOperation }; + } catch (error) { + console.error('Failed to submit IPEX admit:', error); + throw error; + } +} + +/** + * Marks a notification as read. + * @param {SignifyClient} client - The SignifyClient instance. + * @param {string} notificationId - The ID of the notification to mark. + * @returns {Promise} + */ +export async function markNotificationRead( + client: SignifyClient, + notificationId: string +): Promise { + console.log(`Marking notification "${notificationId}" as read...`); + try { + await client.notifications().mark(notificationId); + console.log(`Notification "${notificationId}" marked as read.`); + } catch (error) { + console.error(`Failed to mark notification "${notificationId}" as read:`, error); + throw error; + } +} + +/** + * Deletes a notification. + * @param {SignifyClient} client - The SignifyClient instance. + * @param {string} notificationId - The ID of the notification to delete. + * @returns {Promise} + */ +export async function deleteNotification( + client: SignifyClient, + notificationId: string +): Promise { + console.log(`Deleting notification "${notificationId}"...`); + try { + await client.notifications().delete(notificationId); + console.log(`Notification "${notificationId}" deleted.`); + } catch (error) { + console.error(`Failed to delete notification "${notificationId}":`, error); + throw error; + } +} + + +//-------------------------------------------------------------------------------- + +// --- Credential Presentation Functions --- + +/** + * Submits an IPEX apply (presentation request). + * @param {SignifyClient} client - The SignifyClient instance of the verifier. + * @param {string} senderAidAlias - The alias of the AID applying for presentation. + * @param {string} recipientAidPrefix - The AID prefix of the holder. + * @param {string} schemaSaid - The SAID of the schema being requested. + * @param {any} attributes - The attributes being requested for the credential. + * @param {string} datetime - The timestamp for the apply. + * @returns {Promise<{ operation: Operation; applySaid: string }>} The operation details and SAID of the apply exn. + */ +export async function ipexApplyForCredential( + client: SignifyClient, + senderAidAlias: string, + recipientAidPrefix: string, + schemaSaid: string, + attributes: any, + datetime: string +): Promise<{ operation: Operation; applySaid: string }> { + console.log(`AID "${senderAidAlias}" applying for credential presentation from AID "${recipientAidPrefix}"...`); + try { + const [apply, sigs, _] = await client.ipex().apply({ + senderName: senderAidAlias, + schemaSaid: schemaSaid, + attributes: attributes, + recipient: recipientAidPrefix, + datetime: datetime, + }); + + const applySaid = new Serder(apply).said; // Get SAID of the apply message itself + + const applyOperationDetails = await client + .ipex() + .submitApply(senderAidAlias, apply, sigs, [recipientAidPrefix]); + + const completedOperation = await client + .operations() + .wait(applyOperationDetails, AbortSignal.timeout(DEFAULT_TIMEOUT_MS)); + + if (completedOperation.error) { + throw new Error(`IPEX apply submission failed: ${JSON.stringify(completedOperation.error)}`); + } + console.log(`Successfully submitted IPEX apply with SAID "${applySaid}".`); + await client.operations().delete(completedOperation.name); + return { operation: completedOperation, applySaid }; + } catch (error) { + console.error('Failed to submit IPEX apply:', error); + throw error; + } +} + +/** + * Finds matching credentials based on a filter. + * @param {SignifyClient} client - The SignifyClient instance of the holder. + * @param {any} filter - The filter object to apply (e.g., { '-s': schemaSaid, '-a-attributeName': value }). + * @returns {Promise} An array of matching credentials. + */ +export async function findMatchingCredentials( + client: SignifyClient, + filter: any +): Promise { + console.log('Finding matching credentials with filter:', filter); + try { + const matchingCredentials = await client.credentials().list({ filter }); + console.log(`Found ${matchingCredentials.length} matching credentials.`); + return matchingCredentials; + } catch (error) { + console.error('Failed to find matching credentials:', error); + throw error; + } +} + +/** + * Submits an IPEX offer (presents a credential). + * @param {SignifyClient} client - The SignifyClient instance of the holder. + * @param {string} senderAidAlias - The alias of the AID offering the credential. + * @param {string} recipientAidPrefix - The AID prefix of the verifier. + * @param {any} acdcSad - The Self-Addressing Data (SAD) of the ACDC being offered. + * @param {string} applySaid - The SAID of the IPEX apply message this offer is responding to. + * @param {string} datetime - The timestamp for the offer. + * @returns {Promise<{ operation: Operation }>} The operation details. + */ +export async function ipexOfferCredential( + client: SignifyClient, + senderAidAlias: string, + recipientAidPrefix: string, + acdcSad: any, // This is the SAD of the credential to be offered + applySaid: string, + datetime: string +): Promise<{ operation: Operation }> { + console.log(`AID "${senderAidAlias}" offering credential to AID "${recipientAidPrefix}" in response to apply "${applySaid}"...`); + try { + const [offer, sigs, end] = await client.ipex().offer({ + senderName: senderAidAlias, + recipient: recipientAidPrefix, + acdc: new Serder(acdcSad), // The credential SAD needs to be wrapped in Serder + applySaid: applySaid, + datetime: datetime, + }); + + const offerOperationDetails = await client + .ipex() + .submitOffer(senderAidAlias, offer, sigs, end, [recipientAidPrefix]); + + const completedOperation = await client + .operations() + .wait(offerOperationDetails, AbortSignal.timeout(DEFAULT_TIMEOUT_MS)); + + if (completedOperation.error) { + throw new Error(`IPEX offer submission failed: ${JSON.stringify(completedOperation.error)}`); + } + console.log(`Successfully submitted IPEX offer in response to apply "${applySaid}".`); + await client.operations().delete(completedOperation.name); + return { operation: completedOperation }; + } catch (error) { + console.error('Failed to submit IPEX offer:', error); + throw error; + } +} + +/** + * Submits an IPEX agree (verifier agrees to the offered credential). + * @param {SignifyClient} client - The SignifyClient instance of the verifier. + * @param {string} senderAidAlias - The alias of the AID agreeing to the offer. + * @param {string} recipientAidPrefix - The AID prefix of the holder who made the offer. + * @param {string} offerSaid - The SAID of the IPEX offer message being agreed to. + * @param {string} datetime - The timestamp for the agree. + * @returns {Promise<{ operation: Operation }>} The operation details. + */ +export async function ipexAgreeToOffer( + client: SignifyClient, + senderAidAlias: string, + recipientAidPrefix: string, + offerSaid: string, + datetime: string +): Promise<{ operation: Operation }> { + console.log(`AID "${senderAidAlias}" agreeing to IPEX offer "${offerSaid}" from AID "${recipientAidPrefix}"...`); + try { + const [agree, sigs, _] = await client.ipex().agree({ + senderName: senderAidAlias, + recipient: recipientAidPrefix, + offerSaid: offerSaid, + datetime: datetime, + }); + + const agreeOperationDetails = await client + .ipex() + .submitAgree(senderAidAlias, agree, sigs, [recipientAidPrefix]); + + const completedOperation = await client + .operations() + .wait(agreeOperationDetails, AbortSignal.timeout(DEFAULT_TIMEOUT_MS)); + + if (completedOperation.error) { + throw new Error(`IPEX agree submission failed: ${JSON.stringify(completedOperation.error)}`); + } + console.log(`Successfully submitted IPEX agree for offer "${offerSaid}".`); + await client.operations().delete(completedOperation.name); + return { operation: completedOperation }; + } catch (error) { + console.error('Failed to submit IPEX agree:', error); + throw error; + } +} + + + + + + + + + + + + + + + + + + + + + + +// --- Example Usage --- +export async function test() { + try { + await initializeSignify(); + + // --- Client A (Alfred) Setup --- + console.log('\n--- Initializing Client A (Alfred) ---'); + const { client: clientA, clientState: clientAState } = await initializeAndConnectClient(randomPasscode()); + const alfredClientAID = clientAState?.controller?.state?.i || 'Unknown Client AID A'; + const aidAAlias = 'alfredPrimaryAID'; + console.log('\n--- Creating AID for Alfred ---'); + const { aid: aidAObj} = await createNewAID(clientA, aidAAlias, DEFAULT_IDENTIFIER_ARGS); + const aidAPrefix = aidAObj?.i || 'Unknown AID A Prefix'; + console.log(`Alfred's primary AID: ${aidAPrefix}`); + console.log('\n--- Adding Agent End Role for Alfred ---'); + await addEndRoleForAID(clientA, aidAAlias, 'agent'); + console.log('\n--- Generating OOBI for Alfred ---'); + const alfredOOBI = await generateOOBI(clientA, aidAAlias, 'agent'); + + // --- Client B (Betty) Setup --- + console.log('\n\n--- Initializing Client B (Betty) ---'); + const { client: clientB, clientState: clientBState } = await initializeAndConnectClient(randomPasscode()); + const bettyClientAID = clientBState?.controller?.state?.i || 'Unknown Client AID B'; + const aidBAlias = 'bettyPrimaryAID'; + console.log('\n--- Creating AID for Betty ---'); + const { aid: aidBObj } = await createNewAID(clientB, aidBAlias, DEFAULT_IDENTIFIER_ARGS); + const aidBPrefix = aidBObj?.i || 'Unknown AID B Prefix'; + console.log(`Betty's primary AID: ${aidBPrefix}`); + console.log('\n--- Adding Agent End Role for Betty ---'); + await addEndRoleForAID(clientB, aidBAlias, 'agent'); + console.log('\n--- Generating OOBI for Betty ---'); + const bettyOOBI = await generateOOBI(clientB, aidBAlias, 'agent'); + + // --- OOBI Resolution --- + console.log('\n\n--- Betty resolving Alfred\'s OOBI ---'); + const contactAlfredAlias = 'AlfredsContactForBetty'; + await resolveOOBI(clientB, alfredOOBI, contactAlfredAlias); + + console.log('\n--- Alfred resolving Betty\'s OOBI ---'); + const contactBettyAlias = 'BettysContactForAlfred'; + await resolveOOBI(clientA, bettyOOBI, contactBettyAlias); + + // --- Challenge/Response: Alfred challenges Betty --- + console.log("\n\n--- MUTUAL AUTHENTICATION ---"); + console.log("\n--- Alfred challenges Betty ---"); + + // 1. Alfred generates challenge words for Betty + const alfredChallengeForBetty = await generateChallengeWords(clientA); + + // (Assume words are securely transmitted out-of-band to Betty) + + // 2. Betty responds to Alfred's challenge + console.log(`\nBetty (AID: ${aidBPrefix}) responding to Alfred's (AID: ${aidAPrefix}) challenge...`); + await respondToChallenge(clientB, aidBAlias, aidAPrefix, alfredChallengeForBetty); + + // 3. Alfred verifies Betty's response + console.log(`\nAlfred (AID: ${aidAPrefix}) verifying Betty's (AID: ${aidBPrefix}) response...`); + const verificationBetty = await verifyChallengeResponse(clientA, aidBPrefix, alfredChallengeForBetty); + + // 4. Alfred marks Betty as authenticated if verification succeeded + if (verificationBetty.verified && verificationBetty.said) { + await markChallengeAuthenticated(clientA, aidBPrefix, verificationBetty.said); + console.log(`Alfred has successfully authenticated Betty (AID: ${aidBPrefix}).`); + } else { + console.error(`Alfred failed to authenticate Betty (AID: ${aidBPrefix}).`); + } + + // --- Challenge/Response: Betty challenges Alfred --- + console.log("\n--- Betty challenges Alfred ---"); + + // 1. Betty generates challenge words for Alfred + const bettyChallengeForAlfred = await generateChallengeWords(clientB); + + // (Assume words are securely transmitted out-of-band to Alfred) + + // 2. Alfred responds to Betty's challenge + console.log(`\nAlfred (AID: ${aidAPrefix}) responding to Betty's (AID: ${aidBPrefix}) challenge...`); + await respondToChallenge(clientA, aidAAlias, aidBPrefix, bettyChallengeForAlfred); + + // 3. Betty verifies Alfred's response + console.log(`\nBetty (AID: ${aidBPrefix}) verifying Alfred's (AID: ${aidAPrefix}) response...`); + const verificationAlfred = await verifyChallengeResponse(clientB, aidAPrefix, bettyChallengeForAlfred); + + // 4. Betty marks Alfred as authenticated if verification succeeded + if (verificationAlfred.verified && verificationAlfred.said) { + await markChallengeAuthenticated(clientB, aidAPrefix, verificationAlfred.said); + console.log(`Betty has successfully authenticated Alfred (AID: ${aidAPrefix}).`); + } else { + console.error(`Betty failed to authenticate Alfred (AID: ${aidAPrefix}).`); + } + + console.log('\n\n--- Example scenario with mutual authentication completed! ---'); + console.log(`Alfred's Client AID: ${alfredClientAID}, Primary AID: ${aidAPrefix}`); + console.log(`Betty's Client AID: ${bettyClientAID}, Primary AID: ${aidBPrefix}`); + + // You can inspect contacts to see authentication status + const alfredsContacts = await clientA.contacts().list(); + console.log("\nAlfred's contacts:", JSON.stringify(alfredsContacts, null, 2)); + const bettysContacts = await clientB.contacts().list(); + console.log("\nBetty's contacts:", JSON.stringify(bettysContacts, null, 2)); + } catch (error) { + console.error('\n--- An error occurred in the main example: ---', error); + } +} diff --git a/scripts/demo/vlei/up.sh b/scripts/demo/vlei/up.sh new file mode 100755 index 0000000..fb47fa5 --- /dev/null +++ b/scripts/demo/vlei/up.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# +# Bring up the vLEI ecosystem (witnesses + schema server + KERIA + +# vlei-verifier + signify Deno runner) and wait for the verifier to come green +# and the signify container to finish pre-caching its deps. The whole stack — +# including the SignifyTS runner — lives in this one compose file; no second +# repo is required. +# +# Usage: +# scripts/demo/vlei/up.sh +# +# Env overrides: +# COMPOSE docker compose command (default: "docker compose") +# VERIFIER_URL verifier base URL to health-check (default: http://localhost:7676) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export DATA="${DATA:-$(cd "$SCRIPT_DIR/../../.." && pwd)/data/demo/vlei}" +# shellcheck source=../common.sh +. "$SCRIPT_DIR/../common.sh" + +COMPOSE="${COMPOSE:-docker compose}" +VERIFIER_URL="${VERIFIER_URL:-http://localhost:7676}" + +require_cmd docker +require_cmd curl + +header "vLEI ecosystem — up" +note "compose file: $SCRIPT_DIR/docker-compose.yml" + +# shellcheck disable=SC2086 # COMPOSE may be a multi-word command +$COMPOSE -f "$SCRIPT_DIR/docker-compose.yml" up -d --build + +note "waiting for vlei-verifier /health at $VERIFIER_URL …" +for _ in $(seq 1 60); do + if curl -fsS "$VERIFIER_URL/health" >/dev/null 2>&1; then + ok "vlei-verifier healthy at $VERIFIER_URL" + break + fi + sleep 2 +done + +if ! curl -fsS "$VERIFIER_URL/health" >/dev/null 2>&1; then + fail "vlei-verifier did not become healthy — check '$COMPOSE -f $SCRIPT_DIR/docker-compose.yml logs'" +fi + +# The signify container pre-caches its npm deps at start and touches /tmp/ready +# when done; probe that marker so the first `deno run` exec is fast. exec needs +# the container running (it is — it idles on tail), and returns non-zero until +# the marker exists. +note "waiting for the signify container to finish caching deps …" +signify_ready() { + # shellcheck disable=SC2086 # COMPOSE may be a multi-word command + $COMPOSE -f "$SCRIPT_DIR/docker-compose.yml" exec -T signify test -f /tmp/ready >/dev/null 2>&1 +} +for _ in $(seq 1 60); do + if signify_ready; then + ok "signify container ready" + break + fi + sleep 2 +done + +if ! signify_ready; then + fail "signify container did not become ready — check '$COMPOSE -f $SCRIPT_DIR/docker-compose.yml logs signify'" +fi + +header "Next steps" +note "Run the whole flow with one command: $SCRIPT_DIR/run-vlei.sh" +note " (requires ans-ra running with vlei.base-url=$VERIFIER_URL)" +note "Or step by step:" +note " 1. $SCRIPT_DIR/build-chain.sh — build the chain + present + export (headless)" +note " 2. $SCRIPT_DIR/verify-control-demo.sh — RA-mediated present + verify-control" diff --git a/scripts/demo/vlei/verify-control-demo.sh b/scripts/demo/vlei/verify-control-demo.sh new file mode 100755 index 0000000..82e9a15 --- /dev/null +++ b/scripts/demo/vlei/verify-control-demo.sh @@ -0,0 +1,259 @@ +#!/usr/bin/env bash +# +# vLEI demo: drive the ans-ra RA-mediated register-with-presentation + +# verify-control flow end-to-end against a REAL vlei-verifier, on the +# identity-scoped /v2/ans/identities routes. +# +# register+present → POST /v2/ans/identities +# { value: $LEI, vleiPresentation:{ cesr: $CESR } } +# the RA reads the leaf credential SAID out of the CESR, +# presents the full chain to the verifier itself, pins the +# verifier-reported subject AID on the identity, and +# returns the challenge round (nonce + signingInput) plus +# the advisory presentationStatus. +# sign → the HOLDER signs the served `signingInput` (NOT the bare +# nonce) with the role AID via signify (sign-proof.ts in +# the signify container) — see below. +# re-present → re-POST /v2/ans/identities (same body) while the row is +# PENDING_CONTROL to refresh the verifier's authorization +# window (it ages off after 10 minutes). Returns the SAME +# identityId with a fresh nonce; if the signingInput +# rotated, re-sign it. +# verify-control → POST /v2/ans/identities/{identityId}/verify-control +# { cesrSignature: $SIG } → status: VERIFIED (the RA pins +# the signer AID to the identity; it is never a body value) +# link → POST /v2/ans/identities/{identityId}/links +# { agentIds: [ $AGENT_ID ] } → { linked: 1 } +# show → GET /v2/ans/agents/{agentId} → the computed identities[] +# badge carries the lei identity; poll the TL identity +# audit for the sealed IDENTITY_VERIFIED. +# +# +# WHY sign the signingInput, not the nonce: the lei control proof is uniform +# with the JWS kinds — every kind signs the served `signingInput` (the +# base64url of the JCS-canonical IdentityProofInput), which binds the nonce, +# the identity id, the identifier, and the proof purpose. The RA forwards the +# signature to the verifier as `non_prefixed_digest = signingInput`. +# +# WHY re-present right before verify: the vlei-verifier ages credential +# authorizations off after TimeoutAuth = 600s (10 minutes). verify-control +# re-checks authorization LIVE on every call (it does NOT trust the AUTHORIZED +# status recorded at present time), so a slow manual signing step can let the +# window lapse — surfacing as LEI_NOT_AUTHORIZED even though the signature is +# valid. Re-presenting immediately before the verify keeps the window fresh. +# +# The RA is the SINGLE touchpoint for the verifier: this script never calls the +# verifier directly. build-chain.ts and sign-proof.ts only register the +# synthetic GLEIF root of trust, export the holder's full-chain CESR + LEI, and +# sign the signingInput with the holder key; everything else flows through the RA. +# +# PRECONDITIONS (manual — see README.md): +# 1. vLEI stack is up: scripts/demo/vlei/up.sh +# 2. ans-ra is running with vlei.type: verifier and vlei.base-url pointing at +# the verifier (the `vlei:` block in config/ra-local.yaml). +# 3. An agent owned by the same caller is registered and its id is in +# $AGENT_ID (e.g. from scripts/demo/run-lifecycle.sh). +# 4. build-chain.sh has run (scripts/demo/vlei/build-chain.sh) — it registered +# the root of trust and exported $PRESENTATION_FILE (the holder's +# full-chain CESR + claimed LEI + holder AID) and, for AUTO_SIGN, +# $OUTPUTS_FILE (which carries the holder's roleBran). The signify +# container stays up so the signingInput can be signed with the holder AID. +# +# Required env: +# AGENT_ID the registered agent to link the verified lei identity to +# +# Env overrides: +# PRESENTATION_FILE exported {cesr,lei,aid} JSON +# (default: $DATA/ecr-presentation.json) +# OUTPUTS_FILE exported {roleBran,...} JSON, used by AUTO_SIGN +# (default: alongside PRESENTATION_FILE / tier1-outputs.json) +# LEI claimed LEI (default: read from PRESENTATION_FILE) +# SIGNED_PROOF the signature over the signingInput (indexed Siger qb64). +# When set, the script uses it directly — highest +# precedence. NOTE: a re-present that rotates the nonce +# invalidates a pre-computed SIGNED_PROOF; prefer AUTO_SIGN +# for the live flow. +# AUTO_SIGN when 1 (and SIGNED_PROOF unset), sign the signingInput +# non-interactively by running sign-proof.ts in the signify +# container with the roleBran from $OUTPUTS_FILE. Falls back +# to the interactive /dev/tty paste when unset. +# COMPOSE docker compose command (default: "docker compose") +# RA_URL ans-ra base URL (default: http://localhost:18080) + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export DATA="${DATA:-$(cd "$SCRIPT_DIR/../../.." && pwd)/data/demo/vlei}" +# shellcheck source=../common.sh +. "$SCRIPT_DIR/../common.sh" + +PRESENTATION_FILE="${PRESENTATION_FILE:-$DATA/ecr-presentation.json}" +OUTPUTS_FILE="${OUTPUTS_FILE:-$(dirname "$PRESENTATION_FILE")/tier1-outputs.json}" +COMPOSE="${COMPOSE:-docker compose}" + +require_cmd curl +require_cmd jq + +: "${AGENT_ID:?set AGENT_ID to the registered agent id}" +[ -f "$PRESENTATION_FILE" ] || fail "presentation file not found: $PRESENTATION_FILE — run build-chain.sh (README step 2) so it exports the artifacts" + +CESR="$(jq -r '.cesr' "$PRESENTATION_FILE")" +[ -n "$CESR" ] && [ "$CESR" != "null" ] || fail "no .cesr in $PRESENTATION_FILE" +LEI="${LEI:-$(jq -r '.lei' "$PRESENTATION_FILE")}" +[ -n "$LEI" ] && [ "$LEI" != "null" ] || fail "no LEI — set \$LEI or add .lei to $PRESENTATION_FILE" +# The holder AID is the credential's subject — exported by build-chain.ts and +# the value the RA pins at register time (read from the verifier, not the body). +# We use it only to sanity-check the challenge's kid; the RA derives it itself. +HOLDER_AID="$(jq -r '.aid // empty' "$PRESENTATION_FILE")" + +header "RA-mediated register + verify-control demo (identity routes)" +note "RA: $RA_URL verifier-backed agent: $AGENT_ID LEI: $LEI" +[ -n "$HOLDER_AID" ] && note "holder AID (credential subject): $HOLDER_AID" + +# register_identity — POST /v2/ans/identities { value, vleiPresentation }. +# Echoes the raw response body on stdout so the caller can extract fields and +# fail with the body on a non-2xx. The validate MUST live in the caller, not +# here: this runs inside a $(...) subshell, where `fail` (exit 1) only ends the +# subshell — under `set -e` that does NOT abort the parent. +register_identity() { + local body + body="$(jq -n --arg v "$LEI" --arg cesr "$CESR" \ + '{value:$v, vleiPresentation:{cesr:$cesr}}')" + curl_json POST "/v2/ans/identities" "$body" +} + +# sign_signing_input SIGNING_INPUT — apply the SIGNED_PROOF > AUTO_SIGN > +# interactive-paste precedence and echo ONLY the signature on stdout. The role +# AID's keys live in the KERIA cloud agent, so every path signs with signify. +sign_signing_input() { + local signing_input="$1" sig role_bran + if [ -n "${SIGNED_PROOF:-}" ]; then + printf '%s' "$SIGNED_PROOF" + return 0 + fi + if [ "${AUTO_SIGN:-0}" = "1" ]; then + [ -f "$OUTPUTS_FILE" ] || { echo "AUTO_SIGN=1 but $OUTPUTS_FILE not found — run build-chain.sh first" >&2; return 1; } + role_bran="$(jq -r '.roleBran // empty' "$OUTPUTS_FILE")" + [ -n "$role_bran" ] || { echo "no .roleBran in $OUTPUTS_FILE — re-run build-chain.sh" >&2; return 1; } + # sign-proof.ts prints ONLY the indexed Siger qb64 on stdout; diagnostics + # go to stderr, so command substitution captures just the signature. + # shellcheck disable=SC2086 # COMPOSE may be a multi-word command + sig="$($COMPOSE -f "$SCRIPT_DIR/docker-compose.yml" exec -T signify \ + deno run -A scripts_ts/sign-proof.ts "$role_bran" "$signing_input")" || return 1 + printf '%s' "$sig" + return 0 + fi + if [ -r /dev/tty ]; then + role_bran="$(jq -r '.roleBran // empty' "$OUTPUTS_FILE" 2>/dev/null)" + note "Sign this signingInput with the holder (role) AID, then paste the result:" >&2 + note " ROLE_BRAN=\$(jq -r .roleBran '$OUTPUTS_FILE')" >&2 + note " $COMPOSE -f $SCRIPT_DIR/docker-compose.yml exec -T signify \\" >&2 + note " deno run -A scripts_ts/sign-proof.ts \"\$ROLE_BRAN\" \"$signing_input\"" >&2 + note "(or just re-run this script with AUTO_SIGN=1 to do it automatically)" >&2 + printf "${C_BOLD}paste cesrSignature here:${C_RESET} " >&2 + IFS= read -r sig " >&2 + return 1 +} + +# ----- 1. register+present: RA presents to the verifier, pins the AID ----- +header "1. register+present — POST /v2/ans/identities { value, vleiPresentation }" +note "the RA reads the leaf SAID from the CESR and presents the chain itself" +REG_RESP="$(register_identity)" +IDENTITY_ID="$(printf '%s' "$REG_RESP" | jq -r '.identityId // empty')" +[ -n "$IDENTITY_ID" ] || fail "register did not return an identityId — response: $REG_RESP" +PRESENTATION_STATUS="$(printf '%s' "$REG_RESP" | jq -r '.presentationStatus // empty')" +NONCE="$(printf '%s' "$REG_RESP" | jq -r '.nonce // empty')" +SUBJECT_AID="$(printf '%s' "$REG_RESP" | jq -r '.challenges[0].kid // empty')" +SIGNING_INPUT="$(printf '%s' "$REG_RESP" | jq -r '.challenges[0].signingInput // empty')" +[ -n "$SIGNING_INPUT" ] || fail "register did not return a challenge signingInput — response: $REG_RESP" +ok "identity recorded: $IDENTITY_ID presentationStatus: ${PRESENTATION_STATUS:-}" +note "subject AID (challenge kid): $SUBJECT_AID" +note "nonce: $NONCE" +if [ -n "$HOLDER_AID" ] && [ -n "$SUBJECT_AID" ] && [ "$SUBJECT_AID" != "$HOLDER_AID" ]; then + fail "challenge kid ($SUBJECT_AID) != exported holder AID ($HOLDER_AID) — the signingInput must be signed by the credential subject, so these must match" +fi + +# ----- 2. holder signs the signingInput with the role AID (signify) ----- +header "2. Holder signs the signingInput with the role AID (signify)" +SIG="$(sign_signing_input "$SIGNING_INPUT")" || fail "sign failed — is the signify container up? ($COMPOSE -f $SCRIPT_DIR/docker-compose.yml logs signify)" +SIG="$(printf '%s' "$SIG" | tr -d '[:space:]')" +[ -n "$SIG" ] || fail "no cesrSignature provided" +ok "signature over signingInput: $SIG" + +# ----- 3. re-present: refresh the verifier's 10-min authorization window ----- +# verify-control checks authorization LIVE; the manual signing step above is +# human-paced and may exceed TimeoutAuth (600s). Re-POSTing the same value +# while PENDING_CONTROL is the idempotent re-add: it refreshes the window and +# returns the SAME identityId with a fresh nonce. If the nonce rotated, the +# signingInput changed too, so we re-sign it. +header "3. re-present — refresh the verifier's authorization window" +note "the verifier ages authorizations off after 10 minutes; re-present keeps it fresh" +REPRESENT_RESP="$(register_identity)" +REPRESENTED_ID="$(printf '%s' "$REPRESENT_RESP" | jq -r '.identityId // empty')" +[ -n "$REPRESENTED_ID" ] || fail "re-present did not return an identityId — response: $REPRESENT_RESP" +[ "$REPRESENTED_ID" = "$IDENTITY_ID" ] || fail "re-present returned a different identityId ($REPRESENTED_ID != $IDENTITY_ID) — the idempotent re-add must reuse the row" +SIGNING_INPUT2="$(printf '%s' "$REPRESENT_RESP" | jq -r '.challenges[0].signingInput // empty')" +[ -n "$SIGNING_INPUT2" ] || fail "re-present did not return a challenge signingInput — response: $REPRESENT_RESP" +ok "re-presented (same identity): $REPRESENTED_ID" +if [ "$SIGNING_INPUT2" != "$SIGNING_INPUT" ]; then + note "nonce rotated on re-present — re-signing the new signingInput" + SIGNING_INPUT="$SIGNING_INPUT2" + SIG="$(sign_signing_input "$SIGNING_INPUT")" || fail "re-sign failed" + SIG="$(printf '%s' "$SIG" | tr -d '[:space:]')" + [ -n "$SIG" ] || fail "no cesrSignature provided on re-sign" + ok "re-signed over the fresh signingInput: $SIG" +fi + +# ----- 4. verify-control: RA verifies signature + authorized LEI, pins the AID ----- +header "4. verify-control — POST .../verify-control { cesrSignature }" +note "no aid in the body — the RA pins the signer AID to the recorded identity" +VERIFY_BODY="$(jq -n --arg sig "$SIG" '{cesrSignature:$sig}')" +VERIFY_RESP="$(curl_json POST "/v2/ans/identities/$IDENTITY_ID/verify-control" "$VERIFY_BODY")" +STATUS="$(printf '%s' "$VERIFY_RESP" | jq -r '.status // empty')" +if [ "$STATUS" = "VERIFIED" ]; then + ok "status:VERIFIED — the AID controls a vLEI authorizing $LEI, and signed our challenge" +else + CODE="$(printf '%s' "$VERIFY_RESP" | jq -r '.code // "unknown"')" + DETAIL="$(printf '%s' "$VERIFY_RESP" | jq -r '.detail // empty')" + case "$CODE" in + PRICC_SIGNATURE_INVALID) + fail "verify-control failed (code=$CODE) — the cesrSignature was not signed by the role AID over THIS signingInput. Make sure sign-proof.ts signed the served signingInput with the 'role' alias, not a kli/other key." ;; + LEI_NOT_AUTHORIZED) + fail "verify-control failed (code=$CODE) — the verifier reports no authorized LEI for the AID right now. Confirm the root of trust is registered (build-chain.sh) and the chain authorizes this LEI; if signing took >10 min, just re-run the demo." ;; + LEI_MISMATCH) + fail "verify-control failed (code=$CODE) — the verifier authorized a different LEI than $LEI. Check the credential's LEI matches the claim." ;; + *) + fail "verify-control failed (code=$CODE): ${DETAIL:-no detail} — response: $VERIFY_RESP" ;; + esac +fi + +# The sealed IDENTITY_VERIFIED reaches the TL through the outbox worker. +header "verify the seal landed on the TL identity stream" +poll_tl_identity_audit "$IDENTITY_ID" 1 +ok "IDENTITY_VERIFIED sealed with a Merkle proof on the TL" + +# ----- 5. link: bind the verified identity to the agent (one event) ----- +header "5. link — POST .../links { agentIds: [ $AGENT_ID ] }" +LINK_BODY="$(jq -n --arg a "$AGENT_ID" '{agentIds:[$a]}')" +LINK_RESP="$(curl_json POST "/v2/ans/identities/$IDENTITY_ID/links" "$LINK_BODY")" +LINKED="$(printf '%s' "$LINK_RESP" | jq -r '.linked // empty')" +[ "$LINKED" = "1" ] || fail "link did not report linked:1 — response: $LINK_RESP" +ok "linked: 1" + +# ----- 6. show: the computed identities[] badge on the agent ----- +header "6. show — GET /v2/ans/agents/$AGENT_ID (computed identities[] badge)" +AGENT_RESP="$(curl_json GET "/v2/ans/agents/$AGENT_ID")" +BADGE_KIND="$(printf '%s' "$AGENT_RESP" | jq -r --arg id "$IDENTITY_ID" \ + '.identities[]? | select(.identityId==$id) | .kind // empty')" +BADGE_STATUS="$(printf '%s' "$AGENT_RESP" | jq -r --arg id "$IDENTITY_ID" \ + '.identities[]? | select(.identityId==$id) | .identityStatus // empty')" +[ "$BADGE_KIND" = "lei" ] || fail "agent identities[] does not carry the lei badge for $IDENTITY_ID — response: $AGENT_RESP" +[ "$BADGE_STATUS" = "VERIFIED" ] || fail "lei badge status is '$BADGE_STATUS', expected VERIFIED — response: $AGENT_RESP" +ok "agent carries the lei identity badge: $IDENTITY_ID (VERIFIED)" + +header "Done" +note "register-with-presentation pins the verifier-reported subject AID; verify-control" +note "is a single CESR-signature proof over the served signingInput (uniform with the" +note "JWS kinds), and the RA pins the signer AID to the identity — never a body value." diff --git a/scripts/demo/vlei/witness-config/main/wan.json b/scripts/demo/vlei/witness-config/main/wan.json new file mode 100755 index 0000000..ffb1dab --- /dev/null +++ b/scripts/demo/vlei/witness-config/main/wan.json @@ -0,0 +1,8 @@ +{ + "wan": { + "dt": "2024-12-31T14:06:30.123456+00:00", + "curls": ["tcp://witness-demo:5632/", "http://witness-demo:5642/"] + }, + "dt": "2024-12-31T14:06:30.123456+00:00", + "iurls": [] +} diff --git a/scripts/demo/vlei/witness-config/main/wes.json b/scripts/demo/vlei/witness-config/main/wes.json new file mode 100755 index 0000000..d84b0db --- /dev/null +++ b/scripts/demo/vlei/witness-config/main/wes.json @@ -0,0 +1,8 @@ +{ + "wes": { + "dt": "2024-12-31T14:06:30.123456+00:00", + "curls": ["tcp://witness-demo:5634/", "http://witness-demo:5644/"] + }, + "dt": "2024-12-31T14:06:30.123456+00:00", + "iurls": [] +} diff --git a/scripts/demo/vlei/witness-config/main/wil.json b/scripts/demo/vlei/witness-config/main/wil.json new file mode 100755 index 0000000..22a5fb0 --- /dev/null +++ b/scripts/demo/vlei/witness-config/main/wil.json @@ -0,0 +1,8 @@ +{ + "wil": { + "dt": "2024-12-31T14:06:30.123456+00:00", + "curls": ["tcp://witness-demo:5633/", "http://witness-demo:5643/"] + }, + "dt": "2024-12-31T14:06:30.123456+00:00", + "iurls": [] +} diff --git a/scripts/demo/vlei/witness-config/main/wit.json b/scripts/demo/vlei/witness-config/main/wit.json new file mode 100755 index 0000000..ddef257 --- /dev/null +++ b/scripts/demo/vlei/witness-config/main/wit.json @@ -0,0 +1,8 @@ +{ + "wit": { + "dt": "2024-12-31T14:06:30.123456+00:00", + "curls": ["tcp://witness-demo:5635/", "http://witness-demo:5645/"] + }, + "dt": "2024-12-31T14:06:30.123456+00:00", + "iurls": [] +} diff --git a/scripts/demo/vlei/witness-config/main/wub.json b/scripts/demo/vlei/witness-config/main/wub.json new file mode 100755 index 0000000..10970b2 --- /dev/null +++ b/scripts/demo/vlei/witness-config/main/wub.json @@ -0,0 +1,8 @@ +{ + "wub": { + "dt": "2024-12-31T14:06:30.123456+00:00", + "curls": ["tcp://witness-demo:5636/", "http://witness-demo:5646/"] + }, + "dt": "2024-12-31T14:06:30.123456+00:00", + "iurls": [] +} diff --git a/scripts/demo/vlei/witness-config/main/wyz.json b/scripts/demo/vlei/witness-config/main/wyz.json new file mode 100755 index 0000000..19b228a --- /dev/null +++ b/scripts/demo/vlei/witness-config/main/wyz.json @@ -0,0 +1,8 @@ +{ + "wyz": { + "dt": "2024-12-31T14:06:30.123456+00:00", + "curls": ["tcp://witness-demo:5637/", "http://witness-demo:5647/"] + }, + "dt": "2024-12-31T14:06:30.123456+00:00", + "iurls": [] +} diff --git a/spec/api-spec-tl-v2.yaml b/spec/api-spec-tl-v2.yaml index 4664a78..78cdcf7 100644 --- a/spec/api-spec-tl-v2.yaml +++ b/spec/api-spec-tl-v2.yaml @@ -1203,17 +1203,25 @@ components: conveniences (anyone can derive RFC 7638 from the sealed source) and are never part of the sealed contract. - The postponed `lei` kind is the one deliberate exception: - it will seal the subject AID + a key thumbprint only — there - is no document to quote, the ACDC is PII, and KERI's KEL is - already the authoritative key history. Seal verbatim what - has no other tamper-evident home; commit minimally where one - exists. + The `lei` kind is the one deliberate exception: it seals the + subject AID + a key thumbprint only (`{id: , type: + "vLEI-KERI-AID", thumbprint: base64url(SHA-256(AID))}`) — + there is no document to quote, the ACDC is PII, and KERI's + KEL is already the authoritative key history. Seal verbatim + what has no other tamper-evident home; commit minimally where + one exists. A `lei` seal is therefore NOT offline + re-verifiable from the seal alone: the KEL-backed key state + lives at the vlei-verifier (the documented lei trust + boundary), whereas a JWS seal carries the key material and so + the sealed proof re-verifies offline. required: [verificationMethod, signedProof] properties: verificationMethod: type: object - description: The DID document's verification-method object, verbatim + description: | + For JWS kinds, the DID document's verification-method + object, verbatim. For `lei`, the synthesized subject-AID + object (`id`, `type: vLEI-KERI-AID`, `thumbprint`). example: id: did:web:identity.acme-corp.com#key-1 type: JsonWebKey2020 @@ -1221,7 +1229,10 @@ components: publicKeyJwk: { kty: OKP, crv: Ed25519, x: "0-e2i2_..." } signedProof: type: string - description: The compact JWS over the served IdentityProofInput + description: | + The proof over the served IdentityProofInput — a compact + JWS for the JWS kinds, or the CESR signature by the + subject AID's current key for `lei`. LinkedIdentityView: type: object diff --git a/spec/api-spec-v2.yaml b/spec/api-spec-v2.yaml index 88eef90..adb3821 100644 --- a/spec/api-spec-v2.yaml +++ b/spec/api-spec-v2.yaml @@ -927,9 +927,12 @@ paths: is the idempotent re-add: the same `identityId` returns with a fresh nonce (the prior nonce is superseded). A value already verified — by this owner or any other — returns 409 - `IDENTIFIER_DUPLICATE`. Kinds without an enabled control - verifier (`lei`, postponed) return 422 - `IDENTIFIER_KIND_UNSUPPORTED`. + `IDENTIFIER_DUPLICATE`. A recognized value whose kind has no + enabled control verifier (e.g. `did:plc`, `did:ion`, until + their verifiers ship) returns 422 + `IDENTIFIER_KIND_UNSUPPORTED`. The `lei` kind additionally + requires a `vleiPresentation` at register time; omitting it + returns 422 `IDENTIFIER_PRESENTATION_REQUIRED`. operationId: registerIdentity requestBody: required: true @@ -2063,16 +2066,38 @@ components: description: | Registers (POST) or rotates (PUT) an identifier. The kind is inferred from the value's lexical form — `did:web:` prefix, - `did:key:` prefix, or a 20-character LEI (recognized but - postponed) — never caller-asserted. + `did:key:` prefix, or a 20-character LEI — never + caller-asserted. properties: value: type: string description: The identifier to prove control of example: did:web:identity.acme-corp.com + vleiPresentation: + $ref: '#/components/schemas/VLEIPresentation' required: - value + VLEIPresentation: + type: object + description: | + The lei (vLEI) register-time credential presentation. REQUIRED + for the `lei` kind and omitted for the JWS kinds (`did:web`, + `did:key`). The RA submits the CESR to its configured + vlei-verifier, which derives and pins the subject AID; the + 202's `presentationStatus` reports the verifier's advisory + authorization decision. + properties: + cesr: + type: string + description: | + The full-chain CESR export of the vLEI credential and its + supporting KELs/ACDCs (the `credentials().get(said, true)` + shape). The RA never parses KERI key state itself — the + verifier is the authoritative key-state oracle. + required: + - cesr + IdentityChallengeResponse: type: object description: | @@ -2093,6 +2118,15 @@ components: description: The canonical identifier this round proves status: $ref: '#/components/schemas/IdentityLifecycleStatus' + presentationStatus: + type: string + description: | + The lei register-time advisory authorization status from + the vlei-verifier (`AUTHORIZED` | `PENDING`). Omitted for + kinds with no register-time presentation (`did:web`, + `did:key`). Advisory only — control is finally established + by a LIVE re-authorization at verify-control. + enum: ['AUTHORIZED', 'PENDING'] nonce: type: string description: Base64url 32-byte single-use anti-replay nonce @@ -2135,11 +2169,20 @@ components: VerifyControlRequest: type: object description: | - One compact JWS per proven key. Supported algorithms match - what the verifier implements: EdDSA (Ed25519), ES256 - (ECDSA P-256), and RS256 (RSA >= 2048). Key-agreement keys - (X25519) and curves without a verifier (secp256k1, - P-384/521) are rejected with a precise error. + The control proof. Its members are additive per identifier + kind, and EXACTLY ONE family is set per request: + + - JWS kinds (`did:web`, `did:key`) submit `signedProofs` — + one compact JWS per proven key. + - The `lei` kind submits `cesrSignature` — a single CESR + signature over the served signingInput by the subject + AID's current key. + + Supported JWS algorithms match what the verifier implements: + EdDSA (Ed25519), ES256 (ECDSA P-256), and RS256 (RSA >= + 2048). Key-agreement keys (X25519) and curves without a + verifier (secp256k1, P-384/521) are rejected with a precise + error. properties: signedProofs: type: array @@ -2153,8 +2196,18 @@ components: and MAY carry `jwk` (the signer's public key — required by the quickstart noop resolver, ignored by the web resolver, which always uses the resolved document). - required: - - signedProofs + cesrSignature: + type: string + description: | + The lei proof: a single CESR signature over the served + signingInput by the subject AID's current key. Set only + for the `lei` kind. The RA forwards it to the + vlei-verifier, which resolves the AID's key state from its + KEL and checks the signature over the exact signingInput + bytes (the same payload the JWS kinds sign). + oneOf: + - required: [signedProofs] + - required: [cesrSignature] IdentityLifecycleStatus: type: string From 7e3e424c7708b450838c84cf31a89f1cc02fa6d6 Mon Sep 17 00:00:00 2001 From: James Hateley Date: Fri, 12 Jun 2026 15:52:25 +1000 Subject: [PATCH 11/13] feat: fix after rebase Signed-off-by: James Hateley --- internal/adapter/store/sqlite/identity.go | 7 ++++++- ...entity_subject_aid.sql => 009_identity_subject_aid.sql} | 0 internal/ra/handler/identity_handler_test.go | 1 - internal/ra/service/identity.go | 2 +- internal/ra/service/identity_lei_test.go | 4 ++-- 5 files changed, 9 insertions(+), 5 deletions(-) rename internal/adapter/store/sqlite/migrations/{008_identity_subject_aid.sql => 009_identity_subject_aid.sql} (100%) diff --git a/internal/adapter/store/sqlite/identity.go b/internal/adapter/store/sqlite/identity.go index 35a10a6..3406c77 100644 --- a/internal/adapter/store/sqlite/identity.go +++ b/internal/adapter/store/sqlite/identity.go @@ -316,6 +316,10 @@ func (s *IdentityStore) StageChallenge( if !v.Challenge.ExpiresAt.IsZero() { expires = sql.NullInt64{Int64: v.Challenge.ExpiresAt.UnixMilli(), Valid: true} } + var subjectAID sql.NullString + if v.SubjectAID != "" { + subjectAID = sql.NullString{String: v.SubjectAID, Valid: true} + } res, err := s.db.extx(ctx).ExecContext(ctx, ` UPDATE identities SET challenge_nonce = ?, @@ -323,12 +327,13 @@ func (s *IdentityStore) StageChallenge( challenge_consumed_at_ms = NULL, challenge_claimed_at_ms = NULL, pending_value = ?, + subject_aid = ?, updated_at_ms = ? WHERE identity_id = ? AND status = ? AND (challenge_nonce = ? OR (challenge_nonce IS NULL AND ? = '')) AND (challenge_claimed_at_ms IS NULL OR challenge_claimed_at_ms < ?)`, - v.Challenge.Nonce, expires, v.PendingValue, v.UpdatedAt.UnixMilli(), + v.Challenge.Nonce, expires, v.PendingValue, subjectAID, v.UpdatedAt.UnixMilli(), v.IdentityID, string(expectedStatus), expectedNonce, expectedNonce, staleBefore.UnixMilli()) if err != nil { diff --git a/internal/adapter/store/sqlite/migrations/008_identity_subject_aid.sql b/internal/adapter/store/sqlite/migrations/009_identity_subject_aid.sql similarity index 100% rename from internal/adapter/store/sqlite/migrations/008_identity_subject_aid.sql rename to internal/adapter/store/sqlite/migrations/009_identity_subject_aid.sql diff --git a/internal/ra/handler/identity_handler_test.go b/internal/ra/handler/identity_handler_test.go index 5d3629a..c85dd97 100644 --- a/internal/ra/handler/identity_handler_test.go +++ b/internal/ra/handler/identity_handler_test.go @@ -89,7 +89,6 @@ func newIdentityHTTPFixture(t *testing.T) *identityHTTPFixture { didresolver.NewNoopResolver(), okSealer{}, leiverifier.NewNoop(), - outbox, db, ).WithChallengeTTL(30 * time.Minute) diff --git a/internal/ra/service/identity.go b/internal/ra/service/identity.go index 48ae02b..24e014a 100644 --- a/internal/ra/service/identity.go +++ b/internal/ra/service/identity.go @@ -312,7 +312,7 @@ func (s *IdentityService) Rotate(ctx context.Context, providerID, identityID, ra if err := identity.StageRotation(rawValue, now); err != nil { return nil, err } - return s.challenge(ctx, identity, now, true, opt) + return s.challenge(ctx, identity, now, false, opt) } // challenge mints a fresh nonce on the identity, runs the kind's diff --git a/internal/ra/service/identity_lei_test.go b/internal/ra/service/identity_lei_test.go index 7cd03af..4ae8e6e 100644 --- a/internal/ra/service/identity_lei_test.go +++ b/internal/ra/service/identity_lei_test.go @@ -96,11 +96,11 @@ func TestIdentityLifecycle_LEIHappy(t *testing.T) { t.Fatalf("verified state: %+v", identity) } - rows := fx.drainOutbox(t) + rows := fx.drainSealed(t) if len(rows) != 1 { t.Fatalf("outbox rows: %d", len(rows)) } - inner := fx.decodeOutboxEvent(t, rows[0]) + inner := fx.decodeSealed(t, rows[0]) if inner.EventType != identityevent.TypeIdentityVerified || len(inner.Keys) != 1 { t.Fatalf("sealed event: %+v", inner) } From 8c12c286ae7f1cfd3273a1d6c87102088f4b7c52 Mon Sep 17 00:00:00 2001 From: James Hateley Date: Sat, 13 Jun 2026 11:28:29 +1000 Subject: [PATCH 12/13] feat(vlei): whitespace-tolerant ACDC frame scan + demo cleanup Replace the literal {"v":"ACDC marker in presentedCredentialSAID with a regexp tolerating insignificant JSON whitespace around the brace, key, and colon, so a pretty-printing serializer does not cause the leaf-SAID scan to miss credential frames. Anchored on the version member being first, which the KERI/ACDC serialization rules guarantee. Trim the vlei demo README to remove three-way repetition while keeping every script-verified fact, and tidy the demo scripts, docker-compose, and start.sh vlei config comment. Ignore scripts/demo/vlei/signify/out/. Signed-off-by: James Hateley --- .gitignore | 3 + .../adapter/leiverifier/leiverifier_test.go | 3 + internal/adapter/leiverifier/verifier.go | 25 +- scripts/demo/start.sh | 5 +- scripts/demo/vlei/README.md | 336 ++++------- scripts/demo/vlei/build-chain.sh | 6 +- scripts/demo/vlei/docker-compose.yml | 2 +- scripts/demo/vlei/down.sh | 2 +- scripts/demo/vlei/run-vlei.sh | 101 +++- .../vlei/signify/scripts_ts/build-chain.ts | 16 +- scripts/demo/vlei/signify/scripts_ts/utils.ts | 528 ------------------ scripts/demo/vlei/verify-control-demo.sh | 6 +- 12 files changed, 237 insertions(+), 796 deletions(-) diff --git a/.gitignore b/.gitignore index ba9a85e..948de1a 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,6 @@ Thumbs.db # Dependency directories vendor/ AGENTS.md + +# Demo outputs +scripts/demo/vlei/signify/out/ \ No newline at end of file diff --git a/internal/adapter/leiverifier/leiverifier_test.go b/internal/adapter/leiverifier/leiverifier_test.go index 23b4a9d..91d139d 100644 --- a/internal/adapter/leiverifier/leiverifier_test.go +++ b/internal/adapter/leiverifier/leiverifier_test.go @@ -323,6 +323,9 @@ func TestPresentedCredentialSAID(t *testing.T) { {"escaped strings before d", `{"v":"ACDC10JSON_","note":"a \"quoted\" }brace","d":"EReal"}`, "EReal"}, {"unterminated object", `{"v":"ACDC10JSON_","d":"x"`, ""}, {"marker but no d, then a real one", `{"v":"ACDC10JSON_","x":1}{"v":"ACDC10JSON_","d":"ESecond"}`, "ESecond"}, + // Insignificant JSON whitespace around `{`, the `v` key, and the + // colon must not defeat the frame scan (pretty-printed export). + {"whitespace around version member", "{ \"v\" :\t\"ACDC10JSON_\", \"d\": \"EWS\" }", "EWS"}, // Full-chain exports: the leaf is the credential no other // credential's edge `n` references — independent of frame order. // KERIA emits issuer-first (leaf last); we must not pick the first. diff --git a/internal/adapter/leiverifier/verifier.go b/internal/adapter/leiverifier/verifier.go index 9f35421..afc39c7 100644 --- a/internal/adapter/leiverifier/verifier.go +++ b/internal/adapter/leiverifier/verifier.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "regexp" "strings" "time" @@ -321,15 +322,24 @@ func isQB64(s string) bool { return true } +// acdcFrameMarker matches the start of an ACDC credential frame: a JSON +// object whose first member is the version string `"v":"ACDC…"`. The +// pattern tolerates insignificant JSON whitespace around the `{`, key, +// and colon so a serializer that pretty-prints (or inserts SP/HT) does +// not cause the scan to miss frames. It is deliberately anchored on the +// version member being first, which the KERI/ACDC serialization rules +// guarantee. +var acdcFrameMarker = regexp.MustCompile(`\{\s*"v"\s*:\s*"ACDC`) + // presentedCredentialSAID extracts the SAID of the *presented* (leaf) // credential from a full-chain CESR export — the minimal, targeted read // the real verifier path needs to route PUT /presentations/{said}. It is // NOT a CESR codec: KERI/ACDC serializations are version-string-first, so // an ACDC credential message is always a JSON object whose first member -// is `"v":"ACDC…"`; we locate those frames by brace-balancing (respecting -// JSON string escaping), read each frame's self-addressing `d`, and -// collect the edge node SAIDs (the `n` of each edge) from each frame's -// `e` block. +// is `"v":"ACDC…"`; we locate those frames with acdcFrameMarker and read +// each one by brace-balancing (respecting JSON string escaping), take its +// self-addressing `d`, and collect the edge node SAIDs (the `n` of each +// edge) from each frame's `e` block. // // The presented credential is the most-derived one — the ECR/role // credential at the bottom of the ECR→LE→QVI chain — identified @@ -341,7 +351,6 @@ func isQB64(s string) bool { // so its one frame is the leaf. The end-to-end demo (scripts/demo/vlei) // exercises this against the live verifier. func presentedCredentialSAID(cesr string) string { - const marker = `{"v":"ACDC` type acdcFrame struct { D string `json:"d"` E json.RawMessage `json:"e"` @@ -350,11 +359,11 @@ func presentedCredentialSAID(cesr string) string { referenced := make(map[string]struct{}) offset := 0 for { - rel := strings.Index(cesr[offset:], marker) - if rel < 0 { + loc := acdcFrameMarker.FindStringIndex(cesr[offset:]) + if loc == nil { break } - start := offset + rel + start := offset + loc[0] obj, end := balancedJSONObject(cesr, start) if obj == "" { offset = start + 1 diff --git a/scripts/demo/start.sh b/scripts/demo/start.sh index b809ecf..6db5525 100755 --- a/scripts/demo/start.sh +++ b/scripts/demo/start.sh @@ -133,10 +133,7 @@ vlei: # The lei (vLEI) control verifier behind the "lei" identifier kind. # "noop" runs real Ed25519 crypto but waives the GLEIF authorization # binding; "verifier" routes CESR/KERI questions to a real - # vlei-verifier (scripts/demo/vlei brings one up on :7676). The - # base-url can't be set via ANS_RA_VLEI__BASE_URL — koanf maps a - # single underscore literally, so it would target vlei.base_url, not - # vlei.base-url — hence it is composed into the file here: + # vlei-verifier (scripts/demo/vlei brings one up on :7676). # ANS_VLEI_TYPE=verifier ANS_VLEI_BASE_URL=http://localhost:7676 \ # scripts/demo/start.sh type: ${ANS_VLEI_TYPE:-noop} diff --git a/scripts/demo/vlei/README.md b/scripts/demo/vlei/README.md index 7f2a5c5..06209d2 100644 --- a/scripts/demo/vlei/README.md +++ b/scripts/demo/vlei/README.md @@ -1,48 +1,32 @@ -# vLEI ecosystem + identifier / `verify-control` demo +# vLEI ecosystem + `verify-control` demo -This directory stands up a **local, self-contained** GLEIF/KERI stack so -a genuine AID holding a (self-issued) vLEI can be registered with the RA -on the identity-scoped routes — `POST /v2/ans/identities` (carrying the -full-chain CESR) + `.../verify-control` — and the RA can present it to, -and verify the AID-signed `signingInput` against, a **real -`vlei-verifier`**, seal the `IDENTITY_VERIFIED` event on the TL, and link -the verified `lei` identity to an agent. - -Proves the *RA integration* against the stock -GLEIF verifier, including the credential-chain / authorized-LEI check. +A **local, self-contained** GLEIF/KERI stack that proves the RA's `lei` +identity flow against a **real `vlei-verifier`**: a genuine AID holding a +self-issued vLEI is registered on `POST /v2/ans/identities` (carrying the +full-chain CESR), the RA presents the chain to the verifier and verifies the +AID-signed `signingInput` via `.../verify-control`, seals `IDENTITY_VERIFIED` +on the TL, and links the verified `lei` identity to an agent — including the +credential-chain / authorized-LEI check. ## No genuine LEI/vLEI required -The entire ecosystem is self-issued against a **synthetic local GLEIF -root** registered via `POST /root_of_trust/{aid}` (the verifier runs -with `VERIFIER_ENV=development`, `VERIFY_ROOT_OF_TRUST=True`). The LEI is -any well-formed 20-char string you choose — the verifier checks the -chain to *your* local root, not `api.gleif.org`. The RA's `ValidateLEI` +The whole chain is self-issued against a **synthetic local GLEIF root** +registered via `POST /root_of_trust/{aid}` (verifier runs with +`VERIFIER_ENV=development`, `VERIFY_ROOT_OF_TRUST=True`). The verifier checks +the chain to *your* local root, not `api.gleif.org`. The RA's `ValidateLEI` checks ISO-17442 **format only** (20-char `[A-Z0-9]`), so any well-formed -string works; `build-chain.ts` uses LEI `875500ELOZEL05BVXV37`. +string works; `build-chain.ts` uses `875500ELOZEL05BVXV37`. A genuine vLEI is +needed *only* against the real GLEIF production root — out of scope here. -A genuine vLEI is needed *only* if you point the verifier at the real -GLEIF production root — out of scope here. +## The RA is the single touchpoint for the verifier -## Notes -- **Everything is version-pinned for reproducibility.** GLEIF's - KERI/KERIA/vLEI images and the `vlei-verifier` config schema move - between releases, so the compose file pins every image to a known-good - tag (`weboftrust/keri:1.2.0-rc4`, `gleif/vlei:1.0.0`, - `gleif/keria:0.3.0`) and builds the verifier from the `0.1.5` source. - The verifier uses its own bundled `verifier-config-docker-local.json` - (keri-style `iurls`/`durls` pointing at `witness-demo` + `vlei-server`), - so there is no local config file to keep in sync. Bumping any pin means - re-confirming flags/endpoints and the config shape against that release. -- **The RA is the single touchpoint for the verifier.** The holder hands - the RA their full-chain credential CESR via the `vleiPresentation` of - `POST /v2/ans/identities`; the RA reads the leaf credential SAID out of - it (the only thing it parses — never KERI key state), presents the chain - to the verifier (`PUT /presentations/{said}`), reads the verifier-reported - subject AID, and pins it on the identity. The holder never calls the - verifier directly. The one bootstrap the RA can't do is registering the - *synthetic local* GLEIF root of trust — that is a one-time admin step - against the verifier (`build-chain.ts` does it). +The holder hands the RA the full-chain CESR in the `vleiPresentation` of `POST +/v2/ans/identities`. The RA reads the leaf credential SAID out of it (the only +thing it parses — never KERI key state), presents the chain to the verifier +(`PUT /presentations/{said}`), reads back the subject AID, and pins it on the +identity. The holder never calls the verifier directly. The one thing the RA +can't bootstrap is registering the synthetic local GLEIF root of trust — a +one-time admin step `build-chain.ts` does. ## Components (`docker-compose.yml`) @@ -52,105 +36,90 @@ GLEIF production root — out of scope here. | `vlei-server` | `gleif/vlei:1.0.0` | 7723 | ACDC schema + OOBI server (LE/OOR/ECR/QVI) | | `keria` | `gleif/keria:0.3.0` | 3901-3903 | KERIA edge agent for the holder (signify-ts) | | `vlei-verifier` | built from source @ `0.1.5` | **7676** | the service `ans-ra` calls | -| `signify` | `denoland/deno:alpine-2.8.2` | — | runs `build-chain.ts` (build chain, present, export) and `sign-proof.ts` | - -> `vlei-verifier` is not published to any registry, so the compose file -> builds it from the pinned [`GLEIF-IT/vlei-verifier`](https://github.com/GLEIF-IT/vlei-verifier) -> `0.1.5` tag via a git build context. The first `up.sh` therefore runs a -> `docker build` (git clone + `pip install`) that takes a few minutes; later -> runs reuse the cached `ans-vlei-verifier:0.1.5` image. The verifier loads -> its **bundled** `verifier-config-docker-local.json` (selected via -> `VERIFIER_CONFIG_FILE`), which already points at this stack's `witness-demo` -> and `vlei-server` hostnames — there is no local config file to edit. - -> **The SignifyTS runner is self-contained.** The `signify` service is the -> stock `denoland/deno` image — no custom build. It bind-mounts -> [`signify/`](signify/) in this directory, which holds the demo's -> [`scripts_ts/`](signify/scripts_ts/): `build-chain.ts` (the trust-chain build, -> converted from the old `ans-vlei-verifier.ipynb`), `sign-proof.ts` (the -> standalone proof signer), and `utils.ts` (the SignifyTS helpers, vendored from -> [GLEIF-IT/vlei-trainings](https://github.com/GLEIF-IT/vlei-trainings)). The -> container is on the same Docker network as the rest of the stack and reaches -> `keria`, `vlei-server`, `witness-demo`, and `vlei-verifier` by service name — -> no external network / `docker network connect` required. On start it pre-caches -> the npm deps (`signify-ts`, `libsodium`) so the first `deno run` is fast. The -> exported artifacts land in [`signify/out/`](signify/out/) via the bind mount. -> When re-vendoring, re-pull `utils.ts` from the upstream repo. - -## End-to-end sequence +| `signify` | `denoland/deno:alpine-2.8.2` | — | Deno runner for `build-chain.ts` + `sign-proof.ts` | + +Everything is version-pinned: GLEIF's KERI/KERIA/vLEI images and the verifier +config schema move between releases, so bumping any pin means re-confirming +flags, endpoints, and config shape against that release. + +- **`vlei-verifier` is built, not pulled** — it ships to no registry, so the + compose file builds it from the pinned + [`GLEIF-IT/vlei-verifier`](https://github.com/GLEIF-IT/vlei-verifier) `0.1.5` + tag via a git build context. The first `up.sh` runs a `docker build` (git + clone + `pip install`) taking a few minutes; later runs reuse the cached + `ans-vlei-verifier:0.1.5` image. It loads its **bundled** + `verifier-config-docker-local.json` (`VERIFIER_CONFIG_FILE`), already pointed + at this stack's `witness-demo` + `vlei-server` hostnames — no local config to + edit. +- **`signify` is the stock Deno image** — no custom build. It bind-mounts + [`signify/`](signify/), holding [`scripts_ts/`](signify/scripts_ts/): + `build-chain.ts` (trust-chain build), `sign-proof.ts` (proof signer), and + `utils.ts` (SignifyTS helpers vendored from + [GLEIF-IT/vlei-trainings](https://github.com/GLEIF-IT/vlei-trainings) — + re-pull on re-vendor). It reaches the other services by name on the shared + network and pre-caches its npm deps at start. Exports land in + [`signify/out/`](signify/out/) via the bind mount. + +## Run it + +No prerequisites — `run-vlei.sh` bootstraps everything with **no manual paste**: -### Prerequisites (the RA — once) - -The vLEI stack is self-contained, but the RA owns its own lifecycle, so two -RA-side steps happen first: - -1. **Enable the verifier wiring.** Set the `vlei:` block in - `config/ra-local.yaml` to the real verifier (it ships as `type: noop`): - ```yaml - vlei: - type: verifier - base-url: "http://localhost:7676" - ``` - The identity routes (`POST /v2/ans/identities`, `.../verify-control`, - `.../links`) are always registered — `did:web`/`did:key` use them too. - `vlei.type` only selects which control verifier backs the `lei` kind: - `noop` runs real Ed25519 crypto but waives the GLEIF authorization - binding (zero-infra quickstart), while `verifier` routes every - CESR/KERI question to this stack's `vlei-verifier`. The real end-to-end - flow below requires `type: verifier`. -2. **Start the RA and register an agent.** `scripts/demo/start.sh` starts - `ans-ra`; `scripts/demo/run-lifecycle.sh` registers an agent and writes its - id to `data/demo/vlei/last-agent-id`. That id is the `AGENT_ID` below. +```bash +scripts/demo/vlei/run-vlei.sh # add --down to tear everything down after +``` -### Commands +It starts `ans-ra` (+ `ans-tl`) in verifier mode, registers an agent, brings up +the stack, builds the chain, and runs the register + verify-control flow. +Re-running is safe: a running RA and a previously-registered agent are reused. + +**Why "mode" matters.** `vlei.type` selects the `lei` control verifier: `noop` +runs real Ed25519 crypto but waives the GLEIF authorization binding (zero-infra +quickstart); `verifier` routes every CESR/KERI question to this stack's +`vlei-verifier`. This demo presents **real CESR**, which only `verifier` +accepts. So `run-vlei.sh` guarantees verifier mode — starting the RA in it if +down, or restarting via `stop.sh` + `start.sh --keep` (preserves the registered +agent, SQLite store, and signer keys) if it's running in the `noop` default. +The identity routes are always registered regardless (`did:web`/`did:key` use +them too). Editing `config/ra-local.yaml` has no effect here: `start.sh` +composes its config from `ANS_VLEI_*` env vars. To start in verifier mode up +front and skip the mid-run restart: ```bash -ANS_VLEI_TYPE=verifier ANS_VLEI_BASE_URL=http://localhost:7676 ./scripts/demo/start.sh -AGENT_ID=$(scripts/demo/register.sh --v2) -scripts/demo/vlei/run-vlei.sh +ANS_VLEI_TYPE=verifier ANS_VLEI_BASE_URL=http://localhost:7676 scripts/demo/start.sh ``` -`run-vlei.sh` checks the RA is reachable, then chains the three steps below -with **no manual paste**: - -1. **`up.sh`** — build + start the whole stack (witnesses, vlei-server, KERIA, - vlei-verifier, signify Deno runner) on one Docker network; wait for the - verifier's `/health` (`:7676`) and for the `signify` container to finish - pre-caching its deps. The first run includes a `docker build` of the - verifier image and the signify container's npm-dep download, which together - take a few minutes; later runs reuse the cached image and deps. -2. **`build-chain.sh`** — run [`build-chain.ts`](signify/scripts_ts/build-chain.ts) - via `deno run` in the `signify` container. It builds the synthetic - `GLEIF → QVI(delegated) → LE → ECR` chain, issues the ECR to the `role` - holder AID (LEI `875500ELOZEL05BVXV37`), presents it via IPEX, **registers - the local `gleif` root of trust** at the verifier, and exports two files into - the bind-mounted [`signify/out/`](signify/out/) dir: - - `ecr-presentation.json` — `{cesr, lei, aid}` the shell hands to the RA; - - `tier1-outputs.json` — carries the holder's `roleBran` for the signer. -3. **`verify-control-demo.sh`** (invoked with `AUTO_SIGN=1` and `DATA` pointed - at `build-chain.ts`'s exports) — the RA-mediated register + verify-control flow: - - `POST /v2/ans/identities { value: , vleiPresentation:{ cesr } }` — the - RA reads the leaf SAID, presents the chain to the verifier, pins the - verifier-reported subject AID, and returns the challenge round (nonce + - `signingInput`) plus the advisory `presentationStatus`; - - **auto-sign** — runs [`sign-proof.ts`](signify/scripts_ts/sign-proof.ts) - inside the `signify` container, reconstructing `roleClient` from the - exported `roleBran` and signing the served `signingInput` with the `role` - AID's KERIA-held keys; +### What `run-vlei.sh` chains + +0. **ensure ans-ra + agent** — RA up in verifier mode (per above), then + `register.sh --v2` if `AGENT_ID` / `data/demo/last-agent-id` names none. +1. **`up.sh`** — build + start the stack on one Docker network; wait for the + verifier's `/health` (`:7676`) and for `signify` to finish pre-caching deps. +2. **`build-chain.sh`** — runs + [`build-chain.ts`](signify/scripts_ts/build-chain.ts) via `deno run` in the + `signify` container: builds the synthetic `GLEIF → QVI(delegated) → LE → ECR` + chain, issues the ECR to the `role` holder AID, presents it via IPEX, + **registers the local `gleif` root of trust**, and exports into + [`signify/out/`](signify/out/): + - `ecr-presentation.json` — `{cesr, lei, aid}` the shell hands the RA; + - `holder-state.json` — the holder's `roleBran` for the signer. +3. **`verify-control-demo.sh`** (`AUTO_SIGN=1`, `DATA` → the exports) — the + register + verify-control flow: + - `POST /v2/ans/identities { value:, vleiPresentation:{cesr} }` — RA + reads the leaf SAID, presents the chain, pins the subject AID, returns the + challenge (nonce + `signingInput`) + advisory `presentationStatus`; + - **auto-sign** — [`sign-proof.ts`](signify/scripts_ts/sign-proof.ts) + reconstructs `roleClient` from `roleBran` and signs the `signingInput` with + the `role` AID's KERIA-held keys; - **re-present** — re-POST the same body while `PENDING_CONTROL` to refresh the verifier's authorization window (same `identityId`, fresh nonce); - `POST .../verify-control { cesrSignature }` — **no aid in the body**; the - RA pins the signer AID to the identity → expects `status: VERIFIED`, - then polls the TL for the sealed `IDENTITY_VERIFIED`; - - `POST .../links { agentIds: [ ] }` then - `GET /v2/ans/agents/` → the computed `identities[]` badge - carries the verified `lei` identity. - -Add `--down` to tear the stack down after a successful run. + RA pins the signer AID → expects `status: VERIFIED`, then polls the TL for + the sealed `IDENTITY_VERIFIED`; + - `POST .../links { agentIds:[] }`, then + `GET /v2/ans/agents/` → the computed `identities[]` badge carries + the verified `lei` identity. -### Step by step / fallback - -Each stage is runnable on its own: +### Run stages individually ```bash scripts/demo/vlei/up.sh # stack @@ -160,103 +129,46 @@ AGENT_ID= DATA=scripts/demo/vlei/signify/out \ scripts/demo/vlei/down.sh # tear down ``` -**Manual signing fallback.** To sign by hand, run `verify-control-demo.sh` -**without** `AUTO_SIGN`: it prints the served `signingInput`, then prints the -exact `deno run … sign-proof.ts ` command to sign it -in the `signify` container. Run that, copy the printed signature (indexed Siger -qb64, e.g. `AAB…`), and paste it back at the prompt. You can also pass a -signature non-interactively with `SIGNED_PROOF=`. +**Manual signing fallback.** Run `verify-control-demo.sh` **without** +`AUTO_SIGN`: it prints the served `signingInput` and the exact `deno run … +sign-proof.ts ` command. Run it, copy the printed +signature (indexed Siger qb64, e.g. `AAB…`), paste it at the prompt. Or pass it +non-interactively with `SIGNED_PROOF=`. The `lei` control proof is uniform with the JWS kinds: every kind signs the -served `signingInput` (the base64url of the JCS-canonical `IdentityProofInput`, -which binds the nonce, the identity id, the identifier, and the proof purpose). -The RA forwards the signature to the verifier as `non_prefixed_digest = signingInput`. +served `signingInput` (base64url of the JCS-canonical `IdentityProofInput`, +binding the nonce, identity id, identifier, and proof purpose). The RA forwards +the signature to the verifier as `non_prefixed_digest = signingInput`. + +**The 10-minute authorization window.** The verifier ages authorizations off +after `TimeoutAuth = 600s`, and `verify-control` re-checks authorization +**live** on every call — so a slow manual signing step can lapse the window and +surface as `LEI_NOT_AUTHORIZED` even with a valid signature. The script +re-presents the chain immediately before the verify to reset the window; if you +still hit it, just re-run. -**The 10-minute authorization window.** The `vlei-verifier` ages credential -authorizations off after `TimeoutAuth = 600s`. `verify-control` re-checks -authorization **live** on every call, so a slow manual signing step can lapse -the window and surface as `LEI_NOT_AUTHORIZED` even with a valid signature. The -script re-presents the chain (the idempotent re-add) immediately before the -verify to reset the window; if you still hit it, just re-run. +**Re-registering the root of trust by hand.** `build-chain.sh` does it for you. +If the verifier was restarted (its DB is in-container and ephemeral) and you +need to re-register without re-running the chain build: -**Registering the root of trust by hand.** `build-chain.sh` registers it for -you; if the verifier was restarted (its DB is in-container and ephemeral) and -you need to re-register without re-running the chain build, the raw call is: ```bash curl -X POST http://localhost:7676/root_of_trust/{gleifRootAID} \ -H 'Content-Type: application/json' \ -d '{"vlei":"","oobi":""}' ``` -## Troubleshooting - -### Step 1 (QVI Credential) fails with `unknown AID` - -Symptom — the IPEX grant in *Step 1* dies with: - -``` -HTTP POST /identifiers/gleif/ipex/grant - 400 Bad Request -{"description": "attempt to send to unknown AID="} -``` - -and the keria container loops forever on: - -``` -ERROR eventing .processEscrowDelegables Kevery unescrow failed: No delegation seal found for event. -keri.kering.MissingDelegableApprovalError: No delegation seal found for event. -INFO routing .acceptReply Revery: escrowing without key state for signer on reply ... -``` - -**Cause.** A *previous* run approved the QVI delegation incorrectly — e.g. -older setup code using `identifiers().interact()` (which only anchors -the seal) instead of `delegations().approve()` (which also ingests the -QVI's `dip`). That leaves a QVI `dip` that can never be promoted, churning -in gleif's `delegables` escrow. Because `gleif` uses a **fixed bran** (so -the same gleif agent is reused on every run) and the compose file mounts -**no data volume** for keria (agent DBs live in the container's ephemeral -writable layer), this poison survives across runs — and a plain -`docker compose restart keria` does **not** clear it (same container, same -writable layer). - -**Fix.** Recreate the keria container to wipe all agent state, then re-run -the chain build: - -```bash -docker compose -f scripts/demo/vlei/docker-compose.yml \ - up -d --force-recreate keria -``` - -gleif comes back with the **same prefix** (it is deterministic from the -fixed bran), so any root-of-trust registration still matches. Then just -re-run `scripts/demo/vlei/build-chain.sh`. `build-chain.ts` already uses -`delegations().approve()` in a retry loop, so on a clean keria the -delegation completes and the QVI credential grants/admits cleanly. - -### Benign noise - -`Unverified loc scheme reply URL=… SAID=…` (a `/loc/scheme` reply, often -with a `dt` of `2024-12-31`) is **not** an error you need to chase — it is -transient witness-resolution churn that self-resolves once the witness KEL -loads. Only delegation/escrow errors (`MissingDelegableApprovalError`, -`escrowing without key state`) indicate the poisoned-agent state above. - ## Scope -This implements the **RA-mediated register-with-presentation + -verify-control** flow for the `lei` identifier kind on the identity-scoped -`/v2/ans/identities` routes: +The `lei` kind on the identity-scoped `/v2/ans/identities` routes: -- The RA is the single touchpoint for the verifier: `POST /v2/ans/identities` - carries the holder's full-chain CESR in `vleiPresentation`, the RA presents - it and pins the verifier-reported subject AID on the identity; - `.../verify-control` is a CESR-signature proof over the served `signingInput` - that **pins the signer AID to the identity** — never a request-body value. -- On a clean verify the RA seals an `IDENTITY_VERIFIED` event on the identity's - TL stream (the demo polls the TL identity audit for it), and `.../links` - binds the verified `lei` identity to an agent so it surfaces in the agent's - computed `identities[]` badge. +- `POST /v2/ans/identities` carries the full-chain CESR; the RA presents it and + pins the verifier-reported subject AID. `.../verify-control` is a + CESR-signature proof over the served `signingInput` that **pins the signer + AID to the identity** — never a request-body value. +- On a clean verify the RA seals `IDENTITY_VERIFIED` on the identity's TL + stream, and `.../links` binds the verified `lei` identity to an agent so it + surfaces in the agent's computed `identities[]` badge. - The seal commits the subject AID + a thumbprint only (no JWK, no document): the KEL-backed key state lives at the verifier, so a `lei` seal is **not** - offline-re-verifiable from the seal alone — the documented `lei` trust - boundary (`ans-verify` enforces the AID+thumbprint shape, not an offline - signature re-check). + offline-re-verifiable from the seal alone. `ans-verify` enforces the + AID+thumbprint shape, not an offline signature re-check. diff --git a/scripts/demo/vlei/build-chain.sh b/scripts/demo/vlei/build-chain.sh index 14443f3..96e9bfb 100755 --- a/scripts/demo/vlei/build-chain.sh +++ b/scripts/demo/vlei/build-chain.sh @@ -9,7 +9,7 @@ # On success build-chain.ts has written, into the bind-mounted out dir # (host: scripts/demo/vlei/signify/out/): # - ecr-presentation.json {cesr, lei, aid} consumed by verify-control-demo.sh -# - tier1-outputs.json {roleBran, ...} consumed by the nonce signer +# - holder-state.json {roleBran, ...} consumed by the nonce signer # # Usage: # scripts/demo/vlei/build-chain.sh @@ -42,7 +42,7 @@ $COMPOSE -f "$COMPOSE_FILE" exec -T signify deno run -A "$SCRIPT" ok "build script executed" # Assert the exported artifacts landed on the host via the bind mount. -for f in ecr-presentation.json tier1-outputs.json; do +for f in ecr-presentation.json holder-state.json; do if [ ! -s "$OUT_DIR/$f" ]; then fail "expected $OUT_DIR/$f to exist and be non-empty after the run — check the output above" fi @@ -50,5 +50,5 @@ for f in ecr-presentation.json tier1-outputs.json; do done header "Chain build complete" -note "ecr-presentation.json + tier1-outputs.json are in $OUT_DIR" +note "ecr-presentation.json + holder-state.json are in $OUT_DIR" note "next: $SCRIPT_DIR/verify-control-demo.sh (or run-vlei.sh for the full flow)" diff --git a/scripts/demo/vlei/docker-compose.yml b/scripts/demo/vlei/docker-compose.yml index dc842bc..019456d 100644 --- a/scripts/demo/vlei/docker-compose.yml +++ b/scripts/demo/vlei/docker-compose.yml @@ -181,7 +181,7 @@ services: # second exec (sign-proof.ts) reuses build-chain.ts's downloads. - DENO_DIR=/deno-dir # Bind the signify dir so edits are live AND the exported artifacts - # (out/ecr-presentation.json, out/tier1-outputs.json) land on the host for + # (out/ecr-presentation.json, out/holder-state.json) land on the host for # the shell demo to consume. volumes: - ./signify:/app diff --git a/scripts/demo/vlei/down.sh b/scripts/demo/vlei/down.sh index 001f156..7710a75 100755 --- a/scripts/demo/vlei/down.sh +++ b/scripts/demo/vlei/down.sh @@ -13,7 +13,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" export DATA="${DATA:-$(cd "$SCRIPT_DIR/../../.." && pwd)/data/demo/vlei}" -# shellcheck source=../../common.sh +# shellcheck source=../common.sh . "$SCRIPT_DIR/../common.sh" COMPOSE="${COMPOSE:-docker compose}" diff --git a/scripts/demo/vlei/run-vlei.sh b/scripts/demo/vlei/run-vlei.sh index 4caffbb..333716d 100755 --- a/scripts/demo/vlei/run-vlei.sh +++ b/scripts/demo/vlei/run-vlei.sh @@ -1,37 +1,44 @@ #!/usr/bin/env bash # -# One-command vLEI verify-control demo. +# One-command vLEI verify-control demo — fully standalone. # -# Chains the whole self-contained flow: -# 1. up.sh — bring up the stack (witnesses, vlei-server, KERIA, -# vlei-verifier, signify Deno runner) on one network. -# 2. build-chain.sh — run build-chain.ts headless: build the synthetic -# vLEI trust chain, issue the ECR to the holder AID, -# present it, register the local GLEIF root of trust, -# and export ecr-presentation.json + tier1-outputs.json. +# Bootstraps everything the flow needs and chains it end-to-end with no manual steps: +# 0. ensure ans-ra — start ans-ra (+ ans-tl) in the real vlei +# "verifier" mode if it isn't already running that +# way. The demo presents real CESR, which only the +# verifier backend accepts; the plain start.sh +# default is "noop" (base64url JSON), so a noop RA is +# restarted into verifier mode here. +# 0b. ensure an agent — register one (register.sh --v2) if none exists, to +# link the verified lei identity to. +# 1. up.sh — bring up the stack (witnesses, vlei-server, KERIA, +# vlei-verifier, signify Deno runner) on one network. +# 2. build-chain.sh — run build-chain.ts headless: build the synthetic +# vLEI trust chain, issue the ECR to the holder AID, +# present it, register the local GLEIF root of trust, +# and export ecr-presentation.json + holder-state.json. # 3. verify-control-demo.sh (AUTO_SIGN=1) — RA-mediated register + -# verify-control on /v2/ans/identities, signing the -# served signingInput in-container with the holder -# (role) AID, then linking the verified lei identity -# to the agent. No manual paste. -# -# The RA is NOT started here — it owns its own lifecycle (config + agent -# registration). This script requires it already running with an agent -# registered; it checks both up front and points you at the right script if not. +# verify-control on /v2/ans/identities, signing the +# served signingInput in-container with the holder +# (role) AID, then linking the verified lei identity +# to the agent. No manual paste. # # Usage: -# AGENT_ID=$(scripts/demo/register.sh --v2) # scripts/demo/vlei/run-vlei.sh [--down] # -# Required env: -# AGENT_ID a registered ans agent id (e.g. from scripts/demo/register.sh -# # Flags: -# --down tear the stack down (down.sh) after a successful run. +# --down tear the stack down after a successful run — both the docker +# ecosystem (down.sh) and the ans-ra/ans-tl/ans-dns daemons +# (stop.sh) this run may have started. # # Env overrides: -# COMPOSE docker compose command (default: "docker compose") -# RA_URL ans-ra base URL (default: http://localhost:18080) +# AGENT_ID reuse this already-registered agent instead of +# registering a fresh one (default: the id saved by a +# prior register.sh --v2, else a freshly registered one). +# COMPOSE docker compose command (default: "docker compose") +# RA_URL ans-ra base URL (default: http://localhost:18080) +# VLEI_VERIFIER_URL the vlei-verifier the RA points at in verifier mode +# (default: http://localhost:7676 — the port up.sh exposes) set -euo pipefail @@ -48,18 +55,47 @@ for arg in "$@"; do done OUT_DIR="$SCRIPT_DIR/signify/out" - -# AGENT_ID: from env -[ -n "${AGENT_ID:-}" ] || fail "set AGENT_ID — register an agent first (scripts/demo/register.sh)" +VLEI_VERIFIER_URL="${VLEI_VERIFIER_URL:-http://localhost:7676}" header "vLEI verify-control demo — full run" -note "agent: $AGENT_ID RA: $RA_URL" -# Fail fast if the RA isn't reachable, before standing the stack up. +# 0. Ensure ans-ra is up AND wired to the real vlei-verifier. This demo +# presents real CESR, which only the "verifier" backend accepts; the plain +# start.sh default is "noop" (base64url JSON). Three cases: +# * RA not running → fresh start in verifier mode. +# * RA running, noop mode → restart in verifier mode, preserving data +# (start.sh --keep keeps data/demo: agent id, SQLite store, signer keys; +# the TL still trusts the unchanged RA pubkey). +# * RA running, verifier → leave it. +# The running RA's mode is read from its startup log; start.sh truncates +# ra.log per launch, so the grep reflects the live process. if ! curl -sSf "$RA_URL/v2/admin/ready" >/dev/null 2>&1; then - fail "ans-ra isn't reachable at $RA_URL — run scripts/demo/start.sh first (and ensure the vlei: block in config/ra-local.yaml is enabled)" + note "ans-ra not running — starting ans-ra + ans-tl in vlei verifier mode" + ANS_VLEI_TYPE=verifier ANS_VLEI_BASE_URL="$VLEI_VERIFIER_URL" \ + "$SCRIPT_DIR/../start.sh" +elif grep -Eq 'vleiVerifier=("?)verifier' "$DATA/ra.log" 2>/dev/null; then + ok "ans-ra already in vlei verifier mode" +else + note "ans-ra is in noop vlei mode — restarting in verifier mode (data preserved via --keep)" + "$SCRIPT_DIR/../stop.sh" + ANS_VLEI_TYPE=verifier ANS_VLEI_BASE_URL="$VLEI_VERIFIER_URL" \ + "$SCRIPT_DIR/../start.sh" --keep fi -ok "ans-ra ready at $RA_URL" +ok "ans-ra ready at $RA_URL in vlei verifier mode" + +# 0b. Ensure an agent is registered to link the verified lei identity to. +# Reuse AGENT_ID (env) or the id a prior register.sh --v2 saved; otherwise +# register a fresh one now (the RA is up, so registration can run). +if [ -z "${AGENT_ID:-}" ] && [ -f "$DATA/last-agent-id" ]; then + AGENT_ID=$(cat "$DATA/last-agent-id") +fi +if [ -z "${AGENT_ID:-}" ]; then + note "no agent registered — registering one (register.sh --v2)" + "$SCRIPT_DIR/../register.sh" --v2 >&2 + AGENT_ID=$(cat "$DATA/last-agent-id") +fi +[ -n "${AGENT_ID:-}" ] || fail "agent registration did not produce an id" +note "agent: $AGENT_ID RA: $RA_URL" # 1. stack up "$SCRIPT_DIR/up.sh" @@ -69,11 +105,14 @@ ok "ans-ra ready at $RA_URL" # 3. RA-mediated register + verify-control, auto-signing the signingInput # in-container. DATA points the verify script at build-chain.ts's exported -# artifacts so it finds both ecr-presentation.json and tier1-outputs.json there. +# artifacts so it finds both ecr-presentation.json and holder-state.json there. AGENT_ID="$AGENT_ID" DATA="$OUT_DIR" AUTO_SIGN=1 "$SCRIPT_DIR/verify-control-demo.sh" if [ "$TEARDOWN" = "1" ]; then "$SCRIPT_DIR/down.sh" + # Also stop the ans-ra/ans-tl/ans-dns daemons this run may have started in + # step 0 — down.sh only tears down the docker stack, not the local daemons. + "$SCRIPT_DIR/../stop.sh" fi header "All done" diff --git a/scripts/demo/vlei/signify/scripts_ts/build-chain.ts b/scripts/demo/vlei/signify/scripts_ts/build-chain.ts index 56631bf..803cf27 100644 --- a/scripts/demo/vlei/signify/scripts_ts/build-chain.ts +++ b/scripts/demo/vlei/signify/scripts_ts/build-chain.ts @@ -6,7 +6,7 @@ // LOCAL synthetic GLEIF root of trust at the vlei-verifier, and exports the two // artifacts the shell demo consumes: // - out/ecr-presentation.json {cesr, lei, aid} → verify-control-demo.sh -// - out/tier1-outputs.json {roleBran, ...} → the nonce signer +// - out/holder-state.json {roleBran, ...} → the nonce signer // // This is the headless equivalent of the old Jupyter notebook — same SignifyTS // logic, no notebook/kernel layer. The interactive sign cell is gone entirely; @@ -337,7 +337,7 @@ prContinue(); } // --------------------------------------------------------------------------- -// Step 6 (Path 1): ECR Credential — LE directly issues an Engagement Context +// Step 3: ECR Credential — LE directly issues an Engagement Context // Role credential to the Role holder, chained to the LE's own vLEI credential. // --------------------------------------------------------------------------- { @@ -426,7 +426,13 @@ prContinue(); headers: { "Content-Type": "application/json" }, body: JSON.stringify({ vlei: ecrCesr, oobi: gleifOOBI }), }); - console.log("root_of_trust:", rotResp.status, await rotResp.text()); + const rotBody = await rotResp.text(); + if (!rotResp.ok) { + throw new Error( + `root_of_trust registration failed: ${rotResp.status} ${rotResp.statusText} — ${rotBody}`, + ); + } + console.log("root_of_trust:", rotResp.status, rotBody); // Export {cesr, lei, aid} for the shell demo. The RA parses said+aid out of // the CESR itself and presents it to the verifier on the holder's behalf. @@ -454,6 +460,6 @@ prContinue(); ecrCredentialSaid: ecrCredential.sad.d, gleifOOBI, }; - await Deno.writeTextFile(`${OUT_DIR}/tier1-outputs.json`, JSON.stringify(outputs, null, 2)); - console.log(` wrote ${OUT_DIR}/tier1-outputs.json`); + await Deno.writeTextFile(`${OUT_DIR}/holder-state.json`, JSON.stringify(outputs, null, 2)); + console.log(` wrote ${OUT_DIR}/holder-state.json`); } diff --git a/scripts/demo/vlei/signify/scripts_ts/utils.ts b/scripts/demo/vlei/signify/scripts_ts/utils.ts index c0ca782..51dd866 100644 --- a/scripts/demo/vlei/signify/scripts_ts/utils.ts +++ b/scripts/demo/vlei/signify/scripts_ts/utils.ts @@ -1,5 +1,4 @@ import { - randomPasscode, ready, SignifyClient, Tier, @@ -24,33 +23,12 @@ class Ansi { // Bright/Light versions static readonly BRIGHT_BLACK = '\x1b[90m'; - static readonly BRIGHT_RED = '\x1b[91m'; - static readonly BRIGHT_GREEN = '\x1b[92m'; - static readonly BRIGHT_YELLOW = '\x1b[93m'; static readonly BRIGHT_BLUE = '\x1b[94m'; - static readonly BRIGHT_MAGENTA = '\x1b[95m'; - static readonly BRIGHT_CYAN = '\x1b[96m'; - static readonly BRIGHT_WHITE = '\x1b[97m'; // Background colors - static readonly BG_BLACK = '\x1b[40m'; - static readonly BG_RED = '\x1b[41m'; static readonly BG_GREEN = '\x1b[42m'; static readonly BG_YELLOW = '\x1b[43m'; static readonly BG_BLUE = '\x1b[44m'; - static readonly BG_MAGENTA = '\x1b[45m'; - static readonly BG_CYAN = '\x1b[46m'; - static readonly BG_WHITE = '\x1b[47m'; - - // Bright Background colors - static readonly BG_BRIGHT_BLACK = '\x1b[100m'; - static readonly BG_BRIGHT_RED = '\x1b[101m'; - static readonly BG_BRIGHT_GREEN = '\x1b[102m'; - static readonly BG_BRIGHT_YELLOW = '\x1b[103m'; - static readonly BG_BRIGHT_BLUE = '\x1b[104m'; - static readonly BG_BRIGHT_MAGENTA = '\x1b[105m'; - static readonly BG_BRIGHT_CYAN = '\x1b[106m'; - static readonly BG_BRIGHT_WHITE = '\x1b[107m'; // Styles static readonly BOLD = '\x1b[1m'; @@ -66,10 +44,6 @@ export function prMessage(message: string): void { console.log(`\n${Ansi.BOLD}${Ansi.BRIGHT_BLUE}${message}${Ansi.RESET}\n`); } -export function prAlert(message: string): void { - console.log(`\n${Ansi.BOLD}${Ansi.BG_YELLOW}${Ansi.BRIGHT_BLUE}${message}${Ansi.RESET}\n`); -} - export function prContinue(): void { const message = " You can continue ✅ "; console.log(`\n${Ansi.BOLD}${Ansi.BG_GREEN}${Ansi.BRIGHT_BLACK}${message}${Ansi.RESET}\n\n`); @@ -80,27 +54,6 @@ export function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } -// Function to check the health of a container -export async function isServiceHealthy(healthCheckUrl: string): Promise { - - console.log(`Checking health at: ${healthCheckUrl}`); - - try { - const response = await fetch(healthCheckUrl); - - if (response.ok) { - console.log(`Received status: ${response.status}. Service is healthy.`); - return true; - } else { - console.warn(`Received a non-ok status: ${response.status}. Service is running but may be unhealthy.`); - return false; - } - } catch (error) { - console.error(`Failed to connect to service. It may be down. Error:`, error.message); - return false; - } -} - // Default KERIA connection parameters (adjust as needed for your environment) export const DEFAULT_ADMIN_URL = 'http://keria:3901'; export const DEFAULT_BOOT_URL = 'http://keria:3903'; @@ -110,8 +63,6 @@ export const DEFAULT_RETRIES = 5; // For retries export const ROLE_AGENT = 'agent' export const IPEX_GRANT_ROUTE = '/exn/ipex/grant' export const IPEX_ADMIT_ROUTE = '/exn/ipex/admit' -export const IPEX_APPLY_ROUTE = '/exn/ipex/apply' -export const IPEX_OFFER_ROUTE = '/exn/ipex/offer' export const SCHEMA_SERVER_HOST = 'http://vlei-server:7723'; export const DEFAULT_IDENTIFIER_ARGS = { @@ -306,113 +257,6 @@ export async function resolveOOBI( } } -/** - * Generates challenge words for authentication. - * @param {SignifyClient} client - The SignifyClient instance. - * @param {number} [strength=128] - The bit strength for the challenge (e.g., 128, 256). - * @returns {Promise} A promise that resolves to an array of challenge words. - */ -export async function generateChallengeWords( - client: SignifyClient, - strength: number = 128 -): Promise { - console.log(`Generating ${strength}-bit challenge words...`); - try { - const challenge = await client.challenges().generate(strength); - console.log('Generated challenge words:', challenge.words); - return challenge.words; - } catch (error) { - console.error('Failed to generate challenge words:', error); - throw error; - } -} - -/** - * Responds to a challenge by signing the words and sending them to the challenger. - * @param {SignifyClient} client - The SignifyClient instance of the responder. - * @param {string} sourceAidAlias - The alias of the AID that is responding (signing). - * @param {string} recipientAidPrefix - The AID prefix of the challenger (to whom the response is sent). - * @param {string[]} challengeWords - The array of challenge words to sign. - * @returns {Promise} A promise that resolves when the response is sent. - */ -export async function respondToChallenge( - client: SignifyClient, - sourceAidAlias: string, - recipientAidPrefix: string, - challengeWords: string[] -): Promise { - console.log(`AID alias '${sourceAidAlias}' responding to challenge from AID '${recipientAidPrefix}'...`); - try { - await client.challenges().respond(sourceAidAlias, recipientAidPrefix, challengeWords); - console.log('Challenge response sent.'); - } catch (error) { - console.error('Failed to respond to challenge:', error); - throw error; - } -} - -/** - * Verifies a challenge response received from another AID. - * @param {SignifyClient} client - The SignifyClient instance of the verifier. - * @param {string} allegedSenderAidPrefix - The AID prefix of the AID that allegedly sent the response. - * @param {string[]} originalChallengeWords - The original challenge words that were sent. - * @returns {Promise<{ verified: boolean; said?: string; operation?: Operation }>} - * A promise that resolves to an object indicating if verification was successful, - * the SAID of the signed exchange message, and the operation details. - */ -export async function verifyChallengeResponse( - client: SignifyClient, - allegedSenderAidPrefix: string, - originalChallengeWords: string[] -): Promise<{ verified: boolean; said?: string; operation?: Operation }> { - console.log(`Verifying challenge response from AID '${allegedSenderAidPrefix}'...`); - try { - const verifyOperation = await client.challenges().verify(allegedSenderAidPrefix, originalChallengeWords); - const completedOperation = await client - .operations() - .wait(verifyOperation, AbortSignal.timeout(DEFAULT_TIMEOUT_MS)); - - if (completedOperation.error) { - console.error('Challenge verification failed:', completedOperation.error); - await client.operations().delete(completedOperation.name); - return { verified: false, operation: completedOperation }; - } - - const said = completedOperation.response?.exn?.d; - console.log(`Challenge response verified successfully. SAID of exn: ${said}`); - - await client.operations().delete(completedOperation.name); - - return { verified: true, said: said, operation: completedOperation }; - } catch (error) { - console.error('Failed to verify challenge response:', error); - throw error; - } -} - -/** - * Marks a challenge for a contact as authenticated. - * This is done after successful verification of a challenge response. - * @param {SignifyClient} client - The SignifyClient instance. - * @param {string} contactAidPrefix - The AID prefix of the contact to mark as authenticated. - * @param {string} signedChallengeSaid - The SAID of the signed challenge exchange message (exn). - * @returns {Promise} A promise that resolves when the contact is marked. - */ -export async function markChallengeAuthenticated( - client: SignifyClient, - contactAidPrefix: string, - signedChallengeSaid: string -): Promise { - console.log(`Marking challenge for contact AID '${contactAidPrefix}' as authenticated with SAID '${signedChallengeSaid}'...`); - try { - await client.challenges().responded(contactAidPrefix, signedChallengeSaid); - console.log(`Contact AID '${contactAidPrefix}' marked as authenticated.`); - } catch (error) { - console.error(`Failed to mark challenge as authenticated for contact AID '${contactAidPrefix}':`, error); - throw error; - } -} - export function createTimestamp() { return new Date().toISOString().replace('Z', '000+00:00'); } @@ -455,27 +299,6 @@ export async function createCredentialRegistry( } } -/** - * Retrieves a schema by its SAID. - * @param {SignifyClient} client - The SignifyClient instance. - * @param {string} schemaSaid - The SAID of the schema to retrieve. - * @returns {Promise} The schema object. - */ -export async function getSchema( - client: SignifyClient, - schemaSaid: string -): Promise { - console.log(`Retrieving schema with SAID: ${schemaSaid}...`); - try { - const schema = await client.schemas().get(schemaSaid); - console.log(`Successfully retrieved schema: ${schemaSaid}`); - return schema; - } catch (error) { - console.error(`Failed to retrieve schema "${schemaSaid}":`, error); - throw error; - } -} - /** * Issues a new credential. * @param {SignifyClient} client - The SignifyClient instance. @@ -584,42 +407,6 @@ export async function ipexGrantCredential( } } -/** - * Retrieves the state of a credential. - * Includes retry logic as this might be called before the information has propagated. - * @param {SignifyClient} client - The SignifyClient instance. - * @param {string} registryIdentifier - The registry identifier (regk). - * @param {string} credentialSaid - The SAID of the credential. - * @param {number} [retries=DEFAULT_RETRIES] - Number of retry attempts. - * @param {number} [delayMs=DEFAULT_DELAY_MS] - Delay between retries in milliseconds. - * @returns {Promise} The credential state. - */ -export async function getCredentialState( - client: SignifyClient, - registryIdentifier: string, - credentialSaid: string, - retries: number = DEFAULT_RETRIES, - delayMs: number = DEFAULT_DELAY_MS -): Promise { - console.log(`Querying credential state for SAID "${credentialSaid}" in registry "${registryIdentifier}"...`); - for (let attempt = 1; attempt <= retries; attempt++) { - try { - const credentialState = await client.credentials().state(registryIdentifier, credentialSaid); - console.log('Successfully retrieved credential state.'); - return credentialState; - } catch (error: any) { - console.warn(`[Attempt ${attempt}/${retries}] Failed to get credential state: ${error.message}`); - if (attempt === retries) { - console.error(`Max retries (${retries}) reached for getting credential state.`); - throw error; - } - await new Promise(resolve => setTimeout(resolve, delayMs)); - } - } - // Should not be reached if retries > 0 - throw new Error('Failed to get credential state after all retries.'); -} - /** * Waits for and retrieves a specific notification. * @param {SignifyClient} client - The SignifyClient instance. @@ -728,318 +515,3 @@ export async function markNotificationRead( throw error; } } - -/** - * Deletes a notification. - * @param {SignifyClient} client - The SignifyClient instance. - * @param {string} notificationId - The ID of the notification to delete. - * @returns {Promise} - */ -export async function deleteNotification( - client: SignifyClient, - notificationId: string -): Promise { - console.log(`Deleting notification "${notificationId}"...`); - try { - await client.notifications().delete(notificationId); - console.log(`Notification "${notificationId}" deleted.`); - } catch (error) { - console.error(`Failed to delete notification "${notificationId}":`, error); - throw error; - } -} - - -//-------------------------------------------------------------------------------- - -// --- Credential Presentation Functions --- - -/** - * Submits an IPEX apply (presentation request). - * @param {SignifyClient} client - The SignifyClient instance of the verifier. - * @param {string} senderAidAlias - The alias of the AID applying for presentation. - * @param {string} recipientAidPrefix - The AID prefix of the holder. - * @param {string} schemaSaid - The SAID of the schema being requested. - * @param {any} attributes - The attributes being requested for the credential. - * @param {string} datetime - The timestamp for the apply. - * @returns {Promise<{ operation: Operation; applySaid: string }>} The operation details and SAID of the apply exn. - */ -export async function ipexApplyForCredential( - client: SignifyClient, - senderAidAlias: string, - recipientAidPrefix: string, - schemaSaid: string, - attributes: any, - datetime: string -): Promise<{ operation: Operation; applySaid: string }> { - console.log(`AID "${senderAidAlias}" applying for credential presentation from AID "${recipientAidPrefix}"...`); - try { - const [apply, sigs, _] = await client.ipex().apply({ - senderName: senderAidAlias, - schemaSaid: schemaSaid, - attributes: attributes, - recipient: recipientAidPrefix, - datetime: datetime, - }); - - const applySaid = new Serder(apply).said; // Get SAID of the apply message itself - - const applyOperationDetails = await client - .ipex() - .submitApply(senderAidAlias, apply, sigs, [recipientAidPrefix]); - - const completedOperation = await client - .operations() - .wait(applyOperationDetails, AbortSignal.timeout(DEFAULT_TIMEOUT_MS)); - - if (completedOperation.error) { - throw new Error(`IPEX apply submission failed: ${JSON.stringify(completedOperation.error)}`); - } - console.log(`Successfully submitted IPEX apply with SAID "${applySaid}".`); - await client.operations().delete(completedOperation.name); - return { operation: completedOperation, applySaid }; - } catch (error) { - console.error('Failed to submit IPEX apply:', error); - throw error; - } -} - -/** - * Finds matching credentials based on a filter. - * @param {SignifyClient} client - The SignifyClient instance of the holder. - * @param {any} filter - The filter object to apply (e.g., { '-s': schemaSaid, '-a-attributeName': value }). - * @returns {Promise} An array of matching credentials. - */ -export async function findMatchingCredentials( - client: SignifyClient, - filter: any -): Promise { - console.log('Finding matching credentials with filter:', filter); - try { - const matchingCredentials = await client.credentials().list({ filter }); - console.log(`Found ${matchingCredentials.length} matching credentials.`); - return matchingCredentials; - } catch (error) { - console.error('Failed to find matching credentials:', error); - throw error; - } -} - -/** - * Submits an IPEX offer (presents a credential). - * @param {SignifyClient} client - The SignifyClient instance of the holder. - * @param {string} senderAidAlias - The alias of the AID offering the credential. - * @param {string} recipientAidPrefix - The AID prefix of the verifier. - * @param {any} acdcSad - The Self-Addressing Data (SAD) of the ACDC being offered. - * @param {string} applySaid - The SAID of the IPEX apply message this offer is responding to. - * @param {string} datetime - The timestamp for the offer. - * @returns {Promise<{ operation: Operation }>} The operation details. - */ -export async function ipexOfferCredential( - client: SignifyClient, - senderAidAlias: string, - recipientAidPrefix: string, - acdcSad: any, // This is the SAD of the credential to be offered - applySaid: string, - datetime: string -): Promise<{ operation: Operation }> { - console.log(`AID "${senderAidAlias}" offering credential to AID "${recipientAidPrefix}" in response to apply "${applySaid}"...`); - try { - const [offer, sigs, end] = await client.ipex().offer({ - senderName: senderAidAlias, - recipient: recipientAidPrefix, - acdc: new Serder(acdcSad), // The credential SAD needs to be wrapped in Serder - applySaid: applySaid, - datetime: datetime, - }); - - const offerOperationDetails = await client - .ipex() - .submitOffer(senderAidAlias, offer, sigs, end, [recipientAidPrefix]); - - const completedOperation = await client - .operations() - .wait(offerOperationDetails, AbortSignal.timeout(DEFAULT_TIMEOUT_MS)); - - if (completedOperation.error) { - throw new Error(`IPEX offer submission failed: ${JSON.stringify(completedOperation.error)}`); - } - console.log(`Successfully submitted IPEX offer in response to apply "${applySaid}".`); - await client.operations().delete(completedOperation.name); - return { operation: completedOperation }; - } catch (error) { - console.error('Failed to submit IPEX offer:', error); - throw error; - } -} - -/** - * Submits an IPEX agree (verifier agrees to the offered credential). - * @param {SignifyClient} client - The SignifyClient instance of the verifier. - * @param {string} senderAidAlias - The alias of the AID agreeing to the offer. - * @param {string} recipientAidPrefix - The AID prefix of the holder who made the offer. - * @param {string} offerSaid - The SAID of the IPEX offer message being agreed to. - * @param {string} datetime - The timestamp for the agree. - * @returns {Promise<{ operation: Operation }>} The operation details. - */ -export async function ipexAgreeToOffer( - client: SignifyClient, - senderAidAlias: string, - recipientAidPrefix: string, - offerSaid: string, - datetime: string -): Promise<{ operation: Operation }> { - console.log(`AID "${senderAidAlias}" agreeing to IPEX offer "${offerSaid}" from AID "${recipientAidPrefix}"...`); - try { - const [agree, sigs, _] = await client.ipex().agree({ - senderName: senderAidAlias, - recipient: recipientAidPrefix, - offerSaid: offerSaid, - datetime: datetime, - }); - - const agreeOperationDetails = await client - .ipex() - .submitAgree(senderAidAlias, agree, sigs, [recipientAidPrefix]); - - const completedOperation = await client - .operations() - .wait(agreeOperationDetails, AbortSignal.timeout(DEFAULT_TIMEOUT_MS)); - - if (completedOperation.error) { - throw new Error(`IPEX agree submission failed: ${JSON.stringify(completedOperation.error)}`); - } - console.log(`Successfully submitted IPEX agree for offer "${offerSaid}".`); - await client.operations().delete(completedOperation.name); - return { operation: completedOperation }; - } catch (error) { - console.error('Failed to submit IPEX agree:', error); - throw error; - } -} - - - - - - - - - - - - - - - - - - - - - - -// --- Example Usage --- -export async function test() { - try { - await initializeSignify(); - - // --- Client A (Alfred) Setup --- - console.log('\n--- Initializing Client A (Alfred) ---'); - const { client: clientA, clientState: clientAState } = await initializeAndConnectClient(randomPasscode()); - const alfredClientAID = clientAState?.controller?.state?.i || 'Unknown Client AID A'; - const aidAAlias = 'alfredPrimaryAID'; - console.log('\n--- Creating AID for Alfred ---'); - const { aid: aidAObj} = await createNewAID(clientA, aidAAlias, DEFAULT_IDENTIFIER_ARGS); - const aidAPrefix = aidAObj?.i || 'Unknown AID A Prefix'; - console.log(`Alfred's primary AID: ${aidAPrefix}`); - console.log('\n--- Adding Agent End Role for Alfred ---'); - await addEndRoleForAID(clientA, aidAAlias, 'agent'); - console.log('\n--- Generating OOBI for Alfred ---'); - const alfredOOBI = await generateOOBI(clientA, aidAAlias, 'agent'); - - // --- Client B (Betty) Setup --- - console.log('\n\n--- Initializing Client B (Betty) ---'); - const { client: clientB, clientState: clientBState } = await initializeAndConnectClient(randomPasscode()); - const bettyClientAID = clientBState?.controller?.state?.i || 'Unknown Client AID B'; - const aidBAlias = 'bettyPrimaryAID'; - console.log('\n--- Creating AID for Betty ---'); - const { aid: aidBObj } = await createNewAID(clientB, aidBAlias, DEFAULT_IDENTIFIER_ARGS); - const aidBPrefix = aidBObj?.i || 'Unknown AID B Prefix'; - console.log(`Betty's primary AID: ${aidBPrefix}`); - console.log('\n--- Adding Agent End Role for Betty ---'); - await addEndRoleForAID(clientB, aidBAlias, 'agent'); - console.log('\n--- Generating OOBI for Betty ---'); - const bettyOOBI = await generateOOBI(clientB, aidBAlias, 'agent'); - - // --- OOBI Resolution --- - console.log('\n\n--- Betty resolving Alfred\'s OOBI ---'); - const contactAlfredAlias = 'AlfredsContactForBetty'; - await resolveOOBI(clientB, alfredOOBI, contactAlfredAlias); - - console.log('\n--- Alfred resolving Betty\'s OOBI ---'); - const contactBettyAlias = 'BettysContactForAlfred'; - await resolveOOBI(clientA, bettyOOBI, contactBettyAlias); - - // --- Challenge/Response: Alfred challenges Betty --- - console.log("\n\n--- MUTUAL AUTHENTICATION ---"); - console.log("\n--- Alfred challenges Betty ---"); - - // 1. Alfred generates challenge words for Betty - const alfredChallengeForBetty = await generateChallengeWords(clientA); - - // (Assume words are securely transmitted out-of-band to Betty) - - // 2. Betty responds to Alfred's challenge - console.log(`\nBetty (AID: ${aidBPrefix}) responding to Alfred's (AID: ${aidAPrefix}) challenge...`); - await respondToChallenge(clientB, aidBAlias, aidAPrefix, alfredChallengeForBetty); - - // 3. Alfred verifies Betty's response - console.log(`\nAlfred (AID: ${aidAPrefix}) verifying Betty's (AID: ${aidBPrefix}) response...`); - const verificationBetty = await verifyChallengeResponse(clientA, aidBPrefix, alfredChallengeForBetty); - - // 4. Alfred marks Betty as authenticated if verification succeeded - if (verificationBetty.verified && verificationBetty.said) { - await markChallengeAuthenticated(clientA, aidBPrefix, verificationBetty.said); - console.log(`Alfred has successfully authenticated Betty (AID: ${aidBPrefix}).`); - } else { - console.error(`Alfred failed to authenticate Betty (AID: ${aidBPrefix}).`); - } - - // --- Challenge/Response: Betty challenges Alfred --- - console.log("\n--- Betty challenges Alfred ---"); - - // 1. Betty generates challenge words for Alfred - const bettyChallengeForAlfred = await generateChallengeWords(clientB); - - // (Assume words are securely transmitted out-of-band to Alfred) - - // 2. Alfred responds to Betty's challenge - console.log(`\nAlfred (AID: ${aidAPrefix}) responding to Betty's (AID: ${aidBPrefix}) challenge...`); - await respondToChallenge(clientA, aidAAlias, aidBPrefix, bettyChallengeForAlfred); - - // 3. Betty verifies Alfred's response - console.log(`\nBetty (AID: ${aidBPrefix}) verifying Alfred's (AID: ${aidAPrefix}) response...`); - const verificationAlfred = await verifyChallengeResponse(clientB, aidAPrefix, bettyChallengeForAlfred); - - // 4. Betty marks Alfred as authenticated if verification succeeded - if (verificationAlfred.verified && verificationAlfred.said) { - await markChallengeAuthenticated(clientB, aidAPrefix, verificationAlfred.said); - console.log(`Betty has successfully authenticated Alfred (AID: ${aidAPrefix}).`); - } else { - console.error(`Betty failed to authenticate Alfred (AID: ${aidAPrefix}).`); - } - - console.log('\n\n--- Example scenario with mutual authentication completed! ---'); - console.log(`Alfred's Client AID: ${alfredClientAID}, Primary AID: ${aidAPrefix}`); - console.log(`Betty's Client AID: ${bettyClientAID}, Primary AID: ${aidBPrefix}`); - - // You can inspect contacts to see authentication status - const alfredsContacts = await clientA.contacts().list(); - console.log("\nAlfred's contacts:", JSON.stringify(alfredsContacts, null, 2)); - const bettysContacts = await clientB.contacts().list(); - console.log("\nBetty's contacts:", JSON.stringify(bettysContacts, null, 2)); - } catch (error) { - console.error('\n--- An error occurred in the main example: ---', error); - } -} diff --git a/scripts/demo/vlei/verify-control-demo.sh b/scripts/demo/vlei/verify-control-demo.sh index 82e9a15..7418bf6 100755 --- a/scripts/demo/vlei/verify-control-demo.sh +++ b/scripts/demo/vlei/verify-control-demo.sh @@ -66,7 +66,7 @@ # PRESENTATION_FILE exported {cesr,lei,aid} JSON # (default: $DATA/ecr-presentation.json) # OUTPUTS_FILE exported {roleBran,...} JSON, used by AUTO_SIGN -# (default: alongside PRESENTATION_FILE / tier1-outputs.json) +# (default: alongside PRESENTATION_FILE / holder-state.json) # LEI claimed LEI (default: read from PRESENTATION_FILE) # SIGNED_PROOF the signature over the signingInput (indexed Siger qb64). # When set, the script uses it directly — highest @@ -86,7 +86,7 @@ export DATA="${DATA:-$(cd "$SCRIPT_DIR/../../.." && pwd)/data/demo/vlei}" . "$SCRIPT_DIR/../common.sh" PRESENTATION_FILE="${PRESENTATION_FILE:-$DATA/ecr-presentation.json}" -OUTPUTS_FILE="${OUTPUTS_FILE:-$(dirname "$PRESENTATION_FILE")/tier1-outputs.json}" +OUTPUTS_FILE="${OUTPUTS_FILE:-$(dirname "$PRESENTATION_FILE")/holder-state.json}" COMPOSE="${COMPOSE:-docker compose}" require_cmd curl @@ -231,7 +231,7 @@ fi # The sealed IDENTITY_VERIFIED reaches the TL through the outbox worker. header "verify the seal landed on the TL identity stream" -poll_tl_identity_audit "$IDENTITY_ID" 1 +assert_tl_identity_audit "$IDENTITY_ID" 1 ok "IDENTITY_VERIFIED sealed with a Merkle proof on the TL" # ----- 5. link: bind the verified identity to the agent (one event) ----- From 40735dff531ddc3bddd24f87299be0d48ec7fdf9 Mon Sep 17 00:00:00 2001 From: James Hateley Date: Sat, 13 Jun 2026 17:45:27 +1000 Subject: [PATCH 13/13] fix(vlei): noop lei verifier accepts the real CESR presentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The noop LEIControlVerifier required a bespoke base64url-JSON payload ({publicKey, lei}) while the real verifier consumes a full-chain KERI/CESR export. Presenting the demo's real CESR to a noop-mode RA therefore failed base64url decoding and returned 422 LEI_PRESENTATION_INVALID — diverging from the DNS and did:web noops, which accept the same client payload as their real counterparts and waive only the external-world binding. The noop now consumes the same cesr/cesrSignature the verifier does: it pins the real subject AID read from the presented leaf credential (a.i), echoes the credential's LEI (a.LEI), and structurally accepts a well-formed qb64 signature — waiving the GLEIF authorization, the live AID↔LEI binding, and the cryptographic signature check (no KEL key-state oracle in the quickstart), mirroring the noop DNS verifier. The ACDC frame scan in verifier.go is refactored into a shared scanACDCChain/leafFrame so the noop reads the leaf's a.i/a.LEI; presentedCredentialSAID is preserved as a thin wrapper. No wire-contract change — the OpenAPI cesr field already specifies the full-chain export. Stale "runs real Ed25519 crypto"/"base64url JSON" docs updated. Signed-off-by: James Hateley --- .../adapter/leiverifier/leiverifier_test.go | 128 ++++++++------- internal/adapter/leiverifier/noop.go | 148 +++++++----------- internal/adapter/leiverifier/verifier.go | 83 ++++++---- internal/config/config.go | 9 +- internal/port/leiverifier.go | 7 +- scripts/demo/start.sh | 8 +- scripts/demo/vlei/run-vlei.sh | 17 +- 7 files changed, 201 insertions(+), 199 deletions(-) diff --git a/internal/adapter/leiverifier/leiverifier_test.go b/internal/adapter/leiverifier/leiverifier_test.go index 91d139d..702cc67 100644 --- a/internal/adapter/leiverifier/leiverifier_test.go +++ b/internal/adapter/leiverifier/leiverifier_test.go @@ -2,10 +2,6 @@ package leiverifier import ( "context" - "crypto/ed25519" - "crypto/rand" - "encoding/base64" - "encoding/json" "errors" "net/http" "net/http/httptest" @@ -15,77 +11,70 @@ import ( "github.com/godaddy/ans/internal/domain" ) -// b64 is the unpadded base64url encoding the noop wire shape uses. -func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } +// leafACDC is a minimal single-credential CESR export whose leaf carries +// the subject AID (a.i) and LEI (a.LEI) the noop pins and echoes — the +// same shape the real verifier receives. +const leafACDC = `{"v":"ACDC10JSON00011c_","d":"ECredSAID123","i":"EIssuerAID","s":"ESchema","a":{"i":"EHolderAID","LEI":"5493001KJTIIGC8Y1R17"}}` -// noopFixture mints an Ed25519 keypair and the matching noop -// presentation cesr + subject AID for the public key. -func noopFixture(t *testing.T, lei string) (ed25519.PrivateKey, string, string) { - t.Helper() - pub, priv, err := ed25519.GenerateKey(rand.Reader) +func TestNoopPresent(t *testing.T) { + ctx := context.Background() + n := NewNoop() + + // Present pins the leaf credential's subject AID, echoes its LEI, and + // always reports AUTHORIZED (the live binding is waived). + res, err := n.Present(ctx, leafACDC) if err != nil { - t.Fatal(err) + t.Fatalf("Present: %v", err) } - aid := b64(pub) - raw, err := json.Marshal(noopPresentation{PublicKey: aid, LEI: lei}) - if err != nil { - t.Fatal(err) + if res.SubjectAID != "EHolderAID" || res.LEI != "5493001KJTIIGC8Y1R17" || res.Status != "AUTHORIZED" { + t.Fatalf("present result: %+v", res) } - return priv, aid, b64(raw) } -func TestNoopPresentAndVerify(t *testing.T) { +func TestNoopPresentChainPinsLeafSubject(t *testing.T) { + // Full-chain export: the noop pins the LEAF credential's subject AID + // (the ECR), not an intermediate's — the same leaf selection the real + // verifier uses, independent of frame order. ctx := context.Background() n := NewNoop() - priv, aid, cesr := noopFixture(t, "5493001KJTIIGC8Y1R17") - - // Present recovers the AID + echoes the LEI + always AUTHORIZED. - res, err := n.Present(ctx, cesr) + chain := `{"v":"ACDC10JSON_","d":"EQVI","a":{"i":"EQviSub","LEI":"L"}}` + + `{"v":"ACDC10JSON_","d":"ELE","e":{"d":"Eedge1","qvi":{"n":"EQVI","s":"S"}},"a":{"i":"ELeSub","LEI":"L"}}` + + `{"v":"ACDC10JSON_","d":"EECR","e":{"d":"Eedge2","le":{"n":"ELE","s":"S"}},"a":{"i":"EEcrSub","LEI":"875500ELOZEL05BVXV37"}}` + res, err := n.Present(ctx, chain) if err != nil { t.Fatalf("Present: %v", err) } - if res.SubjectAID != aid || res.LEI != "5493001KJTIIGC8Y1R17" || res.Status != "AUTHORIZED" { + if res.SubjectAID != "EEcrSub" || res.LEI != "875500ELOZEL05BVXV37" { t.Fatalf("present result: %+v", res) } +} - // Authorization authorizes a well-formed AID, asserts no LEI binding. - auth, err := n.Authorization(ctx, aid) +func TestNoopPresentIgnoresKEL(t *testing.T) { + // A real export interleaves KERI KEL events (icp/ixn) with ACDC frames; + // the scan keys on the ACDC version marker, so KEL frames are ignored + // and the leaf credential's subject AID is still pinned. + ctx := context.Background() + n := NewNoop() + withKEL := `{"v":"KERI10JSON0001b7_","t":"icp","d":"EIncept","i":"EIncept","k":["DKey"]}` + leafACDC + res, err := n.Present(ctx, withKEL) if err != nil { - t.Fatalf("Authorization: %v", err) - } - if !auth.Authorized || auth.LEI != "" { - t.Fatalf("authorization: %+v", auth) - } - - // VerifySignature: a real Ed25519 signature over the signing input. - const signingInput = "the-served-signing-input" - sig := ed25519.Sign(priv, []byte(signingInput)) - ok, err := n.VerifySignature(ctx, aid, signingInput, b64(sig)) - if err != nil || !ok { - t.Fatalf("verify good sig: ok=%v err=%v", ok, err) + t.Fatalf("Present: %v", err) } - // Tampered payload does not verify. - if ok, _ := n.VerifySignature(ctx, aid, "other-input", b64(sig)); ok { - t.Fatal("tampered payload should not verify") + if res.SubjectAID != "EHolderAID" { + t.Fatalf("want subject AID EHolderAID, got %q", res.SubjectAID) } } func TestNoopPresentFailures(t *testing.T) { ctx := context.Background() n := NewNoop() - _, validAID, _ := noopFixture(t, "X") - - noLEI, _ := json.Marshal(noopPresentation{PublicKey: validAID}) - badPub, _ := json.Marshal(noopPresentation{PublicKey: "not-base64url-key", LEI: "X"}) - cases := []struct { name string cesr string }{ - {"not base64url", "!!!not-base64!!!"}, - {"not a json object", b64([]byte("not json"))}, - {"bad public key", b64(badPub)}, - {"missing lei", b64(noLEI)}, + {"no ACDC frame", "no acdc credential here"}, + {"leaf without subject AID", `{"v":"ACDC10JSON_","d":"ECred","a":{"LEI":"L"}}`}, + {"leaf with non-qb64 subject AID", `{"v":"ACDC10JSON_","d":"ECred","a":{"i":"../evil","LEI":"L"}}`}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -94,22 +83,41 @@ func TestNoopPresentFailures(t *testing.T) { } }) } +} - // A malformed AID to Authorization is an error (not a silent allow). - if _, err := n.Authorization(ctx, "bogus-aid"); err == nil { - t.Fatal("malformed AID should error") +func TestNoopAuthorization(t *testing.T) { + ctx := context.Background() + n := NewNoop() + // A well-formed AID is authorized with NO live LEI binding asserted. + auth, err := n.Authorization(ctx, "EHolderAID") + if err != nil || !auth.Authorized || auth.LEI != "" { + t.Fatalf("auth=%+v err=%v", auth, err) + } + // A non-qb64 AID is a validation error (mirrors the real verifier guard). + if _, err := n.Authorization(ctx, "../signature/verify"); !isCode(err, "LEI_SUBJECT_AID_INVALID") { + t.Fatalf("want LEI_SUBJECT_AID_INVALID, got %v", err) } +} - // VerifySignature treats malformed AID / signature as a non-verifying - // false, never an I/O error. - if ok, err := n.VerifySignature(ctx, "bogus-aid", "in", b64([]byte("sig"))); ok || err != nil { - t.Fatalf("bad aid: ok=%v err=%v", ok, err) +func TestNoopVerifySignature(t *testing.T) { + ctx := context.Background() + n := NewNoop() + // Structural accept: well-formed qb64 AID + signature → true. + if ok, err := n.VerifySignature(ctx, "EHolderAID", "signing-input", "0BsomeQb64Signature"); err != nil || !ok { + t.Fatalf("structural accept: ok=%v err=%v", ok, err) } - if ok, err := n.VerifySignature(ctx, validAID, "in", "!!!"); ok || err != nil { - t.Fatalf("bad sig encoding: ok=%v err=%v", ok, err) + // A malformed AID or signature is a non-verifying false, never an error. + cases := []struct{ name, aid, sig string }{ + {"bad aid", "../evil", "0Bsig"}, + {"bad sig", "EHolderAID", "!!!not-qb64!!!"}, + {"empty sig", "EHolderAID", ""}, } - if ok, err := n.VerifySignature(ctx, validAID, "in", b64([]byte("too-short"))); ok || err != nil { - t.Fatalf("wrong-length sig: ok=%v err=%v", ok, err) + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if ok, err := n.VerifySignature(ctx, tc.aid, "in", tc.sig); ok || err != nil { + t.Fatalf("ok=%v err=%v", ok, err) + } + }) } } diff --git a/internal/adapter/leiverifier/noop.go b/internal/adapter/leiverifier/noop.go index 18150c5..4040112 100644 --- a/internal/adapter/leiverifier/noop.go +++ b/internal/adapter/leiverifier/noop.go @@ -1,9 +1,10 @@ // Package leiverifier provides the two port.LEIControlVerifier // adapters behind the lei (vLEI) identifier kind: // -// - Noop — zero-infra quickstart verifier; runs REAL Ed25519 -// crypto over the signing input but waives the GLEIF/vlei-verifier -// authorization binding. +// - Noop — zero-infra quickstart verifier; accepts the SAME +// full-chain CESR presentation the real verifier does, pins the real +// subject AID, and waives the external-world bindings (GLEIF +// authorization + cryptographic signature check). // - Verifier — the real thing: a hardened HTTP client for an // internal vlei-verifier service (present / authorize / verify). // @@ -15,9 +16,6 @@ package leiverifier import ( "context" - "crypto/ed25519" - "encoding/base64" - "encoding/json" "github.com/godaddy/ans/internal/domain" "github.com/godaddy/ans/internal/port" @@ -25,113 +23,75 @@ import ( // Noop is the quickstart vLEI verifier. It never dials anywhere. // -// What this preserves and what it waives, stated precisely (the noop -// DNS / noop did:web precedent — real crypto, waived external-world -// binding): +// It consumes the SAME client payload as the real verifier — the +// full-chain CESR export (`vleiPresentation.cesr`) and a qb64 CESR +// signature (`cesrSignature`) — so a registration that works in +// verifier mode works unchanged in noop mode. This is the DNS / did:web +// noop precedent: the client sends identical bytes in either mode, and +// the noop derives its answer from material already in the request. // -// - PRESERVED: VerifySignature runs a genuine Ed25519 verification -// of the registrant's signature over the served signingInput, so -// the sealed proof is not a rubber stamp. The subject AID encodes -// the registrant's public key, so the verify key is recoverable -// from the AID alone — no KEL, no state. -// - WAIVED: "is this CESR really an authorized vLEI credential, and -// is the AID↔LEI binding real?" Anyone can mint a keypair and -// present any LEI. Authorization therefore returns Authorized -// with an empty LEI (no binding asserted), and the service skips -// the LEI-equality check accordingly. +// What this preserves and what it waives, stated precisely: // -// In noop mode the presentation `cesr` is a base64url (unpadded) -// encoding of a small JSON object {"publicKey": "", "lei": ""}, and a signature is the -// base64url (unpadded) Ed25519 signature over the exact signingInput -// bytes. Strictly for local development and tests. NOT for production. +// - PRESERVED: the presentation is a real full-chain CESR export and +// the pinned subject AID is the real holder AID read from the +// presented leaf credential (`a.i`) — so the sealed identity carries +// the genuine KERI AID, and the register-time LEI echoed from the +// credential (`a.LEI`) lets the service check it against the claimed +// value (a structural string compare, no external oracle). +// - WAIVED: "is this an authorized vLEI credential, is the AID↔LEI +// binding real, and does the signature verify against the AID's +// current key?" The quickstart has no KEL key-state oracle, so — +// like the noop DNS verifier, which accepts any well-formed record +// without proving the live zone — it accepts a well-formed qb64 +// signature without a cryptographic check, authorizes any +// well-formed AID, and asserts no live LEI binding (empty LEI). +// +// Strictly for local development and tests. NOT for production. type Noop struct{} // NewNoop returns the quickstart verifier. func NewNoop() *Noop { return &Noop{} } -// noopPresentation is the noop's stand-in for a full-chain CESR export. -type noopPresentation struct { - PublicKey string `json:"publicKey"` - LEI string `json:"lei"` -} - -// Present decodes the noop presentation, recovers the registrant's -// Ed25519 public key, and reports a subject AID that IS the base64url -// encoding of that key (so VerifySignature can recover it). The LEI is -// echoed verbatim and the status is always AUTHORIZED. +// Present reads the presented (leaf) credential from the full-chain +// CESR export, pins its subject AID (`a.i`) as the holder AID, echoes +// the credential's claimed LEI (`a.LEI`), and always reports AUTHORIZED. +// It reads credential attributes only — never KERI key state. func (n *Noop) Present(_ context.Context, cesr string) (port.PresentationResult, error) { - pres, pub, err := decodeNoopPresentation(cesr) - if err != nil { - return port.PresentationResult{}, err + leaf, ok := leafFrame(cesr) + if !ok { + return port.PresentationResult{}, domain.NewValidationError("LEI_PRESENTATION_INVALID", + "vlei presentation carries no ACDC credential") + } + if !isQB64(leaf.A.I) { + return port.PresentationResult{}, domain.NewValidationError("LEI_PRESENTATION_INVALID", + "the presented credential carries no valid subject AID") } return port.PresentationResult{ - SubjectAID: base64.RawURLEncoding.EncodeToString(pub), - LEI: pres.LEI, + SubjectAID: leaf.A.I, + LEI: leaf.A.LEI, Status: "AUTHORIZED", }, nil } -// Authorization always authorizes a well-formed AID and asserts NO -// LEI binding (the waived check) — the service treats the empty LEI -// as "verifier does not constrain the LEI". +// Authorization authorizes any well-formed (qb64) AID and asserts NO +// live LEI binding (the waived check) — the service treats the empty LEI +// as "verifier does not constrain the LEI". A non-qb64 AID is a +// validation error, mirroring the real verifier's guard. func (n *Noop) Authorization(_ context.Context, subjectAID string) (port.AuthorizationResult, error) { - if _, err := decodeSubjectAID(subjectAID); err != nil { - return port.AuthorizationResult{}, err + if !isQB64(subjectAID) { + return port.AuthorizationResult{}, domain.NewValidationError("LEI_SUBJECT_AID_INVALID", + "subject AID is not a valid qb64 identifier") } return port.AuthorizationResult{Authorized: true, LEI: ""}, nil } -// VerifySignature recovers the public key from the subject AID and -// runs a real Ed25519 verification of the signature over the -// signingInput bytes. A malformed AID or signature is a false (not an -// error) — a non-verifying proof, not an I/O failure. -func (n *Noop) VerifySignature(_ context.Context, subjectAID, signingInput, signature string) (bool, error) { - pub, err := decodeSubjectAID(subjectAID) - if err != nil { - return false, nil //nolint:nilerr // malformed AID is a non-verifying proof (false), not an I/O failure - } - sig, err := base64.RawURLEncoding.DecodeString(signature) - if err != nil || len(sig) != ed25519.SignatureSize { - return false, nil //nolint:nilerr // malformed signature is a non-verifying proof (false), not an I/O failure - } - return ed25519.Verify(pub, []byte(signingInput), sig), nil -} - -// decodeNoopPresentation parses the base64url-wrapped JSON presentation -// and validates the embedded Ed25519 public key. -func decodeNoopPresentation(cesr string) (noopPresentation, ed25519.PublicKey, error) { - raw, err := base64.RawURLEncoding.DecodeString(cesr) - if err != nil { - return noopPresentation{}, nil, domain.NewValidationError("LEI_PRESENTATION_INVALID", - "vlei presentation is not valid base64url") - } - var pres noopPresentation - if err := json.Unmarshal(raw, &pres); err != nil { - return noopPresentation{}, nil, domain.NewValidationError("LEI_PRESENTATION_INVALID", - "vlei presentation is not a valid noop presentation object") - } - pub, err := decodeSubjectAID(pres.PublicKey) - if err != nil { - return noopPresentation{}, nil, domain.NewValidationError("LEI_PRESENTATION_INVALID", - "vlei presentation publicKey is not a base64url Ed25519 public key") - } - if pres.LEI == "" { - return noopPresentation{}, nil, domain.NewValidationError("LEI_PRESENTATION_INVALID", - "vlei presentation carries no lei") - } - return pres, pub, nil -} - -// decodeSubjectAID recovers the Ed25519 public key a noop subject AID -// encodes. -func decodeSubjectAID(aid string) (ed25519.PublicKey, error) { - b, err := base64.RawURLEncoding.DecodeString(aid) - if err != nil || len(b) != ed25519.PublicKeySize { - return nil, domain.NewValidationError("LEI_PRESENTATION_INVALID", - "subject AID is not a base64url Ed25519 public key") - } - return ed25519.PublicKey(b), nil +// VerifySignature is a structural check only: the quickstart has no KEL +// key-state oracle to resolve the AID's current key, so it accepts a +// well-formed qb64 signature over the well-formed AID and rejects a +// malformed one. A malformed AID or signature is a non-verifying false +// (not an I/O error) — the waived binding, the noop DNS precedent. +func (n *Noop) VerifySignature(_ context.Context, subjectAID, _, signature string) (bool, error) { + return isQB64(subjectAID) && isQB64(signature), nil } // compile-time conformance. diff --git a/internal/adapter/leiverifier/verifier.go b/internal/adapter/leiverifier/verifier.go index afc39c7..7376f1b 100644 --- a/internal/adapter/leiverifier/verifier.go +++ b/internal/adapter/leiverifier/verifier.go @@ -331,31 +331,32 @@ func isQB64(s string) bool { // guarantee. var acdcFrameMarker = regexp.MustCompile(`\{\s*"v"\s*:\s*"ACDC`) -// presentedCredentialSAID extracts the SAID of the *presented* (leaf) -// credential from a full-chain CESR export — the minimal, targeted read -// the real verifier path needs to route PUT /presentations/{said}. It is -// NOT a CESR codec: KERI/ACDC serializations are version-string-first, so -// an ACDC credential message is always a JSON object whose first member -// is `"v":"ACDC…"`; we locate those frames with acdcFrameMarker and read +// acdcFrame is the subset of an ACDC credential frame the presentation +// scan needs: the self-addressing SAID `d`, the attributes block `a` +// (the subject AID `i` and `LEI` the noop pins/echoes), and the edge +// block `e` (the SAIDs this credential chains to). It is shared by the +// real verifier (which needs the leaf `d` to route PUT /presentations) +// and the noop (which pins the leaf `a.i` and echoes `a.LEI`). +type acdcFrame struct { + D string `json:"d"` + A struct { + I string `json:"i"` + LEI string `json:"LEI"` + } `json:"a"` + E json.RawMessage `json:"e"` +} + +// scanACDCChain walks every ACDC credential frame in a full-chain CESR +// export and returns the parsed frames (in serialization order) plus the +// set of edge-referenced SAIDs (the `n` of each edge). It is NOT a CESR +// codec: KERI/ACDC serializations are version-string-first, so an ACDC +// credential message is always a JSON object whose first member is +// `"v":"ACDC…"`; we locate those frames with acdcFrameMarker and read // each one by brace-balancing (respecting JSON string escaping), take its // self-addressing `d`, and collect the edge node SAIDs (the `n` of each // edge) from each frame's `e` block. -// -// The presented credential is the most-derived one — the ECR/role -// credential at the bottom of the ECR→LE→QVI chain — identified -// structurally as the lone credential whose SAID is NOT referenced by any -// other credential's edge. This is position-independent: KERIA's -// `credentials().get(said, true)` exporter emits the chain in topological -// (issuer-first) order, so the leaf is serialized LAST, but we never rely -// on frame order. A single-credential export (no chain) has no references, -// so its one frame is the leaf. The end-to-end demo (scripts/demo/vlei) -// exercises this against the live verifier. -func presentedCredentialSAID(cesr string) string { - type acdcFrame struct { - D string `json:"d"` - E json.RawMessage `json:"e"` - } - var saids []string +func scanACDCChain(cesr string) ([]acdcFrame, map[string]struct{}) { + var frames []acdcFrame referenced := make(map[string]struct{}) offset := 0 for { @@ -374,19 +375,43 @@ func presentedCredentialSAID(cesr string) string { if err := json.Unmarshal([]byte(obj), &frame); err != nil || frame.D == "" { continue } - saids = append(saids, frame.D) + frames = append(frames, frame) if len(frame.E) > 0 { collectEdgeNodes(frame.E, referenced) } } - // The leaf is the credential no other credential chains to. A - // well-formed linear chain has exactly one such SAID. - for _, d := range saids { - if _, ok := referenced[d]; !ok { - return d + return frames, referenced +} + +// leafFrame returns the *presented* (leaf) credential frame — the +// most-derived one (the ECR/role credential at the bottom of the +// ECR→LE→QVI chain), identified structurally as the lone credential whose +// SAID is NOT referenced by any other credential's edge. This is +// position-independent: KERIA's `credentials().get(said, true)` exporter +// emits the chain in topological (issuer-first) order, so the leaf is +// serialized LAST, but we never rely on frame order. A single-credential +// export (no chain) has no references, so its one frame is the leaf. ok +// is false when the export carries no ACDC credential. The end-to-end +// demo (scripts/demo/vlei) exercises this against the live verifier. +func leafFrame(cesr string) (acdcFrame, bool) { + frames, referenced := scanACDCChain(cesr) + for _, f := range frames { + if _, ok := referenced[f.D]; !ok { + return f, true } } - return "" + return acdcFrame{}, false +} + +// presentedCredentialSAID extracts the SAID of the presented (leaf) +// credential — the minimal, targeted read the real verifier path needs to +// route PUT /presentations/{said}. Empty when the export carries no ACDC. +func presentedCredentialSAID(cesr string) string { + leaf, ok := leafFrame(cesr) + if !ok { + return "" + } + return leaf.D } // collectEdgeNodes walks an ACDC `e` (edge) block and records every edge diff --git a/internal/config/config.go b/internal/config/config.go index 4cb88ab..187b1cf 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -146,10 +146,11 @@ type IdentityResolver struct { // because it is a distinct outbound dependency with its own service // endpoint. // -// "noop" runs real Ed25519 crypto over the signing input but waives -// the GLEIF authorization binding (quickstart — NOT for production); -// "verifier" is a hardened HTTP client for an internal vlei-verifier -// service. +// "noop" accepts the same full-chain CESR presentation as "verifier" +// but waives the external bindings — the GLEIF authorization, the +// AID↔LEI binding, and the signature's cryptographic check (structural +// only, quickstart — NOT for production); "verifier" is a hardened HTTP +// client for an internal vlei-verifier service. type VLEI struct { Type string `koanf:"type"` // "noop" | "verifier" // BaseURL is the internal vlei-verifier service URL, required when diff --git a/internal/port/leiverifier.go b/internal/port/leiverifier.go index 73fe7b9..21729a8 100644 --- a/internal/port/leiverifier.go +++ b/internal/port/leiverifier.go @@ -42,10 +42,13 @@ type AuthorizationResult struct { // following the DNS/DID precedent — a noop adapter for the quickstart // and a real adapter selected by config (`vlei.type: noop | verifier`). // -// The RA never parses CESR/KERI: the verifier is the authoritative +// The real verifier owns all KERI key state: it is the authoritative // key-state oracle. Present reports the subject AID, Authorization // re-checks live authorization, and VerifySignature owns the KEL/key -// state used to check the registrant's signature. +// state used to check the registrant's signature. The noop quickstart +// adapter reads only the leaf credential's subject AID (a credential +// attribute, not key state) and waives the authorization + signature +// checks — the DNS/did:web noop precedent. type LEIControlVerifier interface { // Present submits the full-chain CESR export to the verifier and // returns the parsed subject AID + authorized LEI + presentation diff --git a/scripts/demo/start.sh b/scripts/demo/start.sh index 6db5525..1b46bea 100755 --- a/scripts/demo/start.sh +++ b/scripts/demo/start.sh @@ -131,9 +131,11 @@ identity: vlei: # The lei (vLEI) control verifier behind the "lei" identifier kind. - # "noop" runs real Ed25519 crypto but waives the GLEIF authorization - # binding; "verifier" routes CESR/KERI questions to a real - # vlei-verifier (scripts/demo/vlei brings one up on :7676). + # "noop" accepts the same full-chain CESR presentation but waives the + # external bindings — GLEIF authorization, the AID↔LEI binding, and the + # signature check (structural only); "verifier" routes CESR/KERI + # questions to a real vlei-verifier (scripts/demo/vlei brings one up on + # :7676). # ANS_VLEI_TYPE=verifier ANS_VLEI_BASE_URL=http://localhost:7676 \ # scripts/demo/start.sh type: ${ANS_VLEI_TYPE:-noop} diff --git a/scripts/demo/vlei/run-vlei.sh b/scripts/demo/vlei/run-vlei.sh index 333716d..0073f57 100755 --- a/scripts/demo/vlei/run-vlei.sh +++ b/scripts/demo/vlei/run-vlei.sh @@ -5,10 +5,11 @@ # Bootstraps everything the flow needs and chains it end-to-end with no manual steps: # 0. ensure ans-ra — start ans-ra (+ ans-tl) in the real vlei # "verifier" mode if it isn't already running that -# way. The demo presents real CESR, which only the -# verifier backend accepts; the plain start.sh -# default is "noop" (base64url JSON), so a noop RA is -# restarted into verifier mode here. +# way. Both backends accept the same real CESR, but +# noop waives the GLEIF authorization + signature +# checks; a real end-to-end demo needs the verifier +# backend, so a noop RA is restarted into verifier +# mode here. The plain start.sh default is "noop". # 0b. ensure an agent — register one (register.sh --v2) if none exists, to # link the verified lei identity to. # 1. up.sh — bring up the stack (witnesses, vlei-server, KERIA, @@ -59,9 +60,11 @@ VLEI_VERIFIER_URL="${VLEI_VERIFIER_URL:-http://localhost:7676}" header "vLEI verify-control demo — full run" -# 0. Ensure ans-ra is up AND wired to the real vlei-verifier. This demo -# presents real CESR, which only the "verifier" backend accepts; the plain -# start.sh default is "noop" (base64url JSON). Three cases: +# 0. Ensure ans-ra is up AND wired to the real vlei-verifier. Both backends +# accept the same real CESR, but only "verifier" actually checks GLEIF +# authorization + the signature (noop waives them), so a real end-to-end +# demo needs verifier mode. The plain start.sh default is "noop". Three +# cases: # * RA not running → fresh start in verifier mode. # * RA running, noop mode → restart in verifier mode, preserving data # (start.sh --keep keeps data/demo: agent id, SQLite store, signer keys;