use std::collections::BTreeMap;
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
use crate::budget::{Budget, BudgetUsage};
use crate::decision::PolicyDecision;
pub const AP2_ANCHOR: &str = "ap2:signed-mandate-chain";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Ap2Money {
pub amount_cents: u64,
pub currency: String,
}
impl Ap2Money {
pub fn to_cents(&self) -> Option<u64> {
if self.currency.trim().eq_ignore_ascii_case("USD") {
Some(self.amount_cents)
} else {
None
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Ap2Scope {
#[serde(default)]
pub merchants: Vec<String>,
#[serde(default)]
pub categories: Vec<String>,
pub max_amount: Ap2Money,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Ap2IntentMandate {
pub intent_id: String,
pub subject: String,
pub scope: Ap2Scope,
pub key_id: String,
#[serde(default)]
pub signature: String,
}
impl Ap2IntentMandate {
pub fn signing_bytes(&self) -> Vec<u8> {
let mut merchants = self.scope.merchants.clone();
merchants.sort();
let mut categories = self.scope.categories.clone();
categories.sort();
format!(
"ap2-intent-v1|{}|{}|{}|{}|{}|{}",
self.intent_id,
self.subject,
merchants.join(","),
categories.join(","),
self.scope.max_amount.amount_cents,
self.scope.max_amount.currency.to_ascii_uppercase(),
)
.into_bytes()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Ap2CartMandate {
pub cart_id: String,
pub intent_id: String,
pub merchant: String,
#[serde(default)]
pub category: Option<String>,
pub total: Ap2Money,
pub key_id: String,
#[serde(default)]
pub signature: String,
}
impl Ap2CartMandate {
pub fn signing_bytes(&self) -> Vec<u8> {
format!(
"ap2-cart-v1|{}|{}|{}|{}|{}|{}",
self.cart_id,
self.intent_id,
self.merchant,
self.category.as_deref().unwrap_or(""),
self.total.amount_cents,
self.total.currency.to_ascii_uppercase(),
)
.into_bytes()
}
}
#[derive(Debug, Clone, Default)]
pub struct Ap2Keyring {
keys: BTreeMap<String, VerifyingKey>,
}
impl Ap2Keyring {
pub fn new() -> Self {
Self::default()
}
pub fn insert_key(&mut self, key_id: impl Into<String>, key: VerifyingKey) {
self.keys.insert(key_id.into(), key);
}
pub fn insert_hex(&mut self, key_id: impl Into<String>, hex_key: &str) -> Result<(), String> {
let bytes = hex::decode(hex_key.trim()).map_err(|e| format!("bad hex key: {e}"))?;
let arr: [u8; 32] = bytes
.as_slice()
.try_into()
.map_err(|_| format!("verifying key must be 32 bytes, got {}", bytes.len()))?;
let vk = VerifyingKey::from_bytes(&arr).map_err(|e| format!("invalid ed25519 key: {e}"))?;
self.insert_key(key_id, vk);
Ok(())
}
fn get(&self, key_id: &str) -> Option<&VerifyingKey> {
self.keys.get(key_id)
}
pub fn len(&self) -> usize {
self.keys.len()
}
pub fn is_empty(&self) -> bool {
self.keys.is_empty()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Ap2DenyKind {
IntentCartMismatch,
MissingSignature,
UnknownKey,
InvalidSignature,
Unpriceable,
IntentScopeMismatch,
CartOverCeiling,
}
impl Ap2DenyKind {
pub fn as_str(self) -> &'static str {
match self {
Ap2DenyKind::IntentCartMismatch => "intent_cart_mismatch",
Ap2DenyKind::MissingSignature => "missing_signature",
Ap2DenyKind::UnknownKey => "unknown_key",
Ap2DenyKind::InvalidSignature => "invalid_signature",
Ap2DenyKind::Unpriceable => "unpriceable",
Ap2DenyKind::IntentScopeMismatch => "intent_scope_mismatch",
Ap2DenyKind::CartOverCeiling => "cart_over_ceiling",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Ap2CostEvent {
pub cart_id: String,
pub intent_id: String,
pub merchant: String,
pub cost_cents: u64,
pub currency: String,
}
impl Ap2CostEvent {
pub fn apply_to(&self, usage: &mut BudgetUsage) {
usage.cost_cents = usage.cost_cents.saturating_add(self.cost_cents);
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Ap2GateOutcome {
Authorize {
event: Ap2CostEvent,
budget_remaining_cents: Option<u64>,
},
Deny { kind: Ap2DenyKind, reason: String },
}
impl Ap2GateOutcome {
pub fn is_authorized(&self) -> bool {
matches!(self, Ap2GateOutcome::Authorize { .. })
}
pub fn deny_kind(&self) -> Option<Ap2DenyKind> {
match self {
Ap2GateOutcome::Deny { kind, .. } => Some(*kind),
Ap2GateOutcome::Authorize { .. } => None,
}
}
pub fn alert_line(&self) -> Option<String> {
match self {
Ap2GateOutcome::Authorize { .. } => None,
Ap2GateOutcome::Deny { kind, reason } => Some(format!(
"AP2 spend gate BLOCKED payment [{}]: {reason} — payment not authorized",
kind.as_str()
)),
}
}
pub fn to_policy_decision(&self) -> PolicyDecision {
match self {
Ap2GateOutcome::Authorize { event, .. } => PolicyDecision::allow(format!(
"ap2 payment authorized: {}¢ to {} (intent {}) within budget + scope",
event.cost_cents, event.merchant, event.intent_id
)),
Ap2GateOutcome::Deny { reason, .. } => PolicyDecision::deny(reason.clone()),
}
}
}
fn verify_hex_sig(vk: &VerifyingKey, msg: &[u8], sig_hex: &str) -> Result<(), Ap2DenyKind> {
let sig_hex = sig_hex.trim();
if sig_hex.is_empty() {
return Err(Ap2DenyKind::MissingSignature);
}
let bytes = hex::decode(sig_hex).map_err(|_| Ap2DenyKind::InvalidSignature)?;
let arr: [u8; 64] = bytes
.as_slice()
.try_into()
.map_err(|_| Ap2DenyKind::InvalidSignature)?;
let sig = Signature::from_bytes(&arr);
vk.verify(msg, &sig)
.map_err(|_| Ap2DenyKind::InvalidSignature)
}
fn verify_mandate(
keyring: &Ap2Keyring,
key_id: &str,
msg: &[u8],
sig_hex: &str,
) -> Result<(), Ap2DenyKind> {
let vk = keyring.get(key_id).ok_or(Ap2DenyKind::UnknownKey)?;
verify_hex_sig(vk, msg, sig_hex)
}
pub fn evaluate_ap2_payment(
intent: &Ap2IntentMandate,
cart: &Ap2CartMandate,
keyring: &Ap2Keyring,
budget: &Budget,
usage: &BudgetUsage,
) -> Ap2GateOutcome {
if cart.intent_id != intent.intent_id {
return deny(
Ap2DenyKind::IntentCartMismatch,
format!(
"cart '{}' is bound to intent '{}', not the presented intent '{}'",
cart.cart_id, cart.intent_id, intent.intent_id
),
);
}
let Some(amount_cents) = cart.total.to_cents() else {
return deny(
Ap2DenyKind::Unpriceable,
format!(
"cart total in '{}' cannot be priced against a cents budget (deny-by-default)",
cart.total.currency
),
);
};
if let Err(kind) = verify_mandate(
keyring,
&intent.key_id,
&intent.signing_bytes(),
&intent.signature,
) {
return deny(
kind,
format!(
"intent '{}' signature ({}) failed verification under key '{}'",
intent.intent_id,
kind.as_str(),
intent.key_id
),
);
}
if let Err(kind) = verify_mandate(
keyring,
&cart.key_id,
&cart.signing_bytes(),
&cart.signature,
) {
return deny(
kind,
format!(
"cart '{}' signature ({}) failed verification under key '{}'",
cart.cart_id,
kind.as_str(),
cart.key_id
),
);
}
if let Some(reason) = scope_violation(intent, cart, amount_cents) {
return deny(Ap2DenyKind::IntentScopeMismatch, reason);
}
if !budget.has_cost_headroom(usage, amount_cents) {
let cap = budget.max_cost_cents.unwrap_or(0);
let projected = usage.cost_cents.saturating_add(amount_cents);
return deny(
Ap2DenyKind::CartOverCeiling,
format!(
"ap2 spend gate: paying {amount_cents}¢ to {} would push run cost to {projected}¢ over the {cap}¢ budget (by {}¢)",
cart.merchant,
projected.saturating_sub(cap)
),
);
}
let event = Ap2CostEvent {
cart_id: cart.cart_id.clone(),
intent_id: cart.intent_id.clone(),
merchant: cart.merchant.clone(),
cost_cents: amount_cents,
currency: cart.total.currency.to_ascii_uppercase(),
};
let budget_remaining_cents = budget
.max_cost_cents
.map(|cap| cap.saturating_sub(usage.cost_cents.saturating_add(amount_cents)));
Ap2GateOutcome::Authorize {
event,
budget_remaining_cents,
}
}
fn scope_violation(
intent: &Ap2IntentMandate,
cart: &Ap2CartMandate,
amount_cents: u64,
) -> Option<String> {
let scope = &intent.scope;
if !scope.merchants.is_empty() && !scope.merchants.iter().any(|m| m == &cart.merchant) {
return Some(format!(
"merchant '{}' is not in the intent's authorized merchants",
cart.merchant
));
}
if !scope.categories.is_empty() {
match &cart.category {
Some(c) if scope.categories.iter().any(|sc| sc == c) => {}
_ => {
return Some(format!(
"cart category {:?} is not in the intent's authorized categories",
cart.category
))
}
}
}
if !cart
.total
.currency
.eq_ignore_ascii_case(&scope.max_amount.currency)
{
return Some(format!(
"cart currency '{}' does not match the intent currency '{}'",
cart.total.currency, scope.max_amount.currency
));
}
if amount_cents > scope.max_amount.amount_cents {
return Some(format!(
"cart total {amount_cents}¢ exceeds the user's authorized intent max {}¢",
scope.max_amount.amount_cents
));
}
None
}
fn deny(kind: Ap2DenyKind, reason: String) -> Ap2GateOutcome {
Ap2GateOutcome::Deny { kind, reason }
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::{Signer, SigningKey};
const USER_KEY_ID: &str = "user-key-1";
fn signing_key() -> SigningKey {
SigningKey::from_bytes(&[7u8; 32])
}
fn keyring_with(signer: &SigningKey) -> Ap2Keyring {
let mut kr = Ap2Keyring::new();
kr.insert_key(USER_KEY_ID, signer.verifying_key());
kr
}
fn usd(cents: u64) -> Ap2Money {
Ap2Money {
amount_cents: cents,
currency: "USD".into(),
}
}
fn signed_intent(signer: &SigningKey, max_cents: u64) -> Ap2IntentMandate {
let mut intent = Ap2IntentMandate {
intent_id: "intent-abc".into(),
subject: "user@example.com".into(),
scope: Ap2Scope {
merchants: vec!["acme-store".into()],
categories: vec!["office-supplies".into()],
max_amount: usd(max_cents),
},
key_id: USER_KEY_ID.into(),
signature: String::new(),
};
intent.signature = hex::encode(signer.sign(&intent.signing_bytes()).to_bytes());
intent
}
fn signed_cart(signer: &SigningKey, total_cents: u64) -> Ap2CartMandate {
let mut cart = Ap2CartMandate {
cart_id: "cart-xyz".into(),
intent_id: "intent-abc".into(),
merchant: "acme-store".into(),
category: Some("office-supplies".into()),
total: usd(total_cents),
key_id: USER_KEY_ID.into(),
signature: String::new(),
};
cart.signature = hex::encode(signer.sign(&cart.signing_bytes()).to_bytes());
cart
}
fn budget(cap: u64) -> Budget {
Budget {
max_cost_cents: Some(cap),
..Budget::default()
}
}
#[test]
fn authorizes_a_valid_in_scope_in_budget_payment() {
let signer = signing_key();
let kr = keyring_with(&signer);
let intent = signed_intent(&signer, 5000); let cart = signed_cart(&signer, 4000); let usage = BudgetUsage {
cost_cents: 10,
..BudgetUsage::default()
};
let outcome = evaluate_ap2_payment(&intent, &cart, &kr, &budget(10_000), &usage);
match outcome {
Ap2GateOutcome::Authorize {
event,
budget_remaining_cents,
} => {
assert_eq!(event.cost_cents, 4000);
assert_eq!(event.merchant, "acme-store");
assert_eq!(budget_remaining_cents, Some(10_000 - 10 - 4000));
}
other => panic!("expected authorize, got {other:?}"),
}
}
#[test]
fn authorized_payment_folds_into_the_shared_cost_ledger() {
let signer = signing_key();
let kr = keyring_with(&signer);
let intent = signed_intent(&signer, 5000);
let cart = signed_cart(&signer, 4000);
let mut usage = BudgetUsage {
cost_cents: 30, ..BudgetUsage::default()
};
if let Ap2GateOutcome::Authorize { event, .. } =
evaluate_ap2_payment(&intent, &cart, &kr, &budget(10_000), &usage)
{
event.apply_to(&mut usage);
} else {
panic!("expected authorize");
}
assert_eq!(usage.cost_cents, 4030); assert_eq!(usage.tool_calls, 0);
}
#[test]
fn denies_a_tampered_cart_signature() {
let signer = signing_key();
let kr = keyring_with(&signer);
let intent = signed_intent(&signer, 5000);
let mut cart = signed_cart(&signer, 4000);
cart.total.amount_cents = 1; let outcome = evaluate_ap2_payment(
&intent,
&cart,
&kr,
&budget(10_000),
&BudgetUsage::default(),
);
assert_eq!(outcome.deny_kind(), Some(Ap2DenyKind::InvalidSignature));
assert!(outcome.to_policy_decision().is_denied());
assert!(outcome.alert_line().unwrap().contains("invalid_signature"));
}
#[test]
fn denies_a_missing_signature() {
let signer = signing_key();
let kr = keyring_with(&signer);
let intent = signed_intent(&signer, 5000);
let mut cart = signed_cart(&signer, 4000);
cart.signature = String::new();
let outcome = evaluate_ap2_payment(
&intent,
&cart,
&kr,
&budget(10_000),
&BudgetUsage::default(),
);
assert_eq!(outcome.deny_kind(), Some(Ap2DenyKind::MissingSignature));
}
#[test]
fn denies_an_unknown_signing_key() {
let signer = signing_key();
let empty = Ap2Keyring::new(); let intent = signed_intent(&signer, 5000);
let cart = signed_cart(&signer, 4000);
let outcome = evaluate_ap2_payment(
&intent,
&cart,
&empty,
&budget(10_000),
&BudgetUsage::default(),
);
assert_eq!(outcome.deny_kind(), Some(Ap2DenyKind::UnknownKey));
}
#[test]
fn denies_a_cart_over_the_per_task_ceiling() {
let signer = signing_key();
let kr = keyring_with(&signer);
let intent = signed_intent(&signer, 20_000); let cart = signed_cart(&signer, 15_000); let usage = BudgetUsage {
cost_cents: 0,
..BudgetUsage::default()
};
let outcome = evaluate_ap2_payment(&intent, &cart, &kr, &budget(100), &usage); match &outcome {
Ap2GateOutcome::Deny { kind, reason } => {
assert_eq!(*kind, Ap2DenyKind::CartOverCeiling);
assert!(reason.contains("15000"));
}
other => panic!("expected deny, got {other:?}"),
}
}
#[test]
fn denies_a_merchant_outside_intent_scope() {
let signer = signing_key();
let kr = keyring_with(&signer);
let intent = signed_intent(&signer, 5000);
let mut cart = Ap2CartMandate {
merchant: "evil-merchant".into(), ..signed_cart(&signer, 4000)
};
cart.signature = hex::encode(signer.sign(&cart.signing_bytes()).to_bytes());
let outcome = evaluate_ap2_payment(
&intent,
&cart,
&kr,
&budget(10_000),
&BudgetUsage::default(),
);
assert_eq!(outcome.deny_kind(), Some(Ap2DenyKind::IntentScopeMismatch));
}
#[test]
fn denies_a_cart_over_the_users_own_intent_max() {
let signer = signing_key();
let kr = keyring_with(&signer);
let intent = signed_intent(&signer, 3000); let cart = signed_cart(&signer, 4000); let outcome = evaluate_ap2_payment(
&intent,
&cart,
&kr,
&budget(100_000),
&BudgetUsage::default(),
);
assert_eq!(outcome.deny_kind(), Some(Ap2DenyKind::IntentScopeMismatch));
}
#[test]
fn denies_a_cart_not_bound_to_the_intent() {
let signer = signing_key();
let kr = keyring_with(&signer);
let intent = signed_intent(&signer, 5000);
let mut cart = signed_cart(&signer, 4000);
cart.intent_id = "some-other-intent".into();
cart.signature = hex::encode(signer.sign(&cart.signing_bytes()).to_bytes());
let outcome = evaluate_ap2_payment(
&intent,
&cart,
&kr,
&budget(10_000),
&BudgetUsage::default(),
);
assert_eq!(outcome.deny_kind(), Some(Ap2DenyKind::IntentCartMismatch));
}
#[test]
fn denies_an_unpriceable_non_usd_cart() {
let signer = signing_key();
let kr = keyring_with(&signer);
let intent = signed_intent(&signer, 5000);
let mut cart = signed_cart(&signer, 4000);
cart.total.currency = "EUR".into();
cart.signature = hex::encode(signer.sign(&cart.signing_bytes()).to_bytes());
let outcome = evaluate_ap2_payment(
&intent,
&cart,
&kr,
&budget(10_000),
&BudgetUsage::default(),
);
assert_eq!(outcome.deny_kind(), Some(Ap2DenyKind::Unpriceable));
}
#[test]
fn mandates_round_trip_through_json_and_verify() {
let signer = signing_key();
let kr = keyring_with(&signer);
let intent = signed_intent(&signer, 5000);
let cart = signed_cart(&signer, 4000);
let intent2: Ap2IntentMandate =
serde_json::from_str(&serde_json::to_string(&intent).unwrap()).unwrap();
let cart2: Ap2CartMandate =
serde_json::from_str(&serde_json::to_string(&cart).unwrap()).unwrap();
assert!(evaluate_ap2_payment(
&intent2,
&cart2,
&kr,
&budget(10_000),
&BudgetUsage::default()
)
.is_authorized());
}
#[test]
fn keyring_insert_hex_round_trips() {
let signer = signing_key();
let hexkey = hex::encode(signer.verifying_key().to_bytes());
let mut kr = Ap2Keyring::new();
kr.insert_hex(USER_KEY_ID, &hexkey).expect("valid hex key");
assert_eq!(kr.len(), 1);
assert!(!kr.is_empty());
assert!(kr.insert_hex("bad", "zzzz").is_err());
}
#[test]
fn deny_kind_wire_labels_are_stable() {
assert_eq!(Ap2DenyKind::InvalidSignature.as_str(), "invalid_signature");
assert_eq!(Ap2DenyKind::CartOverCeiling.as_str(), "cart_over_ceiling");
assert_eq!(
Ap2DenyKind::IntentScopeMismatch.as_str(),
"intent_scope_mismatch"
);
}
}