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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

80 changes: 80 additions & 0 deletions crates/agentkeys-broker-server/src/handlers/cap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ use p256::ecdsa::{signature::Signer, Signature, SigningKey};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};

use agentkeys_types::Namespace;

use crate::jwt::verify::verify_session_jwt;
use crate::state::SharedState;

Expand Down Expand Up @@ -83,6 +85,16 @@ pub struct CapPayload {
/// Data class binding (issue #90 followup). REQUIRED; workers reject
/// caps whose data_class doesn't match their bucket.
pub data_class: DataClass,
/// Memory namespaces this cap may read/write (issue #108,
/// agent-iam-strategy.md §3.5). The broker SIGNS this list into the
/// payload; the memory worker filters by deterministic string-set
/// membership. Empty for credential caps (namespaces are a
/// memory-data-class concept only). MUST stay at this field position
/// in both the broker and `agentkeys_worker_creds::verify::CapPayload`
/// — the signature is over the serialized struct, so field order must
/// match byte-for-byte across the sign/verify boundary.
#[serde(default)]
pub namespaces_allowed: Vec<String>,
pub device_key_hash: String,
pub k3_epoch: u64,
pub issued_at: u64,
Expand All @@ -104,6 +116,14 @@ pub struct CapRequest {
pub device_key_hash: String,
#[serde(default = "default_ttl_seconds")]
pub ttl_seconds: u64,
/// Memory namespaces the minted cap should grant (issue #108). Only
/// honored for the `/v1/cap/memory-*` endpoints; ignored for cred
/// caps. Each entry MUST be one of the v0 namespaces or the mint is
/// rejected with 400 (a typo'd namespace must not silently grant
/// nothing). Empty/absent → cap grants no namespaces (worker denies
/// every namespaced read/write).
#[serde(default)]
pub namespaces_allowed: Vec<String>,
}

fn default_ttl_seconds() -> u64 {
Expand Down Expand Up @@ -290,6 +310,15 @@ async fn mint_cap(
// 3. K3EpochCounter.currentEpoch → embed.
let k3_epoch = call_current_epoch(&state.http, &chain.rpc_url, &chain.epoch).await?;

// Namespace claim (issue #108): only memory caps carry namespaces;
// credential caps always get an empty list. Validate + normalize so a
// typo'd namespace is rejected at mint time (not silently signed into
// a cap that grants nothing).
let namespaces_allowed = match data_class {
DataClass::Memory => normalize_namespaces(&req.namespaces_allowed)?,
DataClass::Credentials => Vec::new(),
};

// 4. Build payload + sign.
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
Expand All @@ -305,6 +334,7 @@ async fn mint_cap(
service: req.service.to_lowercase(),
op,
data_class,
namespaces_allowed,
device_key_hash: format!("0x{}", strip_0x_lc(&req.device_key_hash)),
k3_epoch,
issued_at: now,
Expand Down Expand Up @@ -538,6 +568,31 @@ fn strip_0x_lc(s: &str) -> String {
s.strip_prefix("0x").unwrap_or(s).to_lowercase()
}

/// Validate + normalize a requested namespace list for a memory cap.
/// Lowercases, rejects any name outside the v0 set (issue #108), and
/// de-duplicates while preserving first-seen order. An unknown name is a
/// 400 so a typo can't silently mint a cap that grants nothing.
fn normalize_namespaces(requested: &[String]) -> Result<Vec<String>, CapError> {
let mut out: Vec<String> = Vec::with_capacity(requested.len());
for raw in requested {
let lc = raw.trim().to_lowercase();
if !Namespace::is_valid(&lc) {
return Err(CapError::InvalidInput(format!(
"unknown namespace `{raw}` — must be one of: {}",
Namespace::ALL
.iter()
.map(|n| n.as_str())
.collect::<Vec<_>>()
.join(", ")
)));
}
if !out.contains(&lc) {
out.push(lc);
}
}
Ok(out)
}

fn parse_bool_result(s: &str) -> bool {
s.trim_start_matches("0x")
.trim_start_matches('0')
Expand Down Expand Up @@ -628,6 +683,29 @@ mod tests {
assert_eq!(h1, h2);
}

#[test]
fn normalize_namespaces_lowercases_dedupes_and_orders() {
let got = normalize_namespaces(&[
"Travel".into(),
"personal".into(),
"travel".into(), // dup after lowercasing
])
.unwrap();
assert_eq!(got, vec!["travel".to_string(), "personal".to_string()]);
}

#[test]
fn normalize_namespaces_rejects_unknown() {
// `profile` is a memory TYPE, not a namespace — must 400.
let err = normalize_namespaces(&["profile".into()]).unwrap_err();
assert!(matches!(err, CapError::InvalidInput(_)));
}

#[test]
fn normalize_namespaces_empty_is_ok() {
assert_eq!(normalize_namespaces(&[]).unwrap(), Vec::<String>::new());
}

#[test]
fn validate_hex32_accepts_well_formed() {
let valid = "0x".to_string() + &"a".repeat(64);
Expand Down Expand Up @@ -720,6 +798,7 @@ mod tests {
service: "openrouter".into(),
op: CapOp::Store,
data_class: DataClass::Credentials,
namespaces_allowed: vec![],
device_key_hash: format!("0x{}", "c".repeat(64)),
k3_epoch: 1,
issued_at: 1,
Expand Down Expand Up @@ -747,6 +826,7 @@ mod tests {
service: "openrouter".into(),
op: CapOp::Store,
data_class: dc,
namespaces_allowed: vec![],
device_key_hash: format!("0x{}", "c".repeat(64)),
k3_epoch: 1,
issued_at: 1,
Expand Down
8 changes: 8 additions & 0 deletions crates/agentkeys-core/src/audit/bodies.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@ pub struct MemoryTeardownBody {
pub actor_target: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MemoryNamespaceViolationBody {
/// The namespace the cap tried to access but wasn't granted (issue #108).
pub namespace: String,
/// `"get"` or `"put"` — which op was refused.
pub op: String,
}

// ── 20..29 — signs family ──────────────────────────────────────────────

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
Expand Down
8 changes: 6 additions & 2 deletions crates/agentkeys-core/src/audit/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ use thiserror::Error;
pub use bodies::{
CredFetchBody, CredStoreBody, CredTeardownBody, DeviceAddBody, DeviceRevokeBody,
EmailReceiveBody, EmailSendBody, K10RotateBody, K3EpochAdvanceBody, MemoryGetBody,
MemoryPutBody, MemoryTeardownBody, PaymentDirectBody, PaymentEscrowRedeemBody, ScopeGrantBody,
ScopeRevokeBody, SignEip191Body, SignEip712Body,
MemoryNamespaceViolationBody, MemoryPutBody, MemoryTeardownBody, PaymentDirectBody,
PaymentEscrowRedeemBody, ScopeGrantBody, ScopeRevokeBody, SignEip191Body, SignEip712Body,
};
pub use op_kind::AuditOpKind;

Expand Down Expand Up @@ -180,6 +180,7 @@ pub enum TypedAuditBody {
MemoryPut(MemoryPutBody),
MemoryGet(MemoryGetBody),
MemoryTeardown(MemoryTeardownBody),
MemoryNamespaceViolation(MemoryNamespaceViolationBody),
SignEip191(SignEip191Body),
SignEip712(SignEip712Body),
PaymentEscrowRedeem(PaymentEscrowRedeemBody),
Expand Down Expand Up @@ -210,6 +211,9 @@ impl TypedAuditBody {
AuditOpKind::MemoryTeardown => {
Self::MemoryTeardown(serde_json::from_value(value).ok()?)
}
AuditOpKind::MemoryNamespaceViolation => {
Self::MemoryNamespaceViolation(serde_json::from_value(value).ok()?)
}
AuditOpKind::SignEip191 => Self::SignEip191(serde_json::from_value(value).ok()?),
AuditOpKind::SignEip712 => Self::SignEip712(serde_json::from_value(value).ok()?),
AuditOpKind::PaymentEscrowRedeem => {
Expand Down
13 changes: 11 additions & 2 deletions crates/agentkeys-core/src/audit/op_kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
//! Byte ranges with reserved slots:
//!
//! - 0-9 creds family (CredStore=0, CredFetch=1, CredTeardown=2; 3-9 reserved)
//! - 10-19 memory family (MemoryPut=10, MemoryGet=11, MemoryTeardown=12; 13-19 reserved)
//! - 10-19 memory family (MemoryPut=10, MemoryGet=11, MemoryTeardown=12, MemoryNamespaceViolation=13; 14-19 reserved)
//! - 20-29 signs family (SignEip191=20, SignEip712=21; 22-29 reserved)
//! - 30-39 payments family (PaymentEscrowRedeem=30, PaymentDirect=31; 32-39 reserved)
//! - 40-49 scope family (ScopeGrant=40, ScopeRevoke=41; 42-49 reserved)
Expand All @@ -31,6 +31,11 @@ pub enum AuditOpKind {
MemoryPut = 10,
MemoryGet = 11,
MemoryTeardown = 12,
/// A cap-token tried to read/write a memory namespace not in its
/// `namespaces_allowed` claim (issue #108). The worker denies and the
/// MCP server records this audit row so a parent/operator can see the
/// over-reach attempt.
MemoryNamespaceViolation = 13,
SignEip191 = 20,
SignEip712 = 21,
PaymentEscrowRedeem = 30,
Expand All @@ -56,6 +61,7 @@ impl AuditOpKind {
10 => Self::MemoryPut,
11 => Self::MemoryGet,
12 => Self::MemoryTeardown,
13 => Self::MemoryNamespaceViolation,
20 => Self::SignEip191,
21 => Self::SignEip712,
30 => Self::PaymentEscrowRedeem,
Expand Down Expand Up @@ -83,6 +89,7 @@ impl AuditOpKind {
Self::MemoryPut => "memory.put",
Self::MemoryGet => "memory.get",
Self::MemoryTeardown => "memory.teardown",
Self::MemoryNamespaceViolation => "memory.namespace_violation",
Self::SignEip191 => "sign.eip191",
Self::SignEip712 => "sign.eip712",
Self::PaymentEscrowRedeem => "payment.escrow_redeem",
Expand Down Expand Up @@ -115,6 +122,7 @@ mod tests {
AuditOpKind::MemoryPut,
AuditOpKind::MemoryGet,
AuditOpKind::MemoryTeardown,
AuditOpKind::MemoryNamespaceViolation,
AuditOpKind::SignEip191,
AuditOpKind::SignEip712,
AuditOpKind::PaymentEscrowRedeem,
Expand Down Expand Up @@ -142,7 +150,7 @@ mod tests {
/// invariant #1 (open enum). 250 is the reserved-future canary.
#[test]
fn unknown_bytes_return_none() {
for byte in [3u8, 9, 13, 19, 22, 32, 42, 53, 62, 71, 80, 200, 250, 255] {
for byte in [3u8, 9, 14, 19, 22, 32, 42, 53, 62, 71, 80, 200, 250, 255] {
assert_eq!(
AuditOpKind::from_u8(byte),
None,
Expand All @@ -163,6 +171,7 @@ mod tests {
AuditOpKind::MemoryPut as u8,
AuditOpKind::MemoryGet as u8,
AuditOpKind::MemoryTeardown as u8,
AuditOpKind::MemoryNamespaceViolation as u8,
AuditOpKind::SignEip191 as u8,
AuditOpKind::SignEip712 as u8,
AuditOpKind::PaymentEscrowRedeem as u8,
Expand Down
6 changes: 6 additions & 0 deletions crates/agentkeys-mcp-server/src/backend/broker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,10 @@ pub struct BrokerCapRequest {
pub service: String,
pub device_key_hash: String,
pub ttl_seconds: u64,
/// Memory namespaces to grant (issue #108). The broker validates each
/// against the v0 set and signs them into the cap; ignored for cred
/// caps. Skipped on the wire when empty so cred cap-mint bodies are
/// byte-identical to pre-#108.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub namespaces_allowed: Vec<String>,
}
3 changes: 3 additions & 0 deletions crates/agentkeys-mcp-server/src/backend/http_backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ impl Backend for HttpBackend {
service: req.service,
device_key_hash: req.device_key_hash,
ttl_seconds: req.ttl_seconds,
namespaces_allowed: req.namespaces_allowed,
};

let resp = self
Expand Down Expand Up @@ -138,6 +139,7 @@ impl Backend for HttpBackend {
s3_key: parsed.s3_key,
envelope_size: parsed.envelope_size,
namespace: input.namespace,
namespace_violation: parsed.namespace_violation,
})
}

Expand Down Expand Up @@ -169,6 +171,7 @@ impl Backend for HttpBackend {
ok: parsed.ok,
plaintext_b64: parsed.plaintext_b64,
namespace: input.namespace,
namespace_violation: parsed.namespace_violation,
})
}

Expand Down
Loading
Loading