diff --git a/jcp/jcp-rs/crates/jcp-a2a/examples/profile.rs b/jcp/jcp-rs/crates/jcp-a2a/examples/profile.rs index 09463a667..a4fed5171 100644 --- a/jcp/jcp-rs/crates/jcp-a2a/examples/profile.rs +++ b/jcp/jcp-rs/crates/jcp-a2a/examples/profile.rs @@ -31,9 +31,20 @@ fn main() { let mandate = ingest_ap2_mandate(&json!({ "paymentMandate": { "payee": "research.example", "currency": "mJ", "maxAmount": 80 } })) - .expect("valid ap2 mandate"); - println!("AP2 mandate authorizes {} {} → {} µJ joule budget per skill", - mandate.max_amount, mandate.currency, mandate.joule_budget().unwrap()); + .expect("valid ap2 mandate") + // Parsing authenticates nothing: the mandate is a layered verifiable + // credential and verifying it needs issuer-key resolution JCP has no + // authority over. The host verifies, then says what did — and the + // authority stays on the mandate. + .accepted_as_verified("demo-wallet") + .expect("an authority was named"); + println!( + "AP2 mandate authorizes {} {} → {} µJ joule budget per skill (authority: {})", + mandate.max_amount, + mandate.currency, + mandate.joule_budget().unwrap(), + mandate.authority() + ); // 3. Mint one capability-scoped, joule-budgeted grant per advertised skill. let principal = SigningKey::from_bytes(&[1u8; 32]); // the mandate's signer diff --git a/jcp/jcp-rs/crates/jcp-a2a/src/lib.rs b/jcp/jcp-rs/crates/jcp-a2a/src/lib.rs index dfeafbcf9..cf91ff066 100644 --- a/jcp/jcp-rs/crates/jcp-a2a/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-a2a/src/lib.rs @@ -18,7 +18,13 @@ //! resource is `a2a::`, an audience an attacker can't spoof by //! name). The Agent Card *advertises* the skills; the profile *binds* a //! capability to each. -//! - [`ingest_ap2_mandate`] parses an AP2 mandate. A **joule**-denominated +//! - [`ingest_ap2_mandate`] parses an AP2 mandate into an +//! [`UnverifiedAp2Mandate`] — parsing authenticates nothing, and a mandate's +//! amount becomes a ceiling the gate enforces, so the two steps are kept +//! apart: [`UnverifiedAp2Mandate::accepted_as_verified`] takes the ceilings +//! once the host has verified it, naming the authority that did, and the +//! mandate carries that name. Minting also refuses a mandate whose payee is +//! not the agent being called. A **joule**-denominated //! mandate reads as an energy ceiling ([`Ap2Mandate::joule_budget`]); a //! **currency**-denominated one — what the payments ecosystem actually emits — //! reads as a *money* ceiling ([`Ap2Mandate::funds_ceiling`]). The profile @@ -110,6 +116,39 @@ impl AgentCard { pub fn advertises(&self, skill_id: &str) -> bool { self.skills.iter().any(|s| s.id == skill_id) } + + /// Whether this card's agent is the payee an AP2 mandate authorizes. + /// + /// A mandate names who may be paid; a card names who will be called. If + /// they are not the same party, the mandate is authority for *someone + /// else's* work, and spending against it is spending outside it. This is + /// the binding `jcp-x402` already makes by putting the merchant in the + /// grant's audience, applied to the A2A path, which read the payee and + /// then never consulted it. + /// + /// Matched against the agent id, the host of its service URL, and the + /// name — because an AP2 payee is a merchant identifier and an A2A agent + /// id is a service URL, so `research.example` must bind to + /// `https://research.example/a2a`. An empty payee matches nothing: a + /// mandate that names no payee authorizes no one. + pub fn is_payee(&self, payee: &str) -> bool { + let payee = payee.trim(); + if payee.is_empty() { + return false; + } + let eq = |a: &str| !a.is_empty() && a.eq_ignore_ascii_case(payee); + eq(self.agent_id()) || eq(&self.name) || eq(url_host(&self.url)) + } +} + +/// The host of a URL — `https://research.example/a2a` -> `research.example` — +/// or the whole string when it carries no scheme or path. +fn url_host(url: &str) -> &str { + let after_scheme = url.split_once("://").map_or(url, |(_, rest)| rest); + let host = after_scheme.split(['/', '?', '#']).next().unwrap_or(""); + // Strip userinfo and port, neither of which identifies the party. + let host = host.rsplit_once('@').map_or(host, |(_, h)| h); + host.split_once(':').map_or(host, |(h, _)| h) } /// Parse an A2A Agent Card from its JSON form (camelCase per the A2A spec; @@ -156,8 +195,71 @@ pub fn capabilities_from_card(card: &AgentCard) -> Vec { // ───────────────────────────── AP2 mandate ───────────────────────────── -/// An AP2 payment mandate: an authorization for an agent to spend on a -/// principal's behalf, up to a ceiling. +/// An AP2 mandate exactly as parsed, before anything has authenticated it. +/// +/// This type exists because the fields below are attacker-controlled until +/// something checks them. [`ingest_ap2_mandate`] reads JSON; it verifies no +/// signature, resolves no issuer key and consults no revocation list, because +/// the bytes it is handed carry none of that by themselves. A mandate's +/// `max_amount` becomes a *ceiling the gate enforces*, so a mandate accepted +/// without authentication is an attacker setting the budget — the gate stays +/// sound and its limit does not. +/// +/// There is deliberately no path from here to a ceiling except +/// [`Self::accepted_as_verified`], which makes the caller name what did the +/// verifying. Parsing and trusting are two steps, and this type is the one +/// that has not happened yet. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct UnverifiedAp2Mandate { + /// The payee / merchant the mandate claims to authorize. + pub payee: String, + /// The currency the claimed ceiling is denominated in. + pub currency: String, + /// The claimed ceiling, in the currency's minor unit. + pub max_amount: u64, +} + +impl UnverifiedAp2Mandate { + /// Accept this mandate's ceilings, recording what authenticated it. + /// + /// `authority` names the thing that verified the mandate — a wallet, an + /// issuer key id, an SD-JWT verification, a named host subsystem. It is + /// kept on the mandate and reachable through [`Ap2Mandate::authority`], so + /// a ceiling can be traced to the authority that vouched for it rather + /// than appearing from parsed JSON. An empty or blank `authority` is + /// refused: a mandate nothing vouched for is exactly the case this type + /// exists to stop. + /// + /// This crate does not implement AP2's own signature format. The mandate + /// is a layered verifiable credential whose verification needs issuer-key + /// resolution that JCP has no authority over, so the honest boundary is + /// here: the host verifies, and says so on the record. + pub fn accepted_as_verified( + self, + authority: impl Into, + ) -> Result { + let authority = authority.into(); + if authority.trim().is_empty() { + return Err(malformed( + "ap2 mandate", + "accepted_as_verified needs the authority that verified it; \ + a ceiling nothing vouched for is not a ceiling", + )); + } + Ok(Ap2Mandate { + payee: self.payee, + currency: self.currency, + max_amount: self.max_amount, + authority, + }) + } +} + +/// An AP2 payment mandate whose ceilings may be enforced: an authorization for +/// an agent to spend on a principal's behalf, up to a ceiling, **with the +/// authority that authenticated it on the record**. +/// +/// Reachable only through [`UnverifiedAp2Mandate::accepted_as_verified`]. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Ap2Mandate { /// The payee / merchant the mandate authorizes. @@ -167,9 +269,21 @@ pub struct Ap2Mandate { pub currency: String, /// The authorized ceiling, in the currency's minor unit. pub max_amount: u64, + /// What authenticated this mandate. Private so the only way to hold an + /// `Ap2Mandate` is to have named one. + authority: String, } impl Ap2Mandate { + /// What authenticated this mandate, as the host named it. + /// + /// The provenance of an *authority* is the counterpart to the provenance + /// of a *measurement*: a receipt already says where its joules came from, + /// and this says where its ceiling's authority came from. + pub fn authority(&self) -> &str { + &self.authority + } + /// The joule budget this mandate authorizes, in microjoules — `Some` when the /// mandate is **denominated in joules** (the natural form for energy-priced /// agent work). A currency-denominated mandate returns `None`: the profile @@ -207,7 +321,11 @@ impl Ap2Mandate { /// Parse an AP2 mandate from its JSON form. Reads the payee, the currency, and /// the authorized ceiling (`maxAmount` / `amount`); tolerant of the surrounding /// IntentMandate / CartMandate / PaymentMandate envelope. -pub fn ingest_ap2_mandate(mandate: &Value) -> Result { +/// +/// **This authenticates nothing** — hence [`UnverifiedAp2Mandate`]. Call +/// [`UnverifiedAp2Mandate::accepted_as_verified`] once the host has verified +/// it, naming what did. +pub fn ingest_ap2_mandate(mandate: &Value) -> Result { // The amount may sit at the top level or under a `paymentMandate` / `cart`. let root = mandate .get("paymentMandate") @@ -233,7 +351,28 @@ pub fn ingest_ap2_mandate(mandate: &Value) -> Result { if currency.is_empty() { return Err(malformed("ap2 mandate", "no `currency`")); } - Ok(Ap2Mandate { payee, currency, max_amount }) + Ok(UnverifiedAp2Mandate { payee, currency, max_amount }) +} + +/// Refuse to mint against a mandate that authorizes a different party. +/// +/// The mandate is the authority for the spend; the card is who gets called. +/// Minting when they disagree would spend one merchant's authorization on +/// another's work, and no later gate can catch it — by then the ceiling is +/// already the grant's. +fn refuse_unless_payee(card: &AgentCard, mandate: &Ap2Mandate) -> Result<(), A2aError> { + if card.is_payee(&mandate.payee) { + return Ok(()); + } + Err(malformed( + "ap2 mandate", + format!( + "mandate authorizes payee `{}`, but the agent card is `{}`: a mandate \ + for one party is not authority to spend on another's work", + mandate.payee, + card.agent_id() + ), + )) } // ───────────────────────────── the profile ───────────────────────────── @@ -339,6 +478,7 @@ impl A2aProfile { steward_sk: SigningKey, steward: impl Into, ) -> Result { + refuse_unless_payee(card, mandate)?; let budget = mandate.joule_budget().ok_or_else(|| { malformed("ap2 mandate", format!("`{}` is not joule-denominated", mandate.currency)) })?; @@ -365,6 +505,7 @@ impl A2aProfile { steward_sk: SigningKey, steward: impl Into, ) -> Result { + refuse_unless_payee(card, mandate)?; // A joule-denominated mandate has no money ceiling; a currency one has // no joule ceiling. Either way the caller's `budget_uj` bounds energy. let funds = mandate.funds_ceiling(); @@ -543,21 +684,108 @@ mod tests { #[test] fn ap2_joule_budget_maps_units_and_refuses_currency() { - let uj = ingest_ap2_mandate(&json!({ "payee": "m", "currency": "uJ", "maxAmount": 5_000 })).unwrap(); + let uj = ingest_ap2_mandate(&json!({ "payee": "m", "currency": "uJ", "maxAmount": 5_000 })) + .unwrap().accepted_as_verified("test-wallet").unwrap(); assert_eq!(uj.joule_budget(), Some(5_000)); - let j = ingest_ap2_mandate(&json!({ "merchant": "m", "currency": "J", "amount": 3 })).unwrap(); + let j = ingest_ap2_mandate(&json!({ "merchant": "m", "currency": "J", "amount": 3 })) + .unwrap().accepted_as_verified("test-wallet").unwrap(); assert_eq!(j.joule_budget(), Some(3_000_000)); // A currency mandate yields no joule budget — no invented exchange rate. - let usd = ingest_ap2_mandate(&json!({ "payee": "m", "currency": "USD", "maxAmount": 100 })).unwrap(); + let usd = ingest_ap2_mandate(&json!({ "payee": "m", "currency": "USD", "maxAmount": 100 })) + .unwrap().accepted_as_verified("test-wallet").unwrap(); assert_eq!(usd.joule_budget(), None); } + /// A ceiling must name what vouched for it. Parsing JSON vouches for + /// nothing, so a blank authority is refused rather than accepted as a + /// weak one. + #[test] + fn a_ceiling_needs_an_authority_that_is_not_blank() { + let parsed = + ingest_ap2_mandate(&json!({ "payee": "m", "currency": "uJ", "maxAmount": 9 })).unwrap(); + for blank in ["", " ", "\t"] { + assert!( + parsed.clone().accepted_as_verified(blank).is_err(), + "a blank authority ({blank:?}) was accepted" + ); + } + let ok = parsed.accepted_as_verified("wallet:did:example:w1").unwrap(); + assert_eq!(ok.authority(), "wallet:did:example:w1"); + assert_eq!(ok.joule_budget(), Some(9)); + } + + /// The soundness hole this closes: a mandate naming a different payee is + /// authority for someone else's work, and minting against it spent one + /// party's authorization on another's. `jcp-x402` already bound the + /// merchant into the grant audience; the A2A path read the payee and never + /// consulted it. + #[test] + fn a_mandate_for_another_payee_cannot_mint() { + let card = ingest_agent_card(&sample_card()).unwrap(); // planner.example + let wrong = ingest_ap2_mandate( + &json!({ "payee": "attacker.example", "currency": "uJ", "maxAmount": 1_000_000 }), + ) + .unwrap() + .accepted_as_verified("test-wallet") + .unwrap(); + + let err = A2aProfile::from_mandate( + &card, + &wrong, + &sk(1), + Did::from_verifying_key(&sk(2).verifying_key()), + sk(7), + "did:example:a2a-steward", + ) + .err() + .expect("a mandate for attacker.example minted against planner.example"); + assert!( + format!("{err}").contains("attacker.example"), + "the refusal must name the payee it refused: {err}" + ); + + // The priced constructor is the same authority question, so it refuses too. + assert!( + A2aProfile::from_mandate_priced( + &card, + &wrong, + 50_000, + &sk(1), + Did::from_verifying_key(&sk(2).verifying_key()), + sk(7), + "did:example:a2a-steward", + ) + .is_err(), + "from_mandate_priced accepted another party's mandate" + ); + } + + /// A payee binds to the agent by id, by name, or by the host of its URL — + /// because an AP2 payee is a merchant identifier and an A2A agent id is a + /// service URL. A mandate naming no payee authorizes no one. + #[test] + fn a_payee_binds_to_the_agent_by_id_name_or_host() { + let card = ingest_agent_card(&sample_card()).unwrap(); + for ok in ["planner.example", "PLANNER.EXAMPLE", "planner-agent", "https://planner.example/a2a"] { + assert!(card.is_payee(ok), "{ok} should bind"); + } + for bad in ["", " ", "attacker.example", "planner.example.evil", "example"] { + assert!(!card.is_payee(bad), "{bad:?} must not bind"); + } + // Port and userinfo do not identify the party. + let c = ingest_agent_card(&json!({ + "name": "n", "url": "https://user@planner.example:8443/a2a", "skills": [{"id":"s"}] + })) + .unwrap(); + assert!(c.is_payee("planner.example")); + } + #[test] fn ap2_mandate_envelope_unwrapped() { let m = ingest_ap2_mandate(&json!({ "paymentMandate": { "payee": "store", "currency": "mJ", "maxAmount": 7 } })) - .unwrap(); + .unwrap().accepted_as_verified("test-wallet").unwrap(); assert_eq!(m.payee, "store"); assert_eq!(m.joule_budget(), Some(7_000)); // 7 mJ → 7000 µJ } @@ -592,7 +820,9 @@ mod tests { #[test] fn ap2_budget_caps_task_spend() { let card = ingest_agent_card(&sample_card()).unwrap(); - let mandate = ingest_ap2_mandate(&json!({ "payee": "p", "currency": "uJ", "maxAmount": 50_000 })).unwrap(); + let mandate = + ingest_ap2_mandate(&json!({ "payee": "planner.example", "currency": "uJ", "maxAmount": 50_000 })) + .unwrap().accepted_as_verified("test-wallet").unwrap(); let mut profile = A2aProfile::from_mandate( &card, &mandate, @@ -610,7 +840,9 @@ mod tests { #[test] fn from_mandate_refuses_currency_denomination() { let card = ingest_agent_card(&sample_card()).unwrap(); - let usd = ingest_ap2_mandate(&json!({ "payee": "p", "currency": "USD", "maxAmount": 100 })).unwrap(); + let usd = + ingest_ap2_mandate(&json!({ "payee": "planner.example", "currency": "USD", "maxAmount": 100 })) + .unwrap().accepted_as_verified("test-wallet").unwrap(); let r = A2aProfile::from_mandate( &card, &usd, @@ -666,7 +898,7 @@ mod tests { ingest_ap2_mandate(&json!({ "paymentMandate": { "payee": "planner.example", "currency": "USD", "maxAmount": 500 } })) - .unwrap() + .unwrap().accepted_as_verified("test-wallet").unwrap() } #[test] diff --git a/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs b/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs index 9123fb233..844b94604 100644 --- a/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs @@ -1,8 +1,11 @@ //! The energy-native MCP gateway. //! //! An MCP client speaks to an MCP server through this gateway instead of -//! directly. Most traffic (`initialize`, `tools/list`, notifications) passes -//! through untouched. But every `tools/call` is a **JCP enforcement point**: +//! directly. Discovery traffic (`initialize`, the `*/list` methods, +//! notifications) passes through untouched. But every `tools/call`, +//! `resources/read` and `prompts/get` is a **JCP enforcement point** — the +//! last two because each moves a payload the model then reads, and each used +//! to pass through unbudgeted, unmetered and unsealed: //! //! 1. The tool must have a capability grant; the frame is content-addressed to //! `(server, tool)`, so a grant for `read_file` cannot authorize `shell`. @@ -54,9 +57,10 @@ pub trait Upstream { /// What the gateway did with one client message. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Relayed { - /// A non-`tools/call` message forwarded verbatim; here is the response. + /// An ungated message (discovery, `*/list`) forwarded verbatim; here is + /// the response. PassedThrough(String), - /// A `tools/call` the gateway authorized, metered, and forwarded. + /// A gated call the gateway authorized, metered, and forwarded. /// `cost_uj` is the figure debited from the grant: a hardware reading or /// the meter's estimate. The sealed receipt's `energy_provenance`, and the /// same label beside the figure in the response's `_meta`, say which. @@ -68,6 +72,48 @@ pub enum Relayed { Notified, } +/// The action name and payload of a method this gateway **gates**, or `None` +/// for one it relays untouched. +/// +/// Only `tools/call` used to be gated, and the comparison was a bare +/// `method != "tools/call"`. That left two doors open in the chokepoint: +/// +/// - **`resources/read`** returns server-held bytes to the model. It is a data +/// egress, and it went through unbudgeted, unmetered and unsealed — no +/// grant, no debit, no receipt, nothing in the flow lattice to fold a later +/// call against. +/// - **`prompts/get`** returns server-authored text that becomes model input, +/// on the same terms. +/// +/// Each is keyed so a grant is scoped to one resource or prompt rather than to +/// the method: a grant for `resources/read:file:///a` is not authority to read +/// `file:///b`. The key is the action name, so these ride the same grant map, +/// the same content-addressed `mcp_resource`, the same metering, and the same +/// sealed denial as a tool call — an ungranted read is denied and *recorded*, +/// not silently dropped. +/// +/// `initialize`, `tools/list`, `resources/list`, `prompts/list` and +/// notifications still relay untouched: they are discovery, and they move no +/// payload. What a client may *see* listed is a separate question from what it +/// may read, and deriving the listing from the grant is future work. +fn gated_action(method: &str, params: &Value) -> Option<(String, Value)> { + let str_field = |k: &str| params.get(k).and_then(Value::as_str).unwrap_or("").to_string(); + match method { + "tools/call" => { + Some((str_field("name"), params.get("arguments").cloned().unwrap_or(json!({})))) + } + // The uri is the payload: it is what selects the bytes that come back. + "resources/read" => { + Some((format!("resources/read:{}", str_field("uri")), params.clone())) + } + "prompts/get" => Some(( + format!("prompts/get:{}", str_field("name")), + params.get("arguments").cloned().unwrap_or(json!({})), + )), + _ => None, + } +} + /// The gateway: a JCP runtime, the tool grants, an energy meter, and a /// hash-chained receipt log, in front of one MCP server. pub struct Gateway { @@ -199,19 +245,18 @@ impl Gateway { let method = msg.get("method").and_then(Value::as_str).unwrap_or(""); let id = msg.get("id"); - if method != "tools/call" { + let params = msg.get("params").cloned().unwrap_or(Value::Null); + let Some((action, args)) = gated_action(method, ¶ms) else { if id.is_none() { // A notification: forward fire-and-forget, no response. let _ = up.call(line); return Ok(Relayed::Notified); } return Ok(Relayed::PassedThrough(up.call(line)?)); - } + }; - // ---- A tools/call: the enforcement point. - let params = msg.get("params").cloned().unwrap_or(Value::Null); - let tool = params.get("name").and_then(Value::as_str).unwrap_or(""); - let args = params.get("arguments").cloned().unwrap_or(json!({})); + // ---- A gated method: the enforcement point. + let tool = action.as_str(); let args_bytes = serde_json::to_vec(&args).unwrap_or_default(); // No grant for this tool ⇒ deny. We still SEAL the denial, using any @@ -317,7 +362,7 @@ impl Gateway { /// (failing) decision; if the gateway holds no grants at all, the denial /// is synthesized without a receipt (nothing to bind it to). fn deny_unknown(&mut self, tool: &str, id: Option<&Value>) -> Relayed { - let reason = "no grant for tool".to_string(); + let reason = "no grant for action".to_string(); if let Some(anchor) = self.grants.values().next().cloned() { let grant = anchor.grant.clone(); // Frame addressed to the REQUESTED tool, decided against a grant @@ -692,6 +737,93 @@ mod tests { } } + fn read_resource(uri: &str, id: i64) -> String { + json!({"jsonrpc":"2.0","id":id,"method":"resources/read","params":{"uri":uri}}) + .to_string() + } + + /// The hole this closes: `resources/read` returns server-held bytes to the + /// model, and the gateway relayed it on a bare `method != "tools/call"` — + /// no grant, no debit, no receipt. A data egress straight through the + /// chokepoint. + #[test] + fn an_ungranted_resource_read_is_denied_and_never_reaches_the_server() { + let mut g = gw(); + let mut up = MockServer { calls: 0 }; + let r = g.relay(&read_resource("file:///etc/passwd", 1), &mut up).unwrap(); + assert!( + matches!(r, Relayed::Denied { .. }), + "an ungranted resource read was not denied: {r:?}" + ); + assert_eq!(up.calls, 0, "a denied read NEVER reaches the server"); + } + + /// Granted, the same read is authorized, metered and debited like a tool + /// call — so it enters the ledger and the receipt chain. + #[test] + fn a_granted_resource_read_is_metered_and_debited() { + let mut g = Gateway::new( + "files", + &[("resources/read:file:///ok.txt", 50_000_000)], + SigningKey::from_bytes(&[7u8; 32]), + ); + let mut up = MockServer { calls: 0 }; + let r = g.relay(&read_resource("file:///ok.txt", 1), &mut up).unwrap(); + let Relayed::Allowed { cost_uj, budget_remaining_uj, .. } = r else { + panic!("a granted resource read was not allowed: {r:?}"); + }; + assert_eq!(up.calls, 1); + assert!(budget_remaining_uj < 50_000_000, "the read was not debited"); + let _ = cost_uj; + } + + /// A grant is scoped to one resource. Authority to read one file is not + /// authority to read another, which is the whole reason the uri is in the + /// key rather than the method. + #[test] + fn a_resource_grant_does_not_cover_a_different_uri() { + let mut g = Gateway::new( + "files", + &[("resources/read:file:///ok.txt", 50_000_000)], + SigningKey::from_bytes(&[7u8; 32]), + ); + let mut up = MockServer { calls: 0 }; + let r = g.relay(&read_resource("file:///secret.txt", 1), &mut up).unwrap(); + assert!(matches!(r, Relayed::Denied { .. }), "a grant for one uri covered another"); + assert_eq!(up.calls, 0); + } + + /// `prompts/get` returns server-authored text that becomes model input, on + /// the same terms. + #[test] + fn an_ungranted_prompt_fetch_is_denied() { + let mut g = gw(); + let mut up = MockServer { calls: 0 }; + let line = json!({"jsonrpc":"2.0","id":1,"method":"prompts/get", + "params":{"name":"exfiltrate","arguments":{}}}) + .to_string(); + let r = g.relay(&line, &mut up).unwrap(); + assert!(matches!(r, Relayed::Denied { .. }), "an ungranted prompt fetch was relayed"); + assert_eq!(up.calls, 0); + } + + /// Discovery still relays untouched: it moves no payload, and gating it + /// would refuse a client the right to see what exists. + #[test] + fn discovery_methods_still_pass_through() { + let mut g = gw(); + let mut up = MockServer { calls: 0 }; + for method in ["initialize", "tools/list", "resources/list", "prompts/list"] { + let line = json!({"jsonrpc":"2.0","id":1,"method":method}).to_string(); + let r = g.relay(&line, &mut up).unwrap(); + assert!( + matches!(r, Relayed::PassedThrough(_)), + "{method} should relay untouched, got {r:?}" + ); + } + assert_eq!(up.calls, 4); + } + #[test] fn ungranted_tool_is_denied_without_contacting_upstream() { let mut g = gw(); diff --git a/jcp/jcp-rs/crates/jcp-policy/src/lib.rs b/jcp/jcp-rs/crates/jcp-policy/src/lib.rs index 519ad169c..5542c3328 100644 --- a/jcp/jcp-rs/crates/jcp-policy/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-policy/src/lib.rs @@ -136,6 +136,56 @@ impl Policy { Policy { name: name.into(), rules } } + /// The content id of this policy: a digest over its name **and its ordered + /// rules**, so a receipt can commit to the bytes that decided rather than + /// to a label. + /// + /// Without this a sealed receipt attested "denied under policy + /// `prod-egress`" and nothing committed to what `prod-egress` was. Swap the + /// ruleset, keep the name, and every past receipt still verifies while + /// describing a decision the current rules would not make. The name is the + /// operator's word; this is the evidence. + /// + /// The rendering is written out rather than derived, so the digest's + /// definition is readable and a second implementation can reproduce it: + /// each rule contributes its name, its effect and its match, in order. + /// **Order is part of the digest because order is part of the meaning** — + /// [`Self::evaluate`] returns on the first matching `Deny` and takes the + /// first matching `RequireApproval`, so reordering rules can change a + /// verdict without changing the set. + pub fn content_id(&self) -> ContentId { + let rules: Vec = self + .rules + .iter() + .map(|r| { + json!({ + "name": r.name, + "effect": match r.effect { + Effect::Deny => "deny", + Effect::RequireApproval => "require-approval", + }, + // One uniform shape per variant: a kind tag and its + // values. No bools anywhere — the JCR-1 signed core + // forbids them, and `Match::Always` rendered as `true` + // made `content_id` panic on the most ordinary policy + // there is, a blanket approval rule. + "when": match &r.when { + Match::Action(set) => json!({ "kind": "action", "values": set }), + Match::ContentContains(pats) => { + json!({ "kind": "content-contains", "values": pats }) + } + Match::Resource(r) => { + json!({ "kind": "resource", "values": [r.to_hex()] }) + } + Match::Always => json!({ "kind": "always", "values": [] }), + }, + }) + }) + .collect(); + ContentId::of(&json!({ "policy": self.name, "rules": rules })) + .expect("a policy renders to canonical JSON values") + } + /// Evaluate the policy against a call. Any matching `Deny` wins; otherwise the /// first matching `RequireApproval` holds the call; otherwise it allows. pub fn evaluate(&self, ctx: &PolicyContext) -> PolicyVerdict { @@ -306,7 +356,15 @@ impl PolicyEngine { _ => Outcome::Held(rule), }, }; - let wire = policy_wire(&self.policy.name, ctx, &outcome, self.seq, &self.prev, &self.steward); + let wire = policy_wire( + &self.policy.name, + &self.policy.content_id(), + ctx, + &outcome, + self.seq, + &self.prev, + &self.steward, + ); let core = jcr1::signed_core_bytes(&wire).expect("policy wire is a JCR-1 object"); let envelope = jcr1::cose_sign1_envelope(&core, &self.steward_sk, &self.steward); self.prev = ContentId::of_bytes(&envelope); @@ -338,8 +396,10 @@ fn reason_of(outcome: &Outcome) -> Option<&str> { } } +#[allow(clippy::too_many_arguments)] fn policy_wire( policy: &str, + policy_digest: &ContentId, ctx: &PolicyContext, outcome: &Outcome, seq: u64, @@ -349,6 +409,7 @@ fn policy_wire( let mut o = json!({ "policy_receipt": "policy/1", "policy": policy, + "policy_digest": policy_digest.to_hex(), "action": ctx.action, "resource": ctx.resource.to_hex(), "caller": hex::encode(ctx.caller.0), @@ -370,8 +431,11 @@ fn policy_wire( /// The fields recovered from a verified `policy/1` receipt. #[derive(Clone, Debug, PartialEq, Eq)] pub struct PolicyReceipt { - /// The policy name. + /// The policy name — the operator's label, not evidence. pub policy: String, + /// The content id (hex) of the policy that decided: its name and its + /// ordered rules. This is what makes the name checkable. + pub policy_digest: String, /// The action. pub action: String, /// The resource (hex). @@ -410,6 +474,7 @@ pub fn verify(envelope: &[u8], steward: &str, pubkey: &[u8; 32]) -> Result = [ + Match::Always, + Match::Action(vec!["write".into()]), + Match::ContentContains(vec!["write".into()]), + Match::Resource(ContentId::of_bytes(b"write")), + ] + .into_iter() + .map(|w| Policy::new("p", vec![Rule::deny("r", w)]).content_id()) + .collect(); + for i in 0..ids.len() { + for j in i + 1..ids.len() { + assert_ne!(ids[i], ids[j], "two match variants share a digest ({i} vs {j})"); + } + } + } + + /// Order is part of the meaning — `evaluate` returns on the first matching + /// `Deny` — so it must be part of the digest. + #[test] + fn reordering_rules_changes_the_digest() { + let a = Rule::deny("a", Match::Action(vec!["write".into()])); + let b = Rule::require_approval("b", Match::Always); + let one = Policy::new("p", vec![a.clone(), b.clone()]); + let two = Policy::new("p", vec![b, a]); + assert_ne!( + one.content_id(), + two.content_id(), + "rule order changes verdicts but not the digest" + ); + } + + /// The digest must travel on the sealed receipt and come back out, or it is + /// not evidence a third party can check. + #[test] + fn the_sealed_receipt_carries_the_deciding_policys_digest() { + let policy = Policy::new( + "prod-egress", + vec![Rule::deny("no-secrets", Match::ContentContains(vec!["SECRET".into()]))], + ); + let expected = policy.content_id().to_hex(); + let steward_sk = sk(7); + let mut engine = PolicyEngine::new(policy, steward_sk.clone(), "did:example:policy-steward"); + let sealed = engine.decide(&ctx("write", b"this carries a SECRET", did(&sk(2))), None); + let got = verify( + &sealed.receipt, + "did:example:policy-steward", + &steward_sk.verifying_key().to_bytes(), + ) + .expect("a sealed policy receipt verifies"); + assert_eq!(got.outcome, "denied"); + assert_eq!(got.policy, "prod-egress"); + assert_eq!(got.policy_digest, expected, "the receipt must commit to the ruleset"); + } + } diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-transparency-log/src/lib.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-transparency-log/src/lib.rs index 6baa95ffb..40b285d4f 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-transparency-log/src/lib.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-transparency-log/src/lib.rs @@ -295,8 +295,9 @@ pub struct ConsistencyProof { pub old_size: u64, /// New tree size. pub new_size: u64, - /// Proof body (kept opaque here; verification recomputes from - /// the proof's referenced subtrees). + /// The subtree hashes of RFC 6962 §2.1.2, in order. The verifier rebuilds + /// **both** roots from them, so neither root appears here — a path that + /// merely names the two roots is refused. pub audit_path: Vec<[u8; 32]>, } @@ -308,33 +309,105 @@ impl ConsistencyProof { old_root: &[u8; 32], new_root: &[u8; 32], ) -> Result<(), LogError> { - // Verify by walking the audit_path: it carries (old_root, - // ..., new_root). The simple validator confirms first = - // old_root and last = new_root and that adjacent pairs - // hash correctly. - if self.audit_path.is_empty() { - return Err(LogError::ConsistencyMismatch(0)); + // RFC 6962 §2.1.3. The proof is a set of subtree hashes; the verifier + // rebuilds *both* roots from them and compares. That is what makes it + // a proof: the old root is recomputed, not re-read. + // + // The previous implementation checked `audit_path[0] == old_root` and + // `audit_path.last() == new_root` against a path the prover built as + // `vec![old_root, new_root]` — so it compared the operator's claim to + // itself and a split-view operator walked straight through. Its + // comment also said it checked that "adjacent pairs hash correctly", + // which it never did. + let (m, n) = (self.old_size, self.new_size); + if m == 0 || m > n { + return Err(LogError::InvalidConsistencySizes(m, n)); } - if &self.audit_path[0] != old_root { - return Err(LogError::ConsistencyMismatch(0)); + if m == n { + // Nothing was appended: the roots must be equal and a proof + // carrying anything is malformed. + if !self.audit_path.is_empty() { + return Err(LogError::ConsistencyMismatch(0)); + } + return if old_root == new_root { + Ok(()) + } else { + Err(LogError::ConsistencyMismatch(0)) + }; } - if self.audit_path.last() != Some(new_root) { - return Err(LogError::ConsistencyMismatch( - (self.audit_path.len() - 1) as u32, - )); + + let mut node = m - 1; + let mut last = n - 1; + while node & 1 == 1 { + node >>= 1; + last >>= 1; + } + + let mut path = self.audit_path.iter(); + let mut step: u32 = 0; + // When the old tree is a perfect subtree its root is not sent — the + // verifier already has it — so it seeds the walk directly. + let (mut fr, mut sr) = if m.is_power_of_two() { + (*old_root, *old_root) + } else { + let seed = *path.next().ok_or(LogError::ConsistencyMismatch(step))?; + step += 1; + (seed, seed) + }; + + for c in path { + if last == 0 { + return Err(LogError::ConsistencyMismatch(step)); + } + if node & 1 == 1 || node == last { + fr = node_hash(c, &fr); + sr = node_hash(c, &sr); + while node != 0 && node & 1 == 0 { + node >>= 1; + last >>= 1; + } + } else { + sr = node_hash(&sr, c); + } + node >>= 1; + last >>= 1; + step += 1; + } + + if last != 0 || &fr != old_root || &sr != new_root { + return Err(LogError::ConsistencyMismatch(step)); } Ok(()) } } +/// RFC 6962 §2.1.2 `PROOF(m, D[n])`: the subtree hashes an auditor needs to +/// rebuild both roots itself. fn build_consistency_path(hashes: &[[u8; 32]], m: usize, n: usize) -> Vec<[u8; 32]> { - // Minimal consistency-proof shape: [old_root, new_root]. A - // full RFC 6962 §2.1.2 path is provable as well, but the - // shipped log compositions consistently check just root - // equivalence; we keep both roots in the path for verification. - let old_root = merkle_root(&hashes[..m]); - let new_root = merkle_root(&hashes[..n]); - vec![old_root, new_root] + subproof(m, &hashes[..n], true) +} + +/// RFC 6962 §2.1.2 `SUBPROOF(m, D[n], b)`. +/// +/// `b` is the flag the RFC calls "the old tree is a complete subtree of the +/// new one": while it holds, the old root is one the verifier already has and +/// is not sent. That is why a proof for a power-of-two `m` is one element +/// shorter, and why the verifier seeds its walk from `old_root` in that case. +fn subproof(m: usize, d: &[[u8; 32]], b: bool) -> Vec<[u8; 32]> { + let n = d.len(); + if m == n { + return if b { Vec::new() } else { vec![merkle_root(d)] }; + } + let k = largest_power_of_two_lt(n); + if m <= k { + let mut p = subproof(m, &d[..k], b); + p.push(merkle_root(&d[k..])); + p + } else { + let mut p = subproof(m - k, &d[k..], false); + p.push(merkle_root(&d[..k])); + p + } } // ───────────────────────────────────────────────────────────────────── @@ -493,6 +566,109 @@ mod tests { p.verify(&old_root, &t.root()).unwrap(); } + /// The hole this closes. The old prover emitted `[old_root, new_root]` and + /// the old verifier checked exactly those two values — comparing the + /// operator's claim to itself. So this hand-forged "proof", which contains + /// no evidence at all, used to verify. It must not. + #[test] + fn a_proof_that_merely_restates_the_two_roots_is_refused() { + let mut t = MerkleTree::new(); + for s in ["a", "b", "c"] { + t.append(leaf(s)); + } + let old_root = t.root(); + for s in ["d", "e"] { + t.append(leaf(s)); + } + let forged = ConsistencyProof { + old_size: 3, + new_size: 5, + audit_path: vec![old_root, t.root()], + }; + assert!( + forged.verify(&old_root, &t.root()).is_err(), + "a path that just names both roots is not a proof" + ); + // The honest proof for the same transition does verify. + t.consistency_proof(3).unwrap().verify(&old_root, &t.root()).unwrap(); + } + + /// A split view: the operator shows an auditor a new tree that does not + /// extend the old one it published. The proof is built honestly from the + /// *other* history, so every hash in it is real — only the relationship is + /// a lie. This is the attack a consistency proof exists for. + #[test] + fn a_new_tree_that_does_not_extend_the_old_one_is_refused() { + let mut published = MerkleTree::new(); + for s in ["a", "b", "c"] { + published.append(leaf(s)); + } + let published_root = published.root(); + + // A second history that shares a prefix length but not the prefix. + let mut forked = MerkleTree::new(); + for s in ["a", "b", "X"] { + forked.append(leaf(s)); + } + for s in ["d", "e"] { + forked.append(leaf(s)); + } + let proof = forked.consistency_proof(3).unwrap(); + assert!( + proof.verify(&published_root, &forked.root()).is_err(), + "a fork passed as an extension of the published tree" + ); + // Against its own history the same proof is good, so the refusal is + // about the relationship and not a broken prover. + let mut forked_at_3 = MerkleTree::new(); + for s in ["a", "b", "X"] { + forked_at_3.append(leaf(s)); + } + proof.verify(&forked_at_3.root(), &forked.root()).unwrap(); + } + + /// An RFC 6962 implementation is only correct if it is correct for every + /// shape, so this walks every `(m, n)` pair up to 17 leaves — the perfect + /// trees, the unbalanced ones, and `m == n`. + #[test] + fn every_transition_up_to_seventeen_leaves_round_trips() { + let mut roots: Vec<[u8; 32]> = Vec::new(); + let mut t = MerkleTree::new(); + roots.push(t.root()); // size 0 + for i in 0..17 { + t.append(leaf(&format!("leaf-{i}"))); + roots.push(t.root()); + } + let full = t; + let mut checked = 0; + for n in 1..=17usize { + // The tree of size n, and its honest proofs back to every m. + let mut tn = MerkleTree::new(); + for i in 0..n { + tn.append(leaf(&format!("leaf-{i}"))); + } + assert_eq!(tn.root(), roots[n], "root drifted at size {n}"); + for m in 1..=n { + let p = tn + .consistency_proof(m as u64) + .unwrap_or_else(|e| panic!("prover failed for m={m}, n={n}: {e}")); + p.verify(&roots[m], &roots[n]) + .unwrap_or_else(|e| panic!("m={m}, n={n} failed to verify: {e}")); + // A proof is for one transition: it must not verify against a + // different old root. + if m >= 2 { + assert!( + p.verify(&roots[m - 1], &roots[n]).is_err(), + "m={m}, n={n} verified against the wrong old root" + ); + } + checked += 1; + } + } + assert_eq!(checked, 153, "expected every (m,n) pair with 1<=m<=n<=17"); + assert_eq!(full.root(), roots[17]); + } + #[test] fn consistency_proof_rejects_unrelated_roots() { let mut t = MerkleTree::new();