From 733b929b5b98b6de7f78157c92fafb8c5ebd9d1f Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Sun, 31 May 2026 16:49:00 +0800 Subject: [PATCH] mcp+memory: bind namespace into the signed cap service (#147, approach B) memory.put/get now mint the cap with service="memory:" instead of a static "memory". Because the broker signs `service` and the worker already derives the S3 key, AAD, and on-chain scope check from cap.payload.service, this makes the namespace: - tamper-proof (signed into the cap), - authorized via the existing isServiceInScope gate, - storage-segregated (bots//memory/memory:.enc), - AAD-bound, with NO CapPayload change, NO broker change, and no byte-exact broker<->worker signature risk. Also fixes a latent bug where every namespace collided at the single memory.enc key. No worker behavior change (it already keys/scopes/AADs off the signed service); added a test proving namespace-folded services segregate storage. Verified: cargo test -p agentkeys-mcp-server (35) + -p agentkeys-worker-memory green. --- .../agentkeys-mcp-server/src/tools/memory.rs | 26 ++++++++++++------- .../agentkeys-worker-memory/src/handlers.rs | 15 +++++++++++ 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/crates/agentkeys-mcp-server/src/tools/memory.rs b/crates/agentkeys-mcp-server/src/tools/memory.rs index 91f735f5..dbf85385 100644 --- a/crates/agentkeys-mcp-server/src/tools/memory.rs +++ b/crates/agentkeys-mcp-server/src/tools/memory.rs @@ -63,11 +63,14 @@ pub async fn put( "device_key_hash", config.default_device_key_hash.as_deref(), )?; - let service = params - .get("service") - .and_then(|v| v.as_str()) - .unwrap_or("memory") - .to_string(); + // Issue #147 (approach B): fold the namespace into the SIGNED `service`, + // so the cap is cryptographically bound to exactly one namespace and + // authorized via the existing on-chain `isServiceInScope` check. A + // `memory:travel` cap cannot touch `memory:personal` — different service + // ⇒ different scope entry, different S3 key, different AAD. No CapPayload + // change, no broker change: the broker already signs whatever `service` + // it's given and the worker already keys storage + scope + AAD off it. + let service = format!("memory:{namespace}"); let ttl_seconds = params .get("ttl_seconds") .and_then(|v| v.as_u64()) @@ -140,11 +143,14 @@ pub async fn get( "device_key_hash", config.default_device_key_hash.as_deref(), )?; - let service = params - .get("service") - .and_then(|v| v.as_str()) - .unwrap_or("memory") - .to_string(); + // Issue #147 (approach B): fold the namespace into the SIGNED `service`, + // so the cap is cryptographically bound to exactly one namespace and + // authorized via the existing on-chain `isServiceInScope` check. A + // `memory:travel` cap cannot touch `memory:personal` — different service + // ⇒ different scope entry, different S3 key, different AAD. No CapPayload + // change, no broker change: the broker already signs whatever `service` + // it's given and the worker already keys storage + scope + AAD off it. + let service = format!("memory:{namespace}"); let ttl_seconds = params .get("ttl_seconds") .and_then(|v| v.as_u64()) diff --git a/crates/agentkeys-worker-memory/src/handlers.rs b/crates/agentkeys-worker-memory/src/handlers.rs index b11997b9..6fb5eb63 100644 --- a/crates/agentkeys-worker-memory/src/handlers.rs +++ b/crates/agentkeys-worker-memory/src/handlers.rs @@ -281,4 +281,19 @@ mod tests { fn s3_prefix_uses_memory_path() { assert_eq!(s3_prefix("0xABCDEF"), "bots/abcdef/memory/"); } + + #[test] + fn namespace_folded_service_segregates_storage() { + // Issue #147 (approach B): the MCP mints memory caps with + // service="memory:". Because the worker keys S3 off the + // SIGNED service, two namespaces land at distinct keys — a + // `memory:travel` cap physically cannot read/write the + // `memory:personal` object. This is the namespace-isolation gate, + // enforced by construction (signed service ⇒ key + scope + AAD). + let travel = s3_key("0xabc", "memory:travel"); + let personal = s3_key("0xabc", "memory:personal"); + assert_ne!(travel, personal); + assert_eq!(travel, "bots/abc/memory/memory:travel.enc"); + assert!(personal.contains("memory:personal")); + } }