From e19b1d663a7103a34cd6b05bb9468c2af17f20c9 Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Mon, 1 Jun 2026 13:26:31 +0800 Subject: [PATCH 1/6] plan: method-A agent-initiated pairing design (replaces #149 front-half) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flip §10.2 from master-mints-link-code → agent-submits-request + master-claims-by-code (the IoT scan-the-device-QR model). Reuses #149's on-chain bind+scope tail. Unbind/factory-reset deferred → #156 (client) + #155 (on-chain self-revoke). --- .../plans/agent-initiated-pairing-method-a.md | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 docs/spec/plans/agent-initiated-pairing-method-a.md diff --git a/docs/spec/plans/agent-initiated-pairing-method-a.md b/docs/spec/plans/agent-initiated-pairing-method-a.md new file mode 100644 index 0000000..4c3c3b6 --- /dev/null +++ b/docs/spec/plans/agent-initiated-pairing-method-a.md @@ -0,0 +1,72 @@ +# Method A — agent-initiated pairing (replaces #149's master-initiated front-half) + +Status: plan / in-progress. Branch `claude/agent-initiated-pairing` off `main` (post-#149). + +## Decision + rationale +§10.2 agent bootstrap flips from **master-initiated** (#149: master mints a link code → agent redeems) to **agent-initiated** (A: the agent submits a pairing request → the master claims it by scanning/entering the code). Reasons: + +1. **No-input physical devices.** The IoT convention (Matter/HomeKit) is "the device shows a QR/setup code; the owner scans it." A no-keyboard AI companion **cannot** accept a master-minted code typed *into* it — so M is structurally impractical for hardware. A needs only the device's screen (output) + the owner's camera. +2. **Device-initiated unbind / factory-reset → re-pair to a new owner.** Resale / reset is a *device* act. M is master-rooted — the old master would have to cooperate. A handles it: factory reset → fresh key → show a new QR → new owner claims. +3. **Sybil-safe.** The request is *unbound* (names no master) and **inert** until a master deliberately consumes the code, so an agent can't attach itself or flood a master — equivalent to M's "master action is the sole binder." + +Replace (not coexist): A subsumes M's device-review (the master sees `device_pubkey` at claim time), keeps arch.md's "one path, one test surface," and reuses #149's on-chain bind + scope tail unchanged. + +## Flow (A) +``` +1. AGENT (daemon --request-pairing): + - generate/load K10 in the sandbox; pop_sig over agentkeys-agent-pop preimage + - POST /v1/agent/pairing/request { device_pubkey, pop_sig } (no bearer; rate-limited) + - broker verifies pop_sig, stores an UNBOUND request (operator=∅, TTL 600s), + returns { request_id (secret), pairing_code } + - agent DISPLAYS pairing_code (QR / text) and polls (step 4) +2. MASTER (agentkeys agent claim --pairing-code --label A --services memory): + - scan/enter the code; J1_master-gated POST /v1/agent/pairing/claim + - broker: look up unbound request by code; bind to THIS operator_omni; + O_agent = HDKD(O_master, "//label"); mint J1_agent; record pending binding; + mark request claimed. Returns the device + omni to the master. +3. MASTER binds + scopes (REUSED from #149, unchanged): + - registerAgentDevice(deviceKeyHash, operator, actor, …) [msg.sender == master] + - setScopeWithWebauthn(…) (Touch ID) + - POST /v1/agent/pending-bindings/ack +4. AGENT poll → once claimed: { session_jwt: J1_agent, child_omni, … } (device re-proves + possession with a fresh pop_sig to retrieve it). Persists J1_agent. +``` + +Unbind / factory-reset + re-pair is **out of this PR**: client-side unbind + re-pair → **#156**, on-chain self-revoke → **#155**. This PR ships only the pairing-direction flip. + +## Changes (file-by-file, staged) + +### 1. Broker (`crates/agentkeys-broker-server`) +- **NEW** `storage/pairing_requests.rs` — unbound, agent-created request pool keyed by `pairing_code` + `request_id`; `{device_pubkey, pop_sig, status, operator_omni?, child_omni?, label?, created_at}`; `issue / claim / poll / purge_expired`. (Adapt from `link_codes.rs`, which is removed.) +- **NEW** `handlers/agent/request.rs` — `POST /v1/agent/pairing/request` (no bearer, pop_sig-gated, rate-limited). +- **NEW** `handlers/agent/claim.rs` — `POST /v1/agent/pairing/claim` (J1_master-gated): assigns omni, mints J1_agent, records pending binding. +- **NEW** `handlers/agent/poll.rs` — `GET/POST /v1/agent/pairing/poll` (pop_sig-gated): returns J1_agent once claimed. +- **REMOVE** `handlers/agent/{create,redeem}.rs` + `storage/link_codes.rs` + their routes; **KEEP** `pending.rs` (+`/ack`) — the bind tail is reused. +- `mint_oidc_jwt` agent gate (the finding-1/A invariant) is unchanged + still required — J1 still exists pre-on-chain-bind, so the active+operator+actor+role gate stays. + +### 2. Daemon (`crates/agentkeys-daemon`) +- `--init-link-code` → `--request-pairing` (submit → display code → poll → persist J1). + (`--unbind` / factory-reset is deferred → #156.) + +### 3. CLI (`crates/agentkeys-cli`) +- `agent create` → `agent claim --pairing-code --label <…> --services <…>`. + +### 4. Harness (`harness/phase1-wire-demo.sh`) +- Phase P flips: P.0 = **agent** `--request-pairing` (gets code); P.1 = **master** `agent claim`; P.1b/P.2/P.3 (pending/bind/scope) reused. (The unbind → re-pair test is deferred → #156.) + +### 5. Docs +- `docs/arch.md` §10.2 — rewrite the ceremony for A (agent-initiated QR/scan; the IoT model); update §6.2 route list; note unbind (local rekey) + link #155 for on-chain self-revoke. Re-verify §10.2 canonical-names + the route table. +- `docs/operator-runbook-wire.md` — rewrite the pairing walkthrough (agent shows code → master scans/claims → bind → Touch ID), incl. the factory-reset/re-pair demo. + +## Security notes +- `pairing_code` + `request_id` must be **high-entropy** (claim-by-code = whoever holds the code binds; request_id = the agent's retrieval ticket). Display the code only to the intended master (proximity for HW; out-of-band for SW). +- The `request` endpoint is **unauthenticated** → rate-limit + TTL + cap the pool (DoS, not Sybil). +- Master **reviews `device_pubkey`** at claim before `registerAgentDevice` (the M second-factor, preserved). +- Pre-bind window unchanged: J1 exists post-claim, pre-on-chain-bind → the mint-oidc-jwt on-chain gate (active + operator==parent + actor + CAP_MINT) is still mandatory. + +## Verification +Per stage: `cargo build` + `cargo test` (broker/daemon/mcp/core), `cargo fmt --all --check`, `clippy --workspace --all-targets -- -D warnings`, `bash -n` harness. Behavioral confirmation = a live `phase1-wire-demo.sh --real --webauthn` run after a broker redeploy (can't integration-test the sandbox/broker locally). + +## Out of scope (tracked) +- Agent-side unbind / factory-reset + re-pair (client lifecycle) → **#156**. +- On-chain agent self-revocation (contract change + Heima-mainnet redeploy) → **#155**. From 55112ec1a7f07e9a456d9556d4886674e6de7a1a Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Mon, 1 Jun 2026 13:46:34 +0800 Subject: [PATCH 2/6] =?UTF-8?q?agentkeys:=20=C2=A710.2=20method-A=20pairin?= =?UTF-8?q?g=20=E2=80=94=20broker=20request/claim/poll=20endpoints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flip the agent bootstrap from master-initiated (link code) to agent- initiated (the agent shows a code, the master claims it — the Matter/ HomeKit IoT model). Replaces #149's master-mint front-half; reuses the on-chain bind + scope tail unchanged. Broker: - NEW storage/pairing_requests.rs — unbound, agent-created request pool (issue/claim/poll/pending_bindings/mark_bound/purge). J1 is NOT stored at rest; minted fresh at poll time on a re-proved pop_sig. - NEW handlers/agent/request.rs (agent, pop_sig-gated) — open an unbound request, return {request_id (secret), pairing_code (display)}. - NEW handlers/agent/claim.rs (master, J1-gated) — claim by code, derive O_agent=HDKD(O_master,//label), record pending binding. - NEW handlers/agent/poll.rs (agent, pop_sig-gated) — once claimed, mint + return J1_agent. - REMOVE handlers/agent/{create,redeem}.rs + storage/link_codes.rs. - Rename link_code_store -> pairing_request_store across state/boot/main. - Rewire routes: /v1/agent/pairing/{request,claim,poll}; keep pending-bindings + /ack (now keyed by request_id). Tests: 14 store unit tests + agent_bootstrap_flow rewritten for the request->claim->poll flow (5 cases incl. cross-device/bad-pop_sig poll rejection). clippy --all-features --all-targets -D warnings clean. Unbind/factory-reset re-pair deferred -> #156; on-chain self-revoke -> #155. --- crates/agentkeys-broker-server/src/boot.rs | 35 +- .../src/handlers/agent/claim.rs | 116 ++++ .../src/handlers/agent/create.rs | 79 --- .../src/handlers/agent/mod.rs | 32 +- .../src/handlers/agent/pending.rs | 30 +- .../src/handlers/agent/poll.rs | 122 ++++ .../src/handlers/agent/redeem.rs | 109 --- .../src/handlers/agent/request.rs | 87 +++ crates/agentkeys-broker-server/src/lib.rs | 19 +- crates/agentkeys-broker-server/src/main.rs | 2 +- crates/agentkeys-broker-server/src/state.rs | 17 +- .../src/storage/link_codes.rs | 427 ------------ .../src/storage/mod.rs | 10 +- .../src/storage/pairing_requests.rs | 633 ++++++++++++++++++ .../tests/agent_bootstrap_flow.rs | 211 ++++-- .../tests/auth_wallet_flow.rs | 4 +- .../tests/email_flow.rs | 4 +- .../tests/grant_flow.rs | 4 +- .../tests/oauth2_flow.rs | 5 +- .../tests/oidc_flow.rs | 4 +- .../tests/wallet_flow.rs | 4 +- 21 files changed, 1206 insertions(+), 748 deletions(-) create mode 100644 crates/agentkeys-broker-server/src/handlers/agent/claim.rs delete mode 100644 crates/agentkeys-broker-server/src/handlers/agent/create.rs create mode 100644 crates/agentkeys-broker-server/src/handlers/agent/poll.rs delete mode 100644 crates/agentkeys-broker-server/src/handlers/agent/redeem.rs create mode 100644 crates/agentkeys-broker-server/src/handlers/agent/request.rs delete mode 100644 crates/agentkeys-broker-server/src/storage/link_codes.rs create mode 100644 crates/agentkeys-broker-server/src/storage/pairing_requests.rs diff --git a/crates/agentkeys-broker-server/src/boot.rs b/crates/agentkeys-broker-server/src/boot.rs index d6ceef6..4b3a50d 100644 --- a/crates/agentkeys-broker-server/src/boot.rs +++ b/crates/agentkeys-broker-server/src/boot.rs @@ -29,7 +29,9 @@ use crate::jwt::SessionKeypair; use crate::oidc::OidcKeypair; use crate::plugins::audit::{AuditAnchor, AuditPolicy}; use crate::plugins::PluginRegistry; -use crate::storage::{AuthNonceStore, GrantStore, IdentityLinkStore, LinkCodeStore, WalletStore}; +use crate::storage::{ + AuthNonceStore, GrantStore, IdentityLinkStore, PairingRequestStore, WalletStore, +}; /// Outcome of the synchronous Tier-1 boot phase. pub struct BootArtifacts { @@ -41,8 +43,9 @@ pub struct BootArtifacts { pub nonce_store: Arc, pub grant_store: Arc, pub identity_link_store: Arc, - /// §10.2 agent-bootstrap link-code + pending-binding store (issue #144). - pub link_code_store: Arc, + /// §10.2 agent-initiated pairing-request + pending-binding store (issue #144, + /// method A). + pub pairing_request_store: Arc, /// Concrete EmailLink plugin handle (Phase A.1, US-018). Populated /// when `email_link` is in `BROKER_AUTH_METHODS` AND the /// `auth-email-link` feature is compiled in. The registry's auth @@ -185,14 +188,16 @@ pub fn run_tier1(config: &BrokerConfig) -> anyhow::Result { ) })?, ); - let link_code_store = Arc::new(LinkCodeStore::open(&link_codes_path(config)).map_err(|e| { - boot_fail( - env::BROKER_AUDIT_DB_PATH, - &config.audit_db_path.display().to_string(), - format!("LinkCodeStore: {}", e), - "link-codes-db", - ) - })?); + let pairing_request_store = Arc::new( + PairingRequestStore::open(&pairing_requests_path(config)).map_err(|e| { + boot_fail( + env::BROKER_AUDIT_DB_PATH, + &config.audit_db_path.display().to_string(), + format!("PairingRequestStore: {}", e), + "pairing-requests-db", + ) + })?, + ); // 5. Validate + parse plugin selection env vars. Every name in each // list must resolve at compile time (i.e. the corresponding @@ -235,7 +240,7 @@ pub fn run_tier1(config: &BrokerConfig) -> anyhow::Result { nonce_store, grant_store, identity_link_store, - link_code_store, + pairing_request_store, #[cfg(feature = "auth-email-link")] email_link: built.email_link, #[cfg(feature = "auth-oauth2")] @@ -311,12 +316,12 @@ fn identity_links_path(config: &BrokerConfig) -> std::path::PathBuf { .unwrap_or_else(|| std::path::PathBuf::from("identity_links.sqlite")) } -fn link_codes_path(config: &BrokerConfig) -> std::path::PathBuf { +fn pairing_requests_path(config: &BrokerConfig) -> std::path::PathBuf { config .audit_db_path .parent() - .map(|p| p.join("link_codes.sqlite")) - .unwrap_or_else(|| std::path::PathBuf::from("link_codes.sqlite")) + .map(|p| p.join("pairing_requests.sqlite")) + .unwrap_or_else(|| std::path::PathBuf::from("pairing_requests.sqlite")) } #[cfg(feature = "audit-sqlite")] diff --git a/crates/agentkeys-broker-server/src/handlers/agent/claim.rs b/crates/agentkeys-broker-server/src/handlers/agent/claim.rs new file mode 100644 index 0000000..5ccbe3a --- /dev/null +++ b/crates/agentkeys-broker-server/src/handlers/agent/claim.rs @@ -0,0 +1,116 @@ +//! `POST /v1/agent/pairing/claim` — master claims an agent's pairing request +//! (method A §10.2, replaces master-mints-`/v1/agent/create`). +//! +//! Gated by the master's `J1` session bearer. The master scans/enters the +//! `pairing_code` the agent displayed; this is the binding act — the agent never +//! named a master, so an unclaimed request is inert (Sybil-safe). On claim the +//! broker: +//! +//! 1. derives the HDKD child omni `O_agent = SHA256(HDKD_DOMAIN || O_master || "//label")` +//! — the master "adopts" the agent under its own omni tree; +//! 2. assigns `operator_omni` + `child_omni` + `label` + `requested_scope` onto +//! the (previously unbound) row, marking it claimed; +//! 3. returns the captured `device_pubkey` + `device_key_hash` so the master can +//! REVIEW the device (the M second-factor, preserved) and submit +//! `registerAgentDevice` without recomputing the hash. +//! +//! `J1_agent` is NOT minted here — the agent mints it itself at poll time by +//! re-proving device-key possession (so no bearer secret sits at rest, and the +//! JWT TTL starts at retrieval). This handler only flips the request to claimed +//! + records the pending binding the master then approves on chain. + +use axum::{extract::State, http::HeaderMap, http::StatusCode, response::IntoResponse, Json}; +use serde::Deserialize; +use serde_json::json; + +use crate::error::BrokerError; +use crate::handlers::agent::unix_now; +use crate::handlers::grant::require_session_jwt; +use crate::state::SharedState; +use crate::storage::PairingClaim; + +#[derive(Debug, Deserialize)] +pub struct PairingClaimBody { + /// The `pairing_code` the agent displayed (scanned/entered by the master). + pub pairing_code: String, + /// HDKD child label, e.g. `"agent-a"` (`^[a-z0-9-]{1,32}$`). + pub label: String, + /// Scope the master intends to grant the agent (the "app manifest"). + /// Defaults to `"memory"`. Comma-separated service list mirrors + /// `heima-scope-set.sh --services`. + #[serde(default)] + pub requested_scope: Option, +} + +pub async fn pairing_claim( + State(state): State, + headers: HeaderMap, + Json(body): Json, +) -> Result { + let session = require_session_jwt(&headers, &state)?; + let master_omni = session.agentkeys.omni_account; + + agentkeys_core::actor_omni::validate_label(&body.label) + .map_err(|e| BrokerError::BadRequest(format!("invalid label: {e}")))?; + let child_omni = agentkeys_core::actor_omni::child_omni_hex(&master_omni, &body.label) + .map_err(|e| BrokerError::BadRequest(format!("derive child omni: {e}")))?; + + let requested_scope = body + .requested_scope + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| "memory".to_string()); + + let now = unix_now()?; + let (request_id, device_pubkey, pop_sig) = match state.pairing_request_store.claim( + &body.pairing_code, + &master_omni, + &child_omni, + &body.label, + &requested_scope, + now, + )? { + PairingClaim::Claimed { + request_id, + device_pubkey, + pop_sig, + } => (request_id, device_pubkey, pop_sig), + PairingClaim::Expired => { + return Err(BrokerError::Unauthorized( + "pairing request expired (>600s after the agent opened it)".into(), + )); + } + PairingClaim::NotFoundOrClaimed => { + return Err(BrokerError::Unauthorized( + "pairing code unknown or already claimed".into(), + )); + } + }; + + // Best-effort device_key_hash so the master needn't recompute it for + // registerAgentDevice. A malformed stored address (shouldn't happen — it + // round-tripped through /request) degrades to empty rather than failing. + let device_key_hash = + agentkeys_core::device_crypto::device_key_hash(&device_pubkey).unwrap_or_default(); + + tracing::info!( + operator_omni = %master_omni, + child_omni = %child_omni, + label = %body.label, + device = %device_pubkey, + "claimed §10.2 pairing request — pending binding recorded, awaiting on-chain bind" + ); + + Ok(( + StatusCode::OK, + Json(json!({ + "request_id": request_id, + "child_omni": child_omni, + "operator_omni": master_omni, + "label": body.label, + "requested_scope": requested_scope, + "device_pubkey": device_pubkey, + "pop_sig": pop_sig, + "device_key_hash": device_key_hash, + })), + )) +} diff --git a/crates/agentkeys-broker-server/src/handlers/agent/create.rs b/crates/agentkeys-broker-server/src/handlers/agent/create.rs deleted file mode 100644 index 374ec76..0000000 --- a/crates/agentkeys-broker-server/src/handlers/agent/create.rs +++ /dev/null @@ -1,79 +0,0 @@ -//! `POST /v1/agent/create` — master mints a one-time link code (issue #144 §10.2). -//! -//! Gated by the master's `J1` session bearer. Derives the HDKD child omni -//! `O_agent = SHA256(HDKD_DOMAIN || O_master || "//label")`, mints a single-use -//! link code bound to it (TTL 600s), and records the scope the master wants the -//! agent to have (like an app manifest). The master hands the code to the agent -//! out-of-band; the agent redeems it at `/v1/auth/link-code/redeem`. - -use axum::{extract::State, http::HeaderMap, http::StatusCode, response::IntoResponse, Json}; -use serde::Deserialize; -use serde_json::json; - -use crate::error::BrokerError; -use crate::handlers::agent::unix_now; -use crate::handlers::grant::{random_b64url, require_session_jwt}; -use crate::state::SharedState; -use crate::storage::LINK_CODE_TTL_SECONDS; - -#[derive(Debug, Deserialize)] -pub struct AgentCreateBody { - /// HDKD child label, e.g. `"agent-a"` (`^[a-z0-9-]{1,32}$`). - pub label: String, - /// Scope the master intends to grant the agent (the "app manifest"). - /// Defaults to `"memory"`. Comma-separated service list mirrors - /// `heima-scope-set.sh --services`. - #[serde(default)] - pub requested_scope: Option, -} - -pub async fn agent_create( - State(state): State, - headers: HeaderMap, - Json(body): Json, -) -> Result { - let session = require_session_jwt(&headers, &state)?; - let master_omni = session.agentkeys.omni_account; - - agentkeys_core::actor_omni::validate_label(&body.label) - .map_err(|e| BrokerError::BadRequest(format!("invalid label: {e}")))?; - let child_omni = agentkeys_core::actor_omni::child_omni_hex(&master_omni, &body.label) - .map_err(|e| BrokerError::BadRequest(format!("derive child omni: {e}")))?; - - let requested_scope = body - .requested_scope - .filter(|s| !s.trim().is_empty()) - .unwrap_or_else(|| "memory".to_string()); - - let link_code = random_b64url(32); - let now = unix_now()?; - let expires_at = now + LINK_CODE_TTL_SECONDS; - state.link_code_store.issue( - &link_code, - &child_omni, - &master_omni, - &body.label, - &requested_scope, - now, - expires_at, - )?; - - tracing::info!( - operator_omni = %master_omni, - child_omni = %child_omni, - label = %body.label, - "issued §10.2 agent link code" - ); - - Ok(( - StatusCode::OK, - Json(json!({ - "link_code": link_code, - "child_omni": child_omni, - "operator_omni": master_omni, - "label": body.label, - "requested_scope": requested_scope, - "expires_at": expires_at, - })), - )) -} diff --git a/crates/agentkeys-broker-server/src/handlers/agent/mod.rs b/crates/agentkeys-broker-server/src/handlers/agent/mod.rs index 3c7bf1d..e6e312e 100644 --- a/crates/agentkeys-broker-server/src/handlers/agent/mod.rs +++ b/crates/agentkeys-broker-server/src/handlers/agent/mod.rs @@ -1,23 +1,33 @@ -//! §10.2 agent-bootstrap endpoints (issue #144). +//! §10.2 agent-bootstrap endpoints — **method A, agent-initiated** (issue #144, +//! flipped from master-initiated; design doc +//! `docs/spec/plans/agent-initiated-pairing-method-a.md`). //! -//! Three endpoints implement the link-code ceremony with the master submitting -//! the on-chain binding (decision 1 — no contract change, no broker chain key): +//! The agent shows a code, the master claims it (the Matter/HomeKit IoT model), +//! and the master still submits the on-chain binding (decision 1 — no contract +//! change, no broker chain key): //! -//! - `POST /v1/agent/create` (master, `J1_master`-gated) — mint a one-time link -//! code bound to the HDKD child omni `O_agent = SHA256(.. || O_master || "//label")`. -//! - `POST /v1/auth/link-code/redeem` (agent, no bearer) — verify the agent's -//! `pop_sig`, consume the code, mint `J1_agent`, and stash the device artifact -//! as a pending binding. +//! - `POST /v1/agent/pairing/request` (agent, no bearer) — verify the agent's +//! `pop_sig`, store an UNBOUND request (naming no master), return a +//! `pairing_code` to display + a secret `request_id` retrieval ticket. +//! - `POST /v1/agent/pairing/claim` (master, `J1_master`-gated) — claim the +//! code; derive the HDKD child omni `O_agent = SHA256(.. || O_master || "//label")`, +//! mark the request claimed, and stash the device artifact as a pending binding. +//! - `POST /v1/agent/pairing/poll` (agent, no bearer) — once claimed, re-prove +//! device-key possession (fresh `pop_sig`) and mint + retrieve `J1_agent`. //! - `GET /v1/agent/pending-bindings` (master, `J1_master`-gated) — pull the -//! redeemed-but-unbound rows to approve (the push-notification substrate). +//! claimed-but-unbound rows to approve (the push-notification substrate). //! //! The broker never K11-verifies on the agent path — agents are K10-only per the //! contract (`registerAgentDevice` writes `k11CredId = 0`). The master's K11 //! gesture happens later, when it submits the on-chain binding + scope grant. +//! +//! Agent-side unbind / factory-reset + re-pair is out of this PR (→ #156); on- +//! chain agent self-revoke is out of this PR (→ #155). -pub mod create; +pub mod claim; pub mod pending; -pub mod redeem; +pub mod poll; +pub mod request; use std::time::{SystemTime, UNIX_EPOCH}; diff --git a/crates/agentkeys-broker-server/src/handlers/agent/pending.rs b/crates/agentkeys-broker-server/src/handlers/agent/pending.rs index 9767cb4..8b864b2 100644 --- a/crates/agentkeys-broker-server/src/handlers/agent/pending.rs +++ b/crates/agentkeys-broker-server/src/handlers/agent/pending.rs @@ -2,11 +2,13 @@ //! (issue #144 §10.2). //! //! Gated by the master's `J1` session bearer. Returns the operator's rows that -//! have been redeemed (`device_pubkey` + `pop_sig` captured) but not yet bound -//! on-chain — i.e. "agent-A wants to pair + wants `[requested_scope]`". This is -//! the substrate the production push notification carries; the master pulls it, -//! then approves with one K11 gesture (bind + scope). `device_key_hash` is -//! pre-computed so the master can submit `registerAgentDevice` without recomputing. +//! have been claimed (`device_pubkey` + `pop_sig` captured at /request, operator +//! assigned at /claim) but not yet bound on-chain — i.e. "agent-A wants to pair +//! and wants `[requested_scope]`". This is the substrate the production push +//! notification carries; the master pulls it, then approves with one K11 gesture +//! (bind plus scope). `device_key_hash` is pre-computed so the master can submit +//! `registerAgentDevice` without recomputing. Rows are keyed by `request_id` (the +//! method-A handle the master acks by). use axum::{extract::State, http::HeaderMap, http::StatusCode, response::IntoResponse, Json}; use serde::Deserialize; @@ -24,18 +26,18 @@ pub async fn pending_bindings( let session = require_session_jwt(&headers, &state)?; let master_omni = session.agentkeys.omni_account; - let rows = state.link_code_store.pending_bindings(&master_omni)?; + let rows = state.pairing_request_store.pending_bindings(&master_omni)?; let pending: Vec<_> = rows .into_iter() .map(|b| { // Best-effort device_key_hash so the master needn't recompute. A // malformed stored address (shouldn't happen — it round-tripped - // through redeem) degrades to an empty string rather than failing + // through /request) degrades to an empty string rather than failing // the whole list. let device_key_hash = agentkeys_core::device_crypto::device_key_hash(&b.device_pubkey) .unwrap_or_default(); json!({ - "link_code": b.link_code, + "request_id": b.request_id, "child_omni": b.child_omni, "operator_omni": b.operator_omni, "label": b.label, @@ -52,13 +54,13 @@ pub async fn pending_bindings( #[derive(Debug, Deserialize)] pub struct AckBody { - /// The link code whose redeemed binding the master just submitted on chain. - pub link_code: String, + /// The `request_id` whose claimed binding the master just submitted on chain. + pub request_id: String, } /// `POST /v1/agent/pending-bindings/ack` — the master acks that it submitted /// `registerAgentDevice` for this binding, so it drops out of the pending list -/// (issue #144). Without this the rendezvous would never clear — every redeemed +/// (issue #144). Without this the rendezvous would never clear — every claimed /// agent would show as "pending" forever even after it's bound on chain. Scoped /// to the master's omni; idempotent (a second ack is a no-op → `acked: false`). pub async fn ack_binding( @@ -70,10 +72,10 @@ pub async fn ack_binding( let master_omni = session.agentkeys.omni_account; let now = unix_now()?; let updated = state - .link_code_store - .mark_bound(&body.link_code, &master_omni, now)?; + .pairing_request_store + .mark_bound(&body.request_id, &master_omni, now)?; Ok(( StatusCode::OK, - Json(json!({ "acked": updated > 0, "link_code": body.link_code })), + Json(json!({ "acked": updated > 0, "request_id": body.request_id })), )) } diff --git a/crates/agentkeys-broker-server/src/handlers/agent/poll.rs b/crates/agentkeys-broker-server/src/handlers/agent/poll.rs new file mode 100644 index 0000000..45bae15 --- /dev/null +++ b/crates/agentkeys-broker-server/src/handlers/agent/poll.rs @@ -0,0 +1,122 @@ +//! `POST /v1/agent/pairing/poll` — the agent polls its pairing request and, once +//! a master has claimed it, mints + retrieves `J1_agent` (method A §10.2). +//! +//! No bearer (the agent still has no session). The agent presents its secret +//! `request_id` (the retrieval ticket from `/request`) plus a FRESH `pop_sig` +//! over its K10 device key. The broker: +//! +//! 1. verifies `pop_sig` recovers to `device_pubkey` (stateless re-proof — the +//! agent proves it still holds the device key at retrieval time); +//! 2. looks up the request, binding the lookup to `device_pubkey` (a guessed +//! `request_id` without the matching device key is indistinguishable from an +//! unknown one); +//! 3. if still unclaimed → `{ "status": "pending" }` (the agent keeps polling); +//! 4. if claimed → mints `J1_agent` FRESH (HDKD omni + lineage) and returns it. +//! +//! Minting at poll time (not at the master's claim) means no bearer secret is +//! ever stored at rest, and the JWT's TTL starts when the agent actually +//! retrieves it. The agent has `J1_agent` but NO scope until the master's +//! on-chain `registerAgentDevice` + scope grant lands — the mint-oidc-jwt +//! on-chain gate still rejects every downstream mint until then. + +use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; +use serde::Deserialize; +use serde_json::json; + +use crate::error::BrokerError; +use crate::handlers::agent::{session_jwt_ttl_seconds, unix_now}; +use crate::jwt::issue::mint_agent_session_jwt; +use crate::state::SharedState; +use crate::storage::PairingPoll; + +#[derive(Debug, Deserialize)] +pub struct PairingPollBody { + /// The agent's secret retrieval ticket from `/v1/agent/pairing/request`. + pub request_id: String, + /// The agent's K10 EVM address (`0x` + 40 hex). + pub device_pubkey: String, + /// Fresh EIP-191 `pop_sig` over `keccak256("agentkeys-agent-pop:" || device_key_hash)`. + pub pop_sig: String, +} + +pub async fn pairing_poll( + State(state): State, + Json(body): Json, +) -> Result { + // 1. Verify pop_sig FIRST (stateless) — the agent re-proves device-key + // possession at retrieval time. A bad sig touches no state. + let device_key_hash = agentkeys_core::device_crypto::device_key_hash(&body.device_pubkey) + .map_err(|e| BrokerError::BadRequest(format!("bad device_pubkey: {e}")))?; + let pop_payload = agentkeys_core::device_crypto::agent_pop_payload(&device_key_hash); + let recovered = agentkeys_core::device_crypto::ecrecover_eip191(&pop_payload, &body.pop_sig) + .map_err(|e| BrokerError::Unauthorized(format!("pop_sig verify: {e}")))?; + if recovered.to_lowercase() != body.device_pubkey.to_lowercase() { + return Err(BrokerError::Unauthorized(format!( + "pop_sig does not recover to device_pubkey: claimed={}, recovered={recovered}", + body.device_pubkey + ))); + } + + // 2. Read request state (lookup is bound to device_pubkey inside the store). + let now = unix_now()?; + let (operator_omni, child_omni, label) = + match state + .pairing_request_store + .poll(&body.request_id, &body.device_pubkey, now)? + { + PairingPoll::Pending => { + return Ok((StatusCode::OK, Json(json!({ "status": "pending" })))); + } + PairingPoll::Claimed { + operator_omni, + child_omni, + label, + .. + } => (operator_omni, child_omni, label), + PairingPoll::Expired => { + return Err(BrokerError::Unauthorized( + "pairing request expired before any master claimed it".into(), + )); + } + PairingPoll::NotFound => { + return Err(BrokerError::Unauthorized( + "unknown pairing request or device mismatch".into(), + )); + } + }; + + // 3. Mint J1_agent fresh (HDKD omni + lineage). The agent authenticates with + // this immediately, but has NO scope until the master approves the binding. + let derivation_path = format!("//{label}"); + let session_jwt = mint_agent_session_jwt( + &state.session_keypair, + &state.config.oidc_issuer, + &child_omni, + &operator_omni, + &derivation_path, + &body.device_pubkey, + session_jwt_ttl_seconds(), + ) + .map_err(|e| BrokerError::Internal(format!("mint J1_agent: {e}")))?; + + tracing::info!( + operator_omni = %operator_omni, + child_omni = %child_omni, + label = %label, + device = %body.device_pubkey, + "polled §10.2 pairing request — claimed; J1_agent minted at retrieval" + ); + + Ok(( + StatusCode::OK, + Json(json!({ + "status": "claimed", + "session_jwt": session_jwt, + "child_omni": child_omni, + "operator_omni": operator_omni, + "label": label, + "derivation_path": derivation_path, + "device_key_hash": device_key_hash, + })), + )) +} diff --git a/crates/agentkeys-broker-server/src/handlers/agent/redeem.rs b/crates/agentkeys-broker-server/src/handlers/agent/redeem.rs deleted file mode 100644 index ab743d7..0000000 --- a/crates/agentkeys-broker-server/src/handlers/agent/redeem.rs +++ /dev/null @@ -1,109 +0,0 @@ -//! `POST /v1/auth/link-code/redeem` — agent redeems the link code (issue #144 §10.2). -//! -//! No bearer: the link code IS the bearer secret (one-time, TTL-bounded). The -//! agent proves possession of its K10 device key via `pop_sig`. On success the -//! broker mints `J1_agent` (HDKD omni, decoupled from any wallet) and records -//! the device artifact as a pending binding for the master to approve. -//! -//! Order matters: `pop_sig` is verified BEFORE the code is consumed, so an -//! invalid signature does NOT burn the (single-use) code — the agent can retry. -//! `pop_sig` proves device-key possession; the link code proves authorization. - -use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; -use serde::Deserialize; -use serde_json::json; - -use crate::error::BrokerError; -use crate::handlers::agent::{session_jwt_ttl_seconds, unix_now}; -use crate::jwt::issue::mint_agent_session_jwt; -use crate::state::SharedState; -use crate::storage::LinkCodeConsume; - -#[derive(Debug, Deserialize)] -pub struct RedeemBody { - pub link_code: String, - /// The agent's K10 EVM address (`0x` + 40 hex). - pub device_pubkey: String, - /// EIP-191 `pop_sig` over `keccak256("agentkeys-agent-pop:" || device_key_hash)`. - pub pop_sig: String, -} - -pub async fn link_code_redeem( - State(state): State, - Json(body): Json, -) -> Result { - // 1. Verify pop_sig FIRST (stateless — doesn't touch the code), so a bad sig - // leaves the single-use code unconsumed and retryable. - let device_key_hash = agentkeys_core::device_crypto::device_key_hash(&body.device_pubkey) - .map_err(|e| BrokerError::BadRequest(format!("bad device_pubkey: {e}")))?; - let pop_payload = agentkeys_core::device_crypto::agent_pop_payload(&device_key_hash); - let recovered = agentkeys_core::device_crypto::ecrecover_eip191(&pop_payload, &body.pop_sig) - .map_err(|e| BrokerError::Unauthorized(format!("pop_sig verify: {e}")))?; - if recovered.to_lowercase() != body.device_pubkey.to_lowercase() { - return Err(BrokerError::Unauthorized(format!( - "pop_sig does not recover to device_pubkey: claimed={}, recovered={recovered}", - body.device_pubkey - ))); - } - - // 2. Atomically consume the code (single-use, TTL-bounded), capturing the - // device artifact onto the row as a pending binding. - let now = unix_now()?; - let (child_omni, operator_omni, label) = match state.link_code_store.consume( - &body.link_code, - &body.device_pubkey, - &body.pop_sig, - now, - )? { - LinkCodeConsume::Available { - child_omni, - operator_omni, - label, - .. - } => (child_omni, operator_omni, label), - LinkCodeConsume::Expired => { - return Err(BrokerError::Unauthorized( - "link code expired (>600s after issue)".into(), - )); - } - LinkCodeConsume::NotFoundOrConsumed => { - return Err(BrokerError::Unauthorized( - "link code unknown or already redeemed".into(), - )); - } - }; - - // 3. Mint J1_agent (HDKD omni + lineage). The agent authenticates with this - // immediately, but has NO scope until the master approves the binding. - let derivation_path = format!("//{label}"); - let session_jwt = mint_agent_session_jwt( - &state.session_keypair, - &state.config.oidc_issuer, - &child_omni, - &operator_omni, - &derivation_path, - &body.device_pubkey, - session_jwt_ttl_seconds(), - ) - .map_err(|e| BrokerError::Internal(format!("mint J1_agent: {e}")))?; - - tracing::info!( - operator_omni = %operator_omni, - child_omni = %child_omni, - label = %label, - device = %body.device_pubkey, - "redeemed §10.2 link code — J1_agent minted, pending binding recorded" - ); - - Ok(( - StatusCode::OK, - Json(json!({ - "session_jwt": session_jwt, - "child_omni": child_omni, - "operator_omni": operator_omni, - "label": label, - "derivation_path": derivation_path, - "device_key_hash": device_key_hash, - })), - )) -} diff --git a/crates/agentkeys-broker-server/src/handlers/agent/request.rs b/crates/agentkeys-broker-server/src/handlers/agent/request.rs new file mode 100644 index 0000000..ff3adaf --- /dev/null +++ b/crates/agentkeys-broker-server/src/handlers/agent/request.rs @@ -0,0 +1,87 @@ +//! `POST /v1/agent/pairing/request` — the agent opens an **unbound** pairing +//! request (method A §10.2, replaces the master-minted link code). +//! +//! No bearer: the agent has no session yet (that's the whole point of pairing). +//! It proves possession of its K10 device key via `pop_sig`, exactly as the old +//! redeem path did. The broker stores an unbound request (naming no master) and +//! returns: +//! +//! - `pairing_code` — what the agent DISPLAYS (QR / screen text); a master claims +//! the agent by scanning/entering this. Whoever holds it binds, so it is +//! high-entropy (144 bits) — show it only to the intended owner. +//! - `request_id` — the agent's SECRET retrieval ticket; it polls +//! `/v1/agent/pairing/poll` with this + a fresh `pop_sig` to fetch `J1_agent` +//! once a master claims. Never displayed. +//! +//! `pop_sig` is verified BEFORE anything is stored, so a bad signature creates no +//! row (no DoS amplification). The endpoint is unauthenticated → it MUST be +//! rate-limited + pool-capped upstream; the TTL + janitor bound the blast radius. + +use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; +use serde::Deserialize; +use serde_json::json; + +use crate::error::BrokerError; +use crate::handlers::agent::unix_now; +use crate::handlers::grant::random_b64url; +use crate::state::SharedState; +use crate::storage::PAIRING_REQUEST_TTL_SECONDS; + +#[derive(Debug, Deserialize)] +pub struct PairingRequestBody { + /// The agent's K10 EVM address (`0x` + 40 hex). + pub device_pubkey: String, + /// EIP-191 `pop_sig` over `keccak256("agentkeys-agent-pop:" || device_key_hash)`. + pub pop_sig: String, +} + +pub async fn pairing_request( + State(state): State, + Json(body): Json, +) -> Result { + // 1. Verify pop_sig FIRST (stateless), so a bad signature never creates a + // row — the unauthenticated endpoint can't be used to flood the pool with + // junk requests that don't even hold a valid device key. + let device_key_hash = agentkeys_core::device_crypto::device_key_hash(&body.device_pubkey) + .map_err(|e| BrokerError::BadRequest(format!("bad device_pubkey: {e}")))?; + let pop_payload = agentkeys_core::device_crypto::agent_pop_payload(&device_key_hash); + let recovered = agentkeys_core::device_crypto::ecrecover_eip191(&pop_payload, &body.pop_sig) + .map_err(|e| BrokerError::Unauthorized(format!("pop_sig verify: {e}")))?; + if recovered.to_lowercase() != body.device_pubkey.to_lowercase() { + return Err(BrokerError::Unauthorized(format!( + "pop_sig does not recover to device_pubkey: claimed={}, recovered={recovered}", + body.device_pubkey + ))); + } + + // 2. Mint the two secrets + store the UNBOUND request (operator/child_omni + // stay ∅ until a master claims the code). request_id is the agent's + // retrieval ticket (32B); pairing_code is the master-facing claim secret. + let request_id = random_b64url(32); + let pairing_code = random_b64url(18); + let now = unix_now()?; + let expires_at = now + PAIRING_REQUEST_TTL_SECONDS; + state.pairing_request_store.issue( + &request_id, + &pairing_code, + &body.device_pubkey, + &body.pop_sig, + now, + expires_at, + )?; + + tracing::info!( + device = %body.device_pubkey, + "opened §10.2 unbound pairing request — awaiting master claim" + ); + + Ok(( + StatusCode::OK, + Json(json!({ + "request_id": request_id, + "pairing_code": pairing_code, + "device_key_hash": device_key_hash, + "expires_at": expires_at, + })), + )) +} diff --git a/crates/agentkeys-broker-server/src/lib.rs b/crates/agentkeys-broker-server/src/lib.rs index 833508b..bcb528d 100644 --- a/crates/agentkeys-broker-server/src/lib.rs +++ b/crates/agentkeys-broker-server/src/lib.rs @@ -62,16 +62,21 @@ pub fn create_router(state: SharedState) -> Router { "/v1/auth/wallet/verify", post(handlers::auth::wallet_verify::wallet_verify), ) - // §10.2 agent-bootstrap link-code ceremony (issue #144). Master mints a - // code bound to the HDKD child omni; the agent redeems it (proving K10 - // possession) → J1_agent; the master pulls pending bindings to approve. + // §10.2 agent-initiated pairing ceremony (issue #144, method A). The + // agent opens an unbound request (proving K10 possession) + displays a + // pairing_code; the master claims the code → derives the HDKD child omni; + // the agent polls → J1_agent; the master pulls pending bindings to approve. .route( - "/v1/agent/create", - post(handlers::agent::create::agent_create), + "/v1/agent/pairing/request", + post(handlers::agent::request::pairing_request), ) .route( - "/v1/auth/link-code/redeem", - post(handlers::agent::redeem::link_code_redeem), + "/v1/agent/pairing/claim", + post(handlers::agent::claim::pairing_claim), + ) + .route( + "/v1/agent/pairing/poll", + post(handlers::agent::poll::pairing_poll), ) .route( "/v1/agent/pending-bindings", diff --git a/crates/agentkeys-broker-server/src/main.rs b/crates/agentkeys-broker-server/src/main.rs index a5c9597..3c2861e 100644 --- a/crates/agentkeys-broker-server/src/main.rs +++ b/crates/agentkeys-broker-server/src/main.rs @@ -177,7 +177,7 @@ async fn main() -> anyhow::Result<()> { nonce_store: boot_artifacts.nonce_store, grant_store: boot_artifacts.grant_store, identity_link_store: boot_artifacts.identity_link_store, - link_code_store: boot_artifacts.link_code_store, + pairing_request_store: boot_artifacts.pairing_request_store, metrics: Arc::new(agentkeys_broker_server::metrics::Metrics::new()), tier2: Arc::clone(&tier2), #[cfg(feature = "auth-email-link")] diff --git a/crates/agentkeys-broker-server/src/state.rs b/crates/agentkeys-broker-server/src/state.rs index 4e8e812..fe7989b 100644 --- a/crates/agentkeys-broker-server/src/state.rs +++ b/crates/agentkeys-broker-server/src/state.rs @@ -7,7 +7,9 @@ use crate::metrics::Metrics; use crate::oidc::OidcKeypair; use crate::plugins::audit::AuditPolicy; use crate::plugins::PluginRegistry; -use crate::storage::{AuthNonceStore, GrantStore, IdentityLinkStore, LinkCodeStore, WalletStore}; +use crate::storage::{ + AuthNonceStore, GrantStore, IdentityLinkStore, PairingRequestStore, WalletStore, +}; use crate::sts::StsClient; /// Tier-2 reachability state shared with the /readyz handler. @@ -43,12 +45,13 @@ pub struct AppState { /// (issue #72); grants are kept in-tree for master-managed audit and /// potential future re-introduction at the JWT-mint site. pub grant_store: Arc, - /// §10.2 agent-bootstrap link codes + pending-binding records (issue #144). - /// `/v1/agent/create` issues a code bound to the HDKD child omni; - /// `/v1/auth/link-code/redeem` consumes it (capturing device_pubkey + - /// pop_sig) and mints `J1_agent`; `/v1/agent/pending-bindings` lets the - /// master pull redeemed-but-unbound rows to approve. - pub link_code_store: Arc, + /// §10.2 agent-initiated pairing requests + pending-binding records (issue + /// #144, method A). `/v1/agent/pairing/request` opens an unbound request + /// (capturing device_pubkey + pop_sig); `/v1/agent/pairing/claim` binds it to + /// the claiming master (HDKD child omni); `/v1/agent/pairing/poll` mints + /// `J1_agent` once claimed; `/v1/agent/pending-bindings` lets the master pull + /// claimed-but-unbound rows to approve. + pub pairing_request_store: Arc, /// Identity links (Phase B, US-028). Maps verified identities /// (email, oauth2 sub, secondary EVM wallet) to their owning master /// OmniAccount. Recovery flow consults this to find which master diff --git a/crates/agentkeys-broker-server/src/storage/link_codes.rs b/crates/agentkeys-broker-server/src/storage/link_codes.rs deleted file mode 100644 index 3faf18d..0000000 --- a/crates/agentkeys-broker-server/src/storage/link_codes.rs +++ /dev/null @@ -1,427 +0,0 @@ -//! `LinkCodeStore` — the §10.2 agent-bootstrap link code + pending-binding -//! record (issue #144). -//! -//! One row models the full pairing lifecycle: -//! -//! ```text -//! issued (master ran /v1/agent/create — code bound to a child omni) -//! → consumed (agent redeemed at /v1/auth/link-code/redeem — device_pubkey + -//! pop_sig captured; J1_agent minted) -//! → bound (master pulled the pending binding + submitted registerAgentDevice) -//! ``` -//! -//! So this store doubles as the **pending-binding** record the master pulls -//! (the substrate behind the production push notification): a `consumed` row -//! that is not yet `bound` is "agent-A wants to pair + wants `[requested_scope]`". -//! -//! SQLite (not in-memory) because the broker restarts between demo phases; an -//! in-memory store would silently drop codes and produce confusing redeem-404s. -//! Mirrors the single-use / TTL / race-safety posture of `oauth_pending.rs` -//! (atomic `UPDATE ... WHERE consumed_at IS NULL`), but returns `BrokerError` -//! since it's consumed by broker handlers (not the auth-plugin layer). - -use std::path::Path; -use std::sync::{Mutex, MutexGuard}; - -use rusqlite::{params, Connection, OptionalExtension}; - -use crate::error::{BrokerError, BrokerResult}; - -/// Link-code TTL — the window in which an agent must redeem (arch.md §10.2). -pub const LINK_CODE_TTL_SECONDS: i64 = 600; - -/// SQLite-backed link-code + pending-binding store. -pub struct LinkCodeStore { - conn: Mutex, -} - -/// Outcome of [`LinkCodeStore::consume`]. -#[derive(Debug, PartialEq, Eq)] -pub enum LinkCodeConsume { - /// Code was unused + unexpired; consume succeeded. Carries the values the - /// redeem handler needs to mint `J1_agent`. - Available { - child_omni: String, - operator_omni: String, - label: String, - requested_scope: String, - }, - /// Code never existed or was already redeemed (collapsed to one variant so - /// a prober can't distinguish — same posture as the OAuth2/email stores). - NotFoundOrConsumed, - /// Code existed + unused but past its TTL. - Expired, -} - -/// A redeemed-but-not-yet-bound row — what the master pulls from -/// `GET /v1/agent/pending-bindings` to approve. -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] -pub struct PendingBinding { - pub link_code: String, - pub child_omni: String, - pub operator_omni: String, - pub label: String, - pub requested_scope: String, - pub device_pubkey: String, - pub pop_sig: String, -} - -impl LinkCodeStore { - pub fn open(path: &Path) -> BrokerResult { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent) - .map_err(|e| BrokerError::Internal(format!("create link_codes dir: {e}")))?; - } - let conn = Connection::open(path) - .map_err(|e| BrokerError::Internal(format!("open link_codes db: {e}")))?; - let store = Self { - conn: Mutex::new(conn), - }; - store.init_schema()?; - Ok(store) - } - - pub fn open_in_memory() -> BrokerResult { - let conn = Connection::open_in_memory() - .map_err(|e| BrokerError::Internal(format!("open in-memory link_codes db: {e}")))?; - let store = Self { - conn: Mutex::new(conn), - }; - store.init_schema()?; - Ok(store) - } - - fn lock(&self) -> BrokerResult> { - self.conn - .lock() - .map_err(|e| BrokerError::Internal(format!("link_codes mutex poisoned: {e}"))) - } - - fn init_schema(&self) -> BrokerResult<()> { - let conn = self.lock()?; - conn.execute_batch( - "PRAGMA journal_mode=WAL; - PRAGMA synchronous=NORMAL; - CREATE TABLE IF NOT EXISTS link_codes ( - link_code TEXT PRIMARY KEY, - child_omni TEXT NOT NULL, - operator_omni TEXT NOT NULL, - label TEXT NOT NULL, - requested_scope TEXT NOT NULL, - issued_at INTEGER NOT NULL, - expires_at INTEGER NOT NULL, - consumed_at INTEGER, - device_pubkey TEXT, - pop_sig TEXT, - bound_at INTEGER - ); - CREATE INDEX IF NOT EXISTS idx_link_codes_operator - ON link_codes(operator_omni); - CREATE INDEX IF NOT EXISTS idx_link_codes_expires_at - ON link_codes(expires_at);", - ) - .map_err(|e| BrokerError::Internal(format!("init link_codes schema: {e}")))?; - Ok(()) - } - - /// Mint a new link code bound to a child omni (master ran `/v1/agent/create`). - #[allow(clippy::too_many_arguments)] - pub fn issue( - &self, - link_code: &str, - child_omni: &str, - operator_omni: &str, - label: &str, - requested_scope: &str, - issued_at: i64, - expires_at: i64, - ) -> BrokerResult<()> { - let conn = self.lock()?; - conn.execute( - "INSERT INTO link_codes - (link_code, child_omni, operator_omni, label, requested_scope, issued_at, expires_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", - params![ - link_code, - child_omni, - operator_omni, - label, - requested_scope, - issued_at, - expires_at - ], - ) - .map_err(|e| BrokerError::Internal(format!("insert link_code: {e}")))?; - Ok(()) - } - - /// Atomically redeem the code, persisting `device_pubkey` + `pop_sig` onto - /// the row (state → consumed/pending-binding). Race-safe via the conditional - /// `UPDATE ... WHERE consumed_at IS NULL`. - pub fn consume( - &self, - link_code: &str, - device_pubkey: &str, - pop_sig: &str, - now: i64, - ) -> BrokerResult { - let conn = self.lock()?; - let peek: Option<(String, String, String, i64, Option)> = conn - .query_row( - "SELECT child_omni, operator_omni, label, expires_at, consumed_at - FROM link_codes WHERE link_code = ?1", - params![link_code], - |row| { - Ok(( - row.get(0)?, - row.get(1)?, - row.get(2)?, - row.get(3)?, - row.get(4)?, - )) - }, - ) - .optional() - .map_err(|e| BrokerError::Internal(format!("peek link_code: {e}")))?; - - let (child_omni, operator_omni, label, expires_at, consumed_at) = match peek { - None => return Ok(LinkCodeConsume::NotFoundOrConsumed), - Some(t) => t, - }; - if consumed_at.is_some() { - return Ok(LinkCodeConsume::NotFoundOrConsumed); - } - if expires_at < now { - return Ok(LinkCodeConsume::Expired); - } - let requested_scope: String = conn - .query_row( - "SELECT requested_scope FROM link_codes WHERE link_code = ?1", - params![link_code], - |row| row.get(0), - ) - .map_err(|e| BrokerError::Internal(format!("read requested_scope: {e}")))?; - let rows = conn - .execute( - "UPDATE link_codes - SET consumed_at = ?1, device_pubkey = ?2, pop_sig = ?3 - WHERE link_code = ?4 AND consumed_at IS NULL", - params![now, device_pubkey, pop_sig, link_code], - ) - .map_err(|e| BrokerError::Internal(format!("update link_code: {e}")))?; - if rows == 0 { - // Lost the race to a concurrent redeem. - Ok(LinkCodeConsume::NotFoundOrConsumed) - } else { - Ok(LinkCodeConsume::Available { - child_omni, - operator_omni, - label, - requested_scope, - }) - } - } - - /// Rows that have been redeemed but not yet bound on-chain, for one operator - /// — the master's pending-approval queue. Returns oldest-first. - pub fn pending_bindings(&self, operator_omni: &str) -> BrokerResult> { - let conn = self.lock()?; - let mut stmt = conn - .prepare( - "SELECT link_code, child_omni, operator_omni, label, requested_scope, - device_pubkey, pop_sig - FROM link_codes - WHERE operator_omni = ?1 AND consumed_at IS NOT NULL AND bound_at IS NULL - ORDER BY consumed_at ASC", - ) - .map_err(|e| BrokerError::Internal(format!("prepare pending_bindings: {e}")))?; - let rows = stmt - .query_map(params![operator_omni], |row| { - Ok(PendingBinding { - link_code: row.get(0)?, - child_omni: row.get(1)?, - operator_omni: row.get(2)?, - label: row.get(3)?, - requested_scope: row.get(4)?, - device_pubkey: row.get(5)?, - pop_sig: row.get(6)?, - }) - }) - .map_err(|e| BrokerError::Internal(format!("query pending_bindings: {e}")))?; - let mut out = Vec::new(); - for r in rows { - out.push(r.map_err(|e| BrokerError::Internal(format!("row pending_bindings: {e}")))?); - } - Ok(out) - } - - /// Mark a redeemed row as bound (the master acked its on-chain submit), so - /// it drops out of [`pending_bindings`]. Scoped to `operator_omni` — an - /// operator can only ack its own bindings. Idempotent: a second ack matches - /// nothing. Returns the updated row count (1 = acked, 0 = unknown/already-bound). - pub fn mark_bound( - &self, - link_code: &str, - operator_omni: &str, - now: i64, - ) -> BrokerResult { - let conn = self.lock()?; - let n = conn - .execute( - "UPDATE link_codes SET bound_at = ?1 - WHERE link_code = ?2 AND operator_omni = ?3 - AND consumed_at IS NOT NULL AND bound_at IS NULL", - params![now, link_code, operator_omni], - ) - .map_err(|e| BrokerError::Internal(format!("mark_bound link_code: {e}")))?; - Ok(n) - } - - /// Janitor — DELETE expired codes that were never redeemed. Consumed rows are - /// kept (the master may still need to bind them — a binding doesn't expire). - pub fn purge_expired(&self, now: i64, retention_seconds: i64) -> BrokerResult { - let conn = self.lock()?; - let cutoff = now - retention_seconds; - let n = conn - .execute( - "DELETE FROM link_codes WHERE expires_at < ?1 AND consumed_at IS NULL", - params![cutoff], - ) - .map_err(|e| BrokerError::Internal(format!("purge link_codes: {e}")))?; - Ok(n) - } - - /// Writability probe for `/readyz`. - pub fn writable(&self) -> bool { - let Ok(conn) = self.conn.lock() else { - return false; - }; - conn.execute( - "CREATE TABLE IF NOT EXISTS _readyz_probe (id INTEGER PRIMARY KEY)", - [], - ) - .is_ok() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn store() -> LinkCodeStore { - LinkCodeStore::open_in_memory().unwrap() - } - - #[test] - fn issue_then_consume_round_trip() { - let s = store(); - s.issue("lc-1", "child", "op", "agent-a", "memory", 100, 700) - .unwrap(); - let out = s.consume("lc-1", "0xdev", "0xpop", 200).unwrap(); - assert_eq!( - out, - LinkCodeConsume::Available { - child_omni: "child".into(), - operator_omni: "op".into(), - label: "agent-a".into(), - requested_scope: "memory".into(), - } - ); - } - - #[test] - fn second_redeem_is_rejected() { - let s = store(); - s.issue("lc-1", "child", "op", "agent-a", "memory", 100, 700) - .unwrap(); - let _ = s.consume("lc-1", "0xdev", "0xpop", 200).unwrap(); - let replay = s.consume("lc-1", "0xdev", "0xpop", 250).unwrap(); - assert_eq!(replay, LinkCodeConsume::NotFoundOrConsumed); - } - - #[test] - fn expired_code_is_not_consumable() { - let s = store(); - s.issue("lc-1", "child", "op", "agent-a", "memory", 100, 200) - .unwrap(); - assert_eq!( - s.consume("lc-1", "0xdev", "0xpop", 9999).unwrap(), - LinkCodeConsume::Expired - ); - } - - #[test] - fn unknown_code_is_not_found() { - let s = store(); - assert_eq!( - s.consume("nope", "0xdev", "0xpop", 100).unwrap(), - LinkCodeConsume::NotFoundOrConsumed - ); - } - - #[test] - fn pending_bindings_returns_consumed_unbound_rows() { - let s = store(); - s.issue("lc-1", "childA", "op", "agent-a", "memory", 100, 700) - .unwrap(); - s.issue("lc-2", "childB", "op-other", "agent-b", "memory", 100, 700) - .unwrap(); - // Not redeemed yet → no pending binding. - assert!(s.pending_bindings("op").unwrap().is_empty()); - s.consume("lc-1", "0xdevA", "0xpopA", 200).unwrap(); - let pend = s.pending_bindings("op").unwrap(); - assert_eq!(pend.len(), 1); - assert_eq!(pend[0].child_omni, "childA"); - assert_eq!(pend[0].device_pubkey, "0xdevA"); - assert_eq!(pend[0].pop_sig, "0xpopA"); - // Different operator's redemption doesn't leak. - assert!(s - .pending_bindings("op") - .unwrap() - .iter() - .all(|b| b.operator_omni == "op")); - } - - #[test] - fn mark_bound_clears_from_pending() { - let s = store(); - s.issue("lc-1", "childA", "op", "agent-a", "memory", 100, 700) - .unwrap(); - s.consume("lc-1", "0xdevA", "0xpopA", 200).unwrap(); - assert_eq!(s.pending_bindings("op").unwrap().len(), 1); - assert_eq!(s.mark_bound("lc-1", "op", 300).unwrap(), 1); - assert!(s.pending_bindings("op").unwrap().is_empty()); - // Idempotent: a second ack matches nothing. - assert_eq!(s.mark_bound("lc-1", "op", 400).unwrap(), 0); - // Operator-scoped: a different operator cannot ack this binding. - s.issue("lc-2", "childZ", "op", "agent-z", "memory", 100, 700) - .unwrap(); - s.consume("lc-2", "0xdevZ", "0xpopZ", 200).unwrap(); - assert_eq!(s.mark_bound("lc-2", "other-op", 300).unwrap(), 0); - assert_eq!(s.pending_bindings("op").unwrap().len(), 1); - } - - #[test] - fn purge_drops_unredeemed_expired_keeps_pending() { - let s = store(); - s.issue("stale", "childA", "op", "agent-a", "memory", 50, 100) - .unwrap(); - s.issue("redeemed", "childB", "op", "agent-b", "memory", 50, 100) - .unwrap(); - s.consume("redeemed", "0xdevB", "0xpopB", 60).unwrap(); - let n = s.purge_expired(10_000, 100).unwrap(); - assert_eq!(n, 1); // only the unredeemed-expired "stale" row - // The redeemed row survives as a pending binding. - assert_eq!(s.pending_bindings("op").unwrap().len(), 1); - } - - #[test] - fn issue_rejects_duplicate_code() { - let s = store(); - s.issue("dup", "c", "op", "agent-a", "memory", 100, 700) - .unwrap(); - assert!(s - .issue("dup", "c", "op", "agent-a", "memory", 100, 700) - .is_err()); - } -} diff --git a/crates/agentkeys-broker-server/src/storage/mod.rs b/crates/agentkeys-broker-server/src/storage/mod.rs index e0ffa60..93f0d59 100644 --- a/crates/agentkeys-broker-server/src/storage/mod.rs +++ b/crates/agentkeys-broker-server/src/storage/mod.rs @@ -16,11 +16,11 @@ pub mod email_rate_limits; pub mod email_tokens; pub mod grants; pub mod identity_links; -// Issue #144 — §10.2 agent-bootstrap link codes + pending-binding records. -// Unconditional (the agent bootstrap is core, not feature-gated). -pub mod link_codes; +// Issue #144 — §10.2 agent-initiated pairing requests + pending-binding records +// (method A). Unconditional (the agent bootstrap is core, not feature-gated). #[cfg(feature = "auth-oauth2")] pub mod oauth_pending; +pub mod pairing_requests; #[cfg(any(feature = "auth-email-link", feature = "auth-oauth2"))] pub mod rate_limit_mints; pub mod wallets; @@ -32,9 +32,11 @@ pub use email_rate_limits::{EmailRateLimitStore, RateLimitOutcome}; pub use email_tokens::{EmailConsumeOutcome, EmailRequestStatus, EmailTokenStore}; pub use grants::{Grant, GrantConsumeOutcome, GrantStore}; pub use identity_links::{IdentityLink, IdentityLinkStore}; -pub use link_codes::{LinkCodeConsume, LinkCodeStore, PendingBinding, LINK_CODE_TTL_SECONDS}; #[cfg(feature = "auth-oauth2")] pub use oauth_pending::{OAuth2PendingConsume, OAuth2PendingStatus, OAuth2PendingStore}; +pub use pairing_requests::{ + PairingClaim, PairingPoll, PairingRequestStore, PendingBinding, PAIRING_REQUEST_TTL_SECONDS, +}; #[cfg(any(feature = "auth-email-link", feature = "auth-oauth2"))] pub use rate_limit_mints::MintRateLimiter; pub use wallets::WalletStore; diff --git a/crates/agentkeys-broker-server/src/storage/pairing_requests.rs b/crates/agentkeys-broker-server/src/storage/pairing_requests.rs new file mode 100644 index 0000000..5d72131 --- /dev/null +++ b/crates/agentkeys-broker-server/src/storage/pairing_requests.rs @@ -0,0 +1,633 @@ +//! `PairingRequestStore` — the §10.2 **agent-initiated** pairing request + +//! pending-binding record (method A, replaces issue #144's master-initiated +//! link code). +//! +//! One row models the full pairing lifecycle, but the direction is inverted vs +//! the old `link_codes` table: the **agent** opens the row (unbound, naming no +//! master), and the **master** later claims it by code: +//! +//! ```text +//! requested (agent ran POST /v1/agent/pairing/request — device_pubkey + +//! pop_sig captured; operator/child_omni still ∅; pairing_code minted) +//! → claimed (master ran POST /v1/agent/pairing/claim — scanned/entered the +//! code; operator_omni = the master, child_omni = HDKD(O_master,//label)) +//! → bound (master pulled the pending binding + submitted registerAgentDevice +//! + POST /v1/agent/pending-bindings/ack) +//! ``` +//! +//! Why agent-first (vs the old master-first `link_codes`): a no-keyboard IoT +//! device can only *show* a code (QR/screen), not accept one typed into it — the +//! Matter/HomeKit convention. The request is **unbound + inert** until a master +//! deliberately claims the code, so an agent still can't attach itself to a +//! master or flood one (Sybil-safe — the master's claim is the sole binder, +//! exactly as the master's mint was under method M). +//! +//! `J1_agent` is **not** minted or stored here — it is minted fresh at poll time +//! (`handlers/agent/poll.rs`) once the agent re-proves device-key possession, so +//! no bearer secret sits at rest in SQLite and the JWT TTL starts at retrieval. +//! This store only holds the request lifecycle state. +//! +//! SQLite (not in-memory) because the broker restarts between demo phases; an +//! in-memory store would silently drop requests and produce confusing claim/poll +//! 404s. Race-safety mirrors `oauth_pending.rs` (atomic `UPDATE ... WHERE +//! claimed_at IS NULL`); returns `BrokerError` since broker handlers consume it. + +use std::path::Path; +use std::sync::{Mutex, MutexGuard}; + +use rusqlite::{params, Connection, OptionalExtension}; + +use crate::error::{BrokerError, BrokerResult}; + +/// Pairing-request TTL — the window in which a master must claim the agent's +/// request (arch.md §10.2). Same 600s as the old link-code TTL. +pub const PAIRING_REQUEST_TTL_SECONDS: i64 = 600; + +/// SQLite-backed pairing-request + pending-binding store. +pub struct PairingRequestStore { + conn: Mutex, +} + +/// Outcome of [`PairingRequestStore::claim`] (master claims by `pairing_code`). +#[derive(Debug, PartialEq, Eq)] +pub enum PairingClaim { + /// Code was unclaimed + unexpired; claim succeeded. Carries the device + /// artifact the master reviews before `registerAgentDevice` (the M + /// second-factor, preserved) + records as a pending binding. + Claimed { + request_id: String, + device_pubkey: String, + pop_sig: String, + }, + /// Code never existed or was already claimed (collapsed to one variant so a + /// prober can't distinguish — same posture as the OAuth2/email stores). + NotFoundOrClaimed, + /// Code existed + unclaimed but past its TTL. + Expired, +} + +/// Outcome of [`PairingRequestStore::poll`] (agent polls by `request_id`, +/// proving device-key possession out of band in the handler). +#[derive(Debug, PartialEq, Eq)] +pub enum PairingPoll { + /// Request exists + unexpired but no master has claimed it yet. + Pending, + /// A master has claimed the request — carries everything the poll handler + /// needs to mint `J1_agent` fresh. + Claimed { + operator_omni: String, + child_omni: String, + label: String, + requested_scope: String, + }, + /// `request_id` never existed, or the supplied `device_pubkey` doesn't match + /// the row (binding mismatch hidden behind one variant — a prober holding a + /// guessed request_id but the wrong device key can't distinguish). + NotFound, + /// Request expired before any master claimed it. + Expired, +} + +/// A claimed-but-not-yet-bound row — what the master pulls from +/// `GET /v1/agent/pending-bindings` to approve. `request_id` is the stable +/// handle the master acks by (the method-A analog of the old `link_code`). +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct PendingBinding { + pub request_id: String, + pub child_omni: String, + pub operator_omni: String, + pub label: String, + pub requested_scope: String, + pub device_pubkey: String, + pub pop_sig: String, +} + +/// The `SELECT` shape `poll()` reads: +/// `(device_pubkey, expires_at, claimed_at, operator_omni, child_omni, label, requested_scope)`. +/// The four `Option`s are NULL until a master claims the request. +type PollRow = ( + String, + i64, + Option, + Option, + Option, + Option, + Option, +); + +impl PairingRequestStore { + pub fn open(path: &Path) -> BrokerResult { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| BrokerError::Internal(format!("create pairing_requests dir: {e}")))?; + } + let conn = Connection::open(path) + .map_err(|e| BrokerError::Internal(format!("open pairing_requests db: {e}")))?; + let store = Self { + conn: Mutex::new(conn), + }; + store.init_schema()?; + Ok(store) + } + + pub fn open_in_memory() -> BrokerResult { + let conn = Connection::open_in_memory().map_err(|e| { + BrokerError::Internal(format!("open in-memory pairing_requests db: {e}")) + })?; + let store = Self { + conn: Mutex::new(conn), + }; + store.init_schema()?; + Ok(store) + } + + fn lock(&self) -> BrokerResult> { + self.conn + .lock() + .map_err(|e| BrokerError::Internal(format!("pairing_requests mutex poisoned: {e}"))) + } + + fn init_schema(&self) -> BrokerResult<()> { + let conn = self.lock()?; + conn.execute_batch( + "PRAGMA journal_mode=WAL; + PRAGMA synchronous=NORMAL; + CREATE TABLE IF NOT EXISTS pairing_requests ( + request_id TEXT PRIMARY KEY, + pairing_code TEXT NOT NULL UNIQUE, + device_pubkey TEXT NOT NULL, + pop_sig TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + claimed_at INTEGER, + operator_omni TEXT, + child_omni TEXT, + label TEXT, + requested_scope TEXT, + bound_at INTEGER + ); + CREATE INDEX IF NOT EXISTS idx_pairing_requests_operator + ON pairing_requests(operator_omni); + CREATE INDEX IF NOT EXISTS idx_pairing_requests_expires_at + ON pairing_requests(expires_at);", + ) + .map_err(|e| BrokerError::Internal(format!("init pairing_requests schema: {e}")))?; + Ok(()) + } + + /// Open a new **unbound** pairing request (agent ran `/v1/agent/pairing/request`). + /// `operator_omni` / `child_omni` / `label` / `requested_scope` are NULL until + /// a master claims the `pairing_code`. + pub fn issue( + &self, + request_id: &str, + pairing_code: &str, + device_pubkey: &str, + pop_sig: &str, + created_at: i64, + expires_at: i64, + ) -> BrokerResult<()> { + let conn = self.lock()?; + conn.execute( + "INSERT INTO pairing_requests + (request_id, pairing_code, device_pubkey, pop_sig, created_at, expires_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + request_id, + pairing_code, + device_pubkey, + pop_sig, + created_at, + expires_at + ], + ) + .map_err(|e| BrokerError::Internal(format!("insert pairing_request: {e}")))?; + Ok(()) + } + + /// Atomically claim the request by `pairing_code` (master ran + /// `/v1/agent/pairing/claim`), assigning the operator + HDKD child omni + + /// label + requested scope onto the row (state → claimed/pending-binding). + /// Race-safe via the conditional `UPDATE ... WHERE claimed_at IS NULL`. + #[allow(clippy::too_many_arguments)] + pub fn claim( + &self, + pairing_code: &str, + operator_omni: &str, + child_omni: &str, + label: &str, + requested_scope: &str, + now: i64, + ) -> BrokerResult { + let conn = self.lock()?; + let peek: Option<(String, String, String, i64, Option)> = conn + .query_row( + "SELECT request_id, device_pubkey, pop_sig, expires_at, claimed_at + FROM pairing_requests WHERE pairing_code = ?1", + params![pairing_code], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + )) + }, + ) + .optional() + .map_err(|e| BrokerError::Internal(format!("peek pairing_code: {e}")))?; + + let (request_id, device_pubkey, pop_sig, expires_at, claimed_at) = match peek { + None => return Ok(PairingClaim::NotFoundOrClaimed), + Some(t) => t, + }; + if claimed_at.is_some() { + return Ok(PairingClaim::NotFoundOrClaimed); + } + if expires_at < now { + return Ok(PairingClaim::Expired); + } + let rows = conn + .execute( + "UPDATE pairing_requests + SET claimed_at = ?1, operator_omni = ?2, child_omni = ?3, + label = ?4, requested_scope = ?5 + WHERE pairing_code = ?6 AND claimed_at IS NULL", + params![ + now, + operator_omni, + child_omni, + label, + requested_scope, + pairing_code + ], + ) + .map_err(|e| BrokerError::Internal(format!("update pairing_request: {e}")))?; + if rows == 0 { + // Lost the race to a concurrent claim. + Ok(PairingClaim::NotFoundOrClaimed) + } else { + Ok(PairingClaim::Claimed { + request_id, + device_pubkey, + pop_sig, + }) + } + } + + /// Read the request's current state for the agent's poll (`request_id` is the + /// agent's secret retrieval ticket). `device_pubkey` MUST match the row's + /// stored device key — a mismatch collapses to [`PairingPoll::NotFound`] so a + /// prober holding a guessed `request_id` (but not the device key) learns + /// nothing. The handler verifies the fresh `pop_sig` against `device_pubkey` + /// BEFORE calling this (stateless), so this method does no crypto. + pub fn poll( + &self, + request_id: &str, + device_pubkey: &str, + now: i64, + ) -> BrokerResult { + let conn = self.lock()?; + let row: Option = conn + .query_row( + "SELECT device_pubkey, expires_at, claimed_at, + operator_omni, child_omni, label, requested_scope + FROM pairing_requests WHERE request_id = ?1", + params![request_id], + |r| { + Ok(( + r.get(0)?, + r.get(1)?, + r.get(2)?, + r.get(3)?, + r.get(4)?, + r.get(5)?, + r.get(6)?, + )) + }, + ) + .optional() + .map_err(|e| BrokerError::Internal(format!("poll pairing_request: {e}")))?; + + let ( + stored_pubkey, + expires_at, + claimed_at, + operator_omni, + child_omni, + label, + requested_scope, + ) = match row { + None => return Ok(PairingPoll::NotFound), + Some(t) => t, + }; + // Bind the poll to the device key — a guessed request_id without the + // matching device key is indistinguishable from an unknown one. + if stored_pubkey.to_lowercase() != device_pubkey.to_lowercase() { + return Ok(PairingPoll::NotFound); + } + if claimed_at.is_none() { + // Unclaimed: expired vs still-pending. + if expires_at < now { + return Ok(PairingPoll::Expired); + } + return Ok(PairingPoll::Pending); + } + // Claimed rows don't expire (a binding the master is approving is + // long-lived), so we don't re-check expires_at here. + Ok(PairingPoll::Claimed { + operator_omni: operator_omni.unwrap_or_default(), + child_omni: child_omni.unwrap_or_default(), + label: label.unwrap_or_default(), + requested_scope: requested_scope.unwrap_or_default(), + }) + } + + /// Rows that have been claimed but not yet bound on-chain, for one operator + /// — the master's pending-approval queue. Returns oldest-first. + pub fn pending_bindings(&self, operator_omni: &str) -> BrokerResult> { + let conn = self.lock()?; + let mut stmt = conn + .prepare( + "SELECT request_id, child_omni, operator_omni, label, requested_scope, + device_pubkey, pop_sig + FROM pairing_requests + WHERE operator_omni = ?1 AND claimed_at IS NOT NULL AND bound_at IS NULL + ORDER BY claimed_at ASC", + ) + .map_err(|e| BrokerError::Internal(format!("prepare pending_bindings: {e}")))?; + let rows = stmt + .query_map(params![operator_omni], |row| { + Ok(PendingBinding { + request_id: row.get(0)?, + child_omni: row.get(1)?, + operator_omni: row.get(2)?, + label: row.get(3)?, + requested_scope: row.get(4)?, + device_pubkey: row.get(5)?, + pop_sig: row.get(6)?, + }) + }) + .map_err(|e| BrokerError::Internal(format!("query pending_bindings: {e}")))?; + let mut out = Vec::new(); + for r in rows { + out.push(r.map_err(|e| BrokerError::Internal(format!("row pending_bindings: {e}")))?); + } + Ok(out) + } + + /// Mark a claimed row as bound (the master acked its on-chain submit), so it + /// drops out of [`pending_bindings`]. Scoped to `operator_omni` — an operator + /// can only ack its own bindings. Idempotent: a second ack matches nothing. + /// Returns the updated row count (1 = acked, 0 = unknown/already-bound). + pub fn mark_bound( + &self, + request_id: &str, + operator_omni: &str, + now: i64, + ) -> BrokerResult { + let conn = self.lock()?; + let n = conn + .execute( + "UPDATE pairing_requests SET bound_at = ?1 + WHERE request_id = ?2 AND operator_omni = ?3 + AND claimed_at IS NOT NULL AND bound_at IS NULL", + params![now, request_id, operator_omni], + ) + .map_err(|e| BrokerError::Internal(format!("mark_bound pairing_request: {e}")))?; + Ok(n) + } + + /// Janitor — DELETE expired requests that were never claimed. Claimed rows are + /// kept (the master may still need to bind them — a binding doesn't expire). + pub fn purge_expired(&self, now: i64, retention_seconds: i64) -> BrokerResult { + let conn = self.lock()?; + let cutoff = now - retention_seconds; + let n = conn + .execute( + "DELETE FROM pairing_requests WHERE expires_at < ?1 AND claimed_at IS NULL", + params![cutoff], + ) + .map_err(|e| BrokerError::Internal(format!("purge pairing_requests: {e}")))?; + Ok(n) + } + + /// Writability probe for `/readyz`. + pub fn writable(&self) -> bool { + let Ok(conn) = self.conn.lock() else { + return false; + }; + conn.execute( + "CREATE TABLE IF NOT EXISTS _readyz_probe (id INTEGER PRIMARY KEY)", + [], + ) + .is_ok() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn store() -> PairingRequestStore { + PairingRequestStore::open_in_memory().unwrap() + } + + #[test] + fn request_then_claim_round_trip() { + let s = store(); + s.issue("req-1", "code-1", "0xdev", "0xpop", 100, 700) + .unwrap(); + let out = s + .claim("code-1", "op", "child", "agent-a", "memory", 200) + .unwrap(); + assert_eq!( + out, + PairingClaim::Claimed { + request_id: "req-1".into(), + device_pubkey: "0xdev".into(), + pop_sig: "0xpop".into(), + } + ); + } + + #[test] + fn second_claim_is_rejected() { + let s = store(); + s.issue("req-1", "code-1", "0xdev", "0xpop", 100, 700) + .unwrap(); + let _ = s + .claim("code-1", "op", "child", "agent-a", "memory", 200) + .unwrap(); + let replay = s + .claim("code-1", "op2", "child2", "agent-a", "memory", 250) + .unwrap(); + assert_eq!(replay, PairingClaim::NotFoundOrClaimed); + } + + #[test] + fn expired_request_is_not_claimable() { + let s = store(); + s.issue("req-1", "code-1", "0xdev", "0xpop", 100, 200) + .unwrap(); + assert_eq!( + s.claim("code-1", "op", "child", "agent-a", "memory", 9999) + .unwrap(), + PairingClaim::Expired + ); + } + + #[test] + fn unknown_code_is_not_found() { + let s = store(); + assert_eq!( + s.claim("nope", "op", "child", "agent-a", "memory", 100) + .unwrap(), + PairingClaim::NotFoundOrClaimed + ); + } + + #[test] + fn poll_pending_then_claimed() { + let s = store(); + s.issue("req-1", "code-1", "0xdev", "0xpop", 100, 700) + .unwrap(); + assert_eq!(s.poll("req-1", "0xdev", 200).unwrap(), PairingPoll::Pending); + s.claim("code-1", "op", "child", "agent-a", "memory", 250) + .unwrap(); + assert_eq!( + s.poll("req-1", "0xdev", 300).unwrap(), + PairingPoll::Claimed { + operator_omni: "op".into(), + child_omni: "child".into(), + label: "agent-a".into(), + requested_scope: "memory".into(), + } + ); + } + + #[test] + fn poll_with_wrong_device_is_not_found() { + let s = store(); + s.issue("req-1", "code-1", "0xdev", "0xpop", 100, 700) + .unwrap(); + // Right request_id, wrong device key → indistinguishable from unknown. + assert_eq!( + s.poll("req-1", "0xWRONG", 200).unwrap(), + PairingPoll::NotFound + ); + } + + #[test] + fn poll_device_match_is_case_insensitive() { + let s = store(); + // device_pubkey stored as mixed-case "0xAbCd"; poll with lowercase. + s.issue("req-1", "code-1", "0xAbCd", "0xpop", 100, 700) + .unwrap(); + assert_eq!( + s.poll("req-1", "0xabcd", 200).unwrap(), + PairingPoll::Pending + ); + } + + #[test] + fn poll_unclaimed_expired_is_expired() { + let s = store(); + s.issue("req-1", "code-1", "0xdev", "0xpop", 100, 200) + .unwrap(); + assert_eq!( + s.poll("req-1", "0xdev", 9999).unwrap(), + PairingPoll::Expired + ); + } + + #[test] + fn poll_unknown_request_is_not_found() { + let s = store(); + assert_eq!(s.poll("nope", "0xdev", 100).unwrap(), PairingPoll::NotFound); + } + + #[test] + fn pending_bindings_returns_claimed_unbound_rows() { + let s = store(); + s.issue("req-1", "code-1", "0xdevA", "0xpopA", 100, 700) + .unwrap(); + s.issue("req-2", "code-2", "0xdevB", "0xpopB", 100, 700) + .unwrap(); + // Not claimed yet → no pending binding. + assert!(s.pending_bindings("op").unwrap().is_empty()); + s.claim("code-1", "op", "childA", "agent-a", "memory", 200) + .unwrap(); + s.claim("code-2", "op-other", "childB", "agent-b", "memory", 200) + .unwrap(); + let pend = s.pending_bindings("op").unwrap(); + assert_eq!(pend.len(), 1); + assert_eq!(pend[0].request_id, "req-1"); + assert_eq!(pend[0].child_omni, "childA"); + assert_eq!(pend[0].device_pubkey, "0xdevA"); + assert_eq!(pend[0].pop_sig, "0xpopA"); + // Different operator's claim doesn't leak. + assert!(s + .pending_bindings("op") + .unwrap() + .iter() + .all(|b| b.operator_omni == "op")); + } + + #[test] + fn mark_bound_clears_from_pending() { + let s = store(); + s.issue("req-1", "code-1", "0xdevA", "0xpopA", 100, 700) + .unwrap(); + s.claim("code-1", "op", "childA", "agent-a", "memory", 200) + .unwrap(); + assert_eq!(s.pending_bindings("op").unwrap().len(), 1); + assert_eq!(s.mark_bound("req-1", "op", 300).unwrap(), 1); + assert!(s.pending_bindings("op").unwrap().is_empty()); + // Idempotent: a second ack matches nothing. + assert_eq!(s.mark_bound("req-1", "op", 400).unwrap(), 0); + // Operator-scoped: a different operator cannot ack this binding. + s.issue("req-2", "code-2", "0xdevZ", "0xpopZ", 100, 700) + .unwrap(); + s.claim("code-2", "op", "childZ", "agent-z", "memory", 200) + .unwrap(); + assert_eq!(s.mark_bound("req-2", "other-op", 300).unwrap(), 0); + assert_eq!(s.pending_bindings("op").unwrap().len(), 1); + } + + #[test] + fn purge_drops_unclaimed_expired_keeps_pending() { + let s = store(); + s.issue("stale", "code-stale", "0xdevA", "0xpopA", 50, 100) + .unwrap(); + s.issue("claimed", "code-claimed", "0xdevB", "0xpopB", 50, 100) + .unwrap(); + s.claim("code-claimed", "op", "childB", "agent-b", "memory", 60) + .unwrap(); + let n = s.purge_expired(10_000, 100).unwrap(); + assert_eq!(n, 1); // only the unclaimed-expired "stale" row + // The claimed row survives as a pending binding. + assert_eq!(s.pending_bindings("op").unwrap().len(), 1); + } + + #[test] + fn issue_rejects_duplicate_request_id() { + let s = store(); + s.issue("dup", "code-1", "0xdev", "0xpop", 100, 700) + .unwrap(); + assert!(s + .issue("dup", "code-2", "0xdev", "0xpop", 100, 700) + .is_err()); + } + + #[test] + fn issue_rejects_duplicate_pairing_code() { + let s = store(); + s.issue("req-1", "dupcode", "0xdev", "0xpop", 100, 700) + .unwrap(); + assert!(s + .issue("req-2", "dupcode", "0xdev", "0xpop", 100, 700) + .is_err()); + } +} diff --git a/crates/agentkeys-broker-server/tests/agent_bootstrap_flow.rs b/crates/agentkeys-broker-server/tests/agent_bootstrap_flow.rs index 7e2d081..651209b 100644 --- a/crates/agentkeys-broker-server/tests/agent_bootstrap_flow.rs +++ b/crates/agentkeys-broker-server/tests/agent_bootstrap_flow.rs @@ -1,9 +1,11 @@ -//! End-to-end tests for the §10.2 agent-bootstrap link-code ceremony (issue #144): -//! create (master, J1-gated) → redeem (agent, pop_sig) → pending-bindings (master). +//! End-to-end tests for the §10.2 agent-**initiated** pairing ceremony (issue +//! #144, method A): +//! request (agent, pop_sig) → claim (master, J1-gated) → poll (agent, pop_sig) +//! → pending-bindings (master). //! //! Exercises the full HTTP path through `create_router`, including the real //! secp256k1 pop_sig produced by `agentkeys_core::device_crypto::DeviceKey` and -//! verified by the broker's redeem handler — the redeem-critical match. +//! verified by the broker's request + poll handlers — the pop-critical match. use std::path::PathBuf; use std::sync::Arc; @@ -88,8 +90,8 @@ async fn spawn_broker() -> (String, Arc) { identity_link_store: Arc::new( agentkeys_broker_server::storage::IdentityLinkStore::open_in_memory().unwrap(), ), - link_code_store: Arc::new( - agentkeys_broker_server::storage::LinkCodeStore::open_in_memory().unwrap(), + pairing_request_store: Arc::new( + agentkeys_broker_server::storage::PairingRequestStore::open_in_memory().unwrap(), ), metrics: Arc::new(agentkeys_broker_server::metrics::Metrics::new()), tier2: Arc::new(agentkeys_broker_server::state::Tier2State::default()), @@ -124,12 +126,40 @@ fn master_session(state: &AppState) -> (String, String) { (token, master_omni) } +/// Generate a fresh in-sandbox K10 device key. +fn device_key() -> (TempDir, DeviceKey) { + let kd = TempDir::new().unwrap(); + let dk = + DeviceKey::load_or_generate(kd.path().join("dev.key").to_str().unwrap(), true).unwrap(); + (kd, dk) +} + +#[tokio::test] +async fn request_rejects_bad_pop_sig() { + // The agent /request endpoint takes no bearer but MUST hold a valid pop_sig + // — a sig from a different key (recovers to the wrong address) is rejected + // and creates no row (no DoS amplification on the unauthenticated endpoint). + let (broker_url, _state) = spawn_broker().await; + let (_kd, dk) = device_key(); + let (_kd2, other) = device_key(); + let resp = reqwest::Client::new() + .post(format!("{}/v1/agent/pairing/request", broker_url)) + .json(&json!({ + "device_pubkey": dk.address(), + "pop_sig": other.pop_sig().unwrap(), + })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::UNAUTHORIZED); +} + #[tokio::test] -async fn create_requires_master_bearer() { +async fn claim_requires_master_bearer() { let (broker_url, _state) = spawn_broker().await; let resp = reqwest::Client::new() - .post(format!("{}/v1/agent/create", broker_url)) - .json(&json!({ "label": "agent-a" })) + .post(format!("{}/v1/agent/pairing/claim", broker_url)) + .json(&json!({ "pairing_code": "whatever", "label": "agent-a" })) .send() .await .unwrap(); @@ -137,13 +167,15 @@ async fn create_requires_master_bearer() { } #[tokio::test] -async fn create_rejects_bad_label() { +async fn claim_rejects_bad_label() { + // Label is validated before the store is touched, so a bogus code + bad + // label still 400s (no need to open a real request first). let (broker_url, state) = spawn_broker().await; let (bearer, _) = master_session(&state); let resp = reqwest::Client::new() - .post(format!("{}/v1/agent/create", broker_url)) + .post(format!("{}/v1/agent/pairing/claim", broker_url)) .header("Authorization", format!("Bearer {bearer}")) - .json(&json!({ "label": "Agent/A" })) + .json(&json!({ "pairing_code": "whatever", "label": "Agent/A" })) .send() .await .unwrap(); @@ -151,39 +183,78 @@ async fn create_rejects_bad_label() { } #[tokio::test] -async fn full_create_redeem_pending_flow() { +async fn full_request_claim_poll_pending_flow() { let (broker_url, state) = spawn_broker().await; let (bearer, master_omni) = master_session(&state); let client = reqwest::Client::new(); - // create → real broker link code + HDKD child omni. - let create: Value = client - .post(format!("{}/v1/agent/create", broker_url)) + // 1. AGENT generates K10 in the sandbox + opens an unbound pairing request. + let (_kd, dk) = device_key(); + let request: Value = client + .post(format!("{}/v1/agent/pairing/request", broker_url)) + .json(&json!({ + "device_pubkey": dk.address(), + "pop_sig": dk.pop_sig().unwrap(), + })) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let request_id = request["request_id"].as_str().unwrap().to_string(); + let pairing_code = request["pairing_code"].as_str().unwrap().to_string(); + assert!(request["device_key_hash"] + .as_str() + .unwrap() + .starts_with("0x")); + + // 2. AGENT polls BEFORE any master claims → pending. + let pending_poll: Value = client + .post(format!("{}/v1/agent/pairing/poll", broker_url)) + .json(&json!({ + "request_id": request_id, + "device_pubkey": dk.address(), + "pop_sig": dk.pop_sig().unwrap(), + })) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(pending_poll["status"], "pending"); + + // 3. MASTER claims the code (the binding act). Derives the HDKD child omni. + let claim: Value = client + .post(format!("{}/v1/agent/pairing/claim", broker_url)) .header("Authorization", format!("Bearer {bearer}")) - .json(&json!({ "label": "agent-a", "requested_scope": "memory" })) + .json(&json!({ + "pairing_code": pairing_code, + "label": "agent-a", + "requested_scope": "memory", + })) .send() .await .unwrap() .json() .await .unwrap(); - let link_code = create["link_code"].as_str().unwrap().to_string(); - let child_omni = create["child_omni"].as_str().unwrap().to_string(); + let child_omni = claim["child_omni"].as_str().unwrap().to_string(); // Public recomputability (acceptance criterion). assert_eq!( child_omni, agentkeys_core::actor_omni::child_omni_hex(&master_omni, "agent-a").unwrap() ); - assert_eq!(create["operator_omni"], master_omni); + assert_eq!(claim["operator_omni"], master_omni); + assert_eq!(claim["request_id"], request_id); + assert_eq!(claim["device_pubkey"], dk.address()); - // agent generates K10 in the sandbox + redeems with a real pop_sig. - let kd = TempDir::new().unwrap(); - let dk = - DeviceKey::load_or_generate(kd.path().join("dev.key").to_str().unwrap(), true).unwrap(); - let redeem: Value = client - .post(format!("{}/v1/auth/link-code/redeem", broker_url)) + // 4. AGENT polls again → claimed; J1_agent minted at retrieval. + let claimed_poll: Value = client + .post(format!("{}/v1/agent/pairing/poll", broker_url)) .json(&json!({ - "link_code": link_code, + "request_id": request_id, "device_pubkey": dk.address(), "pop_sig": dk.pop_sig().unwrap(), })) @@ -193,8 +264,9 @@ async fn full_create_redeem_pending_flow() { .json() .await .unwrap(); - let j1_agent = redeem["session_jwt"].as_str().unwrap(); - assert_eq!(redeem["child_omni"], child_omni); + assert_eq!(claimed_poll["status"], "claimed"); + let j1_agent = claimed_poll["session_jwt"].as_str().unwrap(); + assert_eq!(claimed_poll["child_omni"], child_omni); // J1_agent carries the HDKD omni + lineage. let claims = verify_session_jwt(&state.session_keypair, TEST_ISSUER, j1_agent).unwrap(); @@ -213,7 +285,7 @@ async fn full_create_redeem_pending_flow() { ); assert_eq!(claims.agentkeys.identity_type, "agent_hdkd"); - // master pulls the pending binding (the push-notification substrate). + // 5. MASTER pulls the pending binding (the push-notification substrate). let pending: Value = client .get(format!("{}/v1/agent/pending-bindings", broker_url)) .header("Authorization", format!("Bearer {bearer}")) @@ -225,6 +297,7 @@ async fn full_create_redeem_pending_flow() { .unwrap(); let arr = pending["pending"].as_array().unwrap(); assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["request_id"], request_id); assert_eq!(arr[0]["child_omni"], child_omni); assert_eq!(arr[0]["device_pubkey"], dk.address()); assert_eq!(arr[0]["requested_scope"], "memory"); @@ -234,12 +307,12 @@ async fn full_create_redeem_pending_flow() { .unwrap() .starts_with("0x")); - // ack the binding (master submitted registerAgentDevice) → the rendezvous - // self-cleans, so a re-run sees an empty pending list (idempotent). + // 6. ack the binding (master submitted registerAgentDevice) → the rendezvous + // self-cleans, so a re-run sees an empty pending list (idempotent). let ack: Value = client .post(format!("{}/v1/agent/pending-bindings/ack", broker_url)) .header("Authorization", format!("Bearer {bearer}")) - .json(&json!({ "link_code": link_code })) + .json(&json!({ "request_id": request_id })) .send() .await .unwrap() @@ -261,7 +334,7 @@ async fn full_create_redeem_pending_flow() { let ack2: Value = client .post(format!("{}/v1/agent/pending-bindings/ack", broker_url)) .header("Authorization", format!("Bearer {bearer}")) - .json(&json!({ "link_code": link_code })) + .json(&json!({ "request_id": request_id })) .send() .await .unwrap() @@ -270,14 +343,11 @@ async fn full_create_redeem_pending_flow() { .unwrap(); assert_eq!(ack2["acked"], false); - // single-use: a second redeem of the same code is rejected. + // 7. single-use: a second claim of the same pairing_code is rejected. let replay = client - .post(format!("{}/v1/auth/link-code/redeem", broker_url)) - .json(&json!({ - "link_code": link_code, - "device_pubkey": dk.address(), - "pop_sig": dk.pop_sig().unwrap(), - })) + .post(format!("{}/v1/agent/pairing/claim", broker_url)) + .header("Authorization", format!("Bearer {bearer}")) + .json(&json!({ "pairing_code": pairing_code, "label": "agent-a" })) .send() .await .unwrap(); @@ -285,47 +355,66 @@ async fn full_create_redeem_pending_flow() { } #[tokio::test] -async fn bad_pop_sig_rejected_and_code_remains_redeemable() { +async fn poll_rejects_wrong_device_and_bad_pop_sig() { let (broker_url, state) = spawn_broker().await; let (bearer, _master_omni) = master_session(&state); let client = reqwest::Client::new(); - let create: Value = client - .post(format!("{}/v1/agent/create", broker_url)) - .header("Authorization", format!("Bearer {bearer}")) - .json(&json!({ "label": "agent-b" })) + + // Open + claim a request so it's in the claimed state. + let (_kd, dk) = device_key(); + let request: Value = client + .post(format!("{}/v1/agent/pairing/request", broker_url)) + .json(&json!({ "device_pubkey": dk.address(), "pop_sig": dk.pop_sig().unwrap() })) .send() .await .unwrap() .json() .await .unwrap(); - let link_code = create["link_code"].as_str().unwrap().to_string(); - - let kd = TempDir::new().unwrap(); - let dk = - DeviceKey::load_or_generate(kd.path().join("dev.key").to_str().unwrap(), true).unwrap(); + let request_id = request["request_id"].as_str().unwrap().to_string(); + let pairing_code = request["pairing_code"].as_str().unwrap().to_string(); + client + .post(format!("{}/v1/agent/pairing/claim", broker_url)) + .header("Authorization", format!("Bearer {bearer}")) + .json(&json!({ "pairing_code": pairing_code, "label": "agent-c" })) + .send() + .await + .unwrap(); - // A pop_sig from a DIFFERENT key must not redeem (recovers to wrong address). - let other = - DeviceKey::load_or_generate(kd.path().join("other.key").to_str().unwrap(), true).unwrap(); - let bad = client - .post(format!("{}/v1/auth/link-code/redeem", broker_url)) + // A pop_sig from a DIFFERENT key cannot poll (recovers to wrong address). + let (_kd2, other) = device_key(); + let bad_sig = client + .post(format!("{}/v1/agent/pairing/poll", broker_url)) .json(&json!({ - "link_code": link_code, + "request_id": request_id, "device_pubkey": dk.address(), "pop_sig": other.pop_sig().unwrap(), })) .send() .await .unwrap(); - assert_eq!(bad.status(), reqwest::StatusCode::UNAUTHORIZED); + assert_eq!(bad_sig.status(), reqwest::StatusCode::UNAUTHORIZED); + + // A valid pop_sig but for a DIFFERENT device_pubkey (the other key's own, + // self-consistent) doesn't match the request's bound device → unauthorized + // (NotFound collapsed to 401 so a guessed request_id leaks nothing). + let wrong_device = client + .post(format!("{}/v1/agent/pairing/poll", broker_url)) + .json(&json!({ + "request_id": request_id, + "device_pubkey": other.address(), + "pop_sig": other.pop_sig().unwrap(), + })) + .send() + .await + .unwrap(); + assert_eq!(wrong_device.status(), reqwest::StatusCode::UNAUTHORIZED); - // The single-use code was NOT burned by the failed attempt — a correct - // pop_sig still redeems (pop_sig verified BEFORE consume). + // The correct device + pop_sig still retrieves J1_agent. let good = client - .post(format!("{}/v1/auth/link-code/redeem", broker_url)) + .post(format!("{}/v1/agent/pairing/poll", broker_url)) .json(&json!({ - "link_code": link_code, + "request_id": request_id, "device_pubkey": dk.address(), "pop_sig": dk.pop_sig().unwrap(), })) diff --git a/crates/agentkeys-broker-server/tests/auth_wallet_flow.rs b/crates/agentkeys-broker-server/tests/auth_wallet_flow.rs index ae3e6e1..ec27cc2 100644 --- a/crates/agentkeys-broker-server/tests/auth_wallet_flow.rs +++ b/crates/agentkeys-broker-server/tests/auth_wallet_flow.rs @@ -108,8 +108,8 @@ async fn spawn_broker_with_wallet_sig() -> (String, Arc) { nonce_store, grant_store: Arc::new(GrantStore::open_in_memory().unwrap()), identity_link_store: Arc::new(IdentityLinkStore::open_in_memory().unwrap()), - link_code_store: Arc::new( - agentkeys_broker_server::storage::LinkCodeStore::open_in_memory().unwrap(), + pairing_request_store: Arc::new( + agentkeys_broker_server::storage::PairingRequestStore::open_in_memory().unwrap(), ), metrics: Arc::new(agentkeys_broker_server::metrics::Metrics::new()), tier2: Arc::new(Tier2State::default()), diff --git a/crates/agentkeys-broker-server/tests/email_flow.rs b/crates/agentkeys-broker-server/tests/email_flow.rs index 568e5df..15e8890 100644 --- a/crates/agentkeys-broker-server/tests/email_flow.rs +++ b/crates/agentkeys-broker-server/tests/email_flow.rs @@ -125,8 +125,8 @@ async fn spawn_broker() -> (String, Arc, Arc) { nonce_store, grant_store: Arc::new(GrantStore::open_in_memory().unwrap()), identity_link_store: Arc::new(IdentityLinkStore::open_in_memory().unwrap()), - link_code_store: Arc::new( - agentkeys_broker_server::storage::LinkCodeStore::open_in_memory().unwrap(), + pairing_request_store: Arc::new( + agentkeys_broker_server::storage::PairingRequestStore::open_in_memory().unwrap(), ), metrics: Arc::new(agentkeys_broker_server::metrics::Metrics::new()), tier2: Arc::new(Tier2State::default()), diff --git a/crates/agentkeys-broker-server/tests/grant_flow.rs b/crates/agentkeys-broker-server/tests/grant_flow.rs index 84542d5..d21bb1c 100644 --- a/crates/agentkeys-broker-server/tests/grant_flow.rs +++ b/crates/agentkeys-broker-server/tests/grant_flow.rs @@ -107,8 +107,8 @@ async fn spawn_broker() -> Harness { nonce_store, grant_store: Arc::new(GrantStore::open_in_memory().unwrap()), identity_link_store: Arc::new(IdentityLinkStore::open_in_memory().unwrap()), - link_code_store: Arc::new( - agentkeys_broker_server::storage::LinkCodeStore::open_in_memory().unwrap(), + pairing_request_store: Arc::new( + agentkeys_broker_server::storage::PairingRequestStore::open_in_memory().unwrap(), ), metrics: Arc::new(agentkeys_broker_server::metrics::Metrics::new()), tier2: Arc::new(Tier2State::default()), diff --git a/crates/agentkeys-broker-server/tests/oauth2_flow.rs b/crates/agentkeys-broker-server/tests/oauth2_flow.rs index 1ab605f..b1e32e4 100644 --- a/crates/agentkeys-broker-server/tests/oauth2_flow.rs +++ b/crates/agentkeys-broker-server/tests/oauth2_flow.rs @@ -18,7 +18,6 @@ #![cfg(feature = "auth-oauth2-google")] use std::collections::HashMap; -use std::sync::atomic::Ordering; use std::sync::Arc; use agentkeys_broker_server::{ @@ -132,8 +131,8 @@ async fn spawn_broker() -> (String, Arc, Arc) { nonce_store, grant_store: Arc::new(GrantStore::open_in_memory().unwrap()), identity_link_store: Arc::new(IdentityLinkStore::open_in_memory().unwrap()), - link_code_store: Arc::new( - agentkeys_broker_server::storage::LinkCodeStore::open_in_memory().unwrap(), + pairing_request_store: Arc::new( + agentkeys_broker_server::storage::PairingRequestStore::open_in_memory().unwrap(), ), metrics: Arc::new(agentkeys_broker_server::metrics::Metrics::new()), tier2: Arc::new(Tier2State::default()), diff --git a/crates/agentkeys-broker-server/tests/oidc_flow.rs b/crates/agentkeys-broker-server/tests/oidc_flow.rs index 61e70d2..2988bb4 100644 --- a/crates/agentkeys-broker-server/tests/oidc_flow.rs +++ b/crates/agentkeys-broker-server/tests/oidc_flow.rs @@ -96,8 +96,8 @@ async fn spawn_broker() -> (String, Arc) { nonce_store, grant_store: Arc::new(GrantStore::open_in_memory().unwrap()), identity_link_store: Arc::new(IdentityLinkStore::open_in_memory().unwrap()), - link_code_store: Arc::new( - agentkeys_broker_server::storage::LinkCodeStore::open_in_memory().unwrap(), + pairing_request_store: Arc::new( + agentkeys_broker_server::storage::PairingRequestStore::open_in_memory().unwrap(), ), metrics: Arc::new(agentkeys_broker_server::metrics::Metrics::new()), tier2: std::sync::Arc::new(agentkeys_broker_server::state::Tier2State::default()), diff --git a/crates/agentkeys-broker-server/tests/wallet_flow.rs b/crates/agentkeys-broker-server/tests/wallet_flow.rs index 00bfabf..4cb8b3d 100644 --- a/crates/agentkeys-broker-server/tests/wallet_flow.rs +++ b/crates/agentkeys-broker-server/tests/wallet_flow.rs @@ -100,8 +100,8 @@ async fn spawn_broker() -> Harness { nonce_store, grant_store: Arc::new(GrantStore::open_in_memory().unwrap()), identity_link_store: Arc::new(IdentityLinkStore::open_in_memory().unwrap()), - link_code_store: Arc::new( - agentkeys_broker_server::storage::LinkCodeStore::open_in_memory().unwrap(), + pairing_request_store: Arc::new( + agentkeys_broker_server::storage::PairingRequestStore::open_in_memory().unwrap(), ), metrics: Arc::new(agentkeys_broker_server::metrics::Metrics::new()), tier2: Arc::new(Tier2State::default()), From a4d12f5e859c850ea0dc61cdcbbfb775ac36a5ed Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Mon, 1 Jun 2026 14:04:49 +0800 Subject: [PATCH 3/6] =?UTF-8?q?agentkeys:=20=C2=A710.2=20method-A=20pairin?= =?UTF-8?q?g=20=E2=80=94=20daemon=20+=20CLI=20+=20harness=20+=20broker-hos?= =?UTF-8?q?t=20smoke?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flip the client + wire harness to agent-initiated pairing (issue #144, method A), matching the broker request/claim/poll endpoints. Daemon (--init-link-code → two one-shots mirroring the two endpoints): - --request-pairing: in-sandbox K10 keygen → POST /v1/agent/pairing/request → print {request_id, pairing_code, …}; persist a 0600 state file so --retrieve-pairing can resolve request_id (--request-id overrides). - --retrieve-pairing: poll /v1/agent/pairing/poll until claimed (bounded by --init-poll-timeout-seconds), mint+persist J1_agent (0600), emit artifact. CLI: agent create → agent claim --pairing-code --label … --services … (POST /v1/agent/pairing/claim). agent pending unchanged (rows now keyed by request_id). Harness phase1-wire-demo.sh Phase P inverts: P.0 agent --request-pairing (shows code) → P.1 master agent claim → P.1b agent --retrieve-pairing (J1) → P.1c pending → P.2 bind + ack-by-request_id → P.3 grant. P.depair unchanged. 404 trap + route names updated. setup-broker-host.sh (runbook-fix-fold-back): nm symbol grep → pairing_{request,claim,poll}|pending_bindings; route smoke → no-bearer POST /v1/agent/pairing/claim must be 401 not 404. cli+daemon: clippy --all-targets -D warnings clean, fmt clean, tests pass (38/38 single-threaded; the 1 parallel-suite failure is a pre-existing k11 enroll test race on a shared HOME path, unrelated). bash -n both scripts OK. --- crates/agentkeys-cli/src/agent_admin.rs | 33 ++-- crates/agentkeys-cli/src/main.rs | 19 +- crates/agentkeys-daemon/src/main.rs | 240 ++++++++++++++++++++---- harness/phase1-wire-demo.sh | 232 ++++++++++++----------- scripts/setup-broker-host.sh | 38 ++-- 5 files changed, 384 insertions(+), 178 deletions(-) diff --git a/crates/agentkeys-cli/src/agent_admin.rs b/crates/agentkeys-cli/src/agent_admin.rs index aaeb0bd..5db1c44 100644 --- a/crates/agentkeys-cli/src/agent_admin.rs +++ b/crates/agentkeys-cli/src/agent_admin.rs @@ -1,6 +1,7 @@ -//! Master-side §10.2 agent admin (issue #144): mint link codes + pull pending -//! bindings. These are the master's half of the link-code ceremony — the agent -//! half lives in the daemon's `--init-link-code` one-shot. +//! Master-side §10.2 agent admin (issue #144, method A): claim agent-initiated +//! pairing requests + pull pending bindings. These are the master's half of the +//! agent-initiated ceremony — the agent half (request + retrieve) lives in the +//! daemon's `--request-pairing` / `--retrieve-pairing` one-shots. //! //! Both commands are gated by the master's `J1` session bearer. The on-chain //! binding and scope grant (the "bind" and "grant" steps the operator approves @@ -31,10 +32,13 @@ fn resolve_bearer(session_bearer: &str) -> Result { Ok(sess.token) } -/// `agentkeys agent create` — master mints a one-time link code bound to the -/// HDKD child omni for `label`, declaring the scope the agent should get. -pub async fn agent_create( +/// `agentkeys agent claim` — master claims an agent's pairing request by the +/// `pairing_code` the agent displayed, binding it under the HDKD child omni for +/// `label` and declaring the scope the agent should get. The agent never named +/// the master; this claim is the binding act (Sybil-safe). +pub async fn agent_claim( broker_url: &str, + pairing_code: &str, label: &str, services: &str, session_bearer: &str, @@ -42,25 +46,30 @@ pub async fn agent_create( let bearer = resolve_bearer(session_bearer)?; let base = broker_url.trim_end_matches('/'); let resp = client()? - .post(format!("{base}/v1/agent/create")) + .post(format!("{base}/v1/agent/pairing/claim")) .bearer_auth(bearer) - .json(&json!({ "label": label, "requested_scope": services })) + .json(&json!({ + "pairing_code": pairing_code, + "label": label, + "requested_scope": services, + })) .send() .await - .context("POST /v1/agent/create")?; + .context("POST /v1/agent/pairing/claim")?; let status = resp.status(); let text = resp.text().await.unwrap_or_default(); if !status.is_success() { - return Err(anyhow!("agent create failed: HTTP {status}: {text}")); + return Err(anyhow!("agent claim failed: HTTP {status}: {text}")); } let v: Value = serde_json::from_str(&text).with_context(|| format!("parse: {text}"))?; Ok(serde_json::to_string_pretty(&v)?) } -/// `agentkeys agent pending` — master pulls redeemed-but-unbound agents (the +/// `agentkeys agent pending` — master pulls claimed-but-unbound agents (the /// production push-notification substrate). Each row is "agent-X wants to pair, /// wants `[requested_scope]`", with the device artifact (`device_pubkey`, -/// `pop_sig`, `device_key_hash`) the master needs to submit `registerAgentDevice`. +/// `pop_sig`, `device_key_hash`) the master needs to submit `registerAgentDevice`, +/// keyed by `request_id`. pub async fn agent_pending(broker_url: &str, session_bearer: &str) -> Result { let bearer = resolve_bearer(session_bearer)?; let base = broker_url.trim_end_matches('/'); diff --git a/crates/agentkeys-cli/src/main.rs b/crates/agentkeys-cli/src/main.rs index 5e6fa52..a9d670b 100644 --- a/crates/agentkeys-cli/src/main.rs +++ b/crates/agentkeys-cli/src/main.rs @@ -688,11 +688,14 @@ enum AgentAction { #[arg(long, help = "Force a fresh device key → fresh pairing (new omni)")] regen: bool, }, - /// Master mints a one-time §10.2 link code bound to the HDKD child omni for - /// `--label`, declaring the scope the agent should get. Hand the code to the - /// agent; it redeems via `agentkeys-daemon --init-link-code`. (issue #144) - #[command(about = "Master: mint a one-time agent link code (HDKD child omni)")] - Create { + /// Master claims an agent's §10.2 pairing request by the `pairing_code` the + /// agent displayed, binding it under the HDKD child omni for `--label` and + /// declaring the scope the agent should get. The agent retrieves J1 via + /// `agentkeys-daemon --retrieve-pairing`. (issue #144, method A) + #[command(about = "Master: claim an agent pairing request by its code (HDKD child omni)")] + Claim { + #[arg(long, help = "The pairing_code the agent displayed (scan / enter)")] + pairing_code: String, #[arg(long, help = "HDKD child label, e.g. agent-a (^[a-z0-9-]{1,32}$)")] label: String, #[arg( @@ -1171,14 +1174,16 @@ async fn main() { ) .await } - AgentAction::Create { + AgentAction::Claim { + pairing_code, label, services, broker_url, session_bearer, } => { - agentkeys_cli::agent_admin::agent_create( + agentkeys_cli::agent_admin::agent_claim( broker_url, + pairing_code, label, services, session_bearer, diff --git a/crates/agentkeys-daemon/src/main.rs b/crates/agentkeys-daemon/src/main.rs index 9cd9be2..9ab22f7 100644 --- a/crates/agentkeys-daemon/src/main.rs +++ b/crates/agentkeys-daemon/src/main.rs @@ -170,16 +170,32 @@ struct Args { #[arg(long, default_value_t = 300)] init_poll_timeout_seconds: u64, - /// Issue #144 §10.2: redeem a one-time master-issued link code. Generates - /// (or reuses) the K10 device key IN THE SANDBOX, POSTs - /// `/v1/auth/link-code/redeem`, persists `J1_agent`, and prints the binding - /// artifact (device_pubkey + pop_sig + omnis) on stdout for the master to - /// submit `registerAgentDevice`. One-shot: redeems and exits (the MCP/proxy - /// surface runs as a separate process per §22c). Requires `--broker-url`. + /// Issue #144 §10.2 (method A): open an agent-INITIATED pairing request. + /// Generates (or reuses) the K10 device key IN THE SANDBOX, POSTs + /// `/v1/agent/pairing/request`, and prints `{request_id, pairing_code, …}` on + /// stdout. The agent DISPLAYS `pairing_code` (QR / screen) for its owner to + /// claim (the Matter/HomeKit model); `request_id` is the secret retrieval + /// ticket for `--retrieve-pairing`. One-shot: requests and exits. Requires + /// `--broker-url`. (The MCP/proxy surface runs as a separate process per §22c.) + #[arg(long, conflicts_with_all = ["init_email", "init_oauth2_google", "recover", "retrieve_pairing"])] + request_pairing: bool, + + /// Issue #144 §10.2 (method A): retrieve `J1_agent` after a master claims the + /// pairing request. Polls `/v1/agent/pairing/poll` (until claimed or + /// `--init-poll-timeout-seconds`), persists `J1_agent`, and prints the binding + /// artifact on stdout for the master's already-submitted `registerAgentDevice`. + /// Resolves `request_id` from `--request-id` or the state file written by + /// `--request-pairing`. One-shot. Requires `--broker-url`. #[arg(long, conflicts_with_all = ["init_email", "init_oauth2_google", "recover"])] - init_link_code: Option, + retrieve_pairing: bool, - /// Path to the agent's K10 device-key file for `--init-link-code`. Defaults + /// The `request_id` returned by `--request-pairing`, for `--retrieve-pairing`. + /// If omitted, read from the pairing state file + /// (`~/.agentkeys/pairing-request.json`). + #[arg(long)] + request_id: Option, + + /// Path to the agent's K10 device-key file for the pairing flow. Defaults /// to the same path as `agentkeys agent device-session` /// (`~/.agentkeys/agent-device.key`) so the CLI + daemon share one key. /// Reused on retry (never auto-regenerated) so a re-run after a failed master @@ -208,11 +224,16 @@ async fn main() -> anyhow::Result<()> { return run_proxy_mode(args).await; } - // Issue #144 §10.2 one-shot bootstrap: redeem a link code, persist J1_agent, - // emit the binding artifact, exit. Runs before the --backend requirement + - // hardening (it needs neither — only --broker-url + the OS RNG for keygen). - if let Some(code) = args.init_link_code.clone() { - return run_link_code_bootstrap(args, &code).await; + // Issue #144 §10.2 (method A) one-shot pairing. Two synchronous steps mirror + // the two broker endpoints: --request-pairing opens the request + prints the + // code; --retrieve-pairing polls until the master claims, then persists + // J1_agent + emits the binding artifact. Both run before the --backend + // requirement + hardening (they need neither — only --broker-url + OS RNG). + if args.request_pairing { + return run_request_pairing(args).await; + } + if args.retrieve_pairing { + return run_retrieve_pairing(args).await; } // 1. Apply kernel hardening @@ -440,19 +461,33 @@ async fn main() -> anyhow::Result<()> { Ok(()) } -/// Issue #144 §10.2 one-shot agent bootstrap: generate (or reuse) the K10 device -/// key in the sandbox, redeem the master-issued link code at the broker, persist -/// `J1_agent`, and print the binding artifact the master needs to submit -/// `registerAgentDevice`. The device key NEVER leaves this machine. +/// Poll cadence for `--retrieve-pairing` while waiting for the master to claim. +/// Internal timing constant (not operator-facing); the overall wait is bounded by +/// `--init-poll-timeout-seconds`. +const PAIRING_POLL_INTERVAL_SECONDS: u64 = 3; + +/// Default state file written by `--request-pairing` and read back by +/// `--retrieve-pairing` (so the two one-shot invocations don't have to thread +/// `request_id` by hand; `--request-id` overrides). 0600. +fn pairing_state_path() -> String { + let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string()); + format!("{home}/.agentkeys/pairing-request.json") +} + +/// `--request-pairing` (method A §10.2): generate (or reuse) the K10 device key +/// in the sandbox, open an agent-INITIATED pairing request at the broker, and +/// print `{request_id, pairing_code, …}` on stdout. The agent DISPLAYS +/// `pairing_code` for its owner to claim (the Matter/HomeKit model); the device +/// key NEVER leaves this machine. /// /// Logs go to stderr; the JSON artifact is the ONLY thing on stdout, so the wire -/// harness can capture it and hand it to the master's bind step (same shape -/// `scripts/heima-agent-create.sh --from-pubkey` consumes). -async fn run_link_code_bootstrap(args: Args, link_code: &str) -> anyhow::Result<()> { +/// harness can capture it. `request_id` is the secret retrieval ticket for the +/// follow-up `--retrieve-pairing`. +async fn run_request_pairing(args: Args) -> anyhow::Result<()> { use agentkeys_core::device_crypto::DeviceKey; let broker_url = args.broker_url.clone().ok_or_else(|| { - anyhow::anyhow!("--broker-url (or AGENTKEYS_BROKER_URL) required for --init-link-code") + anyhow::anyhow!("--broker-url (or AGENTKEYS_BROKER_URL) required for --request-pairing") })?; let base = broker_url.trim_end_matches('/').to_string(); @@ -460,8 +495,8 @@ async fn run_link_code_bootstrap(args: Args, link_code: &str) -> anyhow::Result< .device_key_file .clone() .unwrap_or_else(|| "~/.agentkeys/agent-device.key".to_string()); - // Reuse the existing key on retry (no regen): a failed master bind/grant - // must re-redeem with the SAME device_key_hash so the on-chain submit hits + // Reuse the existing key on retry (no regen): a failed master claim/bind + // must re-request with the SAME device_key_hash so the on-chain submit hits // the already-registered short-circuit instead of binding a second key. let dk = DeviceKey::load_or_generate(&key_file, false).context("load/generate K10 device key")?; @@ -474,26 +509,167 @@ async fn run_link_code_bootstrap(args: Args, link_code: &str) -> anyhow::Result< .build() .context("build http client")?; let resp = client - .post(format!("{base}/v1/auth/link-code/redeem")) + .post(format!("{base}/v1/agent/pairing/request")) .json(&serde_json::json!({ - "link_code": link_code, "device_pubkey": device_pubkey, "pop_sig": pop_sig, })) .send() .await - .context("POST /v1/auth/link-code/redeem")?; + .context("POST /v1/agent/pairing/request")?; let status = resp.status(); let text = resp.text().await.unwrap_or_default(); if !status.is_success() { - anyhow::bail!("link-code redeem failed: HTTP {status}: {text}"); + anyhow::bail!("pairing request failed: HTTP {status}: {text}"); } let body: serde_json::Value = - serde_json::from_str(&text).with_context(|| format!("parse redeem response: {text}"))?; + serde_json::from_str(&text).with_context(|| format!("parse request response: {text}"))?; + let request_id = body + .get("request_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("request response missing request_id: {text}"))?; + let pairing_code = body + .get("pairing_code") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("request response missing pairing_code: {text}"))?; + let expires_at = body.get("expires_at").and_then(|v| v.as_i64()).unwrap_or(0); + + // Persist the request state (0600) so `--retrieve-pairing` can resolve + // request_id without the caller threading it (--request-id overrides). + let state_file = pairing_state_path(); + if let Some(parent) = std::path::Path::new(&state_file).parent() { + std::fs::create_dir_all(parent).ok(); + } + let state_json = serde_json::json!({ + "request_id": request_id, + "pairing_code": pairing_code, + "device_pubkey": device_pubkey, + "expires_at": expires_at, + }) + .to_string(); + agentkeys_core::device_crypto::write_key_0600(&state_file, &state_json) + .context("persist pairing request state (0600)")?; + + // Human-facing prompt on stderr (logs stream): show the code to the owner. + info!( + target: "agentkeys.daemon.init", + device = %device_pubkey, + "agentkeys-daemon opened §10.2 pairing request — show this code to your owner to claim: {pairing_code}" + ); + + // Machine artifact on STDOUT (logs are on stderr). The harness/owner reads + // pairing_code to claim, request_id to retrieve. + println!( + "{}", + serde_json::json!({ + "request_id": request_id, + "pairing_code": pairing_code, + "agent_address": device_pubkey, + "device_key_hash": device_key_hash, + "expires_at": expires_at, + "state_file": state_file, + "key_file": key_file, + }) + ); + Ok(()) +} + +/// `--retrieve-pairing` (method A §10.2): after the master claims the pairing +/// request, poll the broker until `J1_agent` is available, persist it, and print +/// the binding artifact the master's already-submitted `registerAgentDevice` +/// consumes. The device key NEVER leaves this machine. +/// +/// Resolves `request_id` from `--request-id` or the state file written by +/// `--request-pairing`. Polls every `PAIRING_POLL_INTERVAL_SECONDS` until claimed +/// or `--init-poll-timeout-seconds`. +async fn run_retrieve_pairing(args: Args) -> anyhow::Result<()> { + use agentkeys_core::device_crypto::DeviceKey; + + let broker_url = args.broker_url.clone().ok_or_else(|| { + anyhow::anyhow!("--broker-url (or AGENTKEYS_BROKER_URL) required for --retrieve-pairing") + })?; + let base = broker_url.trim_end_matches('/').to_string(); + + // request_id: explicit flag wins; else read the state file from --request-pairing. + let request_id = match args.request_id.clone() { + Some(id) => id, + None => { + let state_file = pairing_state_path(); + let raw = std::fs::read_to_string(&state_file).with_context(|| { + format!("read pairing state file {state_file} (pass --request-id to override)") + })?; + let v: serde_json::Value = serde_json::from_str(&raw) + .with_context(|| format!("parse pairing state file {state_file}"))?; + v.get("request_id") + .and_then(|x| x.as_str()) + .map(String::from) + .ok_or_else(|| { + anyhow::anyhow!("pairing state file {state_file} missing request_id") + })? + } + }; + + let key_file = args + .device_key_file + .clone() + .unwrap_or_else(|| "~/.agentkeys/agent-device.key".to_string()); + // Same key as --request-pairing (never regenerate — the broker bound the + // request to this exact device_pubkey, and poll re-proves possession of it). + let dk = + DeviceKey::load_or_generate(&key_file, false).context("load/generate K10 device key")?; + let device_pubkey = dk.address().to_string(); + let device_key_hash = dk.device_key_hash().context("device_key_hash")?; + let pop_sig = dk.pop_sig().context("pop_sig")?; + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(20)) + .build() + .context("build http client")?; + + // Poll until claimed or the operator-set timeout elapses. + let deadline = SystemTime::now() + Duration::from_secs(args.init_poll_timeout_seconds); + let body: serde_json::Value = loop { + let resp = client + .post(format!("{base}/v1/agent/pairing/poll")) + .json(&serde_json::json!({ + "request_id": request_id, + "device_pubkey": device_pubkey, + "pop_sig": pop_sig, + })) + .send() + .await + .context("POST /v1/agent/pairing/poll")?; + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + if !status.is_success() { + anyhow::bail!("pairing poll failed: HTTP {status}: {text}"); + } + let body: serde_json::Value = + serde_json::from_str(&text).with_context(|| format!("parse poll response: {text}"))?; + let pstatus = body + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + if pstatus == "claimed" { + break body; + } + if SystemTime::now() >= deadline { + anyhow::bail!( + "pairing not claimed within {}s — the master has not run `agentkeys agent claim --pairing-code ` yet", + args.init_poll_timeout_seconds + ); + } + info!( + target: "agentkeys.daemon.init", + "§10.2 pairing request still pending — waiting for the master to claim…" + ); + tokio::time::sleep(Duration::from_secs(PAIRING_POLL_INTERVAL_SECONDS)).await; + }; + let session_jwt = body .get("session_jwt") .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("redeem response missing session_jwt: {text}"))?; + .ok_or_else(|| anyhow::anyhow!("claimed poll response missing session_jwt: {body}"))?; let child_omni = body .get("child_omni") .and_then(|v| v.as_str()) @@ -524,7 +700,7 @@ async fn run_link_code_bootstrap(args: Args, link_code: &str) -> anyhow::Result< .session_id .clone() .unwrap_or_else(|| format!("daemon-{child_omni}")); - session_store::save_session(&sess, &sid).context("save link-code session")?; + session_store::save_session(&sess, &sid).context("save pairing session")?; // Finding 2 (adversarial review): keep the bearer IN the sandbox. Write the // session JWT to an owner-only (0600) file that the in-sandbox MCP server reads @@ -546,7 +722,7 @@ async fn run_link_code_bootstrap(args: Args, link_code: &str) -> anyhow::Result< operator_omni = %operator_omni, device = %device_pubkey, session_id = %sid, - "agentkeys-daemon redeemed §10.2 link code — J1_agent persisted" + "agentkeys-daemon retrieved §10.2 pairing — J1_agent persisted" ); // Binding artifact on STDOUT (logs are on stderr). Same fields the master's @@ -562,7 +738,7 @@ async fn run_link_code_bootstrap(args: Args, link_code: &str) -> anyhow::Result< "device_key_hash": device_key_hash, "pop_sig": pop_sig, "session_file": session_file, - "link_code": link_code, + "request_id": request_id, "key_file": key_file, }) ); diff --git a/harness/phase1-wire-demo.sh b/harness/phase1-wire-demo.sh index 6fac1cc..6271e10 100755 --- a/harness/phase1-wire-demo.sh +++ b/harness/phase1-wire-demo.sh @@ -235,8 +235,9 @@ resolve_sbx_paths() { [[ -n "$SBX_HOME" ]] || { fail "1.0 sandbox home" "could not resolve \$HOME in the sandbox"; return 1; } AGENT_BIN_DST="$SBX_HOME/.local/bin/agentkeys" MCP_BIN_DST="$SBX_HOME/.local/bin/agentkeys-mcp-server" - # §10.2 agent bootstrap (issue #144): the daemon's --init-link-code one-shot - # generates K10 + redeems the master link code IN THE SANDBOX (Phase P.1). + # §10.2 agent bootstrap (issue #144, method A): the daemon's --request-pairing / + # --retrieve-pairing one-shots generate K10 + open + retrieve the pairing IN THE + # SANDBOX (Phases P.0 + P.1b). DAEMON_BIN_DST="$SBX_HOME/.local/bin/agentkeys-daemon" sbx_exec "mkdir -p \"$SBX_HOME/.local/bin\"" >/dev/null } @@ -485,21 +486,24 @@ phase1_sandbox() { done # ─── Phase P — install (pair) ──────────────────────────────────────────────── - # Fresh §10.2 HDKD pairing (issue #144) — the "install an app + approve its - # permissions" story, four steps: - # P.0 create — the MASTER mints a one-time link code bound to the HDKD child - # omni O_agent = SHA256(.. || O_master || "//label") (real - # broker /v1/agent/create; replaces the old openssl stub). - # P.1 install — the AGENT generates its OWN K10 device key IN THE SANDBOX (the - # daemon's --init-link-code one-shot; key never on the master), - # proves possession, redeems the code → J1_agent + binding artifact. - # P.1b pending— the MASTER pulls the rendezvous (`agentkeys agent pending`) and + # Fresh §10.2 HDKD pairing (issue #144, method A — agent-INITIATED, the Matter/ + # HomeKit IoT model: the device shows a code, the owner claims it). Steps: + # P.0 request — the AGENT generates its OWN K10 device key IN THE SANDBOX and + # opens an UNBOUND pairing request (daemon --request-pairing; key + # never on the master), displaying a one-time pairing_code. + # P.1 claim — the MASTER claims that code (`agentkeys agent claim`), binding + # the agent under the HDKD child omni + # O_agent = SHA256(.. || O_master || "//label") and declaring scope. + # P.1b retrieve— the AGENT polls + retrieves J1_agent IN THE SANDBOX (daemon + # --retrieve-pairing; re-proves K10 possession). + # P.1c pending— the MASTER pulls the rendezvous (`agentkeys agent pending`) and # sees this agent awaiting approval. # P.2 bind — the MASTER submits registerAgentDevice (no biometric) + acks the - # broker (clears the binding from pending). + # broker by request_id (clears the binding from pending). # P.3 grant — the MASTER grants the requested scope (one Touch ID). # P.2+P.3 are ONE product approval conceptually; kept as two steps so the test # drives + verifies each deterministically. Skipped under --reuse-agent + --light. + # (Agent-side unbind / factory-reset re-pair is deferred → #156.) # # GENUINE FRESH PAIRING: AGENT_LABEL (default `demo-agent`) is stable and the # child omni is a deterministic HDKD of (O_master, label). The persisted sandbox @@ -543,110 +547,122 @@ phase1_sandbox() { fi ok "P.depair" "clean slate — prior device revoked + sandbox K10 wiped (confirmed); P.1 mints a fresh key" if [[ -z "$SESSION_BEARER" ]]; then - fail "P.0 create" "no operator session bearer (0.7) — cannot mint a link code" + fail "P.1 claim" "no operator session bearer (0.7) — the master cannot claim the agent's pairing request" else - # P.0 master mints a real one-time link code bound to the child omni via the - # `agentkeys agent create` CLI (the operator command we ship). Resolve a LOCAL - # host binary (same release→debug→PATH order as the chain helpers); fall back - # to a raw broker POST only if no local binary exists, so a --real run without - # a host build still works. - local cr link_code child_omni la + # P.0 the AGENT opens an UNBOUND pairing request IN THE SANDBOX (daemon + # --request-pairing; the K10 is minted here, never on the master) and + # displays a one-time pairing_code. 2>/dev/null drops the daemon's stderr + # logs so stdout is clean artifact JSON for jq. Resolve a LOCAL host binary + # (release→debug→PATH order) for the MASTER's claim step below. + local la rq pairing_code request_id rq_addr rq_dkh if [[ -x "$REPO_ROOT/target/release/agentkeys" ]]; then la="$REPO_ROOT/target/release/agentkeys" elif [[ -x "$REPO_ROOT/target/debug/agentkeys" ]]; then la="$REPO_ROOT/target/debug/agentkeys" else la="$(command -v agentkeys 2>/dev/null || true)"; fi - if [[ -n "$la" ]]; then - cr="$("$la" agent create --label "$AGENT_LABEL" --services "${SEED_SCOPE_SERVICES:-memory}" \ - --broker-url "${BROKER_URL%/}" --session-bearer "$SESSION_BEARER" 2>&1)" - else - log " P.0 create: no local agentkeys binary — raw POST fallback (build the host CLI to exercise it: cargo build --release -p agentkeys-cli)" - cr="$(curl -sS --max-time 30 -X POST "${BROKER_URL%/}/v1/agent/create" \ - -H "authorization: Bearer $SESSION_BEARER" -H 'content-type: application/json' \ - -d "$(jq -n --arg label "$AGENT_LABEL" --arg scope "${SEED_SCOPE_SERVICES:-memory}" '{label:$label, requested_scope:$scope}')" 2>&1)" - fi - link_code="$(echo "$cr" | jq -r '.link_code // empty' 2>/dev/null)" - child_omni="$(echo "$cr" | jq -r '.child_omni // empty' 2>/dev/null)" - if [[ -z "$link_code" || -z "$child_omni" ]]; then - if echo "$cr" | grep -q '404'; then - # The #1 P.0 trap: the DEPLOYED broker predates #144, so the §10.2 - # routes (/v1/agent/create, /v1/auth/link-code/redeem, - # /v1/agent/pending-bindings) 404. The harness code is fine — the - # broker host just needs the #144/#149 binary. Name the fix loudly. - fail "P.0 create" "broker has NO §10.2 routes (HTTP 404) — the DEPLOYED broker predates #144. Redeploy it with the #144/#149 code FIRST: reach the broker with 'bash scripts/ssh-broker.sh', then on the host run 'sudo bash scripts/setup-broker-host.sh --ref claude/impl-144-hdkd-bootstrap'. Verify before re-running: a no-bearer 'POST ${BROKER_URL%/}/v1/agent/create' must return 401, not 404. (1.4 MCP + 1.5 seed below are cascades of this — they clear once P.0 works.)" + rq="$(sbx_exec "$DAEMON_BIN_DST --request-pairing --broker-url ${BROKER_URL:-} 2>/dev/null")" + pairing_code="$(echo "$rq" | jq -r '.pairing_code // empty' 2>/dev/null)" + request_id="$(echo "$rq" | jq -r '.request_id // empty' 2>/dev/null)" + rq_addr="$(echo "$rq" | jq -r '.agent_address // empty' 2>/dev/null)" + rq_dkh="$(echo "$rq" | jq -r '.device_key_hash // empty' 2>/dev/null)" + if [[ -z "$pairing_code" || -z "$request_id" || -z "$rq_addr" || -z "$rq_dkh" ]]; then + if echo "$rq" | grep -q '404'; then + # The #1 P.0 trap: the DEPLOYED broker predates this PR, so the §10.2 + # method-A routes (/v1/agent/pairing/{request,claim,poll}) 404. The + # harness code is fine — the broker host just needs the method-A binary. + fail "P.0 request" "broker has NO §10.2 method-A routes (HTTP 404) — the DEPLOYED broker predates this PR. Redeploy it with the method-A binary FIRST: reach the broker with 'bash scripts/ssh-broker.sh', then on the host run 'sudo bash scripts/setup-broker-host.sh' (it pulls origin/). Verify before re-running: a no-bearer 'POST ${BROKER_URL%/}/v1/agent/pairing/claim' must return 401, not 404. (1.4 MCP + 1.5 seed below are cascades of this — they clear once P.0 works.)" else - fail "P.0 create" "agent/create returned no link code: $(echo "$cr" | tr '\n' ' ' | cut -c1-200)" + fail "P.0 request" "in-sandbox daemon --request-pairing returned no pairing_code: $(echo "$rq" | tr '\n' ' ' | cut -c1-200)" fi + elif [[ -n "$prior_dkh" && "$rq_dkh" == "$prior_dkh" ]]; then + fail "P.0 request" "fresh pairing produced the SAME device_key_hash as the prior run ($rq_dkh) — the P.depair K10 wipe did not take; this is NOT a genuine re-pair" else - ok "P.0 create" "📇 master minted link code → child omni ${child_omni:0:14}… (label $AGENT_LABEL)" - # P.1 agent generates K10 + redeems IN THE SANDBOX (daemon one-shot; key - # never on the master). 2>/dev/null drops the daemon's stderr logs so - # stdout is clean artifact JSON for jq. - local ds ds_addr ds_actor ds_dkh ds_pop ds_session_file - ds="$(sbx_exec "$DAEMON_BIN_DST --init-link-code $link_code --broker-url ${BROKER_URL:-} 2>/dev/null")" - ds_addr="$(echo "$ds" | jq -r '.agent_address // empty' 2>/dev/null)" - ds_actor="$(echo "$ds" | jq -r '.actor_omni // empty' 2>/dev/null)" - ds_dkh="$(echo "$ds" | jq -r '.device_key_hash // empty' 2>/dev/null)" - ds_pop="$(echo "$ds" | jq -r '.pop_sig // empty' 2>/dev/null)" - ds_session_file="$(echo "$ds" | jq -r '.session_file // empty' 2>/dev/null)" - if [[ -z "$ds_session_file" || -z "$ds_actor" || -z "$ds_addr" || -z "$ds_dkh" || -z "$ds_pop" ]]; then - fail "P.1 install" "in-sandbox daemon redeem failed: $(echo "$ds" | tr '\n' ' ' | cut -c1-200)" - elif [[ "$ds_actor" != "$child_omni" ]]; then - fail "P.1 install" "redeemed omni ${ds_actor:0:14}… != created child ${child_omni:0:14}… (broker/derivation mismatch)" - elif [[ -n "$prior_dkh" && "$ds_dkh" == "$prior_dkh" ]]; then - fail "P.1 install" "fresh pairing produced the SAME device_key_hash as the prior run ($ds_dkh) — the P.depair K10 wipe did not take; this is NOT a genuine re-pair" + ok "P.0 request" "📲 agent opened pairing request in-sandbox — code ${pairing_code:0:8}…, addr ${rq_addr:0:12}… (K10 SANDBOX-only)" + # P.1 the MASTER claims the displayed code (`agentkeys agent claim` — the + # operator command we ship), binding the agent under the HDKD child omni + + # declaring scope. Raw POST fallback so a --real run without a host build works. + local cl child_omni + if [[ -n "$la" ]]; then + cl="$("$la" agent claim --pairing-code "$pairing_code" --label "$AGENT_LABEL" --services "${SEED_SCOPE_SERVICES:-memory}" \ + --broker-url "${BROKER_URL%/}" --session-bearer "$SESSION_BEARER" 2>&1)" + else + log " P.1 claim: no local agentkeys binary — raw POST fallback (build the host CLI to exercise it: cargo build --release -p agentkeys-cli)" + cl="$(curl -sS --max-time 30 -X POST "${BROKER_URL%/}/v1/agent/pairing/claim" \ + -H "authorization: Bearer $SESSION_BEARER" -H 'content-type: application/json' \ + -d "$(jq -n --arg code "$pairing_code" --arg label "$AGENT_LABEL" --arg scope "${SEED_SCOPE_SERVICES:-memory}" '{pairing_code:$code, label:$label, requested_scope:$scope}')" 2>&1)" + fi + child_omni="$(echo "$cl" | jq -r '.child_omni // empty' 2>/dev/null)" + if [[ -z "$child_omni" ]]; then + fail "P.1 claim" "agent claim returned no child omni: $(echo "$cl" | tr '\n' ' ' | cut -c1-200)" else - # cap-mint (validate_hex32) requires 0x-prefixed omnis, like OPERATOR_OMNI. - # The §10.2 child omni is un-prefixed by design, so normalize to exactly - # one 0x for --default-actor (else memory.put → cap_mint 400 "actor_omni - # must start with 0x"). ds_actor stays un-prefixed for the chain helpers. - ACTOR_OMNI="0x${ds_actor#0x}"; AGENT_SESSION_FILE="$ds_session_file"; DEVICE_KEY_HASH="$ds_dkh" - # Record the (public) device hash in a sandbox sidecar so the NEXT fresh - # run can depair THIS exact device (P.depair). It's the on-chain id, not - # the key — custody is unaffected. - sbx_exec "printf '%s' '$ds_dkh' > ~/.agentkeys/agent-device.hash" >/dev/null 2>&1 || true - ok "P.1 install" "📲 agent redeemed in-sandbox — addr ${ds_addr:0:12}…, omni ${ds_actor:0:14}… (K10 SANDBOX-only, J1_agent minted)" - # P.1b master pulls the pending binding via `agentkeys agent pending` (the - # rendezvous we built) and confirms THIS agent is awaiting approval. - # Read-only + idempotent; best-effort (needs the local CLI). - if [[ -n "$la" ]]; then - local pend; pend="$("$la" agent pending --broker-url "${BROKER_URL%/}" --session-bearer "$SESSION_BEARER" 2>&1)" - if echo "$pend" | jq -e --arg c "$ds_actor" '.pending[]? | select(.child_omni==$c)' >/dev/null 2>&1; then - ok "P.1b pending" "🔔 master sees agent ${ds_actor:0:14}… awaiting approval (agent pending)" + ok "P.1 claim" "📇 master claimed code → child omni ${child_omni:0:14}… (label $AGENT_LABEL)" + # P.1b the AGENT retrieves J1_agent IN THE SANDBOX (daemon --retrieve-pairing; + # re-proves K10 possession, polls until the master's claim lands). 2>/dev/null + # drops stderr logs so stdout is clean artifact JSON for jq. + local ds ds_addr ds_actor ds_dkh ds_pop ds_session_file + ds="$(sbx_exec "$DAEMON_BIN_DST --retrieve-pairing --request-id $request_id --broker-url ${BROKER_URL:-} 2>/dev/null")" + ds_addr="$(echo "$ds" | jq -r '.agent_address // empty' 2>/dev/null)" + ds_actor="$(echo "$ds" | jq -r '.actor_omni // empty' 2>/dev/null)" + ds_dkh="$(echo "$ds" | jq -r '.device_key_hash // empty' 2>/dev/null)" + ds_pop="$(echo "$ds" | jq -r '.pop_sig // empty' 2>/dev/null)" + ds_session_file="$(echo "$ds" | jq -r '.session_file // empty' 2>/dev/null)" + if [[ -z "$ds_session_file" || -z "$ds_actor" || -z "$ds_addr" || -z "$ds_dkh" || -z "$ds_pop" ]]; then + fail "P.1b retrieve" "in-sandbox daemon --retrieve-pairing failed: $(echo "$ds" | tr '\n' ' ' | cut -c1-200)" + elif [[ "$ds_actor" != "$child_omni" ]]; then + fail "P.1b retrieve" "retrieved omni ${ds_actor:0:14}… != claimed child ${child_omni:0:14}… (broker/derivation mismatch)" + else + # cap-mint (validate_hex32) requires 0x-prefixed omnis, like OPERATOR_OMNI. + # The §10.2 child omni is un-prefixed by design, so normalize to exactly + # one 0x for --default-actor (else memory.put → cap_mint 400 "actor_omni + # must start with 0x"). ds_actor stays un-prefixed for the chain helpers. + ACTOR_OMNI="0x${ds_actor#0x}"; AGENT_SESSION_FILE="$ds_session_file"; DEVICE_KEY_HASH="$ds_dkh" + # Record the (public) device hash in a sandbox sidecar so the NEXT fresh + # run can depair THIS exact device (P.depair). It's the on-chain id, not + # the key — custody is unaffected. + sbx_exec "printf '%s' '$ds_dkh' > ~/.agentkeys/agent-device.hash" >/dev/null 2>&1 || true + ok "P.1b retrieve" "📲 agent retrieved J1_agent in-sandbox — addr ${ds_addr:0:12}…, omni ${ds_actor:0:14}… (J1_agent minted at retrieval)" + # P.1c master pulls the pending binding via `agentkeys agent pending` (the + # rendezvous we built) and confirms THIS agent is awaiting approval. + # Read-only + idempotent; best-effort (needs the local CLI). + if [[ -n "$la" ]]; then + local pend; pend="$("$la" agent pending --broker-url "${BROKER_URL%/}" --session-bearer "$SESSION_BEARER" 2>&1)" + if echo "$pend" | jq -e --arg c "$ds_actor" '.pending[]? | select(.child_omni==$c)' >/dev/null 2>&1; then + ok "P.1c pending" "🔔 master sees agent ${ds_actor:0:14}… awaiting approval (agent pending)" + else + skip "P.1c pending" "agent pending did not list ${ds_actor:0:14}… (non-fatal): $(echo "$pend" | tr '\n' ' ' | cut -c1-120)" + fi + fi + # P.2 master binds the SANDBOX-generated device on-chain (it never saw the key). + local reg; reg="$(bash "$REPO_ROOT/scripts/heima-agent-create.sh" --label "$AGENT_LABEL" \ + --agent-address "$ds_addr" --actor-omni "$ds_actor" --device-key-hash "$ds_dkh" --pop-sig "$ds_pop" 2>&1)" + echo "$reg" | sed 's/^/ /' >&2 + # heima-agent-create.sh prints stderr logs + a final JSON line; $reg is + # BOTH (2>&1), so extract the JSON line before jq — jq on the mixed text + # silently fails and would false-FAIL a real registration. The JSON + # discriminates a real tx_hash from the already-registered skip. + local reg_json; reg_json="$(echo "$reg" | grep -oE '\{.*\}' | tail -1)" + if echo "$reg_json" | jq -e '.skipped=="already-registered"' >/dev/null 2>&1; then + fail "P.2 bind" "registerAgentDevice was SKIPPED (already-registered) — P.depair/P.0 did NOT yield a fresh device, so the pairing path was NOT exercised (check P.depair revoke + sandbox wipe): $(echo "$reg" | tr '\n' ' ' | cut -c1-160)" + elif echo "$reg_json" | jq -e '.ok==true and ((.tx_hash // "") != "")' >/dev/null 2>&1; then + ok "P.2 bind" "on-chain registerAgentDevice — REAL tx $(echo "$reg_json" | jq -r '.tx_hash' 2>/dev/null | cut -c1-18)… (fresh K10 from P.depair)" + # Ack the rendezvous BY request_id: tell the broker the master bound this + # device so it drops out of pending-bindings (self-cleaning → idempotent). + curl -sS --max-time 15 -X POST "${BROKER_URL%/}/v1/agent/pending-bindings/ack" \ + -H "authorization: Bearer $SESSION_BEARER" -H 'content-type: application/json' \ + -d "$(jq -n --arg rid "$request_id" '{request_id:$rid}')" >/dev/null 2>&1 \ + && log " P.2 ack: binding acked — cleared from pending (idempotent)" || true else - skip "P.1b pending" "agent pending did not list ${ds_actor:0:14}… (non-fatal): $(echo "$pend" | tr '\n' ' ' | cut -c1-120)" + fail "P.2 bind" "registerAgentDevice failed: $(echo "$reg" | tr '\n' ' ' | cut -c1-160)" + fi + # P.3 master grants the requested scope (one Touch ID). + if [[ "$WEBAUTHN" == true ]]; then + log " P.3 grant: heima-scope-set --webauthn --agent $AGENT_LABEL --services ${SEED_SCOPE_SERVICES:-memory} (expect Touch ID)" + local grant; grant="$(bash "$REPO_ROOT/scripts/heima-scope-set.sh" --webauthn --agent "$AGENT_LABEL" --services "${SEED_SCOPE_SERVICES:-memory}" 2>&1)" + echo "$grant" | sed 's/^/ /' >&2 + echo "$grant" | grep -qiE '"ok"[[:space:]]*:[[:space:]]*true' \ + && ok "P.3 grant" "🔐 master granted [${SEED_SCOPE_SERVICES:-memory}] to ${ds_actor:0:14}… (Touch ID)" \ + || fail "P.3 grant" "scope grant failed: $(echo "$grant" | tr '\n' ' ' | cut -c1-160)" + else + skip "P.3 grant" "no --webauthn — re-run with --real --webauthn so the master can grant the fresh actor's scope (Touch ID)" fi - fi - # P.2 master binds the SANDBOX-generated device on-chain (it never saw the key). - local reg; reg="$(bash "$REPO_ROOT/scripts/heima-agent-create.sh" --label "$AGENT_LABEL" \ - --agent-address "$ds_addr" --actor-omni "$ds_actor" --device-key-hash "$ds_dkh" --pop-sig "$ds_pop" 2>&1)" - echo "$reg" | sed 's/^/ /' >&2 - # heima-agent-create.sh prints stderr logs + a final JSON line; $reg is - # BOTH (2>&1), so extract the JSON line before jq — jq on the mixed text - # silently fails and would false-FAIL a real registration. The JSON - # discriminates a real tx_hash from the already-registered skip. - local reg_json; reg_json="$(echo "$reg" | grep -oE '\{.*\}' | tail -1)" - if echo "$reg_json" | jq -e '.skipped=="already-registered"' >/dev/null 2>&1; then - fail "P.2 bind" "registerAgentDevice was SKIPPED (already-registered) — P.depair/P.1 did NOT yield a fresh device, so the pairing path was NOT exercised (check P.depair revoke + sandbox wipe): $(echo "$reg" | tr '\n' ' ' | cut -c1-160)" - elif echo "$reg_json" | jq -e '.ok==true and ((.tx_hash // "") != "")' >/dev/null 2>&1; then - ok "P.2 bind" "on-chain registerAgentDevice — REAL tx $(echo "$reg_json" | jq -r '.tx_hash' 2>/dev/null | cut -c1-18)… (fresh K10 from P.depair)" - # Ack the rendezvous: tell the broker the master bound this device so it - # drops out of pending-bindings (self-cleaning → idempotent re-runs). - curl -sS --max-time 15 -X POST "${BROKER_URL%/}/v1/agent/pending-bindings/ack" \ - -H "authorization: Bearer $SESSION_BEARER" -H 'content-type: application/json' \ - -d "$(jq -n --arg lc "$link_code" '{link_code:$lc}')" >/dev/null 2>&1 \ - && log " P.2 ack: binding acked — cleared from pending (idempotent)" || true - else - fail "P.2 bind" "registerAgentDevice failed: $(echo "$reg" | tr '\n' ' ' | cut -c1-160)" - fi - # P.3 master grants the requested scope (one Touch ID). - if [[ "$WEBAUTHN" == true ]]; then - log " P.3 grant: heima-scope-set --webauthn --agent $AGENT_LABEL --services ${SEED_SCOPE_SERVICES:-memory} (expect Touch ID)" - local grant; grant="$(bash "$REPO_ROOT/scripts/heima-scope-set.sh" --webauthn --agent "$AGENT_LABEL" --services "${SEED_SCOPE_SERVICES:-memory}" 2>&1)" - echo "$grant" | sed 's/^/ /' >&2 - echo "$grant" | grep -qiE '"ok"[[:space:]]*:[[:space:]]*true' \ - && ok "P.3 grant" "🔐 master granted [${SEED_SCOPE_SERVICES:-memory}] to ${ds_actor:0:14}… (Touch ID)" \ - || fail "P.3 grant" "scope grant failed: $(echo "$grant" | tr '\n' ' ' | cut -c1-160)" - else - skip "P.3 grant" "no --webauthn — re-run with --real --webauthn so the master can grant the fresh actor's scope (Touch ID)" fi fi fi @@ -882,8 +898,8 @@ DOCKERFILE build_linux_binaries() { local agent_bin="$LINUX_TARGET_DIR/release/agentkeys" local mcp_bin="$LINUX_TARGET_DIR/release/agentkeys-mcp-server" - # §10.2 agent bootstrap (issue #144): the daemon does the in-sandbox - # keygen + link-code redeem (Phase P.1). + # §10.2 agent bootstrap (issue #144, method A): the daemon does the in-sandbox + # keygen + pairing request/retrieve (Phases P.0 + P.1b). local daemon_bin="$LINUX_TARGET_DIR/release/agentkeys-daemon" # Idempotent + source-aware: skip when ALL binaries exist and no tracked # source is newer; otherwise (re)build incrementally (caches persist). diff --git a/scripts/setup-broker-host.sh b/scripts/setup-broker-host.sh index 1f1323f..0c0b8b5 100755 --- a/scripts/setup-broker-host.sh +++ b/scripts/setup-broker-host.sh @@ -648,17 +648,17 @@ if command -v nm >/dev/null 2>&1; then warn "nm sees 0 email-link symbols, but cargo claims the feature is on." warn "Continuing — the post-restart /healthz probe will catch any real boot failure." fi - # Issue #144: confirm the §10.2 agent-bootstrap surface is linked (link-code - # endpoints + store). WARN-only; the post-restart route smoke below is the - # authoritative runtime gate. + # Issue #144 (method A): confirm the §10.2 agent-initiated pairing surface is + # linked (pairing request/claim/poll handlers + store). WARN-only; the post- + # restart route smoke below is the authoritative runtime gate. agent_symbols=$(nm "$REPO_ROOT/target/release/agentkeys-broker-server" 2>/dev/null \ - | grep -cE "link_code_redeem|agent_create|pending_bindings|link_codes" \ + | grep -cE "pairing_request|pairing_claim|pairing_poll|pending_bindings" \ || true) if (( agent_symbols > 0 )); then - log " nm sees $agent_symbols §10.2 agent-bootstrap symbol(s) — issue #144 code is linked in" + log " nm sees $agent_symbols §10.2 agent-pairing symbol(s) — issue #144 (method A) code is linked in" else - warn "nm sees 0 §10.2 agent-bootstrap symbols (issue #144). The binary may predate #144." - warn "Continuing — the post-restart /v1/agent/create route smoke will catch a stale binary." + warn "nm sees 0 §10.2 agent-pairing symbols (issue #144 method A). The binary may predate this PR." + warn "Continuing — the post-restart /v1/agent/pairing/claim route smoke will catch a stale binary." fi else log " (nm not installed — skipping symbol-table sanity check)" @@ -1562,20 +1562,20 @@ probe_or_die() { } probe_or_die broker 8091 agentkeys-broker -# Issue #144 — §10.2 agent-bootstrap route smoke. A no-bearer POST to -# /v1/agent/create MUST return 401 (route registered, rejects unauth), NOT 404 -# (a stale binary predating #144 has no such route). Deterministic — no operator -# session / keygen needed — so it can't flake; it's the authoritative "is the -# #144 code actually serving on this host" gate that the nm check above hints at. -agent_create_code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 \ - -X POST -H 'content-type: application/json' -d '{"label":"smoke"}' \ - "http://127.0.0.1:8091/v1/agent/create" 2>/dev/null || echo 000)" -case "$agent_create_code" in - 401) log " §10.2 /v1/agent/create live (401 unauth as expected — issue #144 routes deployed)" ;; - 404) die "POST /v1/agent/create → 404: the running broker binary predates issue #144 (§10.2 routes missing). +# Issue #144 (method A) — §10.2 agent-pairing route smoke. A no-bearer POST to +# /v1/agent/pairing/claim MUST return 401 (master-gated route registered, rejects +# unauth), NOT 404 (a stale binary predating this PR has no such route). +# Deterministic — no operator session / keygen needed — so it can't flake; it's +# the authoritative "is the method-A code actually serving on this host" gate. +agent_claim_code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 \ + -X POST -H 'content-type: application/json' -d '{"pairing_code":"smoke","label":"smoke"}' \ + "http://127.0.0.1:8091/v1/agent/pairing/claim" 2>/dev/null || echo 000)" +case "$agent_claim_code" in + 401) log " §10.2 /v1/agent/pairing/claim live (401 unauth as expected — method-A routes deployed)" ;; + 404) die "POST /v1/agent/pairing/claim → 404: the running broker binary predates this PR (§10.2 method-A routes missing). The build/install did not deploy the new code. Fix: rm -rf $REPO_ROOT/target/release/agentkeys-broker-server && re-run this script (it self-heals with a clean rebuild when the feature is missing)." ;; - *) warn "POST /v1/agent/create → HTTP $agent_create_code (expected 401). Route appears present; continuing (the /healthz probe already passed)." ;; + *) warn "POST /v1/agent/pairing/claim → HTTP $agent_claim_code (expected 401). Route appears present; continuing (the /healthz probe already passed)." ;; esac probe_or_die backend 8090 agentkeys-backend From 054ce09b0c14ca7495b8fa76dfa02f01c5a52c05 Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Mon, 1 Jun 2026 14:19:11 +0800 Subject: [PATCH 4/6] =?UTF-8?q?agentkeys:=20=C2=A710.2=20method-A=20pairin?= =?UTF-8?q?g=20=E2=80=94=20docs=20+=20terminology=20(arch.md,=20runbooks)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reconcile every doc + code-comment surface with the agent-initiated pairing flip (issue #144, method A). arch.md (single source of truth): - §10.2 ceremony fully rewritten for method A (agent requests → master claims → agent retrieves), incl. the IoT/Sybil-safety rationale + the deferred unbind notes (#155/#156). - §6.2 route list: /v1/agent/pairing/{request,claim,poll} replace create + link-code/redeem; pending-bindings ack now by request_id. - §10.4 re-bootstrap inverted; §5 agent_omni row, §10.6 threat row, trust-boundary + actor-role tables, CLI inventory → pairing terms. Solidity link_code_redemption calldata param kept (contract unchanged). operator-runbook-wire.md: Phase P walkthrough (P.0 request → P.1 claim → P.1b retrieve → P.1c pending → P.2 bind+ack → P.3 grant), 404 trap + route checks, troubleshooting rows. v2-stage1-migration-and-demo.md §7: rewritten for method A (also fixes pre-existing drift — the master, not the broker, submits registerAgentDevice). issue-144 plan: superseded-front-half banner → method-A doc. issue-74 ephemeral-rebootstrap paragraph corrected. Code doc-comments (mcp-server config, core actor_omni, cli device_session) → pairing terms. fmt + clippy --all-targets -D warnings clean on the 3 comment-edited crates. --- crates/agentkeys-cli/src/device_session.rs | 6 +- crates/agentkeys-core/src/actor_omni.rs | 2 +- crates/agentkeys-mcp-server/src/config.rs | 2 +- docs/arch.md | 148 ++++++++++-------- docs/operator-runbook-wire.md | 21 +-- .../plans/issue-144-hdkd-agent-bootstrap.md | 8 + .../plans/issue-74-step-1c-device-key-auth.md | 8 +- docs/v2-stage1-migration-and-demo.md | 83 +++++----- 8 files changed, 154 insertions(+), 124 deletions(-) diff --git a/crates/agentkeys-cli/src/device_session.rs b/crates/agentkeys-cli/src/device_session.rs index 03f9ab2..224833f 100644 --- a/crates/agentkeys-cli/src/device_session.rs +++ b/crates/agentkeys-cli/src/device_session.rs @@ -9,9 +9,9 @@ //! //! This fixes the "master bootstrap" violation: the agent key is born here, in //! the sandbox, not on the operator laptop. The full HDKD-literal ceremony -//! (broker `/v1/agent/create` + `/v1/auth/link-code/redeem` + daemon keygen, -//! `O_agent = HDKD(O_master, path)`) is tracked in issue #144 and supersedes -//! this. Pure-shell can't do EIP-191/secp256k1 and the sandbox has no `cast`, so +//! (broker `/v1/agent/pairing/{request,claim,poll}` + daemon keygen, +//! `O_agent = HDKD(O_master, path)`) is tracked in issue #144 (method A) and +//! supersedes this. Pure-shell can't do EIP-191/secp256k1 and the sandbox has no `cast`, so //! the crypto lives in the already-deployed `agentkeys` binary; shell drives it. //! //! Derivations match the broker's wallet_sig verify diff --git a/crates/agentkeys-core/src/actor_omni.rs b/crates/agentkeys-core/src/actor_omni.rs index 0908d61..8f83bcc 100644 --- a/crates/agentkeys-core/src/actor_omni.rs +++ b/crates/agentkeys-core/src/actor_omni.rs @@ -93,7 +93,7 @@ pub fn validate_label(label: &str) -> anyhow::Result<()> { /// /// **PUBLIC + recomputable** (decision 2): anyone holding the parent omni + label /// can recompute the child — unforgeability comes from the master-gated -/// `/v1/agent/create` (needs `J1_master`) + the master-submitted on-chain binding, +/// `/v1/agent/pairing/claim` (needs `J1_master`) + the master-submitted on-chain binding, /// NOT from a secret. The agent's K10 device key is decoupled from this omni. /// `master_omni` is the parent's 32 raw omni bytes (NOT the hex ASCII). pub fn child_omni(master_omni: &[u8; 32], label: &str) -> [u8; 32] { diff --git a/crates/agentkeys-mcp-server/src/config.rs b/crates/agentkeys-mcp-server/src/config.rs index 72ac827..9b3f532 100644 --- a/crates/agentkeys-mcp-server/src/config.rs +++ b/crates/agentkeys-mcp-server/src/config.rs @@ -90,7 +90,7 @@ pub struct Cli { pub agent_session_bearer: Option, /// Path to an owner-only file containing the agent session JWT. Preferred - /// over --agent-session-bearer: the in-sandbox daemon (`--init-link-code`) + /// over --agent-session-bearer: the in-sandbox daemon (`--retrieve-pairing`) /// writes the bearer here (0600) so it never transits the master's shell or /// the process list (adversarial-review finding #2). Used only when /// --agent-session-bearer is not set directly. diff --git a/docs/arch.md b/docs/arch.md index 11bbf87..9897fc4 100644 --- a/docs/arch.md +++ b/docs/arch.md @@ -164,7 +164,7 @@ flowchart TB |---|---|---| | **Master workstation** (host root, no biometric presence) | Stolen J1 session JWT (replay until TTL); stolen K10 (cap-mint as that actor until rotation). Caps bounded by per-actor scope and host-local quotas. | **Cannot complete WebAuthn ceremony** — K11 sealed in hardware requires biometric/PIN. Cannot mutate scope, bind a new device, or rotate K10. Cannot reach other operators' material. | | **Master workstation** (full compromise WITH biometric presence) | Above plus: mutate scope, bind new master device, rotate K10. Bounded to this human's actor tree only. Visible on chain (sovereign mode) — every mutation is auditable. | Cannot reach other operators. Recovery via surviving master devices revokes attacker's bindings within ~60s. | -| **Agent machine** (sandbox root) | Stolen agent K10; stolen session JWT (TTL-bounded). Per-actor binding (Codex finding #1) means caps minted under this K10 are tagged for THIS actor only — cannot impersonate a sibling agent. | Cannot rebind without a fresh master-issued link code; cannot mutate scope; cannot reach master wallet's material; cannot reach sibling agents. PrincipalTag at STS prevents cross-agent S3 access. | +| **Agent machine** (sandbox root) | Stolen agent K10; stolen session JWT (TTL-bounded). Per-actor binding (Codex finding #1) means caps minted under this K10 are tagged for THIS actor only — cannot impersonate a sibling agent. | Cannot rebind without a fresh pairing the master claims; cannot mutate scope; cannot reach master wallet's material; cannot reach sibling agents. PrincipalTag at STS prevents cross-agent S3 access. | | **Broker process** | Mint session JWTs; co-sign caps with K1. Caps still require valid K10 sig from a registered device AND valid K11 assertion for master mutations — broker compromise alone cannot fabricate a usable master-mutation cap. | Cannot derive K4 wallets (no K3); cannot decrypt credentials (no KEK access without mTLS + chain epoch check); cannot reach AWS (no IAM principal). | | **Signer enclave (TEE)** (assuming attestation defeated) | Derive any K4 wallet; derive any KEK. Catastrophic for credentials. | Cannot mint session JWTs (no K1); cannot mint caps (no K1); cannot bypass per-actor binding on chain (registry is authoritative); cannot reach S3 directly. TEE attestation is the threat-model floor — see §13. | | **One worker** (e.g., credentials-service compromised) | Decrypt credentials for that data class for callers presenting valid caps (cannot forge caps). Cannot read other data classes (separate workers, separate IAM, separate prefixes — §17). | Cannot mutate scope; cannot bind devices; cannot mint own caps; cannot reach memory / audit / email / payment data; cannot escalate to other workers. | @@ -204,7 +204,7 @@ Pinned to disambiguate the same value showing up under different labels across c | `actor_omni` | **The durable per-actor cryptographic anchor.** `SHA256("agentkeys" \|\| "evm" \|\| initial_master_wallet_K3_v1)`. **Frozen at first SIWE-bind**; never rotates with K3, never changes with wallet rotation. The Layer 1 identifier per §6. | `omni_account` (JWT claim + CLI `whoami` field), `agentkeys_actor_omni` (AWS PrincipalTag key), `OMNI_A` / `OMNI_B` (demo shell vars). | | `current_master_wallet` | **The current chain identity** = `HKDF(K3_v[current_epoch], O_master)`. Rotates each K3 epoch. Appears on chain as `msg.sender` in sovereign mode. The Layer 2 identifier per §6. | `master_wallet`, `wallet_address` (JWT claim shape pre-rotation), `MASTER_WALLET` (demo shell var). When historical K3 epochs are in scope, qualify with `master_wallet_K3_v[N]`. | | `identity_omni` | **The transient identity omni** — `SHA256("agentkeys" \|\| identity_type \|\| identity_value)`. Used internally by the broker between init and SIWE-verify; never carried in a post-SIWE JWT. | `identity_omni_email` / `identity_omni_oauth2` (when narrowing to a specific identity type), `identity omni` (init-flow CLI log line). | -| `agent_omni` | **A child actor omni** = `SHA256("agentkeys-hdkd-v1" \|\| O_master \|\| "//