Skip to content

Identity + audit endpoint placement: broker = policy/audit-of-record, signer = execution/audit-emitter (resolves open question from #77) #78

@hanwencheng

Description

@hanwencheng

Where the discussion lived

The open question was framed in issue #77 §"Open question — identity/audit endpoint placement" but stopped at "needs design." It is repeated implicitly in the user's "broker constructs → user signs locally → signer submits" flow proposal that motivated issue #76 (device-key auth). docs/spec/architecture.md §2 (trust boundaries) sketches the broker/signer split but does not nail the endpoint surface.

This issue commits to a concrete answer + migration plan so #77's cleanup can proceed and the post-#76 wire shapes are unblocked.

Architectural commitment

The two trust boundaries are non-overlapping by responsibility:

Concern Owner Why
Identity ⇄ omni mapping Broker Already mints session JWTs after the identity ceremony; identity-link writes are policy decisions and must be co-located with the policy authority.
Identity-link queries Broker Single source of truth; signer must remain stateless w.r.t. who-owns-what.
Audit of record Broker Already owns plugin_mint_log via the pluggable AuditAnchor trait (issue #64). The audit-anchor durability invariant ("no credential released unless every anchor wrote Ok") is broker-side and must stay there.
Audit emission for signer-side actions Signer emits → Broker writes Signer is the source of truth for what it did (derive-address, sign-message, future submit); broker is the durability boundary. Signer POSTs an audit record to a broker callback; broker decides anchors.
AWS / GCP / cloud submission Daemon-direct (no signer hop) OIDC JWT + AssumeRoleWithWebIdentity already works; routing through the signer adds latency + a SPOF without security benefit (per-PrincipalTag scoping already bounds blast radius). Don't break what works.
Chain (Heima / EVM) submission Broker prepares payload → user signs locally with device key + JWT → signer signs with omni-derived key + submits to chain RPC + emits audit This is the user's flow from #77. Fits because chain submission needs the omni-derived secp256k1 key (K4), which only the signer holds.

The cloud-vs-chain split is the load-bearing decision: cloud already has a working daemon-direct path that is more secure than routing through the signer (the daemon never sees long-lived AWS keys; STS issues per-session creds bound to the OIDC JWT's PrincipalTag). Forcing every submission through the signer would regress this. The signer's submission role is scoped to operations that require K4 — i.e. on-chain anchoring.

Concrete endpoint moves

DELETE (legacy duplicates on mock-server :8090)

Endpoint Replacement Callers to repoint
POST /identity/link (mock-server) POST /v1/wallet/link (broker) crates/agentkeys-cli/src/lib.rs:709 (cmd_link) — was using legacy bearer; needs session-JWT migration first.
GET /identity/resolve (mock-server) GET /v1/wallet/links (broker) — returns the master's full list; daemon filters locally. Or add GET /v1/wallet/resolve?identity_type=...&identity_value=... if filtered lookup is needed. crates/agentkeys-cli/src/lib.rs:877 (used by cmd_grant for alias/email lookup); crates/agentkeys-daemon/src/main.rs:409.
GET /audit/query (mock-server) GET /v1/audit/query (broker — does not exist yet, see ADD below) crates/agentkeys-cli/src/lib.rs:621 (cmd_audit_query) and crates/agentkeys-core/src/mock_client.rs:286,288.

ADD (broker)

Endpoint Shape Notes
GET /v1/audit/query Query: ?owner=...&agent=...&service=...&since=...&limit=... ; Response: rows from plugin_mint_log filtered by session-JWT scope Replaces mock-server's /audit/query. Auth: session JWT. Scope: claims.agentkeys.omni_account constrains visible rows (master sees own + child rows; agents see their own). Reads from plugin_mint_log directly via state.audit_query; no new storage.
POST /v1/chain/anchor/prepare (post-#76) Body: {op_type, op_payload, chain_id, nonce_hint?} ; Response: {canonical_payload (CBOR or JSON), nonce, gas_hint, expires_at, prepare_id} Builds the unsigned chain transaction. Auth: session JWT. Stores prepare_id row so the signer can dedupe + bound replay.
POST /v1/audit/signer-event (post-#76, internal) Body: {prepare_id?, signer_action, omni_account, payload_hash, outcome, outcome_detail?} ; Response: {audit_record_id} Signer-only callback so signer-side actions land in the same plugin_mint_log. Auth: signer-to-broker mTLS or a dedicated signer service token (decision deferred to design step).

ADD (signer)

Endpoint Shape Notes
POST /dev/submit-anchored-tx (post-#76) Body: {prepare_id, canonical_payload, omni_account, device_sig, session_jwt} ; Response: {tx_hash, anchor_audit_id} Verifies device sig (per #76) + session JWT scope ; signs canonical_payload with K4 (HKDF-derived secp256k1 from K3 + omni) ; submits to configured chain RPC ; POSTs audit row to broker /v1/audit/signer-event ; returns tx hash. Wire shape pinned in docs/spec/signer-protocol.md.

KEEP-AS-IS

Migration phases

Phase 1 — unblock #77 cleanup (no #76 dependency)

  1. Add GET /v1/audit/query at broker (reads plugin_mint_log, scope-filtered by session JWT).
  2. Repoint CLI cmd_link (lib.rs:709) and cmd_grant's identity-resolve call (lib.rs:877) to broker /v1/wallet/* — requires session JWT, so depends on agentkeys init having shipped (already done in agentkeys: stage 7+ — issue #74 step 1 (dev_key_service signer + bootstrap chain) #75).
  3. Repoint daemon's /identity/resolve call (daemon/src/main.rs:409) to broker /v1/wallet/links (filter locally) or to a new broker /v1/wallet/resolve endpoint.
  4. Repoint CLI cmd_audit_query (lib.rs:621) and mock_client.rs:286,288 to broker.
  5. Delete mock-server handlers::identity + handlers::audit modules and their routes.
  6. Verification gate: grep -rn '/identity/\|/audit/query' crates/ scripts/ returns only doc/historical refs.

Phase 2 — chain-submission surface (depends on #76 landing)

  1. Pin POST /v1/chain/anchor/prepare + POST /dev/submit-anchored-tx + POST /v1/audit/signer-event wire shapes in docs/spec/signer-protocol.md.
  2. Implement prepare at broker — stub canonical_payload builder for an EVM eth_sendRawTransaction shape; add SQLite table chain_anchor_prepares (prepare_id, omni_account, payload_hash, expires_at, consumed_at).
  3. Implement submit-anchored-tx at signer — verify device sig (per Retire the bearer-JWT-only daemon→signer /dev/* path (align to arch §14.2; rescoped from Step 1c) #76) + session JWT + prepare_id not consumed; sign with K4; POST to broker audit callback; today returns submitted: false, reason: "chain RPC not configured" with a pending audit row.
  4. Implement signer-event at broker — append-only into plugin_mint_log (or sibling chain_anchor_log if column shape diverges); auth via BROKER_SIGNER_AUTH_TOKEN env var (rotated per deploy via setup-broker-host.sh) — deliberately not device-key, since this is signer-→-broker not user-→-signer.

Phase 3 — wire to live chain (depends on Heima pallets per heima-gaps §5)

  1. Replace stub canonical-payload builder with real Heima extrinsic encoding for pallet-credential-grants, pallet-email-grants (when they exist), etc.
  2. Configure chain RPC endpoint via SIGNER_CHAIN_RPC_URL env var; signer's submit becomes a real submitAndWatchExtrinsic.
  3. Backfill chain_anchor_log rows from pending to confirmed once tx receipts arrive (Phase C reconciliation per migrations/0001_v2_schema.sql:119).

Trust-shape verification

Compromise Cloud flow (today) Chain flow (post-Phase 2)
Operator workstation Stolen JWT (replay until exp); stolen device key (post-#76, sign on operator's behalf until rotation) Same — device key gates submit-anchored-tx exactly as it gates sign-message.
Broker Mint session/OIDC JWTs; query AWS as scoped principal Mint JWTs + craft prepare payloads — but cannot make signer accept them without operator's device-key signature (per #76).
Signer Derive any wallet, sign any message, now also: submit any chain tx if it can also forge a device-key sig — which it cannot from K3 alone Same caveat; broker compromise + signer compromise jointly = full takeover, but neither alone is sufficient for chain submission.

This matches and extends architecture.md §2's compromise table; that table needs a row for the chain-submit row added in Phase 2.

Out of scope (separate issues)

Dependencies

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions