1use std::collections::BTreeMap;
40
41use ed25519_dalek::{Signature, Verifier, VerifyingKey};
42use serde::{Deserialize, Serialize};
43
44use crate::budget::{Budget, BudgetUsage};
45use crate::decision::PolicyDecision;
46
47pub const AP2_ANCHOR: &str = "ap2:signed-mandate-chain";
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct Ap2Money {
59 pub amount_cents: u64,
61 pub currency: String,
63}
64
65impl Ap2Money {
66 pub fn to_cents(&self) -> Option<u64> {
69 if self.currency.trim().eq_ignore_ascii_case("USD") {
70 Some(self.amount_cents)
71 } else {
72 None
73 }
74 }
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81pub struct Ap2Scope {
82 #[serde(default)]
84 pub merchants: Vec<String>,
85 #[serde(default)]
87 pub categories: Vec<String>,
88 pub max_amount: Ap2Money,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96pub struct Ap2IntentMandate {
97 pub intent_id: String,
99 pub subject: String,
101 pub scope: Ap2Scope,
103 pub key_id: String,
105 #[serde(default)]
107 pub signature: String,
108}
109
110impl Ap2IntentMandate {
111 pub fn signing_bytes(&self) -> Vec<u8> {
115 let mut merchants = self.scope.merchants.clone();
116 merchants.sort();
117 let mut categories = self.scope.categories.clone();
118 categories.sort();
119 format!(
120 "ap2-intent-v1|{}|{}|{}|{}|{}|{}",
121 self.intent_id,
122 self.subject,
123 merchants.join(","),
124 categories.join(","),
125 self.scope.max_amount.amount_cents,
126 self.scope.max_amount.currency.to_ascii_uppercase(),
127 )
128 .into_bytes()
129 }
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
135pub struct Ap2CartMandate {
136 pub cart_id: String,
138 pub intent_id: String,
140 pub merchant: String,
142 #[serde(default)]
145 pub category: Option<String>,
146 pub total: Ap2Money,
148 pub key_id: String,
150 #[serde(default)]
152 pub signature: String,
153}
154
155impl Ap2CartMandate {
156 pub fn signing_bytes(&self) -> Vec<u8> {
160 format!(
161 "ap2-cart-v1|{}|{}|{}|{}|{}|{}",
162 self.cart_id,
163 self.intent_id,
164 self.merchant,
165 self.category.as_deref().unwrap_or(""),
166 self.total.amount_cents,
167 self.total.currency.to_ascii_uppercase(),
168 )
169 .into_bytes()
170 }
171}
172
173#[derive(Debug, Clone, Default)]
176pub struct Ap2Keyring {
177 keys: BTreeMap<String, VerifyingKey>,
178}
179
180impl Ap2Keyring {
181 pub fn new() -> Self {
184 Self::default()
185 }
186
187 pub fn insert_key(&mut self, key_id: impl Into<String>, key: VerifyingKey) {
189 self.keys.insert(key_id.into(), key);
190 }
191
192 pub fn insert_hex(&mut self, key_id: impl Into<String>, hex_key: &str) -> Result<(), String> {
195 let bytes = hex::decode(hex_key.trim()).map_err(|e| format!("bad hex key: {e}"))?;
196 let arr: [u8; 32] = bytes
197 .as_slice()
198 .try_into()
199 .map_err(|_| format!("verifying key must be 32 bytes, got {}", bytes.len()))?;
200 let vk = VerifyingKey::from_bytes(&arr).map_err(|e| format!("invalid ed25519 key: {e}"))?;
201 self.insert_key(key_id, vk);
202 Ok(())
203 }
204
205 fn get(&self, key_id: &str) -> Option<&VerifyingKey> {
206 self.keys.get(key_id)
207 }
208
209 pub fn len(&self) -> usize {
211 self.keys.len()
212 }
213
214 pub fn is_empty(&self) -> bool {
216 self.keys.is_empty()
217 }
218}
219
220#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
223#[serde(rename_all = "snake_case")]
224pub enum Ap2DenyKind {
225 IntentCartMismatch,
227 MissingSignature,
229 UnknownKey,
231 InvalidSignature,
233 Unpriceable,
236 IntentScopeMismatch,
238 CartOverCeiling,
240}
241
242impl Ap2DenyKind {
243 pub fn as_str(self) -> &'static str {
245 match self {
246 Ap2DenyKind::IntentCartMismatch => "intent_cart_mismatch",
247 Ap2DenyKind::MissingSignature => "missing_signature",
248 Ap2DenyKind::UnknownKey => "unknown_key",
249 Ap2DenyKind::InvalidSignature => "invalid_signature",
250 Ap2DenyKind::Unpriceable => "unpriceable",
251 Ap2DenyKind::IntentScopeMismatch => "intent_scope_mismatch",
252 Ap2DenyKind::CartOverCeiling => "cart_over_ceiling",
253 }
254 }
255}
256
257#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
261pub struct Ap2CostEvent {
262 pub cart_id: String,
264 pub intent_id: String,
266 pub merchant: String,
268 pub cost_cents: u64,
270 pub currency: String,
272}
273
274impl Ap2CostEvent {
275 pub fn apply_to(&self, usage: &mut BudgetUsage) {
280 usage.cost_cents = usage.cost_cents.saturating_add(self.cost_cents);
281 }
282}
283
284#[derive(Debug, Clone, PartialEq, Eq)]
287pub enum Ap2GateOutcome {
288 Authorize {
293 event: Ap2CostEvent,
294 budget_remaining_cents: Option<u64>,
295 },
296 Deny { kind: Ap2DenyKind, reason: String },
298}
299
300impl Ap2GateOutcome {
301 pub fn is_authorized(&self) -> bool {
303 matches!(self, Ap2GateOutcome::Authorize { .. })
304 }
305
306 pub fn deny_kind(&self) -> Option<Ap2DenyKind> {
308 match self {
309 Ap2GateOutcome::Deny { kind, .. } => Some(*kind),
310 Ap2GateOutcome::Authorize { .. } => None,
311 }
312 }
313
314 pub fn alert_line(&self) -> Option<String> {
317 match self {
318 Ap2GateOutcome::Authorize { .. } => None,
319 Ap2GateOutcome::Deny { kind, reason } => Some(format!(
320 "AP2 spend gate BLOCKED payment [{}]: {reason} — payment not authorized",
321 kind.as_str()
322 )),
323 }
324 }
325
326 pub fn to_policy_decision(&self) -> PolicyDecision {
329 match self {
330 Ap2GateOutcome::Authorize { event, .. } => PolicyDecision::allow(format!(
331 "ap2 payment authorized: {}¢ to {} (intent {}) within budget + scope",
332 event.cost_cents, event.merchant, event.intent_id
333 )),
334 Ap2GateOutcome::Deny { reason, .. } => PolicyDecision::deny(reason.clone()),
335 }
336 }
337}
338
339fn verify_hex_sig(vk: &VerifyingKey, msg: &[u8], sig_hex: &str) -> Result<(), Ap2DenyKind> {
342 let sig_hex = sig_hex.trim();
343 if sig_hex.is_empty() {
344 return Err(Ap2DenyKind::MissingSignature);
345 }
346 let bytes = hex::decode(sig_hex).map_err(|_| Ap2DenyKind::InvalidSignature)?;
347 let arr: [u8; 64] = bytes
348 .as_slice()
349 .try_into()
350 .map_err(|_| Ap2DenyKind::InvalidSignature)?;
351 let sig = Signature::from_bytes(&arr);
352 vk.verify(msg, &sig)
353 .map_err(|_| Ap2DenyKind::InvalidSignature)
354}
355
356fn verify_mandate(
358 keyring: &Ap2Keyring,
359 key_id: &str,
360 msg: &[u8],
361 sig_hex: &str,
362) -> Result<(), Ap2DenyKind> {
363 let vk = keyring.get(key_id).ok_or(Ap2DenyKind::UnknownKey)?;
364 verify_hex_sig(vk, msg, sig_hex)
365}
366
367pub fn evaluate_ap2_payment(
380 intent: &Ap2IntentMandate,
381 cart: &Ap2CartMandate,
382 keyring: &Ap2Keyring,
383 budget: &Budget,
384 usage: &BudgetUsage,
385) -> Ap2GateOutcome {
386 if cart.intent_id != intent.intent_id {
388 return deny(
389 Ap2DenyKind::IntentCartMismatch,
390 format!(
391 "cart '{}' is bound to intent '{}', not the presented intent '{}'",
392 cart.cart_id, cart.intent_id, intent.intent_id
393 ),
394 );
395 }
396
397 let Some(amount_cents) = cart.total.to_cents() else {
399 return deny(
400 Ap2DenyKind::Unpriceable,
401 format!(
402 "cart total in '{}' cannot be priced against a cents budget (deny-by-default)",
403 cart.total.currency
404 ),
405 );
406 };
407
408 if let Err(kind) = verify_mandate(
410 keyring,
411 &intent.key_id,
412 &intent.signing_bytes(),
413 &intent.signature,
414 ) {
415 return deny(
416 kind,
417 format!(
418 "intent '{}' signature ({}) failed verification under key '{}'",
419 intent.intent_id,
420 kind.as_str(),
421 intent.key_id
422 ),
423 );
424 }
425 if let Err(kind) = verify_mandate(
426 keyring,
427 &cart.key_id,
428 &cart.signing_bytes(),
429 &cart.signature,
430 ) {
431 return deny(
432 kind,
433 format!(
434 "cart '{}' signature ({}) failed verification under key '{}'",
435 cart.cart_id,
436 kind.as_str(),
437 cart.key_id
438 ),
439 );
440 }
441
442 if let Some(reason) = scope_violation(intent, cart, amount_cents) {
444 return deny(Ap2DenyKind::IntentScopeMismatch, reason);
445 }
446
447 if !budget.has_cost_headroom(usage, amount_cents) {
449 let cap = budget.max_cost_cents.unwrap_or(0);
450 let projected = usage.cost_cents.saturating_add(amount_cents);
451 return deny(
452 Ap2DenyKind::CartOverCeiling,
453 format!(
454 "ap2 spend gate: paying {amount_cents}¢ to {} would push run cost to {projected}¢ over the {cap}¢ budget (by {}¢)",
455 cart.merchant,
456 projected.saturating_sub(cap)
457 ),
458 );
459 }
460
461 let event = Ap2CostEvent {
463 cart_id: cart.cart_id.clone(),
464 intent_id: cart.intent_id.clone(),
465 merchant: cart.merchant.clone(),
466 cost_cents: amount_cents,
467 currency: cart.total.currency.to_ascii_uppercase(),
468 };
469 let budget_remaining_cents = budget
470 .max_cost_cents
471 .map(|cap| cap.saturating_sub(usage.cost_cents.saturating_add(amount_cents)));
472 Ap2GateOutcome::Authorize {
473 event,
474 budget_remaining_cents,
475 }
476}
477
478fn scope_violation(
481 intent: &Ap2IntentMandate,
482 cart: &Ap2CartMandate,
483 amount_cents: u64,
484) -> Option<String> {
485 let scope = &intent.scope;
486 if !scope.merchants.is_empty() && !scope.merchants.iter().any(|m| m == &cart.merchant) {
487 return Some(format!(
488 "merchant '{}' is not in the intent's authorized merchants",
489 cart.merchant
490 ));
491 }
492 if !scope.categories.is_empty() {
493 match &cart.category {
494 Some(c) if scope.categories.iter().any(|sc| sc == c) => {}
495 _ => {
496 return Some(format!(
497 "cart category {:?} is not in the intent's authorized categories",
498 cart.category
499 ))
500 }
501 }
502 }
503 if !cart
504 .total
505 .currency
506 .eq_ignore_ascii_case(&scope.max_amount.currency)
507 {
508 return Some(format!(
509 "cart currency '{}' does not match the intent currency '{}'",
510 cart.total.currency, scope.max_amount.currency
511 ));
512 }
513 if amount_cents > scope.max_amount.amount_cents {
514 return Some(format!(
515 "cart total {amount_cents}¢ exceeds the user's authorized intent max {}¢",
516 scope.max_amount.amount_cents
517 ));
518 }
519 None
520}
521
522fn deny(kind: Ap2DenyKind, reason: String) -> Ap2GateOutcome {
523 Ap2GateOutcome::Deny { kind, reason }
524}
525
526#[cfg(test)]
527mod tests {
528 use super::*;
529 use ed25519_dalek::{Signer, SigningKey};
530
531 const USER_KEY_ID: &str = "user-key-1";
532
533 fn signing_key() -> SigningKey {
534 SigningKey::from_bytes(&[7u8; 32])
536 }
537
538 fn keyring_with(signer: &SigningKey) -> Ap2Keyring {
539 let mut kr = Ap2Keyring::new();
540 kr.insert_key(USER_KEY_ID, signer.verifying_key());
541 kr
542 }
543
544 fn usd(cents: u64) -> Ap2Money {
545 Ap2Money {
546 amount_cents: cents,
547 currency: "USD".into(),
548 }
549 }
550
551 fn signed_intent(signer: &SigningKey, max_cents: u64) -> Ap2IntentMandate {
552 let mut intent = Ap2IntentMandate {
553 intent_id: "intent-abc".into(),
554 subject: "user@example.com".into(),
555 scope: Ap2Scope {
556 merchants: vec!["acme-store".into()],
557 categories: vec!["office-supplies".into()],
558 max_amount: usd(max_cents),
559 },
560 key_id: USER_KEY_ID.into(),
561 signature: String::new(),
562 };
563 intent.signature = hex::encode(signer.sign(&intent.signing_bytes()).to_bytes());
564 intent
565 }
566
567 fn signed_cart(signer: &SigningKey, total_cents: u64) -> Ap2CartMandate {
568 let mut cart = Ap2CartMandate {
569 cart_id: "cart-xyz".into(),
570 intent_id: "intent-abc".into(),
571 merchant: "acme-store".into(),
572 category: Some("office-supplies".into()),
573 total: usd(total_cents),
574 key_id: USER_KEY_ID.into(),
575 signature: String::new(),
576 };
577 cart.signature = hex::encode(signer.sign(&cart.signing_bytes()).to_bytes());
578 cart
579 }
580
581 fn budget(cap: u64) -> Budget {
582 Budget {
583 max_cost_cents: Some(cap),
584 ..Budget::default()
585 }
586 }
587
588 #[test]
589 fn authorizes_a_valid_in_scope_in_budget_payment() {
590 let signer = signing_key();
591 let kr = keyring_with(&signer);
592 let intent = signed_intent(&signer, 5000); let cart = signed_cart(&signer, 4000); let usage = BudgetUsage {
595 cost_cents: 10,
596 ..BudgetUsage::default()
597 };
598 let outcome = evaluate_ap2_payment(&intent, &cart, &kr, &budget(10_000), &usage);
599 match outcome {
600 Ap2GateOutcome::Authorize {
601 event,
602 budget_remaining_cents,
603 } => {
604 assert_eq!(event.cost_cents, 4000);
605 assert_eq!(event.merchant, "acme-store");
606 assert_eq!(budget_remaining_cents, Some(10_000 - 10 - 4000));
607 }
608 other => panic!("expected authorize, got {other:?}"),
609 }
610 }
611
612 #[test]
613 fn authorized_payment_folds_into_the_shared_cost_ledger() {
614 let signer = signing_key();
615 let kr = keyring_with(&signer);
616 let intent = signed_intent(&signer, 5000);
617 let cart = signed_cart(&signer, 4000);
618 let mut usage = BudgetUsage {
619 cost_cents: 30, ..BudgetUsage::default()
621 };
622 if let Ap2GateOutcome::Authorize { event, .. } =
623 evaluate_ap2_payment(&intent, &cart, &kr, &budget(10_000), &usage)
624 {
625 event.apply_to(&mut usage);
626 } else {
627 panic!("expected authorize");
628 }
629 assert_eq!(usage.cost_cents, 4030); assert_eq!(usage.tool_calls, 0);
631 }
632
633 #[test]
634 fn denies_a_tampered_cart_signature() {
635 let signer = signing_key();
636 let kr = keyring_with(&signer);
637 let intent = signed_intent(&signer, 5000);
638 let mut cart = signed_cart(&signer, 4000);
639 cart.total.amount_cents = 1; let outcome = evaluate_ap2_payment(
642 &intent,
643 &cart,
644 &kr,
645 &budget(10_000),
646 &BudgetUsage::default(),
647 );
648 assert_eq!(outcome.deny_kind(), Some(Ap2DenyKind::InvalidSignature));
649 assert!(outcome.to_policy_decision().is_denied());
650 assert!(outcome.alert_line().unwrap().contains("invalid_signature"));
651 }
652
653 #[test]
654 fn denies_a_missing_signature() {
655 let signer = signing_key();
656 let kr = keyring_with(&signer);
657 let intent = signed_intent(&signer, 5000);
658 let mut cart = signed_cart(&signer, 4000);
659 cart.signature = String::new();
660 let outcome = evaluate_ap2_payment(
661 &intent,
662 &cart,
663 &kr,
664 &budget(10_000),
665 &BudgetUsage::default(),
666 );
667 assert_eq!(outcome.deny_kind(), Some(Ap2DenyKind::MissingSignature));
668 }
669
670 #[test]
671 fn denies_an_unknown_signing_key() {
672 let signer = signing_key();
673 let empty = Ap2Keyring::new(); let intent = signed_intent(&signer, 5000);
675 let cart = signed_cart(&signer, 4000);
676 let outcome = evaluate_ap2_payment(
677 &intent,
678 &cart,
679 &empty,
680 &budget(10_000),
681 &BudgetUsage::default(),
682 );
683 assert_eq!(outcome.deny_kind(), Some(Ap2DenyKind::UnknownKey));
684 }
685
686 #[test]
687 fn denies_a_cart_over_the_per_task_ceiling() {
688 let signer = signing_key();
691 let kr = keyring_with(&signer);
692 let intent = signed_intent(&signer, 20_000); let cart = signed_cart(&signer, 15_000); let usage = BudgetUsage {
695 cost_cents: 0,
696 ..BudgetUsage::default()
697 };
698 let outcome = evaluate_ap2_payment(&intent, &cart, &kr, &budget(100), &usage); match &outcome {
700 Ap2GateOutcome::Deny { kind, reason } => {
701 assert_eq!(*kind, Ap2DenyKind::CartOverCeiling);
702 assert!(reason.contains("15000"));
703 }
704 other => panic!("expected deny, got {other:?}"),
705 }
706 }
707
708 #[test]
709 fn denies_a_merchant_outside_intent_scope() {
710 let signer = signing_key();
711 let kr = keyring_with(&signer);
712 let intent = signed_intent(&signer, 5000);
713 let mut cart = Ap2CartMandate {
714 merchant: "evil-merchant".into(), ..signed_cart(&signer, 4000)
716 };
717 cart.signature = hex::encode(signer.sign(&cart.signing_bytes()).to_bytes());
720 let outcome = evaluate_ap2_payment(
721 &intent,
722 &cart,
723 &kr,
724 &budget(10_000),
725 &BudgetUsage::default(),
726 );
727 assert_eq!(outcome.deny_kind(), Some(Ap2DenyKind::IntentScopeMismatch));
728 }
729
730 #[test]
731 fn denies_a_cart_over_the_users_own_intent_max() {
732 let signer = signing_key();
735 let kr = keyring_with(&signer);
736 let intent = signed_intent(&signer, 3000); let cart = signed_cart(&signer, 4000); let outcome = evaluate_ap2_payment(
739 &intent,
740 &cart,
741 &kr,
742 &budget(100_000),
743 &BudgetUsage::default(),
744 );
745 assert_eq!(outcome.deny_kind(), Some(Ap2DenyKind::IntentScopeMismatch));
746 }
747
748 #[test]
749 fn denies_a_cart_not_bound_to_the_intent() {
750 let signer = signing_key();
751 let kr = keyring_with(&signer);
752 let intent = signed_intent(&signer, 5000);
753 let mut cart = signed_cart(&signer, 4000);
754 cart.intent_id = "some-other-intent".into();
755 cart.signature = hex::encode(signer.sign(&cart.signing_bytes()).to_bytes());
756 let outcome = evaluate_ap2_payment(
757 &intent,
758 &cart,
759 &kr,
760 &budget(10_000),
761 &BudgetUsage::default(),
762 );
763 assert_eq!(outcome.deny_kind(), Some(Ap2DenyKind::IntentCartMismatch));
764 }
765
766 #[test]
767 fn denies_an_unpriceable_non_usd_cart() {
768 let signer = signing_key();
769 let kr = keyring_with(&signer);
770 let intent = signed_intent(&signer, 5000);
771 let mut cart = signed_cart(&signer, 4000);
772 cart.total.currency = "EUR".into();
773 cart.signature = hex::encode(signer.sign(&cart.signing_bytes()).to_bytes());
774 let outcome = evaluate_ap2_payment(
775 &intent,
776 &cart,
777 &kr,
778 &budget(10_000),
779 &BudgetUsage::default(),
780 );
781 assert_eq!(outcome.deny_kind(), Some(Ap2DenyKind::Unpriceable));
782 }
783
784 #[test]
785 fn mandates_round_trip_through_json_and_verify() {
786 let signer = signing_key();
788 let kr = keyring_with(&signer);
789 let intent = signed_intent(&signer, 5000);
790 let cart = signed_cart(&signer, 4000);
791 let intent2: Ap2IntentMandate =
792 serde_json::from_str(&serde_json::to_string(&intent).unwrap()).unwrap();
793 let cart2: Ap2CartMandate =
794 serde_json::from_str(&serde_json::to_string(&cart).unwrap()).unwrap();
795 assert!(evaluate_ap2_payment(
796 &intent2,
797 &cart2,
798 &kr,
799 &budget(10_000),
800 &BudgetUsage::default()
801 )
802 .is_authorized());
803 }
804
805 #[test]
806 fn keyring_insert_hex_round_trips() {
807 let signer = signing_key();
808 let hexkey = hex::encode(signer.verifying_key().to_bytes());
809 let mut kr = Ap2Keyring::new();
810 kr.insert_hex(USER_KEY_ID, &hexkey).expect("valid hex key");
811 assert_eq!(kr.len(), 1);
812 assert!(!kr.is_empty());
813 assert!(kr.insert_hex("bad", "zzzz").is_err());
815 }
816
817 #[test]
818 fn deny_kind_wire_labels_are_stable() {
819 assert_eq!(Ap2DenyKind::InvalidSignature.as_str(), "invalid_signature");
820 assert_eq!(Ap2DenyKind::CartOverCeiling.as_str(), "cart_over_ceiling");
821 assert_eq!(
822 Ap2DenyKind::IntentScopeMismatch.as_str(),
823 "intent_scope_mismatch"
824 );
825 }
826}