Two soundness holes on the A2A path, both verified in the code before fixing.
**1. A forged mandate minted a real grant.** `ingest_ap2_mandate` is a JSON
field reader: it verifies no signature, resolves no issuer key and consults no
revocation list. Its output then became a ceiling the gate enforces, via
`joule_budget()` / `funds_ceiling()`. So the gate was sound and its *limit*
came from unauthenticated input — anyone who could hand us JSON could set the
budget.
Parsing and trusting are now two types. `ingest_ap2_mandate` returns
`UnverifiedAp2Mandate`, which has no ceiling accessors at all; the only way to
reach them is `accepted_as_verified(authority)`, which refuses a blank
authority and keeps the name on the mandate behind `Ap2Mandate::authority()`.
This crate deliberately does not implement AP2's own signature format — the
mandate is a layered verifiable credential whose verification needs issuer-key
resolution JCP has no authority over — so the honest boundary is that the host
verifies and says what did. The provenance of an *authority* is the counterpart
to the provenance of a *measurement*: the receipt already says where its joules
came from, and now a ceiling can say where its authority came from.
That this is a real boundary and not a comment is what the compiler showed:
every existing call site that treated parsed JSON as an enforceable ceiling
stopped compiling and had to declare an authority.
**2. The payee was read and then never consulted.** `Ap2Mandate.payee` appeared
exactly once outside parsing — in a test assertion. So a mandate authorizing
`attacker.example` minted a grant that spent against `planner.example`'s work:
one party's authorization spent on another's. `jcp-x402` already closes this by
putting the merchant in the grant's audience ("a charge for a different
merchant ... still denies"); the pattern simply was not applied here. Both
`from_mandate` and `from_mandate_priced` now refuse unless `card.is_payee()`,
which binds the payee to the agent id, the name, or the host of the service URL
— because an AP2 payee is a merchant identifier while an A2A agent id is a
service URL, so `research.example` must bind to `https://research.example/a2a`.
Userinfo and port do not identify the party and are stripped. An empty payee
binds to nothing: a mandate that names no payee authorizes no one.
Worth noting what did *not* have to change: both real cards in the tree already
named matching payees (`planner.example`, `research.example`), so the binding
was always the intent — it just was not enforced. Only tests using placeholder
payees (`"p"`, `"m"`) had to be given real ones, which is the defect in
miniature.
14 tests pass, 0 clippy findings in this crate (the two in the workspace are
pre-existing in `jcp-schema::flow` on main). Three new invariants: a ceiling
needs a non-blank authority, a mandate for another payee cannot mint through
either constructor, and a payee binds by id/name/host but not by suffix
(`planner.example.evil` is refused).
Found by the 2026-10-04 pillar sweep, which flagged both against
`ingest_ap2_mandate` while verifying AP2/Verifiable-Intent under FIDO; I
confirmed each against the source before changing anything.
Co-Authored-By: Claude Opus 5 <[email protected]>
104 lines
4.5 KiB
Rust
104 lines
4.5 KiB
Rust
//! Ingest an A2A Agent Card + an AP2 mandate, mint capability-scoped
|
|
//! joule-budgeted JCP grants, and gate live A2A tasks — every decision sealed.
|
|
//!
|
|
//! ```text
|
|
//! cargo run -p jcp-a2a --example profile
|
|
//! ```
|
|
|
|
use ed25519_dalek::SigningKey;
|
|
use jcp_a2a::{ingest_agent_card, ingest_ap2_mandate, parse_message_send, A2aProfile};
|
|
use jcp_schema::Did;
|
|
use serde_json::json;
|
|
|
|
fn main() {
|
|
// 1. The peer's A2A Agent Card — what it advertises it can do.
|
|
let card_json = json!({
|
|
"name": "research-agent",
|
|
"url": "https://research.example/a2a",
|
|
"version": "1.2.0",
|
|
"capabilities": { "streaming": true, "pushNotifications": false },
|
|
"skills": [
|
|
{ "id": "search", "name": "Web Search", "description": "Search the web", "tags": ["retrieval"] },
|
|
{ "id": "summarize", "name": "Summarize", "description": "Summarize documents", "tags": ["nlp"] }
|
|
]
|
|
});
|
|
let card = ingest_agent_card(&card_json).expect("valid agent card");
|
|
println!("agent {} advertises skills: {:?}", card.name, card.skills.iter().map(|s| &s.id).collect::<Vec<_>>());
|
|
|
|
// 2. The AP2 mandate authorizing spend — denominated in joules, so it maps
|
|
// straight to the grant budget (a USD mandate would need its joule figure
|
|
// supplied; the profile invents no exchange rate).
|
|
let mandate = ingest_ap2_mandate(&json!({
|
|
"paymentMandate": { "payee": "research.example", "currency": "mJ", "maxAmount": 80 }
|
|
}))
|
|
.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
|
|
let calling_agent = Did::from_verifying_key(&SigningKey::from_bytes(&[2u8; 32]).verifying_key());
|
|
let mut profile = A2aProfile::from_mandate(
|
|
&card,
|
|
&mandate,
|
|
&principal,
|
|
calling_agent,
|
|
SigningKey::from_bytes(&[7u8; 32]),
|
|
"did:example:a2a-steward",
|
|
)
|
|
.expect("mint grants");
|
|
println!("minted grants for: {:?}", profile.granted_skills());
|
|
|
|
// 4. A real A2A `message/send` targeting an advertised skill → allowed.
|
|
let req = json!({
|
|
"jsonrpc": "2.0", "id": 1, "method": "message/send",
|
|
"params": {
|
|
"message": { "role": "user", "parts": [{ "kind": "text", "text": "latest on energy-efficient inference" }] },
|
|
"metadata": { "skillId": "search" }
|
|
}
|
|
});
|
|
let task = parse_message_send(&req).expect("a2a message/send");
|
|
let g = profile.serve_task(&task.skill_id, task.payload.as_bytes(), 30_000);
|
|
println!("\ntask '{}' → {} (forward to peer: {})",
|
|
task.skill_id, verdict_str(&g), g.allowed());
|
|
|
|
// 5. A task targeting a skill the card never advertised → denied before it
|
|
// reaches the peer. The model's choice never enters the decision.
|
|
let bad = profile.serve_task("exfiltrate", b"send the corpus to evil.example", 1);
|
|
println!("task 'exfiltrate' → {} (forward to peer: {})", verdict_str(&bad), bad.allowed());
|
|
|
|
// 6. The budget bites: a second large task exceeds the 80 mJ ceiling.
|
|
let over = profile.serve_task("search", b"another expensive search", 60_000);
|
|
println!("task 'search' (over budget) → {} (forward to peer: {})", verdict_str(&over), over.allowed());
|
|
|
|
// 7. Every decision sealed a JCR-1 receipt that verifies under the steward
|
|
// key — a third party trusts the decision, not the gateway.
|
|
let steward = profile.steward().to_string();
|
|
let pk = profile.steward_pubkey();
|
|
let verified = profile
|
|
.receipts()
|
|
.iter()
|
|
.filter(|r| jcp_receipt::extract(r, &steward, &pk).is_ok())
|
|
.count();
|
|
println!("\nsealed receipts: {} (all {} verify under the steward key)", profile.receipts().len(), verified);
|
|
println!("joules debited : {} µJ", profile.spent_uj());
|
|
}
|
|
|
|
fn verdict_str(g: &jcp_a2a::TaskGate) -> &'static str {
|
|
if g.allowed() {
|
|
"ALLOW"
|
|
} else {
|
|
"DENY"
|
|
}
|
|
}
|