Skip to content
Open
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
11 changes: 8 additions & 3 deletions crates/agentkeys-broker-server/src/handlers/agent/claim.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
//! 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")`
//! 1. derives the HDKD child omni for the initial generation path
//! `//label/0`;
//! — 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;
Expand Down Expand Up @@ -52,8 +53,12 @@ pub async fn pairing_claim(

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 child_omni = agentkeys_core::actor_omni::child_omni_generation_hex(
&master_omni,
&body.label,
agentkeys_core::actor_omni::INITIAL_CHILD_GENERATION,
)
.map_err(|e| BrokerError::BadRequest(format!("derive child omni: {e}")))?;

let requested_scope = body
.requested_scope
Expand Down
2 changes: 1 addition & 1 deletion crates/agentkeys-broker-server/src/handlers/agent/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
//! `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")`,
//! code; derive the HDKD child omni `O_agent = SHA256(.. || O_master || "//label/0")`,
//! 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`.
Expand Down
6 changes: 5 additions & 1 deletion crates/agentkeys-broker-server/src/handlers/agent/poll.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,11 @@ pub async fn pairing_poll(

// 3. Mint J1_agent fresh (HDKD omni + lineage). The agent authenticates with
// this immediately, but has NO scope until the master approves the binding.
let derivation_path = format!("//{label}");
let derivation_path = agentkeys_core::actor_omni::generation_derivation_path(
&label,
agentkeys_core::actor_omni::INITIAL_CHILD_GENERATION,
)
.map_err(|e| BrokerError::Internal(format!("derive J1_agent path: {e}")))?;
let session_jwt = mint_agent_session_jwt(
&state.session_keypair,
&state.config.oidc_issuer,
Expand Down
2 changes: 1 addition & 1 deletion crates/agentkeys-broker-server/src/jwt/verify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ pub struct AgentKeysClaims {
/// Agent only: the parent (master) omni this child was HDKD-derived from.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_omni: Option<String>,
/// Agent only: the HDKD path, e.g. `"//agent-a"`.
/// Agent only: the HDKD path, e.g. `"//agent-a/0"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub derivation_path: Option<String>,
/// Agent only: the K10 device address whose pop_sig redeemed the link code.
Expand Down
5 changes: 3 additions & 2 deletions crates/agentkeys-broker-server/tests/agent_bootstrap_flow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ async fn full_request_claim_poll_pending_flow() {
// Public recomputability (acceptance criterion).
assert_eq!(
child_omni,
agentkeys_core::actor_omni::child_omni_hex(&master_omni, "agent-a").unwrap()
agentkeys_core::actor_omni::child_omni_generation_hex(&master_omni, "agent-a", 0).unwrap()
);
assert_eq!(claim["operator_omni"], master_omni);
assert_eq!(claim["request_id"], request_id);
Expand Down Expand Up @@ -277,8 +277,9 @@ async fn full_request_claim_poll_pending_flow() {
);
assert_eq!(
claims.agentkeys.derivation_path.as_deref(),
Some("//agent-a")
Some("//agent-a/0")
);
assert_eq!(claimed_poll["derivation_path"], "//agent-a/0");
assert_eq!(
claims.agentkeys.device_pubkey.as_deref(),
Some(dk.address())
Expand Down
88 changes: 85 additions & 3 deletions crates/agentkeys-core/src/actor_omni.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,13 @@ pub fn actor_omni_hex(wallet: &WalletAddress) -> String {
/// 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).
/// Initial child-key generation. The first pair flow always reserves the
/// generation suffix by deriving at `<label>/0`.
pub const INITIAL_CHILD_GENERATION: u32 = 0;

/// Validate an HDKD child base label (`^[a-z0-9-]{1,32}$`). The label is stored
/// and echoed as the logical agent name; derivation appends a numeric generation
/// suffix separately, so the base label must stay path-separator-free.
pub fn validate_label(label: &str) -> anyhow::Result<()> {
if label.is_empty() || label.len() > 32 {
return Err(anyhow::anyhow!(
Expand All @@ -85,6 +89,18 @@ pub fn validate_label(label: &str) -> anyhow::Result<()> {
Ok(())
}

/// HDKD path segment for one logical agent generation, e.g. `agent-a/0`.
pub fn generation_label(label: &str, generation: u32) -> anyhow::Result<String> {
validate_label(label)?;
Ok(format!("{label}/{generation}"))
}

/// JWT/audit derivation path for one logical agent generation, e.g.
/// `//agent-a/0`.
pub fn generation_derivation_path(label: &str, generation: u32) -> anyhow::Result<String> {
Ok(format!("//{}", generation_label(label, generation)?))
}

/// HDKD child actor omni (issue #144 / arch.md §6.2):
///
/// ```text
Expand All @@ -108,6 +124,19 @@ pub fn child_omni(master_omni: &[u8; 32], label: &str) -> [u8; 32] {
out
}

/// HDKD child actor omni for a logical agent at a numeric generation. This is
/// the production path for pair/rotation flows; generation 0 is the initial
/// child key, and higher generations intentionally produce different child
/// omnis for key rotation without recycling the base label.
pub fn child_omni_generation(
master_omni: &[u8; 32],
label: &str,
generation: u32,
) -> anyhow::Result<[u8; 32]> {
let generation_label = generation_label(label, generation)?;
Ok(child_omni(master_omni, &generation_label))
}

/// [`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/<hex>/...`
Expand All @@ -127,6 +156,29 @@ pub fn child_omni_hex(master_omni_hex: &str, label: &str) -> anyhow::Result<Stri
Ok(hex::encode(child_omni(&master, label)))
}

/// [`child_omni_generation`] over a hex parent omni, returning un-prefixed
/// 64-char lowercase hex.
pub fn child_omni_generation_hex(
master_omni_hex: &str,
label: &str,
generation: u32,
) -> anyhow::Result<String> {
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_generation(
&master, label, generation,
)?))
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -204,6 +256,29 @@ mod tests {
);
}

#[test]
fn child_omni_generation_appends_numeric_suffix() {
let parent = "00".repeat(32);
let gen0 = child_omni_generation_hex(&parent, "agent-a", 0).unwrap();
let explicit_path = child_omni_hex(&parent, "agent-a/0").unwrap();
assert_eq!(gen0, explicit_path);
assert_eq!(
generation_derivation_path("agent-a", INITIAL_CHILD_GENERATION).unwrap(),
"//agent-a/0"
);
}

#[test]
fn child_omni_generation_distinguishes_rotations() {
let parent = "11".repeat(32);
let gen0 = child_omni_generation_hex(&parent, "agent-a", 0).unwrap();
let gen1 = child_omni_generation_hex(&parent, "agent-a", 1).unwrap();
let gen2 = child_omni_generation_hex(&parent, "agent-a", 2).unwrap();
assert_ne!(gen0, gen1);
assert_ne!(gen1, gen2);
assert_ne!(gen0, gen2);
}

#[test]
fn child_omni_distinct_per_label_and_parent() {
let p1 = "11".repeat(32);
Expand Down Expand Up @@ -233,4 +308,11 @@ mod tests {
assert!(validate_label("agent a").is_err()); // whitespace
assert!(validate_label(&"a".repeat(33)).is_err()); // too long
}

#[test]
fn generation_helpers_reject_path_recycling_labels() {
assert!(generation_label("agent-a", 0).is_ok());
assert!(generation_label("agent-a/0", 0).is_err());
assert!(generation_label("agent a", 0).is_err());
}
}
29 changes: 19 additions & 10 deletions crates/agentkeys-daemon/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1483,12 +1483,17 @@ fn is_omni_hex(s: &str) -> bool {
/// (`^[a-z0-9-]{1,32}$`), matching the broker's `format!("//{label}")`.
fn is_derivation_path(s: &str) -> bool {
match s.strip_prefix("//") {
Some(label) => {
Some(rest) => {
let Some((label, gen)) = rest.rsplit_once('/') else {
return false;
};
!label.is_empty()
&& label.len() <= 32
&& label
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
&& !gen.is_empty()
&& gen.bytes().all(|b| b.is_ascii_digit())
}
None => false,
}
Expand Down Expand Up @@ -1911,7 +1916,7 @@ mod pairing_poll_tests {
"0xdevice",
"childomni",
"operomni",
"//hermes",
"//hermes/0",
"dkh",
"popsig",
"/s.jwt",
Expand Down Expand Up @@ -2178,8 +2183,8 @@ mod pairing_poll_tests {
// Valid shapes (64-char lowercase hex omni; //label path) pass.
assert!(is_omni_hex(&"0123456789abcdef".repeat(4)));
assert!(is_omni_hex(&"a".repeat(64)));
assert!(is_derivation_path("//hermes"));
assert!(is_derivation_path("//agent-01"));
assert!(is_derivation_path("//hermes/0"));
assert!(is_derivation_path("//agent-01/0"));

// Reflected tokens / wrong shapes are rejected — these would otherwise
// be logged + printed on stdout from a claimed body.
Expand All @@ -2199,8 +2204,11 @@ mod pairing_poll_tests {
for bad in [
"//session_jwt=SENTINEL_JWT", // label charset
"//UPPER",
"/hermes", // single slash
"//", // empty label
"/hermes", // single slash
"//", // empty label
"//hermes", // missing generation suffix
"//hermes/",
"//hermes/a",
"session_jwt=x",
"",
] {
Expand All @@ -2216,12 +2224,13 @@ mod pairing_poll_tests {
// 64-char lowercase hex operator omni; child is its REAL HDKD derivation
// (the semantic check requires child_omni == HDKD(operator, label)).
let operator = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
let child = agentkeys_core::actor_omni::child_omni_hex(operator, "hermes").unwrap();
let child =
agentkeys_core::actor_omni::child_omni_generation_hex(operator, "hermes", 0).unwrap();
let ok = serde_json::json!({
"session_jwt": "tok",
"child_omni": child.clone(),
"operator_omni": operator,
"derivation_path": "//hermes",
"derivation_path": "//hermes/0",
});
assert!(validate_claimed_binding(&ok).is_ok());

Expand All @@ -2242,7 +2251,7 @@ mod pairing_poll_tests {
// Missing session_jwt is rejected (before any field/HDKD check) without
// echoing the body.
let no_jwt = serde_json::json!({
"child_omni": child.clone(), "operator_omni": operator, "derivation_path": "//hermes",
"child_omni": child.clone(), "operator_omni": operator, "derivation_path": "//hermes/0",
});
assert!(validate_claimed_binding(&no_jwt).is_err());
}
Expand All @@ -2258,7 +2267,7 @@ mod pairing_poll_tests {
"session_jwt": "tok",
"child_omni": wrong_child,
"operator_omni": operator,
"derivation_path": "//hermes",
"derivation_path": "//hermes/0",
});
let err = validate_claimed_binding(&v)
.expect_err("HDKD child mismatch must be rejected")
Expand Down
8 changes: 4 additions & 4 deletions crates/agentkeys-daemon/src/ui_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1430,7 +1430,7 @@ mod tests {
label: "FoloToy bear".into(),
role: "agent".into(),
parent: Some("master".into()),
derivation: "//folotoy".into(),
derivation: "//folotoy/0".into(),
device: "FoloToy hardware".into(),
device_pubkey: "D_pub_folotoy".into(),
last_active: "now".into(),
Expand Down Expand Up @@ -1459,7 +1459,7 @@ mod tests {
label: "FoloToy bear".into(),
role: "agent".into(),
parent: Some("master".into()),
derivation: "//folotoy".into(),
derivation: "//folotoy/0".into(),
device: "FoloToy hardware".into(),
device_pubkey: "D_pub_folotoy".into(),
last_active: "now".into(),
Expand Down Expand Up @@ -1499,7 +1499,7 @@ mod tests {
omni_hex: "x".into(),
label: "agent-1".into(),
parent: Some("master".into()),
derivation: "//agent1".into(),
derivation: "//agent1/0".into(),
device: "".into(),
device_pubkey: "".into(),
last_active: "now".into(),
Expand Down Expand Up @@ -1713,7 +1713,7 @@ mod tests {
label: "seed".into(),
role: "agent".into(),
parent: Some("master".into()),
derivation: "//seed".into(),
derivation: "//seed/0".into(),
device: "".into(),
device_pubkey: "".into(),
last_active: "now".into(),
Expand Down
Loading
Loading