Four things our signed artifacts sealed by name instead of by bytes #153
5 changed files with 758 additions and 49 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue