Four things our signed artifacts sealed by name instead of by bytes #153

Merged
dcharlot merged 4 commits from fix/ap2-mandate-authority into main 2026-10-05 08:00:48 -04:00
5 changed files with 758 additions and 49 deletions

View file

@ -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

View file

@ -18,7 +18,13 @@
//! resource is `a2a:<agent>:<skill>`, 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<Capability> {
// ───────────────────────────── 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<String>,
) -> Result<Ap2Mandate, A2aError> {
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<Ap2Mandate, A2aError> {
///
/// **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<UnverifiedAp2Mandate, A2aError> {
// 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<Ap2Mandate, A2aError> {
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<String>,
) -> Result<Self, A2aError> {
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<String>,
) -> Result<Self, A2aError> {
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://[email protected]: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]

View file

@ -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, &params) 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();

View file

@ -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<Value> = 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<Polic
};
Ok(PolicyReceipt {
policy: s("policy")?,
policy_digest: s("policy_digest")?,
action: s("action")?,
resource: s("resource")?,
caller: s("caller")?,
@ -554,4 +619,97 @@ mod tests {
bad[n - 1] ^= 1;
assert!(verify(&bad, eng.steward(), &eng.steward_pubkey()).is_err());
}
/// The soundness hole this closes: the receipt sealed a *name*, so the
/// ruleset could be swapped underneath it and every past receipt still
/// verified while describing a decision the new rules would not make.
#[test]
fn a_policy_digest_distinguishes_rulesets_sharing_a_name() {
let strict = Policy::new(
"prod-egress",
vec![Rule::deny("no-secrets", Match::ContentContains(vec!["SECRET".into()]))],
);
// Same name. No rules at all — it denies nothing.
let hollow = Policy::new("prod-egress", vec![]);
assert_eq!(strict.name, hollow.name);
assert_ne!(
strict.content_id(),
hollow.content_id(),
"two different rulesets under one name share a digest, so the name is \
still the only thing the receipt commits to"
);
// And the digest is stable: the same policy renders the same id.
assert_eq!(strict.content_id(), strict.content_id());
}
/// Every match variant must render, including the blanket one. `Always`
/// first rendered as a JSON `true`, and the JCR-1 signed core forbids
/// bools — so `content_id` panicked on "require approval for every write",
/// the most ordinary policy there is. Pinned per variant.
#[test]
fn every_match_variant_renders_to_a_digest() {
for when in [
Match::Always,
Match::Action(vec!["write".into()]),
Match::ContentContains(vec!["SECRET".into()]),
Match::Resource(ContentId::of_bytes(b"res")),
] {
let p = Policy::new("p", vec![Rule::require_approval("r", when.clone())]);
// Must not panic, and must be stable.
assert_eq!(p.content_id(), p.content_id(), "unstable digest for {when:?}");
}
// And the variants are distinguishable from one another.
let ids: Vec<_> = [
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");
}
}

View file

@ -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();