open-standards/jcp/jcp-rs/crates/jcp-a2a/examples/profile.rs
Transaction Science 002160be2f jcp-a2a: an unverified AP2 mandate could set the ceiling, and name any payee
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]>
2026-10-05 07:22:11 -04:00

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"
}
}