Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 20 additions & 15 deletions crates/agentkeys-broker-server/src/boot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ use crate::jwt::SessionKeypair;
use crate::oidc::OidcKeypair;
use crate::plugins::audit::{AuditAnchor, AuditPolicy};
use crate::plugins::PluginRegistry;
use crate::storage::{AuthNonceStore, GrantStore, IdentityLinkStore, LinkCodeStore, WalletStore};
use crate::storage::{
AuthNonceStore, GrantStore, IdentityLinkStore, PairingRequestStore, WalletStore,
};

/// Outcome of the synchronous Tier-1 boot phase.
pub struct BootArtifacts {
Expand All @@ -41,8 +43,9 @@ pub struct BootArtifacts {
pub nonce_store: Arc<AuthNonceStore>,
pub grant_store: Arc<GrantStore>,
pub identity_link_store: Arc<IdentityLinkStore>,
/// §10.2 agent-bootstrap link-code + pending-binding store (issue #144).
pub link_code_store: Arc<LinkCodeStore>,
/// §10.2 agent-initiated pairing-request + pending-binding store (issue #144,
/// method A).
pub pairing_request_store: Arc<PairingRequestStore>,
/// 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
Expand Down Expand Up @@ -185,14 +188,16 @@ pub fn run_tier1(config: &BrokerConfig) -> anyhow::Result<BootArtifacts> {
)
})?,
);
let link_code_store = Arc::new(LinkCodeStore::open(&link_codes_path(config)).map_err(|e| {
boot_fail(
env::BROKER_AUDIT_DB_PATH,
&config.audit_db_path.display().to_string(),
format!("LinkCodeStore: {}", e),
"link-codes-db",
)
})?);
let pairing_request_store = Arc::new(
PairingRequestStore::open(&pairing_requests_path(config)).map_err(|e| {
boot_fail(
env::BROKER_AUDIT_DB_PATH,
&config.audit_db_path.display().to_string(),
format!("PairingRequestStore: {}", e),
"pairing-requests-db",
)
})?,
);

// 5. Validate + parse plugin selection env vars. Every name in each
// list must resolve at compile time (i.e. the corresponding
Expand Down Expand Up @@ -235,7 +240,7 @@ pub fn run_tier1(config: &BrokerConfig) -> anyhow::Result<BootArtifacts> {
nonce_store,
grant_store,
identity_link_store,
link_code_store,
pairing_request_store,
#[cfg(feature = "auth-email-link")]
email_link: built.email_link,
#[cfg(feature = "auth-oauth2")]
Expand Down Expand Up @@ -311,12 +316,12 @@ fn identity_links_path(config: &BrokerConfig) -> std::path::PathBuf {
.unwrap_or_else(|| std::path::PathBuf::from("identity_links.sqlite"))
}

fn link_codes_path(config: &BrokerConfig) -> std::path::PathBuf {
fn pairing_requests_path(config: &BrokerConfig) -> std::path::PathBuf {
config
.audit_db_path
.parent()
.map(|p| p.join("link_codes.sqlite"))
.unwrap_or_else(|| std::path::PathBuf::from("link_codes.sqlite"))
.map(|p| p.join("pairing_requests.sqlite"))
.unwrap_or_else(|| std::path::PathBuf::from("pairing_requests.sqlite"))
}

#[cfg(feature = "audit-sqlite")]
Expand Down
116 changes: 116 additions & 0 deletions crates/agentkeys-broker-server/src/handlers/agent/claim.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
//! `POST /v1/agent/pairing/claim` — master claims an agent's pairing request
//! (method A §10.2, replaces master-mints-`/v1/agent/create`).
//!
//! Gated by the master's `J1` session bearer. The master scans/enters the
//! `pairing_code` the agent displayed; this is the binding act — the agent never
//! named a master, so an unclaimed request is inert (Sybil-safe). On claim the
//! broker:
//!
//! 1. derives the HDKD child omni `O_agent = SHA256(HDKD_DOMAIN || O_master || "//label")`
//! — the master "adopts" the agent under its own omni tree;
//! 2. assigns `operator_omni` + `child_omni` + `label` + `requested_scope` onto
//! the (previously unbound) row, marking it claimed;
//! 3. returns the captured `device_pubkey` + `device_key_hash` so the master can
//! REVIEW the device (the M second-factor, preserved) and submit
//! `registerAgentDevice` without recomputing the hash.
//!
//! `J1_agent` is NOT minted here — the agent mints it itself at poll time by
//! re-proving device-key possession (so no bearer secret sits at rest, and the
//! JWT TTL starts at retrieval). This handler only flips the request to claimed
//! + records the pending binding the master then approves on chain.

use axum::{extract::State, http::HeaderMap, http::StatusCode, response::IntoResponse, Json};
use serde::Deserialize;
use serde_json::json;

use crate::error::BrokerError;
use crate::handlers::agent::unix_now;
use crate::handlers::grant::require_session_jwt;
use crate::state::SharedState;
use crate::storage::PairingClaim;

#[derive(Debug, Deserialize)]
pub struct PairingClaimBody {
/// The `pairing_code` the agent displayed (scanned/entered by the master).
pub pairing_code: String,
/// HDKD child label, e.g. `"agent-a"` (`^[a-z0-9-]{1,32}$`).
pub label: String,
/// Scope the master intends to grant the agent (the "app manifest").
/// Defaults to `"memory"`. Comma-separated service list mirrors
/// `heima-scope-set.sh --services`.
#[serde(default)]
pub requested_scope: Option<String>,
}

pub async fn pairing_claim(
State(state): State<SharedState>,
headers: HeaderMap,
Json(body): Json<PairingClaimBody>,
) -> Result<impl IntoResponse, BrokerError> {
let session = require_session_jwt(&headers, &state)?;
let master_omni = session.agentkeys.omni_account;

agentkeys_core::actor_omni::validate_label(&body.label)
.map_err(|e| BrokerError::BadRequest(format!("invalid label: {e}")))?;
let child_omni = agentkeys_core::actor_omni::child_omni_hex(&master_omni, &body.label)
.map_err(|e| BrokerError::BadRequest(format!("derive child omni: {e}")))?;

let requested_scope = body
.requested_scope
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| "memory".to_string());

let now = unix_now()?;
let (request_id, device_pubkey, pop_sig) = match state.pairing_request_store.claim(
&body.pairing_code,
&master_omni,
&child_omni,
&body.label,
&requested_scope,
now,
)? {
PairingClaim::Claimed {
request_id,
device_pubkey,
pop_sig,
} => (request_id, device_pubkey, pop_sig),
PairingClaim::Expired => {
return Err(BrokerError::Unauthorized(
"pairing request expired (>600s after the agent opened it)".into(),
));
}
PairingClaim::NotFoundOrClaimed => {
return Err(BrokerError::Unauthorized(
"pairing code unknown or already claimed".into(),
));
}
};

// Best-effort device_key_hash so the master needn't recompute it for
// registerAgentDevice. A malformed stored address (shouldn't happen — it
// round-tripped through /request) degrades to empty rather than failing.
let device_key_hash =
agentkeys_core::device_crypto::device_key_hash(&device_pubkey).unwrap_or_default();

tracing::info!(
operator_omni = %master_omni,
child_omni = %child_omni,
label = %body.label,
device = %device_pubkey,
"claimed §10.2 pairing request — pending binding recorded, awaiting on-chain bind"
);

Ok((
StatusCode::OK,
Json(json!({
"request_id": request_id,
"child_omni": child_omni,
"operator_omni": master_omni,
"label": body.label,
"requested_scope": requested_scope,
"device_pubkey": device_pubkey,
"pop_sig": pop_sig,
"device_key_hash": device_key_hash,
})),
))
}
79 changes: 0 additions & 79 deletions crates/agentkeys-broker-server/src/handlers/agent/create.rs

This file was deleted.

32 changes: 21 additions & 11 deletions crates/agentkeys-broker-server/src/handlers/agent/mod.rs
Original file line number Diff line number Diff line change
@@ -1,23 +1,33 @@
//! §10.2 agent-bootstrap endpoints (issue #144).
//! §10.2 agent-bootstrap endpoints — **method A, agent-initiated** (issue #144,
//! flipped from master-initiated; design doc
//! `docs/spec/plans/agent-initiated-pairing-method-a.md`).
//!
//! Three endpoints implement the link-code ceremony with the master submitting
//! the on-chain binding (decision 1 — no contract change, no broker chain key):
//! The agent shows a code, the master claims it (the Matter/HomeKit IoT model),
//! and the master still submits the on-chain binding (decision 1 — no contract
//! change, no broker chain key):
//!
//! - `POST /v1/agent/create` (master, `J1_master`-gated) — mint a one-time link
//! code bound to the HDKD child omni `O_agent = SHA256(.. || O_master || "//label")`.
//! - `POST /v1/auth/link-code/redeem` (agent, no bearer) — verify the agent's
//! `pop_sig`, consume the code, mint `J1_agent`, and stash the device artifact
//! as a pending binding.
//! - `POST /v1/agent/pairing/request` (agent, no bearer) — verify the agent's
//! `pop_sig`, store an UNBOUND request (naming no master), return a
//! `pairing_code` to display + a secret `request_id` retrieval ticket.
//! - `POST /v1/agent/pairing/claim` (master, `J1_master`-gated) — claim the
//! code; derive the HDKD child omni `O_agent = SHA256(.. || O_master || "//label")`,
//! mark the request claimed, and stash the device artifact as a pending binding.
//! - `POST /v1/agent/pairing/poll` (agent, no bearer) — once claimed, re-prove
//! device-key possession (fresh `pop_sig`) and mint + retrieve `J1_agent`.
//! - `GET /v1/agent/pending-bindings` (master, `J1_master`-gated) — pull the
//! redeemed-but-unbound rows to approve (the push-notification substrate).
//! claimed-but-unbound rows to approve (the push-notification substrate).
//!
//! The broker never K11-verifies on the agent path — agents are K10-only per the
//! contract (`registerAgentDevice` writes `k11CredId = 0`). The master's K11
//! gesture happens later, when it submits the on-chain binding + scope grant.
//!
//! Agent-side unbind / factory-reset + re-pair is out of this PR (→ #156); on-
//! chain agent self-revoke is out of this PR (→ #155).

pub mod create;
pub mod claim;
pub mod pending;
pub mod redeem;
pub mod poll;
pub mod request;

use std::time::{SystemTime, UNIX_EPOCH};

Expand Down
30 changes: 16 additions & 14 deletions crates/agentkeys-broker-server/src/handlers/agent/pending.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@
//! (issue #144 §10.2).
//!
//! Gated by the master's `J1` session bearer. Returns the operator's rows that
//! have been redeemed (`device_pubkey` + `pop_sig` captured) but not yet bound
//! on-chain — i.e. "agent-A wants to pair + wants `[requested_scope]`". This is
//! the substrate the production push notification carries; the master pulls it,
//! then approves with one K11 gesture (bind + scope). `device_key_hash` is
//! pre-computed so the master can submit `registerAgentDevice` without recomputing.
//! have been claimed (`device_pubkey` + `pop_sig` captured at /request, operator
//! assigned at /claim) but not yet bound on-chain — i.e. "agent-A wants to pair
//! and wants `[requested_scope]`". This is the substrate the production push
//! notification carries; the master pulls it, then approves with one K11 gesture
//! (bind plus scope). `device_key_hash` is pre-computed so the master can submit
//! `registerAgentDevice` without recomputing. Rows are keyed by `request_id` (the
//! method-A handle the master acks by).

use axum::{extract::State, http::HeaderMap, http::StatusCode, response::IntoResponse, Json};
use serde::Deserialize;
Expand All @@ -24,18 +26,18 @@ pub async fn pending_bindings(
let session = require_session_jwt(&headers, &state)?;
let master_omni = session.agentkeys.omni_account;

let rows = state.link_code_store.pending_bindings(&master_omni)?;
let rows = state.pairing_request_store.pending_bindings(&master_omni)?;
let pending: Vec<_> = rows
.into_iter()
.map(|b| {
// Best-effort device_key_hash so the master needn't recompute. A
// malformed stored address (shouldn't happen — it round-tripped
// through redeem) degrades to an empty string rather than failing
// through /request) degrades to an empty string rather than failing
// the whole list.
let device_key_hash = agentkeys_core::device_crypto::device_key_hash(&b.device_pubkey)
.unwrap_or_default();
json!({
"link_code": b.link_code,
"request_id": b.request_id,
"child_omni": b.child_omni,
"operator_omni": b.operator_omni,
"label": b.label,
Expand All @@ -52,13 +54,13 @@ pub async fn pending_bindings(

#[derive(Debug, Deserialize)]
pub struct AckBody {
/// The link code whose redeemed binding the master just submitted on chain.
pub link_code: String,
/// The `request_id` whose claimed binding the master just submitted on chain.
pub request_id: String,
}

/// `POST /v1/agent/pending-bindings/ack` — the master acks that it submitted
/// `registerAgentDevice` for this binding, so it drops out of the pending list
/// (issue #144). Without this the rendezvous would never clear — every redeemed
/// (issue #144). Without this the rendezvous would never clear — every claimed
/// agent would show as "pending" forever even after it's bound on chain. Scoped
/// to the master's omni; idempotent (a second ack is a no-op → `acked: false`).
pub async fn ack_binding(
Expand All @@ -70,10 +72,10 @@ pub async fn ack_binding(
let master_omni = session.agentkeys.omni_account;
let now = unix_now()?;
let updated = state
.link_code_store
.mark_bound(&body.link_code, &master_omni, now)?;
.pairing_request_store
.mark_bound(&body.request_id, &master_omni, now)?;
Ok((
StatusCode::OK,
Json(json!({ "acked": updated > 0, "link_code": body.link_code })),
Json(json!({ "acked": updated > 0, "request_id": body.request_id })),
))
}
Loading
Loading