From bdda79e82348e76a4e34e199661fae1e86362fdb Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Sun, 31 May 2026 15:52:17 +0800 Subject: [PATCH 01/23] =?UTF-8?q?feat:=20full=20=C2=A710.2=20HDKD=20agent?= =?UTF-8?q?=20bootstrap=20=E2=80=94=20broker=20link-code=20endpoints=20+?= =?UTF-8?q?=20daemon=20redeem=20(#144)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Converges the PR #141 interim §10.2 to the literal ceremony: the master mints a one-time link code bound to a hard-derived child omni O_agent = SHA256("agentkeys-hdkd-v1" || O_master || "//label"); the agent daemon generates its own K10 in the sandbox, redeems the code (pop_sig), and the broker mints J1_agent carrying the HDKD omni + parent lineage. The master then approves the binding + scope async (push → one Touch ID), iOS/Android-style. - core: device_crypto (shared K10 keygen / EIP-191 / ecrecover / pop_sig + DeviceKey) + HDKD child_omni/child_omni_hex + validate_label (frozen vectors) - broker: POST /v1/agent/create (J1_master-gated), POST /v1/auth/link-code/redeem (pop_sig verified before consume → retryable), GET /v1/agent/pending-bindings; SQLite link-code + pending-binding store; AgentKeysClaims + mint_agent_session_jwt; mint-oidc-jwt now reads actor_omni from the verified claim (STS-relay prerequisite; wallet sessions byte-identical, regression-tested) - daemon: --init-link-code one-shot (in-sandbox keygen → redeem → persist J1_agent → emit binding artifact) - cli: agentkeys agent create + agent pending (master-side) - harness: Phase P rework — P.0 create (real broker code) → P.1 install (daemon --init-link-code) → P.2 bind → P.3 grant; build + upload the daemon binary - ci / broker-setup: §10.2 route smoke (401 = live, 404 = stale binary) + nm symbol check in setup-broker-host.sh; bump the deploy job timeout 15→25min + SSM executionTimeout 900→1500 for the larger broker build closure (agentkeys-core) - docs: arch.md §10.2 (async master-submits ceremony), §5 agent_omni row, §6.2, route list; operator-runbook-wire.md Phase P + troubleshooting; new docs/spec/plans/issue-144-hdkd-agent-bootstrap.md Decisions (master submits the on-chain binding — no contract change; child omni is public + recomputable; daemon owns keygen+redeem with shared core) and deviations are recorded in docs/spec/plans/issue-144-hdkd-agent-bootstrap.md. Tests: core 141; broker 179 lib + agent_bootstrap_flow integration + oidc regression; clippy -D warnings clean (default features); cargo fmt clean; harness bash -n OK. --- .github/workflows/harness-ci.yml | 21 +- Cargo.lock | 1 + crates/agentkeys-broker-server/Cargo.toml | 4 + crates/agentkeys-broker-server/src/boot.rs | 21 +- .../src/handlers/agent/create.rs | 79 ++++ .../src/handlers/agent/mod.rs | 41 ++ .../src/handlers/agent/pending.rs | 49 +++ .../src/handlers/agent/redeem.rs | 109 +++++ .../src/handlers/grant/mod.rs | 4 +- .../src/handlers/mod.rs | 1 + .../src/handlers/oidc.rs | 82 +++- .../agentkeys-broker-server/src/jwt/issue.rs | 44 ++ .../agentkeys-broker-server/src/jwt/verify.rs | 14 + crates/agentkeys-broker-server/src/lib.rs | 15 + crates/agentkeys-broker-server/src/main.rs | 1 + crates/agentkeys-broker-server/src/state.rs | 8 +- .../src/storage/link_codes.rs | 413 ++++++++++++++++++ .../src/storage/mod.rs | 4 + .../tests/agent_bootstrap_flow.rs | 300 +++++++++++++ .../tests/auth_wallet_flow.rs | 3 + .../tests/email_flow.rs | 3 + .../tests/grant_flow.rs | 3 + .../tests/oauth2_flow.rs | 3 + .../tests/oidc_flow.rs | 3 + .../tests/wallet_flow.rs | 3 + crates/agentkeys-cli/src/agent_admin.rs | 80 ++++ crates/agentkeys-cli/src/lib.rs | 1 + crates/agentkeys-cli/src/main.rs | 62 +++ crates/agentkeys-core/Cargo.toml | 3 + crates/agentkeys-core/src/actor_omni.rs | 124 ++++++ crates/agentkeys-core/src/device_crypto.rs | 277 ++++++++++++ crates/agentkeys-core/src/lib.rs | 1 + crates/agentkeys-daemon/src/main.rs | 143 +++++- docs/arch.md | 78 ++-- docs/operator-runbook-wire.md | 15 +- .../plans/issue-144-hdkd-agent-bootstrap.md | 55 +++ harness/phase1-wire-demo.sh | 125 ++++-- scripts/setup-broker-host.sh | 29 ++ 38 files changed, 2113 insertions(+), 109 deletions(-) create mode 100644 crates/agentkeys-broker-server/src/handlers/agent/create.rs create mode 100644 crates/agentkeys-broker-server/src/handlers/agent/mod.rs create mode 100644 crates/agentkeys-broker-server/src/handlers/agent/pending.rs create mode 100644 crates/agentkeys-broker-server/src/handlers/agent/redeem.rs create mode 100644 crates/agentkeys-broker-server/src/storage/link_codes.rs create mode 100644 crates/agentkeys-broker-server/tests/agent_bootstrap_flow.rs create mode 100644 crates/agentkeys-cli/src/agent_admin.rs create mode 100644 crates/agentkeys-core/src/device_crypto.rs create mode 100644 docs/spec/plans/issue-144-hdkd-agent-bootstrap.md diff --git a/.github/workflows/harness-ci.yml b/.github/workflows/harness-ci.yml index 86bbcf3c..960b4d29 100644 --- a/.github/workflows/harness-ci.yml +++ b/.github/workflows/harness-ci.yml @@ -260,7 +260,12 @@ jobs: (needs.detect-changes.outputs.broker_changed == 'true' || (github.event_name == 'workflow_dispatch' && inputs.force_deploy_broker == 'true')) runs-on: ubuntu-latest - timeout-minutes: 15 + # 25min (was 15): issue #144 adds agentkeys-core to the broker's build closure + # (aws-sdk-s3, keyring/zbus, aes-gcm, …), so a COLD cargo cache rebuild runs + # longer. Warm (sccache) builds are still ~3min; this headroom only matters on + # the first build after a dep change, and prevents the GH-job-cancels-a-still- + # running-build race that flaked PR #141's first attempt. + timeout-minutes: 25 permissions: id-token: write contents: read @@ -408,7 +413,7 @@ jobs: # expansion (no modifier bugs per CLAUDE.md heredoc-trap rule). params=$(jq -n --arg script "$deploy_script" '{ commands: [$script], - executionTimeout: ["900"] + executionTimeout: ["1500"] }') cmd_id=$(aws ssm send-command \ @@ -428,10 +433,12 @@ jobs: INSTANCE_ID: ${{ secrets.TEST_BROKER_INSTANCE_ID }} run: | set -euo pipefail - # Poll every 10s for up to 15 min. The command runs setup-broker-host.sh - # which rebuilds + restarts broker/signer/4 workers; cold cargo cache - # can be ~10min, warm ~3min. - for i in $(seq 1 90); do + # Poll every 10s for up to 25 min. The command runs setup-broker-host.sh + # which rebuilds + restarts broker/signer/4 workers; a cold cargo cache + # is longer since issue #144 grew the broker's dep closure (agentkeys-core + # → aws-sdk-s3 / keyring / aes-gcm), warm (sccache) ~3min. Must stay ≤ the + # job timeout-minutes (25) and the SSM executionTimeout (1500s). + for i in $(seq 1 150); do sleep 10 status=$(aws ssm get-command-invocation \ --region "$REGION" \ @@ -469,7 +476,7 @@ jobs: ;; esac done - echo "::error::SSM command $SSM_COMMAND_ID did not complete within 15min" + echo "::error::SSM command $SSM_COMMAND_ID did not complete within 25min" exit 1 harness-e2e: diff --git a/Cargo.lock b/Cargo.lock index 20dc1dd1..0c072289 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -42,6 +42,7 @@ name = "agentkeys-broker-server" version = "0.1.0" dependencies = [ "agentkeys-broker-server", + "agentkeys-core", "agentkeys-mock-server", "agentkeys-types", "anyhow", diff --git a/crates/agentkeys-broker-server/Cargo.toml b/crates/agentkeys-broker-server/Cargo.toml index 49aef69d..6ebfe507 100644 --- a/crates/agentkeys-broker-server/Cargo.toml +++ b/crates/agentkeys-broker-server/Cargo.toml @@ -13,6 +13,10 @@ path = "src/lib.rs" [dependencies] agentkeys-types = { workspace = true } +# Issue #144 — shared device_crypto (EIP-191 ecrecover for link-code redeem +# pop_sig verify) + HDKD child_omni derivation. One source of truth across +# daemon/CLI/broker. +agentkeys-core = { workspace = true } axum = { version = "0.7", features = ["json"] } tokio = { workspace = true } serde = { workspace = true } diff --git a/crates/agentkeys-broker-server/src/boot.rs b/crates/agentkeys-broker-server/src/boot.rs index 363d9d86..d6ceef67 100644 --- a/crates/agentkeys-broker-server/src/boot.rs +++ b/crates/agentkeys-broker-server/src/boot.rs @@ -29,7 +29,7 @@ use crate::jwt::SessionKeypair; use crate::oidc::OidcKeypair; use crate::plugins::audit::{AuditAnchor, AuditPolicy}; use crate::plugins::PluginRegistry; -use crate::storage::{AuthNonceStore, GrantStore, IdentityLinkStore, WalletStore}; +use crate::storage::{AuthNonceStore, GrantStore, IdentityLinkStore, LinkCodeStore, WalletStore}; /// Outcome of the synchronous Tier-1 boot phase. pub struct BootArtifacts { @@ -41,6 +41,8 @@ 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, /// 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 @@ -183,6 +185,14 @@ 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", + ) + })?); // 5. Validate + parse plugin selection env vars. Every name in each // list must resolve at compile time (i.e. the corresponding @@ -225,6 +235,7 @@ pub fn run_tier1(config: &BrokerConfig) -> anyhow::Result { nonce_store, grant_store, identity_link_store, + link_code_store, #[cfg(feature = "auth-email-link")] email_link: built.email_link, #[cfg(feature = "auth-oauth2")] @@ -300,6 +311,14 @@ 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 { + config + .audit_db_path + .parent() + .map(|p| p.join("link_codes.sqlite")) + .unwrap_or_else(|| std::path::PathBuf::from("link_codes.sqlite")) +} + #[cfg(feature = "audit-sqlite")] fn open_sqlite_anchor(config: &BrokerConfig) -> Result, anyhow::Error> { use crate::plugins::audit::sqlite::SqliteAnchor; diff --git a/crates/agentkeys-broker-server/src/handlers/agent/create.rs b/crates/agentkeys-broker-server/src/handlers/agent/create.rs new file mode 100644 index 00000000..374ec76a --- /dev/null +++ b/crates/agentkeys-broker-server/src/handlers/agent/create.rs @@ -0,0 +1,79 @@ +//! `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 new file mode 100644 index 00000000..3c7bf1dd --- /dev/null +++ b/crates/agentkeys-broker-server/src/handlers/agent/mod.rs @@ -0,0 +1,41 @@ +//! §10.2 agent-bootstrap endpoints (issue #144). +//! +//! Three endpoints implement the link-code ceremony with the master submitting +//! 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. +//! - `GET /v1/agent/pending-bindings` (master, `J1_master`-gated) — pull the +//! redeemed-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. + +pub mod create; +pub mod pending; +pub mod redeem; + +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::error::{BrokerError, BrokerResult}; + +/// Unix seconds, mapped to `BrokerError::Internal` on the (impossible) clock-skew error. +pub(crate) fn unix_now() -> BrokerResult { + Ok(SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| BrokerError::Internal(format!("clock before unix epoch: {e}")))? + .as_secs() as i64) +} + +/// Session-JWT TTL (seconds) for `J1_agent` — same env + default as the wallet +/// session path (`wallet_verify`), so agent and master sessions age uniformly. +pub(crate) fn session_jwt_ttl_seconds() -> u64 { + std::env::var(crate::env::BROKER_SESSION_JWT_TTL_SECONDS) + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(18_000) +} diff --git a/crates/agentkeys-broker-server/src/handlers/agent/pending.rs b/crates/agentkeys-broker-server/src/handlers/agent/pending.rs new file mode 100644 index 00000000..116de32d --- /dev/null +++ b/crates/agentkeys-broker-server/src/handlers/agent/pending.rs @@ -0,0 +1,49 @@ +//! `GET /v1/agent/pending-bindings` — master pulls redeemed-but-unbound agents +//! (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. + +use axum::{extract::State, http::HeaderMap, http::StatusCode, response::IntoResponse, Json}; +use serde_json::json; + +use crate::error::BrokerError; +use crate::handlers::grant::require_session_jwt; +use crate::state::SharedState; + +pub async fn pending_bindings( + State(state): State, + headers: HeaderMap, +) -> Result { + 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 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 + // 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, + "child_omni": b.child_omni, + "operator_omni": b.operator_omni, + "label": b.label, + "requested_scope": b.requested_scope, + "device_pubkey": b.device_pubkey, + "pop_sig": b.pop_sig, + "device_key_hash": device_key_hash, + }) + }) + .collect(); + + Ok((StatusCode::OK, Json(json!({ "pending": pending })))) +} diff --git a/crates/agentkeys-broker-server/src/handlers/agent/redeem.rs b/crates/agentkeys-broker-server/src/handlers/agent/redeem.rs new file mode 100644 index 00000000..ab743d7f --- /dev/null +++ b/crates/agentkeys-broker-server/src/handlers/agent/redeem.rs @@ -0,0 +1,109 @@ +//! `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/grant/mod.rs b/crates/agentkeys-broker-server/src/handlers/grant/mod.rs index 005011be..04f98957 100644 --- a/crates/agentkeys-broker-server/src/handlers/grant/mod.rs +++ b/crates/agentkeys-broker-server/src/handlers/grant/mod.rs @@ -26,8 +26,8 @@ pub(crate) fn random_b64url(byte_len: usize) -> String { } /// Extract + verify a session JWT from `Authorization: Bearer `. -/// Used by every grant endpoint. -pub(super) fn require_session_jwt( +/// Used by every grant endpoint and by the §10.2 agent handlers (issue #144). +pub(crate) fn require_session_jwt( headers: &HeaderMap, state: &SharedState, ) -> Result { diff --git a/crates/agentkeys-broker-server/src/handlers/mod.rs b/crates/agentkeys-broker-server/src/handlers/mod.rs index 30f8c12d..e4e89df6 100644 --- a/crates/agentkeys-broker-server/src/handlers/mod.rs +++ b/crates/agentkeys-broker-server/src/handlers/mod.rs @@ -1,3 +1,4 @@ +pub mod agent; pub mod auth; pub mod broker_status; pub mod cap; diff --git a/crates/agentkeys-broker-server/src/handlers/oidc.rs b/crates/agentkeys-broker-server/src/handlers/oidc.rs index c6a39e0d..321cad17 100644 --- a/crates/agentkeys-broker-server/src/handlers/oidc.rs +++ b/crates/agentkeys-broker-server/src/handlers/oidc.rs @@ -95,11 +95,25 @@ pub async fn mint_oidc_jwt( } }; + // The actor_omni is read VERBATIM from the verified (broker-signed) session + // claim — never re-derived from a wallet (issue #144). A J1_agent has no + // wallet, so re-deriving would yield an empty/garbage tag → STS creds scoped + // to the wrong prefix → worker 403. For wallet/master sessions the claim + // equals the old re-derived value, so this is byte-identical for them. let wallet = session_claims.agentkeys.wallet_address; - tracing::Span::current().record("wallet", wallet.as_str()); + let actor_omni = session_claims.agentkeys.omni_account; + // Report id for audit/span/response: the wallet for master sessions, the + // actor_omni for agents (no wallet). Drives the STS session name downstream. + let report_id = if wallet.is_empty() { + actor_omni.clone() + } else { + wallet.clone() + }; + tracing::Span::current().record("wallet", report_id.as_str()); let (claims, _now, exp) = build_oidc_jwt_claims( &state.config.oidc_issuer, + &actor_omni, &wallet, state.config.oidc_jwt_ttl_seconds, ); @@ -109,7 +123,7 @@ pub async fn mint_oidc_jwt( state.audit.record_mint( MintRecord { requester_token: token, - requester_wallet: &wallet, + requester_wallet: &report_id, requested_role: "oidc_jwt", session_duration_seconds: state.config.oidc_jwt_ttl_seconds as i32, sts_session_name: &state.oidc.kid, @@ -121,7 +135,7 @@ pub async fn mint_oidc_jwt( Ok(Json(MintOidcJwtResponse { jwt, - wallet, + wallet: report_id, expiration: exp, })) } @@ -156,6 +170,7 @@ pub async fn mint_oidc_jwt( /// v1 can be retired from the claim set. pub(crate) fn build_oidc_jwt_claims( issuer: &str, + actor_omni: &str, wallet: &str, ttl_seconds: u64, ) -> (serde_json::Value, i64, i64) { @@ -165,24 +180,30 @@ pub(crate) fn build_oidc_jwt_claims( .unwrap_or(0); let exp = now + ttl_seconds as i64; let wallet_lc = wallet.to_lowercase(); - // v2 actor_omni = SHA256("agentkeys" || "evm" || wallet_lc). Lives in - // `crate::identity::omni_account::derive_omni_account` so the broker - // never reimplements the hash — same function the storage layer uses - // when keying identity-link rows on omni. - let actor_omni = - crate::identity::omni_account::derive_omni_account("evm", &wallet_lc).to_string(); + // `actor_omni` is supplied VERBATIM from the verified session claim (issue + // #144) — the broker signed it at session-mint time, so it's trusted and is + // NOT re-derived here. For a wallet/master session it equals + // SHA256("agentkeys"||"evm"||wallet_lc) (what the old code computed); for an + // agent J1 it is the HDKD child omni. The v1 `agentkeys_user_wallet` tag + // falls back to the actor_omni when there's no wallet so it's never empty + // (a v1 policy keyed on it then resolves to the same `bots//` prefix). + let user_wallet = if wallet_lc.is_empty() { + actor_omni + } else { + wallet_lc.as_str() + }; let claims = json!({ "iss": issuer, - "sub": format!("agentkeys:agent:{}", wallet_lc), + "sub": format!("agentkeys:agent:{}", user_wallet), "aud": "sts.amazonaws.com", "iat": now, "exp": exp, - "agentkeys_user_wallet": wallet_lc, + "agentkeys_user_wallet": user_wallet, "agentkeys_actor_omni": actor_omni, "https://aws.amazon.com/tags": { "principal_tags": { - "agentkeys_user_wallet": [wallet_lc], + "agentkeys_user_wallet": [user_wallet], "agentkeys_actor_omni": [actor_omni], }, "transitive_tag_keys": [ @@ -194,3 +215,40 @@ pub(crate) fn build_oidc_jwt_claims( (claims, now, exp) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::identity::omni_account::derive_omni_account; + + #[test] + fn wallet_session_oidc_tags_unchanged_after_144() { + // Regression: for a wallet/master session the actor_omni read from the + // claim EQUALS the value the old code re-derived from the wallet, so the + // OIDC tag set is byte-identical to pre-#144. + let wallet = "0xAbCdEf0123456789abcdef0123456789ABCDef00"; + let wallet_lc = wallet.to_lowercase(); + let actor_omni = derive_omni_account("evm", &wallet_lc).to_string(); + let (claims, _n, _e) = build_oidc_jwt_claims("https://issuer", &actor_omni, wallet, 300); + assert_eq!(claims["agentkeys_actor_omni"], actor_omni); + assert_eq!(claims["agentkeys_user_wallet"], wallet_lc); + assert_eq!(claims["sub"], format!("agentkeys:agent:{wallet_lc}")); + let tags = &claims["https://aws.amazon.com/tags"]["principal_tags"]; + assert_eq!(tags["agentkeys_actor_omni"][0], actor_omni); + assert_eq!(tags["agentkeys_user_wallet"][0], wallet_lc); + } + + #[test] + fn agent_session_tags_carry_hdkd_omni_and_no_empty_wallet() { + // Agent J1: HDKD child omni, empty wallet → the actor tag IS the HDKD + // omni (so STS scopes creds to bots//...), and the v1 user_wallet + // tag falls back to the omni rather than being empty. + let child = "a".repeat(64); + let (claims, _n, _e) = build_oidc_jwt_claims("https://issuer", &child, "", 300); + assert_eq!(claims["agentkeys_actor_omni"], child); + assert_eq!(claims["agentkeys_user_wallet"], child); + assert_eq!(claims["sub"], format!("agentkeys:agent:{child}")); + let tags = &claims["https://aws.amazon.com/tags"]["principal_tags"]; + assert_eq!(tags["agentkeys_actor_omni"][0], child); + } +} diff --git a/crates/agentkeys-broker-server/src/jwt/issue.rs b/crates/agentkeys-broker-server/src/jwt/issue.rs index 1b541847..4704fa70 100644 --- a/crates/agentkeys-broker-server/src/jwt/issue.rs +++ b/crates/agentkeys-broker-server/src/jwt/issue.rs @@ -62,6 +62,50 @@ pub fn mint_session_jwt( keypair.sign_jwt(&claims) } +/// Mint `J1_agent` — the agent session JWT for the §10.2 link-code bootstrap +/// (issue #144). Unlike `mint_session_jwt`, the actor omni is the **HDKD child** +/// (decoupled from any wallet — `wallet_address` is empty), and the claim +/// carries the agent lineage (`parent_omni`, `derivation_path`, `device_pubkey`). +/// `identity_type` is `"agent_hdkd"` and `identity_value` is the K10 device +/// address. The `omni_account` is the un-prefixed 64-hex child omni so it +/// matches the PrincipalTag + S3-prefix shape downstream. +#[allow(clippy::too_many_arguments)] +pub fn mint_agent_session_jwt( + keypair: &SessionKeypair, + issuer: &str, + child_omni: &str, + parent_omni: &str, + derivation_path: &str, + device_pubkey: &str, + ttl_seconds: u64, +) -> BrokerResult { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| BrokerError::Internal(format!("clock before unix epoch: {e}")))? + .as_secs(); + let exp = now + ttl_seconds; + + let claims = json!({ + "iss": issuer, + "sub": format!("agentkeys:agent:{}", child_omni), + "aud": "agentkeys:broker", + "exp": exp, + "iat": now, + "jti": ulid_like(), + "agentkeys": { + "omni_account": child_omni, + "wallet_address": "", + "identity_type": "agent_hdkd", + "identity_value": device_pubkey, + "parent_omni": parent_omni, + "derivation_path": derivation_path, + "device_pubkey": device_pubkey, + } + }); + + keypair.sign_jwt(&claims) +} + /// Mint an `audit_proof` JWT for a capability grant (Phase B, US-025). /// /// Per plan §3.5.5: the audit_proof is the broker's ES256 signature diff --git a/crates/agentkeys-broker-server/src/jwt/verify.rs b/crates/agentkeys-broker-server/src/jwt/verify.rs index 0fe38b39..1f1e1586 100644 --- a/crates/agentkeys-broker-server/src/jwt/verify.rs +++ b/crates/agentkeys-broker-server/src/jwt/verify.rs @@ -25,12 +25,26 @@ pub struct SessionClaims { } /// The custom `agentkeys` namespace inside the session JWT. +/// +/// The `parent_omni` / `derivation_path` / `device_pubkey` fields are present +/// only on agent sessions (`J1_agent`, issue #144). They are `Option` with +/// `serde(default)` so existing wallet/master tokens (which never carried them) +/// still deserialize — the change is purely additive. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct AgentKeysClaims { pub omni_account: String, pub wallet_address: String, pub identity_type: String, pub identity_value: String, + /// Agent only: the parent (master) omni this child was HDKD-derived from. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_omni: Option, + /// Agent only: the HDKD path, e.g. `"//agent-a"`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub derivation_path: Option, + /// Agent only: the K10 device address whose pop_sig redeemed the link code. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub device_pubkey: Option, } /// Verify a session JWT against the broker's session keypair. Validates diff --git a/crates/agentkeys-broker-server/src/lib.rs b/crates/agentkeys-broker-server/src/lib.rs index dcc92e00..6dae7864 100644 --- a/crates/agentkeys-broker-server/src/lib.rs +++ b/crates/agentkeys-broker-server/src/lib.rs @@ -62,6 +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. + .route( + "/v1/agent/create", + post(handlers::agent::create::agent_create), + ) + .route( + "/v1/auth/link-code/redeem", + post(handlers::agent::redeem::link_code_redeem), + ) + .route( + "/v1/agent/pending-bindings", + get(handlers::agent::pending::pending_bindings), + ) // Phase B grant endpoints (US-026). .route( "/v1/grant/create", diff --git a/crates/agentkeys-broker-server/src/main.rs b/crates/agentkeys-broker-server/src/main.rs index 212a4c35..a5c95974 100644 --- a/crates/agentkeys-broker-server/src/main.rs +++ b/crates/agentkeys-broker-server/src/main.rs @@ -177,6 +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, 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 878d6e8f..4e8e8122 100644 --- a/crates/agentkeys-broker-server/src/state.rs +++ b/crates/agentkeys-broker-server/src/state.rs @@ -7,7 +7,7 @@ use crate::metrics::Metrics; use crate::oidc::OidcKeypair; use crate::plugins::audit::AuditPolicy; use crate::plugins::PluginRegistry; -use crate::storage::{AuthNonceStore, GrantStore, IdentityLinkStore, WalletStore}; +use crate::storage::{AuthNonceStore, GrantStore, IdentityLinkStore, LinkCodeStore, WalletStore}; use crate::sts::StsClient; /// Tier-2 reachability state shared with the /readyz handler. @@ -43,6 +43,12 @@ 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, /// 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 new file mode 100644 index 00000000..bda4705b --- /dev/null +++ b/crates/agentkeys-broker-server/src/storage/link_codes.rs @@ -0,0 +1,413 @@ +//! `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 (master acked the on-chain submit). Idempotent + /// — a second ack is a no-op. Returns the matched/updated row count. + pub fn mark_bound(&self, link_code: &str, now: i64) -> BrokerResult { + let conn = self.lock()?; + let n = conn + .execute( + "UPDATE link_codes SET bound_at = ?1 + WHERE link_code = ?2 AND consumed_at IS NOT NULL AND bound_at IS NULL", + params![now, link_code], + ) + .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", 300).unwrap(), 1); + assert!(s.pending_bindings("op").unwrap().is_empty()); + // Idempotent: a second ack matches nothing. + assert_eq!(s.mark_bound("lc-1", 400).unwrap(), 0); + } + + #[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 4d2087f3..e0ffa60b 100644 --- a/crates/agentkeys-broker-server/src/storage/mod.rs +++ b/crates/agentkeys-broker-server/src/storage/mod.rs @@ -16,6 +16,9 @@ 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; #[cfg(feature = "auth-oauth2")] pub mod oauth_pending; #[cfg(any(feature = "auth-email-link", feature = "auth-oauth2"))] @@ -29,6 +32,7 @@ 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}; #[cfg(any(feature = "auth-email-link", feature = "auth-oauth2"))] diff --git a/crates/agentkeys-broker-server/tests/agent_bootstrap_flow.rs b/crates/agentkeys-broker-server/tests/agent_bootstrap_flow.rs new file mode 100644 index 00000000..6b8fad14 --- /dev/null +++ b/crates/agentkeys-broker-server/tests/agent_bootstrap_flow.rs @@ -0,0 +1,300 @@ +//! 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). +//! +//! 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. + +use std::path::PathBuf; +use std::sync::Arc; + +use agentkeys_broker_server::audit::AuditLog; +use agentkeys_broker_server::config::BrokerConfig; +use agentkeys_broker_server::create_router; +use agentkeys_broker_server::identity::derive_omni_account; +use agentkeys_broker_server::jwt::issue::mint_session_jwt; +use agentkeys_broker_server::jwt::verify::verify_session_jwt; +use agentkeys_broker_server::oidc::OidcKeypair; +use agentkeys_broker_server::state::AppState; +use agentkeys_broker_server::sts::{AssumedCredentials, StsClient, StubStsClient}; +use agentkeys_core::device_crypto::DeviceKey; +use serde_json::{json, Value}; +use tempfile::TempDir; + +const TEST_ISSUER: &str = "https://oidc.test.invalid"; + +fn stub_creds() -> AssumedCredentials { + AssumedCredentials { + access_key_id: "ASIA-stub".into(), + secret_access_key: "stub".into(), + session_token: "stub".into(), + expiration_unix: 9_999_999_999, + } +} + +async fn spawn_broker() -> (String, Arc) { + let tmp = Box::leak(Box::new(TempDir::new().unwrap())); + let keypair_path = tmp.path().join("oidc-keypair.json"); + let oidc = OidcKeypair::generate_and_persist(&keypair_path).unwrap(); + let sts: Arc = Arc::new(StubStsClient::ok(stub_creds())); + let config = BrokerConfig { + data_role_arn: "arn:aws:iam::000:role/test".into(), + audit_db_path: PathBuf::from(":memory:"), + aws_region: "us-east-1".into(), + session_duration_seconds: 3600, + shutdown_grace_seconds: 5, + oidc_issuer: TEST_ISSUER.into(), + oidc_keypair_path: keypair_path, + oidc_jwt_ttl_seconds: 300, + }; + let http = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(2)) + .build() + .unwrap(); + let session_keypair = agentkeys_broker_server::jwt::SessionKeypair::generate_and_persist( + &tmp.path().join("session-keypair.json"), + ) + .unwrap(); + let wallet_store = + Arc::new(agentkeys_broker_server::storage::WalletStore::open_in_memory().unwrap()); + let nonce_store = + Arc::new(agentkeys_broker_server::storage::AuthNonceStore::open_in_memory().unwrap()); + let sqlite_anchor: Arc = Arc::new( + agentkeys_broker_server::plugins::audit::sqlite::SqliteAnchor::open_in_memory().unwrap(), + ); + let registry = Arc::new(agentkeys_broker_server::plugins::PluginRegistry { + auth: std::collections::HashMap::new(), + wallet: Arc::new( + agentkeys_broker_server::plugins::wallet::keystore::ClientSideKeystoreProvisioner::new( + Arc::clone(&wallet_store), + ), + ), + audit: vec![sqlite_anchor], + }); + let state = Arc::new(AppState { + config, + http, + audit: AuditLog::open_in_memory().unwrap(), + sts, + oidc: Arc::new(oidc), + session_keypair: Arc::new(session_keypair), + registry, + audit_policy: agentkeys_broker_server::plugins::audit::AuditPolicy::SqlitePrimary, + wallet_store, + nonce_store, + grant_store: Arc::new( + agentkeys_broker_server::storage::GrantStore::open_in_memory().unwrap(), + ), + 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(), + ), + metrics: Arc::new(agentkeys_broker_server::metrics::Metrics::new()), + tier2: Arc::new(agentkeys_broker_server::state::Tier2State::default()), + #[cfg(feature = "auth-email-link")] + email_link: None, + #[cfg(feature = "auth-oauth2")] + oauth2: None, + }); + let app = create_router(state.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (format!("http://{}", addr), state) +} + +/// Mint a master J1 session bound to a fresh master omni; returns (bearer, master_omni). +fn master_session(state: &AppState) -> (String, String) { + let master_wallet = "0xabcdef0123456789abcdef0123456789abcdef01"; + let master_omni = derive_omni_account("evm", master_wallet).to_string(); + let token = mint_session_jwt( + &state.session_keypair, + TEST_ISSUER, + &master_omni, + master_wallet, + "evm", + master_wallet, + 3600, + ) + .unwrap(); + (token, master_omni) +} + +#[tokio::test] +async fn create_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" })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn create_rejects_bad_label() { + let (broker_url, state) = spawn_broker().await; + let (bearer, _) = master_session(&state); + let resp = reqwest::Client::new() + .post(format!("{}/v1/agent/create", broker_url)) + .header("Authorization", format!("Bearer {bearer}")) + .json(&json!({ "label": "Agent/A" })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn full_create_redeem_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)) + .header("Authorization", format!("Bearer {bearer}")) + .json(&json!({ "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(); + // 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); + + // 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)) + .json(&json!({ + "link_code": link_code, + "device_pubkey": dk.address(), + "pop_sig": dk.pop_sig().unwrap(), + })) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let j1_agent = redeem["session_jwt"].as_str().unwrap(); + assert_eq!(redeem["child_omni"], child_omni); + + // J1_agent carries the HDKD omni + lineage. + let claims = verify_session_jwt(&state.session_keypair, TEST_ISSUER, j1_agent).unwrap(); + assert_eq!(claims.agentkeys.omni_account, child_omni); + assert_eq!( + claims.agentkeys.parent_omni.as_deref(), + Some(master_omni.as_str()) + ); + assert_eq!( + claims.agentkeys.derivation_path.as_deref(), + Some("//agent-a") + ); + assert_eq!( + claims.agentkeys.device_pubkey.as_deref(), + Some(dk.address()) + ); + assert_eq!(claims.agentkeys.identity_type, "agent_hdkd"); + + // 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}")) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let arr = pending["pending"].as_array().unwrap(); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["child_omni"], child_omni); + assert_eq!(arr[0]["device_pubkey"], dk.address()); + assert_eq!(arr[0]["requested_scope"], "memory"); + assert!(arr[0]["pop_sig"].as_str().unwrap().starts_with("0x")); + assert!(arr[0]["device_key_hash"] + .as_str() + .unwrap() + .starts_with("0x")); + + // single-use: a second redeem of the same 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(), + })) + .send() + .await + .unwrap(); + assert_eq!(replay.status(), reqwest::StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn bad_pop_sig_rejected_and_code_remains_redeemable() { + 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" })) + .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(); + + // 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)) + .json(&json!({ + "link_code": link_code, + "device_pubkey": dk.address(), + "pop_sig": other.pop_sig().unwrap(), + })) + .send() + .await + .unwrap(); + assert_eq!(bad.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). + let good = 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(), + })) + .send() + .await + .unwrap(); + assert_eq!(good.status(), reqwest::StatusCode::OK); +} diff --git a/crates/agentkeys-broker-server/tests/auth_wallet_flow.rs b/crates/agentkeys-broker-server/tests/auth_wallet_flow.rs index fe33f6fa..ae3e6e15 100644 --- a/crates/agentkeys-broker-server/tests/auth_wallet_flow.rs +++ b/crates/agentkeys-broker-server/tests/auth_wallet_flow.rs @@ -108,6 +108,9 @@ 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(), + ), metrics: Arc::new(agentkeys_broker_server::metrics::Metrics::new()), tier2: Arc::new(Tier2State::default()), #[cfg(feature = "auth-email-link")] diff --git a/crates/agentkeys-broker-server/tests/email_flow.rs b/crates/agentkeys-broker-server/tests/email_flow.rs index 4b98232b..568e5dfd 100644 --- a/crates/agentkeys-broker-server/tests/email_flow.rs +++ b/crates/agentkeys-broker-server/tests/email_flow.rs @@ -125,6 +125,9 @@ 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(), + ), metrics: Arc::new(agentkeys_broker_server::metrics::Metrics::new()), tier2: Arc::new(Tier2State::default()), email_link: Some(plugin.clone()), diff --git a/crates/agentkeys-broker-server/tests/grant_flow.rs b/crates/agentkeys-broker-server/tests/grant_flow.rs index 007eaf38..84542d5d 100644 --- a/crates/agentkeys-broker-server/tests/grant_flow.rs +++ b/crates/agentkeys-broker-server/tests/grant_flow.rs @@ -107,6 +107,9 @@ 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(), + ), metrics: Arc::new(agentkeys_broker_server::metrics::Metrics::new()), tier2: Arc::new(Tier2State::default()), #[cfg(feature = "auth-email-link")] diff --git a/crates/agentkeys-broker-server/tests/oauth2_flow.rs b/crates/agentkeys-broker-server/tests/oauth2_flow.rs index 09707cc3..1ab605f4 100644 --- a/crates/agentkeys-broker-server/tests/oauth2_flow.rs +++ b/crates/agentkeys-broker-server/tests/oauth2_flow.rs @@ -132,6 +132,9 @@ 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(), + ), metrics: Arc::new(agentkeys_broker_server::metrics::Metrics::new()), tier2: Arc::new(Tier2State::default()), #[cfg(feature = "auth-email-link")] diff --git a/crates/agentkeys-broker-server/tests/oidc_flow.rs b/crates/agentkeys-broker-server/tests/oidc_flow.rs index 3ad980af..61e70d28 100644 --- a/crates/agentkeys-broker-server/tests/oidc_flow.rs +++ b/crates/agentkeys-broker-server/tests/oidc_flow.rs @@ -96,6 +96,9 @@ 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(), + ), metrics: Arc::new(agentkeys_broker_server::metrics::Metrics::new()), tier2: std::sync::Arc::new(agentkeys_broker_server::state::Tier2State::default()), #[cfg(feature = "auth-email-link")] diff --git a/crates/agentkeys-broker-server/tests/wallet_flow.rs b/crates/agentkeys-broker-server/tests/wallet_flow.rs index 7c9c3603..00bfabfa 100644 --- a/crates/agentkeys-broker-server/tests/wallet_flow.rs +++ b/crates/agentkeys-broker-server/tests/wallet_flow.rs @@ -100,6 +100,9 @@ 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(), + ), metrics: Arc::new(agentkeys_broker_server::metrics::Metrics::new()), tier2: Arc::new(Tier2State::default()), #[cfg(feature = "auth-email-link")] diff --git a/crates/agentkeys-cli/src/agent_admin.rs b/crates/agentkeys-cli/src/agent_admin.rs new file mode 100644 index 00000000..aaeb0bd9 --- /dev/null +++ b/crates/agentkeys-cli/src/agent_admin.rs @@ -0,0 +1,80 @@ +//! 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. +//! +//! 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 +//! with one Touch ID) stay in the chain helpers (`heima-agent-create.sh +//! --from-pubkey` and `heima-scope-set.sh --webauthn`) because chain submission +//! lives in shell + `cast`; those two helpers are the deterministic two-step +//! split the test drives. `agent pending` is the production-flow rendezvous: the +//! master discovers "agent-X wants to pair, wants `[scope]`" by pulling the broker. + +use anyhow::{anyhow, Context, Result}; +use serde_json::{json, Value}; + +fn client() -> Result { + reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(20)) + .build() + .context("build http client") +} + +/// Resolve the master `J1` bearer: explicit `session_bearer` if non-empty, else +/// the stored `master` session token. +fn resolve_bearer(session_bearer: &str) -> Result { + if !session_bearer.trim().is_empty() { + return Ok(session_bearer.trim().to_string()); + } + let sess = agentkeys_core::session_store::load_session("master") + .context("no --session-bearer given and no stored `master` session to fall back on")?; + 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( + broker_url: &str, + label: &str, + services: &str, + session_bearer: &str, +) -> Result { + let bearer = resolve_bearer(session_bearer)?; + let base = broker_url.trim_end_matches('/'); + let resp = client()? + .post(format!("{base}/v1/agent/create")) + .bearer_auth(bearer) + .json(&json!({ "label": label, "requested_scope": services })) + .send() + .await + .context("POST /v1/agent/create")?; + 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}")); + } + 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 +/// 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`. +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('/'); + let resp = client()? + .get(format!("{base}/v1/agent/pending-bindings")) + .bearer_auth(bearer) + .send() + .await + .context("GET /v1/agent/pending-bindings")?; + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(anyhow!("agent pending failed: HTTP {status}: {text}")); + } + let v: Value = serde_json::from_str(&text).with_context(|| format!("parse: {text}"))?; + Ok(serde_json::to_string_pretty(&v)?) +} diff --git a/crates/agentkeys-cli/src/lib.rs b/crates/agentkeys-cli/src/lib.rs index e655650b..10b9c4fb 100644 --- a/crates/agentkeys-cli/src/lib.rs +++ b/crates/agentkeys-cli/src/lib.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use std::sync::Arc; +pub mod agent_admin; pub mod device_session; pub mod hook; pub mod k11; diff --git a/crates/agentkeys-cli/src/main.rs b/crates/agentkeys-cli/src/main.rs index 8ca1d4b3..5e6fa521 100644 --- a/crates/agentkeys-cli/src/main.rs +++ b/crates/agentkeys-cli/src/main.rs @@ -688,6 +688,50 @@ 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 { + #[arg(long, help = "HDKD child label, e.g. agent-a (^[a-z0-9-]{1,32}$)")] + label: String, + #[arg( + long, + default_value = "memory", + help = "Scope the agent should get (the app-manifest); granted at approve" + )] + services: String, + #[arg( + long, + env = "AGENTKEYS_BROKER_URL", + help = "Broker base URL (OIDC issuer)" + )] + broker_url: String, + #[arg( + long, + default_value = "", + help = "Master J1 bearer (defaults to the stored `master` session)" + )] + session_bearer: String, + }, + /// Master pulls redeemed-but-unbound agents — "agent-X wants to pair + wants + /// [scope]" — the production push-notification substrate. Each row carries + /// the device artifact the master submits with registerAgentDevice. (issue #144) + #[command(about = "Master: list agents awaiting binding approval")] + Pending { + #[arg( + long, + env = "AGENTKEYS_BROKER_URL", + help = "Broker base URL (OIDC issuer)" + )] + broker_url: String, + #[arg( + long, + default_value = "", + help = "Master J1 bearer (defaults to the stored `master` session)" + )] + session_bearer: String, + }, } async fn cmd_chain(ctx: &CommandContext, action: &ChainAction) -> anyhow::Result { @@ -1127,6 +1171,24 @@ async fn main() { ) .await } + AgentAction::Create { + label, + services, + broker_url, + session_bearer, + } => { + agentkeys_cli::agent_admin::agent_create( + broker_url, + label, + services, + session_bearer, + ) + .await + } + AgentAction::Pending { + broker_url, + session_bearer, + } => agentkeys_cli::agent_admin::agent_pending(broker_url, session_bearer).await, }, }; diff --git a/crates/agentkeys-core/Cargo.toml b/crates/agentkeys-core/Cargo.toml index ffdc3392..986ee68e 100644 --- a/crates/agentkeys-core/Cargo.toml +++ b/crates/agentkeys-core/Cargo.toml @@ -33,6 +33,9 @@ rand = "0.8" # (tests, CLI preview); sha3 for keccak256 in the EIP-712 encoder. k256 = { version = "0.13", features = ["ecdsa", "sha2"] } sha3 = "0.10" +# Issue #144 — device_crypto keygen (SigningKey::random). Same rand_core version +# as the CLI/broker so the workspace lock is unchanged. +rand_core = { version = "0.6", features = ["std"] } [dev-dependencies] tempfile = "3" diff --git a/crates/agentkeys-core/src/actor_omni.rs b/crates/agentkeys-core/src/actor_omni.rs index ed35f045..0908d61e 100644 --- a/crates/agentkeys-core/src/actor_omni.rs +++ b/crates/agentkeys-core/src/actor_omni.rs @@ -62,6 +62,71 @@ pub fn actor_omni_hex(wallet: &WalletAddress) -> String { hex::encode(actor_omni_from_wallet(wallet)) } +/// Domain tag for HDKD child-omni derivation (issue #144 / arch.md §6.2). +/// Distinct from `DOMAIN` so a wallet-omni and a child-omni can never collide. +const HDKD_DOMAIN: &[u8] = b"agentkeys-hdkd-v1"; + +/// Validate an HDKD child label (`^[a-z0-9-]{1,32}$`). The label is spliced into +/// the child-omni digest AND stored/echoed on chain + in JWT claims, so it must +/// be a tight charset (no path separators, no whitespace, no uppercase). +pub fn validate_label(label: &str) -> anyhow::Result<()> { + if label.is_empty() || label.len() > 32 { + return Err(anyhow::anyhow!( + "label must be 1..=32 chars, got {}", + label.len() + )); + } + if !label + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + { + return Err(anyhow::anyhow!("label must match ^[a-z0-9-]+$: {label}")); + } + Ok(()) +} + +/// HDKD child actor omni (issue #144 / arch.md §6.2): +/// +/// ```text +/// O_child = SHA256(HDKD_DOMAIN || O_parent_bytes || "//" || label) +/// ``` +/// +/// **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, +/// 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] { + let mut hasher = Sha256::new(); + hasher.update(HDKD_DOMAIN); + hasher.update(master_omni); + hasher.update(b"//"); + hasher.update(label.as_bytes()); + let digest = hasher.finalize(); + let mut out = [0u8; 32]; + out.copy_from_slice(&digest); + out +} + +/// [`child_omni`] over a hex parent omni (`0x`-prefixed or not), returning the +/// child as **un-prefixed** 64-char lowercase hex — matching the `omni_account` +/// JWT claim, the `agentkeys_actor_omni` PrincipalTag, and the `bots//...` +/// S3 prefix. Does NOT validate the label; call [`validate_label`] first. +pub fn child_omni_hex(master_omni_hex: &str, label: &str) -> anyhow::Result { + let h = master_omni_hex.trim(); + let h = h.strip_prefix("0x").unwrap_or(h); + let bytes = hex::decode(h).map_err(|e| anyhow::anyhow!("parent omni not hex: {e}"))?; + if bytes.len() != 32 { + return Err(anyhow::anyhow!( + "parent omni must be 32 bytes, got {}", + bytes.len() + )); + } + let mut master = [0u8; 32]; + master.copy_from_slice(&bytes); + Ok(hex::encode(child_omni(&master, label))) +} + #[cfg(test)] mod tests { use super::*; @@ -109,4 +174,63 @@ mod tests { let expected = hex::encode(hasher.finalize()); assert_eq!(hex, expected); } + + #[test] + fn child_omni_pinned_known_value() { + // Frozen vector: a drive-by edit to HDKD_DOMAIN or the input layout + // trips this immediately. Recompute only on an intentional §6.2 change. + let master = [0u8; 32]; + let got = child_omni(&master, "agent-a"); + let mut hasher = Sha256::new(); + hasher.update(b"agentkeys-hdkd-v1"); + hasher.update(master); + hasher.update(b"//"); + hasher.update(b"agent-a"); + let expected: [u8; 32] = hasher.finalize().into(); + assert_eq!(got, expected); + } + + #[test] + fn child_omni_hex_is_un_prefixed_64_and_prefix_agnostic() { + let parent = "00".repeat(32); + let c = child_omni_hex(&parent, "agent-a").unwrap(); + assert_eq!(c.len(), 64); + assert!(!c.starts_with("0x")); + assert!(c.chars().all(|ch| ch.is_ascii_hexdigit())); + // A 0x-prefixed parent yields the identical child. + assert_eq!( + c, + child_omni_hex(&format!("0x{parent}"), "agent-a").unwrap() + ); + } + + #[test] + fn child_omni_distinct_per_label_and_parent() { + let p1 = "11".repeat(32); + let p2 = "22".repeat(32); + assert_ne!( + child_omni_hex(&p1, "agent-a").unwrap(), + child_omni_hex(&p1, "agent-b").unwrap() + ); + assert_ne!( + child_omni_hex(&p1, "agent-a").unwrap(), + child_omni_hex(&p2, "agent-a").unwrap() + ); + } + + #[test] + fn child_omni_hex_rejects_bad_parent_len() { + assert!(child_omni_hex("0xdeadbeef", "agent-a").is_err()); + } + + #[test] + fn validate_label_accepts_good_rejects_bad() { + assert!(validate_label("agent-a").is_ok()); + assert!(validate_label("a1-b2-c3").is_ok()); + assert!(validate_label("").is_err()); + assert!(validate_label("Agent-A").is_err()); // uppercase + assert!(validate_label("agent/a").is_err()); // path sep + assert!(validate_label("agent a").is_err()); // whitespace + assert!(validate_label(&"a".repeat(33)).is_err()); // too long + } } diff --git a/crates/agentkeys-core/src/device_crypto.rs b/crates/agentkeys-core/src/device_crypto.rs new file mode 100644 index 00000000..81a2e0d0 --- /dev/null +++ b/crates/agentkeys-core/src/device_crypto.rs @@ -0,0 +1,277 @@ +//! Shared device-key crypto for the §10.2 agent bootstrap (issue #144). +//! +//! De-duplicates the secp256k1 / EIP-191 / keccak helpers previously inlined in +//! `agentkeys-cli::device_session` and the broker's `plugins::auth::wallet_sig`, +//! so the daemon (keygen + link-code redeem), the CLI (interim device-session), +//! and the broker (redeem `pop_sig` verify) agree byte-for-byte. +//! +//! The proof-of-possession preimage is the **deployed** one — +//! `keccak256("agentkeys-agent-pop:" || device_key_hash_hex)` — matching +//! `scripts/heima-agent-create.sh` and the on-chain `registerAgentDevice` inputs. +//! (arch.md §10.2 still shows the stale `link_code || D_pub`; the doc is being +//! reconciled to this preimage, not the other way around.) + +use std::path::Path; + +use anyhow::{anyhow, Context, Result}; +use k256::ecdsa::{RecoveryId, Signature, SigningKey, VerifyingKey}; +use sha3::{Digest, Keccak256}; + +/// Keccak-256 over `bytes`. +pub fn keccak256(bytes: &[u8]) -> [u8; 32] { + let mut h = Keccak256::new(); + h.update(bytes); + h.finalize().into() +} + +/// EVM address (`0x` + 40 lowercase hex) = last 20 bytes of +/// keccak256(uncompressed pubkey x‖y). +pub fn evm_address(vk: &VerifyingKey) -> String { + let point = vk.to_encoded_point(false); + let xy = &point.as_bytes()[1..]; // drop the 0x04 SEC1 tag → 64 bytes + let hash = keccak256(xy); + format!("0x{}", hex::encode(&hash[12..])) +} + +/// `device_key_hash = keccak256(address_bytes)` as `0x` + 64 hex. `addr` may be +/// `0x`-prefixed and any case; the 20 raw address bytes are hashed (not ASCII). +pub fn device_key_hash(addr: &str) -> Result { + let addr_lc = addr.trim().to_lowercase(); + let hex_part = addr_lc.strip_prefix("0x").unwrap_or(&addr_lc); + let addr_bytes = hex::decode(hex_part).context("address is not hex")?; + if addr_bytes.len() != 20 { + return Err(anyhow!( + "address must be 20 bytes, got {}", + addr_bytes.len() + )); + } + Ok(format!("0x{}", hex::encode(keccak256(&addr_bytes)))) +} + +/// The agent proof-of-possession preimage: +/// `keccak256("agentkeys-agent-pop:" || device_key_hash_hex)`, where +/// `device_key_hash_hex` is the `0x`-prefixed string from [`device_key_hash`]. +pub fn agent_pop_payload(device_key_hash_hex: &str) -> [u8; 32] { + keccak256(format!("agentkeys-agent-pop:{device_key_hash_hex}").as_bytes()) +} + +/// EIP-191 `personal_sign` over `message`, producing 65-byte `r‖s‖v` hex +/// (`v ∈ {27,28}`, low-s via k256). Matches the broker's ecrecover envelope. +pub fn eip191_sign(sk: &SigningKey, message: &[u8]) -> Result { + let prefix = format!("\x19Ethereum Signed Message:\n{}", message.len()); + let mut h = Keccak256::new(); + h.update(prefix.as_bytes()); + h.update(message); + let digest = h.finalize(); + let (sig, recid): (Signature, RecoveryId) = sk + .sign_prehash_recoverable(&digest) + .context("sign_prehash_recoverable")?; + let mut out = sig.to_bytes().to_vec(); // 64 bytes r‖s + out.push(27 + recid.to_byte()); + Ok(format!("0x{}", hex::encode(out))) +} + +/// EIP-191 ecrecover: recover the `0x`-lowercase signer address from a 65-byte +/// `r‖s‖v` hex signature over `message`. `v ∈ {0,1,27,28}`. +pub fn ecrecover_eip191(message: &[u8], signature_hex: &str) -> Result { + let sig_hex = signature_hex.trim().trim_start_matches("0x"); + let sig_bytes = hex::decode(sig_hex).context("signature is not hex")?; + if sig_bytes.len() != 65 { + return Err(anyhow!( + "signature must be 65 bytes, got {}", + sig_bytes.len() + )); + } + let recovery_id_byte = match sig_bytes[64] { + v @ (0 | 1) => v, + v @ (27 | 28) => v - 27, + other => return Err(anyhow!("unsupported v byte: {other}")), + }; + let recovery_id = RecoveryId::try_from(recovery_id_byte).context("bad recovery id")?; + let signature = Signature::from_slice(&sig_bytes[..64]).context("bad sig bytes")?; + let prefix = format!("\x19Ethereum Signed Message:\n{}", message.len()); + let mut h = Keccak256::new(); + h.update(prefix.as_bytes()); + h.update(message); + let digest = h.finalize(); + let vk = VerifyingKey::recover_from_prehash(&digest, &signature, recovery_id) + .map_err(|e| anyhow!("recover failed: {e}"))?; + Ok(evm_address(&vk)) +} + +fn expand_home(p: &str) -> String { + if let Some(rest) = p.strip_prefix("~/") { + if let Ok(home) = std::env::var("HOME") { + return format!("{home}/{rest}"); + } + } + p.to_string() +} + +#[cfg(unix)] +pub fn write_key_0600(path: &str, content: &str) -> Result<()> { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + let mut f = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path) + .with_context(|| format!("open {path} (0600)"))?; + f.write_all(content.as_bytes())?; + Ok(()) +} + +#[cfg(not(unix))] +pub fn write_key_0600(path: &str, content: &str) -> Result<()> { + std::fs::write(path, content).with_context(|| format!("write {path}")) +} + +/// Before loading an EXISTING device key, verify it's a regular, owner-only +/// file. A copied/restored key with group/other read bits — or a symlink to +/// another file — would otherwise still produce a valid signer, silently +/// breaking the "key never leaves / only the owner can use it" guarantee. We +/// reject (not auto-repair): loose perms mean the key may already be exposed. +#[cfg(unix)] +pub fn enforce_owner_only(path: &str) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + let meta = std::fs::symlink_metadata(path).with_context(|| format!("stat {path}"))?; + if meta.file_type().is_symlink() { + return Err(anyhow!( + "device key {path} is a symlink — refusing (key-custody); use a real owner-only file or regenerate" + )); + } + if !meta.file_type().is_file() { + return Err(anyhow!( + "device key {path} is not a regular file — refusing" + )); + } + let mode = meta.permissions().mode() & 0o777; + if mode & 0o077 != 0 { + return Err(anyhow!( + "device key {path} has loose permissions {mode:o} (group/other bits set) — \ + it may already be exposed. Run `chmod 600 {path}` (or regenerate) and retry." + )); + } + Ok(()) +} + +#[cfg(not(unix))] +pub fn enforce_owner_only(_path: &str) -> Result<()> { + Ok(()) +} + +/// An agent's secp256k1 device key (K10). Generated IN THE SANDBOX and never +/// leaves it; used only for the redeem `pop_sig` and per-request cap-mint sigs. +/// Its omni is decoupled from this key (issue #144 decision 2 — the omni is the +/// broker-minted HDKD child). +pub struct DeviceKey { + sk: SigningKey, + address: String, +} + +impl DeviceKey { + /// Load the owner-only key file if present (rejecting loose perms / symlinks), + /// else generate a fresh key and persist it `0600`. `regen` forces a fresh + /// key. `key_file` may use a leading `~/`. + /// + /// On a failed bind/grant the caller should re-run WITHOUT `regen` so the + /// same key (→ same `device_key_hash`) is reused and the on-chain submit + /// hits the `already-registered` short-circuit instead of binding a 2nd key. + pub fn load_or_generate(key_file: &str, regen: bool) -> Result { + let key_path = expand_home(key_file); + if regen { + let _ = std::fs::remove_file(&key_path); + } + let sk = if Path::new(&key_path).exists() { + enforce_owner_only(&key_path)?; + let raw = std::fs::read_to_string(&key_path).context("read device key file")?; + let raw = raw.trim().trim_start_matches("0x"); + let bytes = hex::decode(raw).context("device key file is not hex")?; + SigningKey::from_slice(&bytes).context("invalid secp256k1 device key")? + } else { + let sk = SigningKey::random(&mut rand_core::OsRng); + if let Some(dir) = Path::new(&key_path).parent() { + std::fs::create_dir_all(dir).ok(); + } + write_key_0600(&key_path, &format!("0x{}", hex::encode(sk.to_bytes())))?; + sk + }; + let address = evm_address(sk.verifying_key()); + Ok(Self { sk, address }) + } + + /// The K10 EVM address (`0x` + 40 lowercase hex). + pub fn address(&self) -> &str { + &self.address + } + + /// `device_key_hash = keccak256(address_bytes)`. + pub fn device_key_hash(&self) -> Result { + device_key_hash(&self.address) + } + + /// EIP-191 sign `message` with this device key. + pub fn sign_eip191(&self, message: &[u8]) -> Result { + eip191_sign(&self.sk, message) + } + + /// Proof-of-possession signature the broker verifies at link-code redeem and + /// the master submits with `registerAgentDevice`: + /// `EIP-191( keccak256("agentkeys-agent-pop:" || device_key_hash) )`. + pub fn pop_sig(&self) -> Result { + let dkh = self.device_key_hash()?; + self.sign_eip191(&agent_pop_payload(&dkh)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn eip191_round_trip_recovers_signer() { + let sk = SigningKey::random(&mut rand_core::OsRng); + let addr = evm_address(sk.verifying_key()); + let msg = b"hello agentkeys"; + let sig = eip191_sign(&sk, msg).unwrap(); + let recovered = ecrecover_eip191(msg, &sig).unwrap(); + assert_eq!(recovered, addr); + } + + #[test] + fn pop_sig_recovers_to_device_address() { + // The redeem-critical match: the broker recovers the device address from + // pop_sig over agent_pop_payload(device_key_hash) and checks it equals + // the supplied device_pubkey. + let dir = tempfile::tempdir().unwrap(); + let kf = dir.path().join("dev.key"); + let dk = DeviceKey::load_or_generate(kf.to_str().unwrap(), true).unwrap(); + let dkh = dk.device_key_hash().unwrap(); + let pop = dk.pop_sig().unwrap(); + let recovered = ecrecover_eip191(&agent_pop_payload(&dkh), &pop).unwrap(); + assert_eq!(recovered, dk.address()); + } + + #[test] + fn device_key_persists_and_reloads_same_address() { + let dir = tempfile::tempdir().unwrap(); + let kf = dir.path().join("dev.key"); + let a = DeviceKey::load_or_generate(kf.to_str().unwrap(), false).unwrap(); + let b = DeviceKey::load_or_generate(kf.to_str().unwrap(), false).unwrap(); + assert_eq!(a.address(), b.address()); + } + + #[test] + fn device_key_hash_is_0x_64_hex() { + let h = device_key_hash("0x0000000000000000000000000000000000000000").unwrap(); + assert!(h.starts_with("0x")); + assert_eq!(h.len(), 66); + } + + #[test] + fn ecrecover_rejects_wrong_length() { + assert!(ecrecover_eip191(b"x", "0x00").is_err()); + } +} diff --git a/crates/agentkeys-core/src/lib.rs b/crates/agentkeys-core/src/lib.rs index b9fedcaa..5b5926c4 100644 --- a/crates/agentkeys-core/src/lib.rs +++ b/crates/agentkeys-core/src/lib.rs @@ -4,6 +4,7 @@ pub mod auth_request; pub mod backend; pub mod chain_profile; pub mod clear_signing; +pub mod device_crypto; pub mod init_flow; pub mod mock_client; pub mod otp; diff --git a/crates/agentkeys-daemon/src/main.rs b/crates/agentkeys-daemon/src/main.rs index e7187dd3..19f6bf0d 100644 --- a/crates/agentkeys-daemon/src/main.rs +++ b/crates/agentkeys-daemon/src/main.rs @@ -1,11 +1,11 @@ use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use agentkeys_core::backend::CredentialBackend; use agentkeys_core::init_flow; use agentkeys_core::mock_client::MockHttpClient; use agentkeys_core::session_store; -use agentkeys_types::WalletAddress; +use agentkeys_types::{Session, WalletAddress}; use anyhow::Context; use clap::Parser; use tracing::info; @@ -169,6 +169,23 @@ struct Args { /// or OAuth2 callback before failing init. #[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`. + #[arg(long, conflicts_with_all = ["init_email", "init_oauth2_google", "recover"])] + init_link_code: Option, + + /// Path to the agent's K10 device-key file for `--init-link-code`. 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 + /// bind/grant keeps the same `device_key_hash` (already-registered skip). + #[arg(long, env = "AGENTKEYS_DEVICE_KEY_FILE")] + device_key_file: Option, } #[tokio::main] @@ -191,6 +208,13 @@ 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; + } + // 1. Apply kernel hardening let _hardening_report = hardening::apply_hardening()?; @@ -416,6 +440,121 @@ 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. +/// +/// 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<()> { + 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") + })?; + let base = broker_url.trim_end_matches('/').to_string(); + + let key_file = args + .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 + // 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")?; + 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")?; + let resp = client + .post(format!("{base}/v1/auth/link-code/redeem")) + .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")?; + 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}"); + } + let body: serde_json::Value = + serde_json::from_str(&text).with_context(|| format!("parse redeem response: {text}"))?; + let session_jwt = body + .get("session_jwt") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("redeem response missing session_jwt: {text}"))?; + let child_omni = body + .get("child_omni") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + let operator_omni = body + .get("operator_omni") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + let derivation_path = body + .get("derivation_path") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + + // Persist J1_agent so a daemon restart resumes (Session.wallet = K10 address; + // the HDKD omni rides inside the J1 claims, not in Session.wallet). + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let sess = Session { + token: session_jwt.to_string(), + wallet: WalletAddress(device_pubkey.clone()), + scope: None, + created_at: now, + ttl_seconds: 18_000, + }; + let sid = args + .session_id + .clone() + .unwrap_or_else(|| format!("daemon-{child_omni}")); + session_store::save_session(&sess, &sid).context("save link-code session")?; + + info!( + target: "agentkeys.daemon.init", + child_omni = %child_omni, + operator_omni = %operator_omni, + device = %device_pubkey, + session_id = %sid, + "agentkeys-daemon redeemed §10.2 link code — J1_agent persisted" + ); + + // Binding artifact on STDOUT (logs are on stderr). Same fields the master's + // chain helper consumes; pop_sig + device_key_hash let the master submit + // registerAgentDevice without re-deriving. + println!( + "{}", + serde_json::json!({ + "agent_address": device_pubkey, + "actor_omni": child_omni, + "operator_omni": operator_omni, + "derivation_path": derivation_path, + "device_key_hash": device_key_hash, + "pop_sig": pop_sig, + "session_jwt": session_jwt, + "link_code": link_code, + "key_file": key_file, + }) + ); + Ok(()) +} + /// Drive the issue-#74-step-1 bootstrap chain. Reads `--init-email` / /// `--init-oauth2-google` / `--signer-url` / `--broker-url` / /// `--init-chain-id` / `--init-poll-timeout-seconds` from `args` and diff --git a/docs/arch.md b/docs/arch.md index d1f69cdc..fff94b09 100644 --- a/docs/arch.md +++ b/docs/arch.md @@ -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** = `HDKD(O_master, "//