1use serde::Serialize;
50use serde_json::Value;
51use sha2::{Digest, Sha256};
52
53use crate::approval::VerifiedResponse;
54use crate::canon::canon_args;
55use crate::signed::{Envelope, canonical_bytes};
56use crate::{Signer, verify};
57
58const KIND_INTENT: &str = "ap2.intent.v1";
60const KIND_CART: &str = "ap2.cart.v1";
62const KIND_PAYMENT: &str = "ap2.payment.v1";
64
65#[must_use]
71pub fn mandate_hash(signed_payload: &[u8]) -> String {
72 let mut hasher = Sha256::new();
73 hasher.update(signed_payload);
74 crate::hex::lower(&hasher.finalize())
75}
76
77#[derive(Debug, Clone, Copy)]
83pub struct IntentFields<'a> {
84 pub caller: &'a str,
87 pub conversation_id: &'a str,
89 pub scope_description: &'a str,
92 pub currency: &'a str,
95 pub max_total_base_units: &'a str,
100 pub issued_at_unix: u64,
102 pub expires_at_unix: u64,
105 pub nonce: &'a str,
107}
108
109impl<'a> IntentFields<'a> {
110 const fn canonical_json(&self) -> IntentCanonical<'a> {
111 IntentCanonical {
112 kind: KIND_INTENT,
113 caller: self.caller,
114 conversation_id: self.conversation_id,
115 scope_description: self.scope_description,
116 currency: self.currency,
117 max_total_base_units: self.max_total_base_units,
118 issued_at_unix: self.issued_at_unix,
119 expires_at_unix: self.expires_at_unix,
120 nonce: self.nonce,
121 }
122 }
123}
124
125#[derive(Serialize)]
130struct IntentCanonical<'a> {
131 kind: &'static str,
132 caller: &'a str,
133 conversation_id: &'a str,
134 scope_description: &'a str,
135 currency: &'a str,
136 max_total_base_units: &'a str,
137 issued_at_unix: u64,
138 expires_at_unix: u64,
139 nonce: &'a str,
140}
141
142#[derive(Debug, Clone)]
144pub struct VerifiedIntentMandate {
145 pub caller: String,
147 pub conversation_id: String,
149 pub scope_description: String,
151 pub currency: String,
153 pub max_total_base_units: String,
155 pub issued_at_unix: u64,
157 pub expires_at_unix: u64,
159 pub nonce: String,
161 pub signer_public_key: Vec<u8>,
163}
164
165#[must_use]
171pub fn sign_intent_mandate(
172 fields: &IntentFields<'_>,
173 signer: &Signer,
174) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
175 Envelope::seal(fields.canonical_json(), signer)
176}
177
178#[must_use]
185pub fn verify_signed_intent_mandate(payload: &[u8]) -> Option<VerifiedIntentMandate> {
186 let v: Value = serde_json::from_slice(payload).ok()?;
187 if v.get("kind")?.as_str()? != KIND_INTENT {
188 return None;
189 }
190 let caller = v.get("caller")?.as_str()?.to_owned();
191 let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
192 let scope_description = v.get("scope_description")?.as_str()?.to_owned();
193 let currency = v.get("currency")?.as_str()?.to_owned();
194 let max_total_base_units = v.get("max_total_base_units")?.as_str()?.to_owned();
195 let issued_at_unix = v.get("issued_at_unix")?.as_u64()?;
196 let expires_at_unix = v.get("expires_at_unix")?.as_u64()?;
197 let nonce = v.get("nonce")?.as_str()?.to_owned();
198 let (pk, sig) = envelope_signature(&v)?;
199
200 let fields = IntentFields {
201 caller: &caller,
202 conversation_id: &conversation_id,
203 scope_description: &scope_description,
204 currency: ¤cy,
205 max_total_base_units: &max_total_base_units,
206 issued_at_unix,
207 expires_at_unix,
208 nonce: &nonce,
209 };
210 if verify(&pk, &canonical_bytes(&fields.canonical_json()), &sig) {
211 Some(VerifiedIntentMandate {
212 caller,
213 conversation_id,
214 scope_description,
215 currency,
216 max_total_base_units,
217 issued_at_unix,
218 expires_at_unix,
219 nonce,
220 signer_public_key: pk,
221 })
222 } else {
223 None
224 }
225}
226
227#[derive(Debug, Clone, Copy)]
230pub struct CartFields<'a> {
231 pub intent_hash: &'a str,
233 pub caller: &'a str,
236 pub conversation_id: &'a str,
238 pub merchant_host: &'a str,
240 pub currency: &'a str,
242 pub amount_base_units: &'a str,
244 pub issued_at_unix: u64,
246 pub expires_at_unix: u64,
248 pub nonce: &'a str,
250}
251
252impl<'a> CartFields<'a> {
253 const fn canonical_json(&self) -> CartCanonical<'a> {
254 CartCanonical {
255 kind: KIND_CART,
256 intent_hash: self.intent_hash,
257 caller: self.caller,
258 conversation_id: self.conversation_id,
259 merchant_host: self.merchant_host,
260 currency: self.currency,
261 amount_base_units: self.amount_base_units,
262 issued_at_unix: self.issued_at_unix,
263 expires_at_unix: self.expires_at_unix,
264 nonce: self.nonce,
265 }
266 }
267}
268
269#[derive(Serialize)]
274struct CartCanonical<'a> {
275 kind: &'static str,
276 intent_hash: &'a str,
277 caller: &'a str,
278 conversation_id: &'a str,
279 merchant_host: &'a str,
280 currency: &'a str,
281 amount_base_units: &'a str,
282 issued_at_unix: u64,
283 expires_at_unix: u64,
284 nonce: &'a str,
285}
286
287#[derive(Debug, Clone)]
289pub struct VerifiedCartMandate {
290 pub intent_hash: String,
292 pub caller: String,
294 pub conversation_id: String,
296 pub merchant_host: String,
298 pub currency: String,
300 pub amount_base_units: String,
302 pub issued_at_unix: u64,
304 pub expires_at_unix: u64,
306 pub nonce: String,
308 pub signer_public_key: Vec<u8>,
310}
311
312#[must_use]
315pub fn sign_cart_mandate(fields: &CartFields<'_>, signer: &Signer) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
316 Envelope::seal(fields.canonical_json(), signer)
317}
318
319#[must_use]
322pub fn verify_signed_cart_mandate(payload: &[u8]) -> Option<VerifiedCartMandate> {
323 let v: Value = serde_json::from_slice(payload).ok()?;
324 if v.get("kind")?.as_str()? != KIND_CART {
325 return None;
326 }
327 let intent_hash = v.get("intent_hash")?.as_str()?.to_owned();
328 let caller = v.get("caller")?.as_str()?.to_owned();
329 let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
330 let merchant_host = v.get("merchant_host")?.as_str()?.to_owned();
331 let currency = v.get("currency")?.as_str()?.to_owned();
332 let amount_base_units = v.get("amount_base_units")?.as_str()?.to_owned();
333 let issued_at_unix = v.get("issued_at_unix")?.as_u64()?;
334 let expires_at_unix = v.get("expires_at_unix")?.as_u64()?;
335 let nonce = v.get("nonce")?.as_str()?.to_owned();
336 let (pk, sig) = envelope_signature(&v)?;
337
338 let fields = CartFields {
339 intent_hash: &intent_hash,
340 caller: &caller,
341 conversation_id: &conversation_id,
342 merchant_host: &merchant_host,
343 currency: ¤cy,
344 amount_base_units: &amount_base_units,
345 issued_at_unix,
346 expires_at_unix,
347 nonce: &nonce,
348 };
349 if verify(&pk, &canonical_bytes(&fields.canonical_json()), &sig) {
350 Some(VerifiedCartMandate {
351 intent_hash,
352 caller,
353 conversation_id,
354 merchant_host,
355 currency,
356 amount_base_units,
357 issued_at_unix,
358 expires_at_unix,
359 nonce,
360 signer_public_key: pk,
361 })
362 } else {
363 None
364 }
365}
366
367#[derive(Debug, Clone, Copy)]
370pub struct PaymentFields<'a> {
371 pub cart_hash: &'a str,
373 pub caller: &'a str,
375 pub conversation_id: &'a str,
377 pub args_json: &'a str,
381 pub currency: &'a str,
383 pub amount_base_units: &'a str,
385 pub issued_at_unix: u64,
387 pub expires_at_unix: u64,
389 pub nonce: &'a str,
391}
392
393impl<'a> PaymentFields<'a> {
394 const fn canonical_json(&self) -> PaymentCanonical<'a> {
395 PaymentCanonical {
396 kind: KIND_PAYMENT,
397 cart_hash: self.cart_hash,
398 caller: self.caller,
399 conversation_id: self.conversation_id,
400 args_json: self.args_json,
401 currency: self.currency,
402 amount_base_units: self.amount_base_units,
403 issued_at_unix: self.issued_at_unix,
404 expires_at_unix: self.expires_at_unix,
405 nonce: self.nonce,
406 }
407 }
408}
409
410#[derive(Serialize)]
415struct PaymentCanonical<'a> {
416 kind: &'static str,
417 cart_hash: &'a str,
418 caller: &'a str,
419 conversation_id: &'a str,
420 args_json: &'a str,
421 currency: &'a str,
422 amount_base_units: &'a str,
423 issued_at_unix: u64,
424 expires_at_unix: u64,
425 nonce: &'a str,
426}
427
428#[derive(Debug, Clone)]
430pub struct VerifiedPaymentMandate {
431 pub cart_hash: String,
433 pub caller: String,
435 pub conversation_id: String,
437 pub args_json: String,
439 pub currency: String,
441 pub amount_base_units: String,
443 pub issued_at_unix: u64,
445 pub expires_at_unix: u64,
447 pub nonce: String,
449 pub signer_public_key: Vec<u8>,
451}
452
453#[must_use]
456pub fn sign_payment_mandate(
457 fields: &PaymentFields<'_>,
458 signer: &Signer,
459) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
460 Envelope::seal(fields.canonical_json(), signer)
461}
462
463#[must_use]
466pub fn verify_signed_payment_mandate(payload: &[u8]) -> Option<VerifiedPaymentMandate> {
467 let v: Value = serde_json::from_slice(payload).ok()?;
468 if v.get("kind")?.as_str()? != KIND_PAYMENT {
469 return None;
470 }
471 let cart_hash = v.get("cart_hash")?.as_str()?.to_owned();
472 let caller = v.get("caller")?.as_str()?.to_owned();
473 let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
474 let args_json = v.get("args_json")?.as_str()?.to_owned();
475 let currency = v.get("currency")?.as_str()?.to_owned();
476 let amount_base_units = v.get("amount_base_units")?.as_str()?.to_owned();
477 let issued_at_unix = v.get("issued_at_unix")?.as_u64()?;
478 let expires_at_unix = v.get("expires_at_unix")?.as_u64()?;
479 let nonce = v.get("nonce")?.as_str()?.to_owned();
480 let (pk, sig) = envelope_signature(&v)?;
481
482 let fields = PaymentFields {
483 cart_hash: &cart_hash,
484 caller: &caller,
485 conversation_id: &conversation_id,
486 args_json: &args_json,
487 currency: ¤cy,
488 amount_base_units: &amount_base_units,
489 issued_at_unix,
490 expires_at_unix,
491 nonce: &nonce,
492 };
493 if verify(&pk, &canonical_bytes(&fields.canonical_json()), &sig) {
494 Some(VerifiedPaymentMandate {
495 cart_hash,
496 caller,
497 conversation_id,
498 args_json,
499 currency,
500 amount_base_units,
501 issued_at_unix,
502 expires_at_unix,
503 nonce,
504 signer_public_key: pk,
505 })
506 } else {
507 None
508 }
509}
510
511fn envelope_signature(v: &Value) -> Option<(Vec<u8>, Vec<u8>)> {
514 let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
515 let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
516 Some((pk, sig))
517}
518
519#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
529pub enum MandateError {
530 #[error("intent mandate is malformed or its signature does not verify")]
532 InvalidIntent,
533 #[error("cart mandate is malformed or its signature does not verify")]
535 InvalidCart,
536 #[error("payment mandate is malformed or its signature does not verify")]
538 InvalidPayment,
539 #[error("{0} mandate is not signed by a trusted key")]
543 UntrustedSigner(&'static str),
544 #[error("{0} mandate is bound to a different conversation")]
547 ConversationMismatch(&'static str),
548 #[error("mandate chain is not bound to one consistent caller")]
550 CallerMismatch,
551 #[error("{kind} mandate expired at {expires_at_unix}")]
553 Expired {
554 kind: &'static str,
556 expires_at_unix: u64,
558 },
559 #[error("cart mandate does not chain to the presented intent mandate")]
561 CartNotChainedToIntent,
562 #[error("payment mandate does not chain to the presented cart mandate")]
564 PaymentNotChainedToCart,
565 #[error("{0} mandate carries an unparseable base-unit amount")]
569 MalformedAmount(&'static str),
570 #[error("cart amount {cart} exceeds the intent ceiling {intent}")]
572 AmountWidensAtCart {
573 cart: u128,
575 intent: u128,
577 },
578 #[error("payment amount {payment} exceeds the cart amount {cart}")]
580 AmountWidensAtPayment {
581 payment: u128,
583 cart: u128,
585 },
586 #[error("mandate currency does not match across the chain")]
588 CurrencyMismatch,
589 #[error("cart mandate carries no merchant scope")]
592 EmptyScope,
593 #[error(
596 "mandate authorizes at most {authorized} base units against {authorized_host}; \
597 requested {requested} base units against {requested_host}"
598 )]
599 ExceedsAuthorization {
600 authorized: u128,
602 authorized_host: String,
604 requested: u128,
606 requested_host: String,
608 },
609 #[error("the payment mandate is bound to a different tool call's args")]
613 ArgsBindingMismatch,
614 #[error("the HITL approval does not authorize this exact call; no mandate was minted")]
617 ApprovalDoesNotAuthorizeCall,
618}
619
620#[derive(Debug, Clone, Default)]
623pub struct MandateChain {
624 pub intent: Vec<u8>,
626 pub cart: Vec<u8>,
628 pub payment: Vec<u8>,
630}
631
632const PERSONA_TRUST_MAX_AGE_SECS: u64 = 300;
639
640#[derive(Debug, Clone, Copy)]
658pub struct PersonaSignerTrust<'a> {
659 pub signing_public_key: &'a [u8],
662 pub revoked: bool,
665 pub checked_at_unix: u64,
667}
668
669impl PersonaSignerTrust<'_> {
670 const fn is_trustworthy(&self, now_unix: u64) -> bool {
676 !self.revoked
677 && self.checked_at_unix <= now_unix
678 && now_unix.saturating_sub(self.checked_at_unix) <= PERSONA_TRUST_MAX_AGE_SECS
679 }
680}
681
682impl MandateChain {
683 pub fn verify(
713 &self,
714 conversation_id: &str,
715 issuer_public_key: &[u8],
716 persona_signer: Option<PersonaSignerTrust<'_>>,
717 now_unix: u64,
718 ) -> Result<VerifiedMandateChain, MandateError> {
719 let intent =
720 verify_signed_intent_mandate(&self.intent).ok_or(MandateError::InvalidIntent)?;
721 let cart = verify_signed_cart_mandate(&self.cart).ok_or(MandateError::InvalidCart)?;
722 let payment =
723 verify_signed_payment_mandate(&self.payment).ok_or(MandateError::InvalidPayment)?;
724
725 let links: [(&'static str, &str, u64); 3] = [
728 ("intent", &intent.conversation_id, intent.expires_at_unix),
729 ("cart", &cart.conversation_id, cart.expires_at_unix),
730 ("payment", &payment.conversation_id, payment.expires_at_unix),
731 ];
732 for (label, conv, expires_at_unix) in links {
733 if conv != conversation_id {
734 return Err(MandateError::ConversationMismatch(label));
735 }
736 if expires_at_unix <= now_unix {
737 return Err(MandateError::Expired {
738 kind: label,
739 expires_at_unix,
740 });
741 }
742 }
743
744 let intent_user_signed = persona_signer.is_some_and(|trust| {
752 trust.is_trustworthy(now_unix) && intent.signer_public_key == trust.signing_public_key
753 });
754 if !intent_user_signed && intent.signer_public_key != issuer_public_key {
755 return Err(MandateError::UntrustedSigner("intent"));
756 }
757 if cart.signer_public_key != issuer_public_key {
758 return Err(MandateError::UntrustedSigner("cart"));
759 }
760 if payment.signer_public_key != issuer_public_key {
761 return Err(MandateError::UntrustedSigner("payment"));
762 }
763 if intent.caller != cart.caller || cart.caller != payment.caller {
764 return Err(MandateError::CallerMismatch);
765 }
766
767 if cart.intent_hash != mandate_hash(&self.intent) {
772 return Err(MandateError::CartNotChainedToIntent);
773 }
774 if payment.cart_hash != mandate_hash(&self.cart) {
775 return Err(MandateError::PaymentNotChainedToCart);
776 }
777
778 let cart_amount: u128 = cart
783 .amount_base_units
784 .parse()
785 .map_err(|_| MandateError::MalformedAmount("cart"))?;
786 let payment_amount: u128 = payment
787 .amount_base_units
788 .parse()
789 .map_err(|_| MandateError::MalformedAmount("payment"))?;
790 if !intent.max_total_base_units.is_empty() {
791 let ceiling: u128 = intent
792 .max_total_base_units
793 .parse()
794 .map_err(|_| MandateError::MalformedAmount("intent"))?;
795 if cart_amount > ceiling {
796 return Err(MandateError::AmountWidensAtCart {
797 cart: cart_amount,
798 intent: ceiling,
799 });
800 }
801 }
802 if payment_amount > cart_amount {
803 return Err(MandateError::AmountWidensAtPayment {
804 payment: payment_amount,
805 cart: cart_amount,
806 });
807 }
808
809 if intent.currency != cart.currency || cart.currency != payment.currency {
810 return Err(MandateError::CurrencyMismatch);
811 }
812 if cart.merchant_host.is_empty() {
813 return Err(MandateError::EmptyScope);
814 }
815
816 Ok(VerifiedMandateChain {
817 authorized_amount_base_units: payment_amount,
818 merchant_host: cart.merchant_host.clone(),
819 currency: payment.currency.clone(),
820 caller: payment.caller.clone(),
821 conversation_id: payment.conversation_id.clone(),
822 args_json: payment.args_json.clone(),
823 intent,
824 cart,
825 payment,
826 })
827 }
828}
829
830#[derive(Debug, Clone)]
833pub struct VerifiedMandateChain {
834 pub authorized_amount_base_units: u128,
837 pub merchant_host: String,
840 pub currency: String,
842 pub caller: String,
844 pub conversation_id: String,
846 pub args_json: String,
848 pub intent: VerifiedIntentMandate,
850 pub cart: VerifiedCartMandate,
852 pub payment: VerifiedPaymentMandate,
854}
855
856impl VerifiedMandateChain {
857 pub fn authorize(
880 &self,
881 requested_base_units: u128,
882 requested_host: &str,
883 args_json: &str,
884 ) -> Result<(), MandateError> {
885 if canon_args(args_json) != canon_args(&self.args_json) {
886 return Err(MandateError::ArgsBindingMismatch);
887 }
888 let host_ok = self.merchant_host.eq_ignore_ascii_case(requested_host);
889 if !host_ok || requested_base_units > self.authorized_amount_base_units {
890 return Err(MandateError::ExceedsAuthorization {
891 authorized: self.authorized_amount_base_units,
892 authorized_host: self.merchant_host.clone(),
893 requested: requested_base_units,
894 requested_host: requested_host.to_owned(),
895 });
896 }
897 Ok(())
898 }
899}
900
901pub fn resolve(
923 chain: Option<&MandateChain>,
924 issuer_public_key: Option<&[u8]>,
925 persona_signer: Option<PersonaSignerTrust<'_>>,
926 conversation_id: &str,
927 now_unix: u64,
928) -> Result<Option<VerifiedMandateChain>, MandateError> {
929 match (chain, issuer_public_key) {
930 (Some(c), Some(key)) => c
931 .verify(conversation_id, key, persona_signer, now_unix)
932 .map(Some),
933 _ => Ok(None),
934 }
935}
936
937#[allow(clippy::too_many_arguments)] pub fn payment_mandate_from_approval(
969 approved: &VerifiedResponse,
970 request_id: &str,
971 tool_name: &str,
972 args_json: &str,
973 cart_payload: &[u8],
974 issued_at_unix: u64,
975 expires_at_unix: u64,
976 nonce: &str,
977 signer: &Signer,
978) -> Result<Vec<u8>, MandateError> {
979 if !approved.authorizes_call(request_id, tool_name, args_json) {
980 return Err(MandateError::ApprovalDoesNotAuthorizeCall);
981 }
982 let cart = verify_signed_cart_mandate(cart_payload).ok_or(MandateError::InvalidCart)?;
983 if cart.caller != approved.caller {
984 return Err(MandateError::CallerMismatch);
985 }
986 if cart.conversation_id != approved.conversation_id {
987 return Err(MandateError::ConversationMismatch("cart"));
988 }
989 let fields = PaymentFields {
990 cart_hash: &mandate_hash(cart_payload),
991 caller: &approved.caller,
992 conversation_id: &approved.conversation_id,
993 args_json,
994 currency: &cart.currency,
995 amount_base_units: &cart.amount_base_units,
996 issued_at_unix,
997 expires_at_unix,
998 nonce,
999 };
1000 let (payload, _sig, _pk) = sign_payment_mandate(&fields, signer);
1001 Ok(payload)
1002}
1003
1004#[cfg(test)]
1005mod tests {
1006 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
1007
1008 use super::*;
1009 use crate::approval::{ApprovalSigner, response_payload, verify_signed_response};
1010
1011 fn active_persona_trust(signing_public_key: &[u8], now_unix: u64) -> PersonaSignerTrust<'_> {
1015 PersonaSignerTrust {
1016 signing_public_key,
1017 revoked: false,
1018 checked_at_unix: now_unix,
1019 }
1020 }
1021
1022 fn revoked_persona_trust(signing_public_key: &[u8], now_unix: u64) -> PersonaSignerTrust<'_> {
1027 PersonaSignerTrust {
1028 signing_public_key,
1029 revoked: true,
1030 checked_at_unix: now_unix,
1031 }
1032 }
1033
1034 fn intent(signer: &Signer) -> (Vec<u8>, IntentFields<'static>) {
1035 let fields = IntentFields {
1036 caller: "slack:T1:U9",
1037 conversation_id: "conv-1",
1038 scope_description: "research report purchases",
1039 currency: "0xUSD",
1040 max_total_base_units: "1000000",
1041 issued_at_unix: 1_000,
1042 expires_at_unix: 10_000,
1043 nonce: "intent-nonce-1",
1044 };
1045 let (payload, _sig, _pk) = sign_intent_mandate(&fields, signer);
1046 (payload, fields)
1047 }
1048
1049 #[test]
1050 fn intent_mandate_round_trips() {
1051 let signer = Signer::from_seed(1);
1052 let (payload, fields) = intent(&signer);
1053 let verified = verify_signed_intent_mandate(&payload).expect("verifies");
1054 assert_eq!(verified.caller, fields.caller);
1055 assert_eq!(verified.conversation_id, fields.conversation_id);
1056 assert_eq!(verified.max_total_base_units, fields.max_total_base_units);
1057 assert_eq!(verified.signer_public_key, signer.public_key_bytes());
1058 }
1059
1060 #[test]
1061 fn intent_mandate_tampered_amount_fails() {
1062 let signer = Signer::from_seed(1);
1063 let (payload, _fields) = intent(&signer);
1064 let mut v: Value = serde_json::from_slice(&payload).unwrap();
1065 v["max_total_base_units"] = Value::String("999999999".to_owned());
1066 assert!(verify_signed_intent_mandate(&v.to_string().into_bytes()).is_none());
1067 }
1068
1069 #[test]
1070 fn intent_mandate_wrong_kind_rejected() {
1071 let signer = Signer::from_seed(1);
1074 let cart_fields = CartFields {
1075 intent_hash: "deadbeef",
1076 caller: "slack:T1:U9",
1077 conversation_id: "conv-1",
1078 merchant_host: "api.example.com",
1079 currency: "0xUSD",
1080 amount_base_units: "500000",
1081 issued_at_unix: 1_000,
1082 expires_at_unix: 5_000,
1083 nonce: "cart-nonce-1",
1084 };
1085 let (cart_payload, _sig, _pk) = sign_cart_mandate(&cart_fields, &signer);
1086 assert!(verify_signed_intent_mandate(&cart_payload).is_none());
1087 }
1088
1089 #[test]
1090 fn cart_mandate_round_trips_and_chains_by_hash() {
1091 let signer = Signer::from_seed(2);
1092 let (intent_payload, _fields) = intent(&signer);
1093 let intent_hash = mandate_hash(&intent_payload);
1094
1095 let cart_fields = CartFields {
1096 intent_hash: &intent_hash,
1097 caller: "slack:T1:U9",
1098 conversation_id: "conv-1",
1099 merchant_host: "api.example.com",
1100 currency: "0xUSD",
1101 amount_base_units: "500000",
1102 issued_at_unix: 1_000,
1103 expires_at_unix: 5_000,
1104 nonce: "cart-nonce-1",
1105 };
1106 let (cart_payload, _sig, _pk) = sign_cart_mandate(&cart_fields, &signer);
1107 let verified = verify_signed_cart_mandate(&cart_payload).expect("cart verifies");
1108 assert_eq!(verified.intent_hash, intent_hash);
1109 assert_eq!(verified.merchant_host, "api.example.com");
1110 }
1111
1112 #[test]
1113 fn cart_mandate_tampered_intent_hash_fails() {
1114 let signer = Signer::from_seed(2);
1115 let (intent_payload, _fields) = intent(&signer);
1116 let intent_hash = mandate_hash(&intent_payload);
1117 let cart_fields = CartFields {
1118 intent_hash: &intent_hash,
1119 caller: "slack:T1:U9",
1120 conversation_id: "conv-1",
1121 merchant_host: "api.example.com",
1122 currency: "0xUSD",
1123 amount_base_units: "500000",
1124 issued_at_unix: 1_000,
1125 expires_at_unix: 5_000,
1126 nonce: "cart-nonce-1",
1127 };
1128 let (payload, _sig, _pk) = sign_cart_mandate(&cart_fields, &signer);
1129 let mut v: Value = serde_json::from_slice(&payload).unwrap();
1130 v["intent_hash"] = Value::String("0".repeat(64));
1131 assert!(verify_signed_cart_mandate(&v.to_string().into_bytes()).is_none());
1132 }
1133
1134 #[test]
1135 fn payment_mandate_round_trips_and_chains_by_hash() {
1136 let signer = Signer::from_seed(3);
1137 let (intent_payload, _fields) = intent(&signer);
1138 let intent_hash = mandate_hash(&intent_payload);
1139 let cart_fields = CartFields {
1140 intent_hash: &intent_hash,
1141 caller: "slack:T1:U9",
1142 conversation_id: "conv-1",
1143 merchant_host: "api.example.com",
1144 currency: "0xUSD",
1145 amount_base_units: "500000",
1146 issued_at_unix: 1_000,
1147 expires_at_unix: 5_000,
1148 nonce: "cart-nonce-1",
1149 };
1150 let (cart_payload, _sig, _pk) = sign_cart_mandate(&cart_fields, &signer);
1151 let cart_hash = mandate_hash(&cart_payload);
1152
1153 let payment_fields = PaymentFields {
1154 cart_hash: &cart_hash,
1155 caller: "slack:T1:U9",
1156 conversation_id: "conv-1",
1157 args_json: r#"{"url":"https://api.example.com/report"}"#,
1158 currency: "0xUSD",
1159 amount_base_units: "250000",
1160 issued_at_unix: 1_000,
1161 expires_at_unix: 4_000,
1162 nonce: "payment-nonce-1",
1163 };
1164 let (payment_payload, _sig, _pk) = sign_payment_mandate(&payment_fields, &signer);
1165 let verified = verify_signed_payment_mandate(&payment_payload).expect("payment verifies");
1166 assert_eq!(verified.cart_hash, cart_hash);
1167 assert_eq!(verified.amount_base_units, "250000");
1168 assert_eq!(verified.args_json, payment_fields.args_json);
1169 }
1170
1171 #[test]
1172 fn payment_mandate_tampered_args_json_fails() {
1173 let signer = Signer::from_seed(3);
1174 let payment_fields = PaymentFields {
1175 cart_hash: "deadbeef",
1176 caller: "slack:T1:U9",
1177 conversation_id: "conv-1",
1178 args_json: r#"{"url":"https://api.example.com/report"}"#,
1179 currency: "0xUSD",
1180 amount_base_units: "250000",
1181 issued_at_unix: 1_000,
1182 expires_at_unix: 4_000,
1183 nonce: "payment-nonce-1",
1184 };
1185 let (payload, _sig, _pk) = sign_payment_mandate(&payment_fields, &signer);
1186 let mut v: Value = serde_json::from_slice(&payload).unwrap();
1187 v["args_json"] = Value::String(r#"{"url":"https://evil.example.com/steal"}"#.to_owned());
1188 assert!(verify_signed_payment_mandate(&v.to_string().into_bytes()).is_none());
1189 }
1190
1191 #[test]
1192 fn mandate_hash_is_stable_and_sensitive_to_signature() {
1193 let signer_a = Signer::from_seed(9);
1194 let signer_b = Signer::from_seed(10);
1195 let fields = IntentFields {
1196 caller: "slack:T1:U9",
1197 conversation_id: "conv-1",
1198 scope_description: "x",
1199 currency: "0xUSD",
1200 max_total_base_units: "1000",
1201 issued_at_unix: 1,
1202 expires_at_unix: 2,
1203 nonce: "n",
1204 };
1205 let (payload_a, _s, _p) = sign_intent_mandate(&fields, &signer_a);
1206 let (payload_b, _s, _p) = sign_intent_mandate(&fields, &signer_b);
1207 assert_eq!(mandate_hash(&payload_a), mandate_hash(&payload_a));
1209 assert_ne!(mandate_hash(&payload_a), mandate_hash(&payload_b));
1212 }
1213
1214 #[test]
1215 fn garbage_payload_returns_none_not_panic() {
1216 assert!(verify_signed_intent_mandate(b"not json").is_none());
1217 assert!(verify_signed_cart_mandate(b"{}").is_none());
1218 assert!(verify_signed_payment_mandate(b"[]").is_none());
1219 }
1220
1221 const CONV: &str = "conv-1";
1222 const CALLER: &str = "slack:T1:U9";
1223 const HOST: &str = "api.example.com";
1224 const USD: &str = "0x20c0000000000000000000000000000000000000";
1225 const ARGS: &str = r#"{"url":"https://api.example.com/report"}"#;
1226
1227 fn issuer() -> Signer {
1228 Signer::from_seed(99)
1229 }
1230
1231 fn intent_fields(max_total: &str) -> IntentFields<'_> {
1232 IntentFields {
1233 caller: CALLER,
1234 conversation_id: CONV,
1235 scope_description: "research report purchases",
1236 currency: USD,
1237 max_total_base_units: max_total,
1238 issued_at_unix: 100,
1239 expires_at_unix: 10_000,
1240 nonce: "n-intent",
1241 }
1242 }
1243
1244 fn valid_chain(signer: &Signer) -> MandateChain {
1247 let (intent, _s, _p) = sign_intent_mandate(&intent_fields("1000000"), signer);
1248 let intent_hash = mandate_hash(&intent);
1249 let (cart, _s, _p) = sign_cart_mandate(
1250 &CartFields {
1251 intent_hash: &intent_hash,
1252 caller: CALLER,
1253 conversation_id: CONV,
1254 merchant_host: HOST,
1255 currency: USD,
1256 amount_base_units: "500000",
1257 issued_at_unix: 100,
1258 expires_at_unix: 9_000,
1259 nonce: "n-cart",
1260 },
1261 signer,
1262 );
1263 let cart_hash = mandate_hash(&cart);
1264 let (payment, _s, _p) = sign_payment_mandate(
1265 &PaymentFields {
1266 cart_hash: &cart_hash,
1267 caller: CALLER,
1268 conversation_id: CONV,
1269 args_json: ARGS,
1270 currency: USD,
1271 amount_base_units: "500000",
1272 issued_at_unix: 100,
1273 expires_at_unix: 8_000,
1274 nonce: "n-payment",
1275 },
1276 signer,
1277 );
1278 MandateChain {
1279 intent,
1280 cart,
1281 payment,
1282 }
1283 }
1284
1285 #[test]
1286 fn valid_chain_verifies_and_authorizes_within_bounds() {
1287 let signer = issuer();
1288 let chain = valid_chain(&signer);
1289 let verified = chain
1290 .verify(CONV, &signer.public_key_bytes(), None, 1_000)
1291 .expect("a mutually consistent, unexpired chain must verify");
1292 assert_eq!(verified.authorized_amount_base_units, 500_000);
1293 assert_eq!(verified.merchant_host, HOST);
1294 assert_eq!(verified.caller, CALLER);
1295
1296 verified.authorize(500_000, HOST, ARGS).expect("at-cap ok");
1298 verified.authorize(1, HOST, ARGS).expect("under-cap ok");
1299 verified
1301 .authorize(500_000, "API.Example.COM", ARGS)
1302 .expect("case-insensitive host");
1303
1304 assert!(matches!(
1306 verified.authorize(500_001, HOST, ARGS),
1307 Err(MandateError::ExceedsAuthorization { .. })
1308 ));
1309 assert!(matches!(
1311 verified.authorize(1, "evil.example.com", ARGS),
1312 Err(MandateError::ExceedsAuthorization { .. })
1313 ));
1314 assert!(matches!(
1317 verified.authorize(1, HOST, r#"{"url":"https://api.example.com/OTHER"}"#),
1318 Err(MandateError::ArgsBindingMismatch)
1319 ));
1320 }
1321
1322 #[test]
1323 fn args_binding_matches_by_value_not_key_order() {
1324 let signer = issuer();
1328 let (intent, _s, _p) = sign_intent_mandate(&intent_fields(""), &signer);
1329 let intent_hash = mandate_hash(&intent);
1330 let (cart, _s, _p) = sign_cart_mandate(
1331 &CartFields {
1332 intent_hash: &intent_hash,
1333 caller: CALLER,
1334 conversation_id: CONV,
1335 merchant_host: HOST,
1336 currency: USD,
1337 amount_base_units: "500000",
1338 issued_at_unix: 100,
1339 expires_at_unix: 9_000,
1340 nonce: "n-cart",
1341 },
1342 &signer,
1343 );
1344 let (payment, _s, _p) = sign_payment_mandate(
1345 &PaymentFields {
1346 cart_hash: &mandate_hash(&cart),
1347 caller: CALLER,
1348 conversation_id: CONV,
1349 args_json: r#"{"max_spend":"0.10","url":"https://api.example.com/r"}"#,
1350 currency: USD,
1351 amount_base_units: "500000",
1352 issued_at_unix: 100,
1353 expires_at_unix: 8_000,
1354 nonce: "n-payment",
1355 },
1356 &signer,
1357 );
1358 let verified = MandateChain {
1359 intent,
1360 cart,
1361 payment,
1362 }
1363 .verify(CONV, &signer.public_key_bytes(), None, 1_000)
1364 .expect("chain verifies");
1365 verified
1366 .authorize(
1367 1,
1368 HOST,
1369 r#"{"url":"https://api.example.com/r","max_spend":"0.10"}"#,
1370 )
1371 .expect("reordered-but-equal args must pass the binding");
1372 }
1373
1374 #[test]
1375 fn tampered_link_fails_signature_verification() {
1376 let signer = issuer();
1377 let mut chain = valid_chain(&signer);
1378 let mut v: serde_json::Value = serde_json::from_slice(&chain.cart).unwrap();
1381 v["amount_base_units"] = serde_json::Value::String("999999999".to_owned());
1382 chain.cart = v.to_string().into_bytes();
1383 assert_eq!(
1384 chain
1385 .verify(CONV, &signer.public_key_bytes(), None, 1_000)
1386 .unwrap_err(),
1387 MandateError::InvalidCart
1388 );
1389 }
1390
1391 #[test]
1392 fn validly_signed_but_unchained_links_are_rejected() {
1393 let signer = issuer();
1397 let chain = valid_chain(&signer);
1398 let (other_intent, _s, _p) = sign_intent_mandate(&intent_fields("2000000"), &signer);
1399 let other_hash = mandate_hash(&other_intent);
1400 let (unchained_cart, _s, _p) = sign_cart_mandate(
1401 &CartFields {
1402 intent_hash: &other_hash, caller: CALLER,
1404 conversation_id: CONV,
1405 merchant_host: HOST,
1406 currency: USD,
1407 amount_base_units: "500000",
1408 issued_at_unix: 100,
1409 expires_at_unix: 9_000,
1410 nonce: "n-cart",
1411 },
1412 &signer,
1413 );
1414 let broken = MandateChain {
1415 intent: chain.intent.clone(),
1416 cart: unchained_cart.clone(),
1417 payment: chain.payment.clone(),
1418 };
1419 assert_eq!(
1420 broken
1421 .verify(CONV, &signer.public_key_bytes(), None, 1_000)
1422 .unwrap_err(),
1423 MandateError::CartNotChainedToIntent
1424 );
1425
1426 let (payment_for_other, _s, _p) = sign_payment_mandate(
1429 &PaymentFields {
1430 cart_hash: &mandate_hash(&unchained_cart),
1431 caller: CALLER,
1432 conversation_id: CONV,
1433 args_json: ARGS,
1434 currency: USD,
1435 amount_base_units: "500000",
1436 issued_at_unix: 100,
1437 expires_at_unix: 8_000,
1438 nonce: "n-payment",
1439 },
1440 &signer,
1441 );
1442 let broken = MandateChain {
1443 intent: chain.intent,
1444 cart: chain.cart,
1445 payment: payment_for_other,
1446 };
1447 assert_eq!(
1448 broken
1449 .verify(CONV, &signer.public_key_bytes(), None, 1_000)
1450 .unwrap_err(),
1451 MandateError::PaymentNotChainedToCart
1452 );
1453 }
1454
1455 #[test]
1456 fn widened_amounts_are_rejected() {
1457 let signer = issuer();
1458 let (intent, _s, _p) = sign_intent_mandate(&intent_fields("100"), &signer);
1460 let intent_hash = mandate_hash(&intent);
1461 let (cart, _s, _p) = sign_cart_mandate(
1462 &CartFields {
1463 intent_hash: &intent_hash,
1464 caller: CALLER,
1465 conversation_id: CONV,
1466 merchant_host: HOST,
1467 currency: USD,
1468 amount_base_units: "999", issued_at_unix: 100,
1470 expires_at_unix: 9_000,
1471 nonce: "n-cart",
1472 },
1473 &signer,
1474 );
1475 let (payment, _s, _p) = sign_payment_mandate(
1476 &PaymentFields {
1477 cart_hash: &mandate_hash(&cart),
1478 caller: CALLER,
1479 conversation_id: CONV,
1480 args_json: ARGS,
1481 currency: USD,
1482 amount_base_units: "999",
1483 issued_at_unix: 100,
1484 expires_at_unix: 8_000,
1485 nonce: "n-payment",
1486 },
1487 &signer,
1488 );
1489 let chain = MandateChain {
1490 intent,
1491 cart,
1492 payment,
1493 };
1494 assert_eq!(
1495 chain
1496 .verify(CONV, &signer.public_key_bytes(), None, 1_000)
1497 .unwrap_err(),
1498 MandateError::AmountWidensAtCart {
1499 cart: 999,
1500 intent: 100
1501 }
1502 );
1503
1504 let base = valid_chain(&signer);
1506 let (over_payment, _s, _p) = sign_payment_mandate(
1507 &PaymentFields {
1508 cart_hash: &mandate_hash(&base.cart),
1509 caller: CALLER,
1510 conversation_id: CONV,
1511 args_json: ARGS,
1512 currency: USD,
1513 amount_base_units: "500001", issued_at_unix: 100,
1515 expires_at_unix: 8_000,
1516 nonce: "n-payment",
1517 },
1518 &signer,
1519 );
1520 let chain = MandateChain {
1521 intent: base.intent,
1522 cart: base.cart,
1523 payment: over_payment,
1524 };
1525 assert_eq!(
1526 chain
1527 .verify(CONV, &signer.public_key_bytes(), None, 1_000)
1528 .unwrap_err(),
1529 MandateError::AmountWidensAtPayment {
1530 payment: 500_001,
1531 cart: 500_000
1532 }
1533 );
1534 }
1535
1536 #[test]
1537 fn unbounded_intent_ceiling_admits_any_cart_amount() {
1538 let signer = issuer();
1541 let (intent, _s, _p) = sign_intent_mandate(&intent_fields(""), &signer);
1542 let intent_hash = mandate_hash(&intent);
1543 let (cart, _s, _p) = sign_cart_mandate(
1544 &CartFields {
1545 intent_hash: &intent_hash,
1546 caller: CALLER,
1547 conversation_id: CONV,
1548 merchant_host: HOST,
1549 currency: USD,
1550 amount_base_units: "123456789",
1551 issued_at_unix: 100,
1552 expires_at_unix: 9_000,
1553 nonce: "n-cart",
1554 },
1555 &signer,
1556 );
1557 let (payment, _s, _p) = sign_payment_mandate(
1558 &PaymentFields {
1559 cart_hash: &mandate_hash(&cart),
1560 caller: CALLER,
1561 conversation_id: CONV,
1562 args_json: ARGS,
1563 currency: USD,
1564 amount_base_units: "123456789",
1565 issued_at_unix: 100,
1566 expires_at_unix: 8_000,
1567 nonce: "n-payment",
1568 },
1569 &signer,
1570 );
1571 let chain = MandateChain {
1572 intent,
1573 cart,
1574 payment,
1575 };
1576 let verified = chain
1577 .verify(CONV, &signer.public_key_bytes(), None, 1_000)
1578 .expect("empty intent ceiling is unbounded");
1579 assert_eq!(verified.authorized_amount_base_units, 123_456_789);
1580 }
1581
1582 #[test]
1583 fn expired_link_is_rejected() {
1584 let signer = issuer();
1585 let chain = valid_chain(&signer);
1586 assert_eq!(
1589 chain
1590 .verify(CONV, &signer.public_key_bytes(), None, 8_000)
1591 .unwrap_err(),
1592 MandateError::Expired {
1593 kind: "payment",
1594 expires_at_unix: 8_000
1595 }
1596 );
1597 assert_eq!(
1599 chain
1600 .verify(CONV, &signer.public_key_bytes(), None, 50_000)
1601 .unwrap_err(),
1602 MandateError::Expired {
1603 kind: "intent",
1604 expires_at_unix: 10_000
1605 }
1606 );
1607 assert!(
1609 chain
1610 .verify(CONV, &signer.public_key_bytes(), None, 1)
1611 .is_ok()
1612 );
1613 }
1614
1615 #[test]
1616 fn untrusted_issuer_is_rejected() {
1617 let signer = issuer();
1618 let chain = valid_chain(&signer);
1619 let wrong_key = Signer::from_seed(1).public_key_bytes();
1620 assert_eq!(
1621 chain.verify(CONV, &wrong_key, None, 1_000).unwrap_err(),
1622 MandateError::UntrustedSigner("intent")
1623 );
1624 }
1625
1626 fn chain_with_user_signed_intent(
1632 issuer_signer: &Signer,
1633 persona_signer: &Signer,
1634 ) -> MandateChain {
1635 let (intent, _s, _p) = sign_intent_mandate(&intent_fields("1000000"), persona_signer);
1636 let intent_hash = mandate_hash(&intent);
1637 let (cart, _s, _p) = sign_cart_mandate(
1638 &CartFields {
1639 intent_hash: &intent_hash,
1640 caller: CALLER,
1641 conversation_id: CONV,
1642 merchant_host: HOST,
1643 currency: USD,
1644 amount_base_units: "500000",
1645 issued_at_unix: 100,
1646 expires_at_unix: 9_000,
1647 nonce: "n-cart",
1648 },
1649 issuer_signer,
1650 );
1651 let cart_hash = mandate_hash(&cart);
1652 let (payment, _s, _p) = sign_payment_mandate(
1653 &PaymentFields {
1654 cart_hash: &cart_hash,
1655 caller: CALLER,
1656 conversation_id: CONV,
1657 args_json: ARGS,
1658 currency: USD,
1659 amount_base_units: "500000",
1660 issued_at_unix: 100,
1661 expires_at_unix: 8_000,
1662 nonce: "n-payment",
1663 },
1664 issuer_signer,
1665 );
1666 MandateChain {
1667 intent,
1668 cart,
1669 payment,
1670 }
1671 }
1672
1673 #[test]
1674 fn user_signed_intent_with_platform_cart_and_payment_verifies() {
1675 let issuer_signer = issuer();
1680 let persona_signer = Signer::from_seed(555);
1681 let persona_key = persona_signer.public_key_bytes();
1682 let chain = chain_with_user_signed_intent(&issuer_signer, &persona_signer);
1683 let verified = chain
1684 .verify(
1685 CONV,
1686 &issuer_signer.public_key_bytes(),
1687 Some(active_persona_trust(&persona_key, 1_000)),
1688 1_000,
1689 )
1690 .expect("a user-signed intent with platform cart/payment must verify");
1691 assert_eq!(verified.intent.signer_public_key, persona_key);
1692 assert_eq!(verified.authorized_amount_base_units, 500_000);
1693 }
1694
1695 #[test]
1696 fn platform_signed_intent_still_verifies_when_persona_has_an_active_credential() {
1697 let issuer_signer = issuer();
1702 let persona_signer = Signer::from_seed(555);
1703 let persona_key = persona_signer.public_key_bytes();
1704 let chain = valid_chain(&issuer_signer); assert!(
1706 chain
1707 .verify(
1708 CONV,
1709 &issuer_signer.public_key_bytes(),
1710 Some(active_persona_trust(&persona_key, 1_000)),
1711 1_000,
1712 )
1713 .is_ok()
1714 );
1715 }
1716
1717 #[test]
1718 fn intent_signed_by_a_non_recorded_key_is_refused() {
1719 let issuer_signer = issuer();
1723 let persona_signer = Signer::from_seed(555);
1724 let persona_key = persona_signer.public_key_bytes();
1725 let stranger = Signer::from_seed(556);
1726 let chain = chain_with_user_signed_intent(&issuer_signer, &stranger);
1727 assert_eq!(
1728 chain
1729 .verify(
1730 CONV,
1731 &issuer_signer.public_key_bytes(),
1732 Some(active_persona_trust(&persona_key, 1_000)),
1733 1_000,
1734 )
1735 .unwrap_err(),
1736 MandateError::UntrustedSigner("intent")
1737 );
1738 }
1739
1740 #[test]
1741 fn user_signed_intent_is_refused_when_no_persona_key_is_resolved() {
1742 let issuer_signer = issuer();
1747 let persona_signer = Signer::from_seed(555);
1748 let chain = chain_with_user_signed_intent(&issuer_signer, &persona_signer);
1749 assert_eq!(
1750 chain
1751 .verify(CONV, &issuer_signer.public_key_bytes(), None, 1_000)
1752 .unwrap_err(),
1753 MandateError::UntrustedSigner("intent")
1754 );
1755 }
1756
1757 #[test]
1758 fn user_signed_intent_is_refused_when_the_credential_is_revoked() {
1759 let issuer_signer = issuer();
1767 let persona_signer = Signer::from_seed(555);
1768 let persona_key = persona_signer.public_key_bytes();
1769 let chain = chain_with_user_signed_intent(&issuer_signer, &persona_signer);
1770 assert_eq!(
1771 chain
1772 .verify(
1773 CONV,
1774 &issuer_signer.public_key_bytes(),
1775 Some(revoked_persona_trust(&persona_key, 1_000)),
1776 1_000,
1777 )
1778 .unwrap_err(),
1779 MandateError::UntrustedSigner("intent")
1780 );
1781 }
1782
1783 #[test]
1784 fn user_signed_intent_is_refused_when_the_revocation_check_is_stale() {
1785 let issuer_signer = issuer();
1792 let persona_signer = Signer::from_seed(555);
1793 let persona_key = persona_signer.public_key_bytes();
1794 let chain = chain_with_user_signed_intent(&issuer_signer, &persona_signer);
1795 let now_unix = 5_000;
1798 let checked_at_unix = now_unix - PERSONA_TRUST_MAX_AGE_SECS - 1;
1799 assert_eq!(
1800 chain
1801 .verify(
1802 CONV,
1803 &issuer_signer.public_key_bytes(),
1804 Some(PersonaSignerTrust {
1805 signing_public_key: &persona_key,
1806 revoked: false,
1807 checked_at_unix,
1808 }),
1809 now_unix,
1810 )
1811 .unwrap_err(),
1812 MandateError::UntrustedSigner("intent")
1813 );
1814 }
1815
1816 #[test]
1817 fn user_signed_intent_is_refused_when_the_revocation_check_is_dated_in_the_future() {
1818 let issuer_signer = issuer();
1822 let persona_signer = Signer::from_seed(555);
1823 let persona_key = persona_signer.public_key_bytes();
1824 let chain = chain_with_user_signed_intent(&issuer_signer, &persona_signer);
1825 assert_eq!(
1826 chain
1827 .verify(
1828 CONV,
1829 &issuer_signer.public_key_bytes(),
1830 Some(PersonaSignerTrust {
1831 signing_public_key: &persona_key,
1832 revoked: false,
1833 checked_at_unix: 1_001,
1834 }),
1835 1_000,
1836 )
1837 .unwrap_err(),
1838 MandateError::UntrustedSigner("intent")
1839 );
1840 }
1841
1842 #[test]
1843 fn cart_and_payment_never_trust_the_persona_key_even_when_intent_does() {
1844 let issuer_signer = issuer();
1849 let persona_signer = Signer::from_seed(555);
1850 let persona_key = persona_signer.public_key_bytes();
1851 let issuer_key = issuer_signer.public_key_bytes();
1852
1853 let (intent, _s, _p) = sign_intent_mandate(&intent_fields("1000000"), &persona_signer);
1854 let intent_hash = mandate_hash(&intent);
1855
1856 let (bad_cart, _s, _p) = sign_cart_mandate(
1858 &CartFields {
1859 intent_hash: &intent_hash,
1860 caller: CALLER,
1861 conversation_id: CONV,
1862 merchant_host: HOST,
1863 currency: USD,
1864 amount_base_units: "500000",
1865 issued_at_unix: 100,
1866 expires_at_unix: 9_000,
1867 nonce: "n-cart",
1868 },
1869 &persona_signer,
1870 );
1871 let (payment_for_bad_cart, _s, _p) = sign_payment_mandate(
1872 &PaymentFields {
1873 cart_hash: &mandate_hash(&bad_cart),
1874 caller: CALLER,
1875 conversation_id: CONV,
1876 args_json: ARGS,
1877 currency: USD,
1878 amount_base_units: "500000",
1879 issued_at_unix: 100,
1880 expires_at_unix: 8_000,
1881 nonce: "n-payment",
1882 },
1883 &issuer_signer,
1884 );
1885 let chain = MandateChain {
1886 intent: intent.clone(),
1887 cart: bad_cart,
1888 payment: payment_for_bad_cart,
1889 };
1890 assert_eq!(
1891 chain
1892 .verify(
1893 CONV,
1894 &issuer_key,
1895 Some(active_persona_trust(&persona_key, 1_000)),
1896 1_000,
1897 )
1898 .unwrap_err(),
1899 MandateError::UntrustedSigner("cart")
1900 );
1901
1902 let (good_cart, _s, _p) = sign_cart_mandate(
1904 &CartFields {
1905 intent_hash: &intent_hash,
1906 caller: CALLER,
1907 conversation_id: CONV,
1908 merchant_host: HOST,
1909 currency: USD,
1910 amount_base_units: "500000",
1911 issued_at_unix: 100,
1912 expires_at_unix: 9_000,
1913 nonce: "n-cart",
1914 },
1915 &issuer_signer,
1916 );
1917 let (bad_payment, _s, _p) = sign_payment_mandate(
1918 &PaymentFields {
1919 cart_hash: &mandate_hash(&good_cart),
1920 caller: CALLER,
1921 conversation_id: CONV,
1922 args_json: ARGS,
1923 currency: USD,
1924 amount_base_units: "500000",
1925 issued_at_unix: 100,
1926 expires_at_unix: 8_000,
1927 nonce: "n-payment",
1928 },
1929 &persona_signer,
1930 );
1931 let chain = MandateChain {
1932 intent,
1933 cart: good_cart,
1934 payment: bad_payment,
1935 };
1936 assert_eq!(
1937 chain
1938 .verify(
1939 CONV,
1940 &issuer_key,
1941 Some(active_persona_trust(&persona_key, 1_000)),
1942 1_000,
1943 )
1944 .unwrap_err(),
1945 MandateError::UntrustedSigner("payment")
1946 );
1947 }
1948
1949 #[test]
1950 fn splicing_a_user_signed_intent_under_a_different_chain_still_breaks_the_hash_link() {
1951 let issuer_signer = issuer();
1957 let persona_signer = Signer::from_seed(555);
1958 let persona_key = persona_signer.public_key_bytes();
1959
1960 let mut foreign_fields = intent_fields("1000000");
1964 foreign_fields.nonce = "n-intent-foreign";
1965 let (foreign_intent, _s, _p) = sign_intent_mandate(&foreign_fields, &persona_signer);
1966
1967 let chain = chain_with_user_signed_intent(&issuer_signer, &persona_signer);
1969
1970 let spliced = MandateChain {
1971 intent: foreign_intent,
1972 cart: chain.cart,
1973 payment: chain.payment,
1974 };
1975 assert_eq!(
1976 spliced
1977 .verify(
1978 CONV,
1979 &issuer_signer.public_key_bytes(),
1980 Some(active_persona_trust(&persona_key, 1_000)),
1981 1_000,
1982 )
1983 .unwrap_err(),
1984 MandateError::CartNotChainedToIntent
1985 );
1986 }
1987
1988 #[test]
1989 fn conversation_and_caller_bindings_are_enforced() {
1990 let signer = issuer();
1991 let chain = valid_chain(&signer);
1992 assert_eq!(
1994 chain
1995 .verify("conv-OTHER", &signer.public_key_bytes(), None, 1_000)
1996 .unwrap_err(),
1997 MandateError::ConversationMismatch("intent")
1998 );
1999
2000 let (payment, _s, _p) = sign_payment_mandate(
2003 &PaymentFields {
2004 cart_hash: &mandate_hash(&chain.cart),
2005 caller: "slack:T1:UATTACKER",
2006 conversation_id: CONV,
2007 args_json: ARGS,
2008 currency: USD,
2009 amount_base_units: "500000",
2010 issued_at_unix: 100,
2011 expires_at_unix: 8_000,
2012 nonce: "n-payment",
2013 },
2014 &signer,
2015 );
2016 let cross_caller = MandateChain {
2017 intent: chain.intent,
2018 cart: chain.cart,
2019 payment,
2020 };
2021 assert_eq!(
2022 cross_caller
2023 .verify(CONV, &signer.public_key_bytes(), None, 1_000)
2024 .unwrap_err(),
2025 MandateError::CallerMismatch
2026 );
2027 }
2028
2029 #[test]
2030 fn currency_mismatch_is_rejected() {
2031 let signer = issuer();
2032 let chain = valid_chain(&signer);
2033 let (payment, _s, _p) = sign_payment_mandate(
2034 &PaymentFields {
2035 cart_hash: &mandate_hash(&chain.cart),
2036 caller: CALLER,
2037 conversation_id: CONV,
2038 args_json: ARGS,
2039 currency: "0xOTHER",
2040 amount_base_units: "500000",
2041 issued_at_unix: 100,
2042 expires_at_unix: 8_000,
2043 nonce: "n-payment",
2044 },
2045 &signer,
2046 );
2047 let cross_currency = MandateChain {
2048 intent: chain.intent,
2049 cart: chain.cart,
2050 payment,
2051 };
2052 assert_eq!(
2053 cross_currency
2054 .verify(CONV, &signer.public_key_bytes(), None, 1_000)
2055 .unwrap_err(),
2056 MandateError::CurrencyMismatch
2057 );
2058 }
2059
2060 #[test]
2061 fn resolve_is_a_noop_when_unconfigured_or_absent() {
2062 let signer = issuer();
2063 let chain = valid_chain(&signer);
2064 let key = signer.public_key_bytes();
2065
2066 assert!(matches!(
2069 resolve(None, Some(&key), None, CONV, 1_000),
2070 Ok(None)
2071 ));
2072 assert!(matches!(resolve(None, None, None, CONV, 1_000), Ok(None)));
2073
2074 assert!(matches!(
2078 resolve(Some(&chain), None, None, CONV, 1_000),
2079 Ok(None)
2080 ));
2081
2082 assert!(matches!(
2084 resolve(Some(&chain), Some(&key), None, CONV, 1_000),
2085 Ok(Some(_))
2086 ));
2087 }
2088
2089 #[test]
2090 fn resolve_fails_closed_on_an_invalid_presented_chain() {
2091 let signer = issuer();
2093 let chain = valid_chain(&signer);
2094 let key = signer.public_key_bytes();
2095 assert!(matches!(
2096 resolve(Some(&chain), Some(&key), None, CONV, 50_000).unwrap_err(),
2097 MandateError::Expired { .. }
2098 ));
2099 }
2100
2101 #[test]
2102 fn resolve_refuses_a_revoked_persona_signer_at_the_same_seam_the_proxy_calls() {
2103 let issuer_signer = issuer();
2109 let persona_signer = Signer::from_seed(555);
2110 let persona_key = persona_signer.public_key_bytes();
2111 let chain = chain_with_user_signed_intent(&issuer_signer, &persona_signer);
2112 let issuer_key = issuer_signer.public_key_bytes();
2113 assert_eq!(
2114 resolve(
2115 Some(&chain),
2116 Some(&issuer_key),
2117 Some(revoked_persona_trust(&persona_key, 1_000)),
2118 CONV,
2119 1_000,
2120 )
2121 .unwrap_err(),
2122 MandateError::UntrustedSigner("intent")
2123 );
2124 }
2125
2126 fn hitl_approval(approved: bool, args_json: &str) -> VerifiedResponse {
2129 let approval_signer = ApprovalSigner::from_seed(7);
2130 let (payload, _sig, _pk) = response_payload(
2131 "req-1",
2132 "paid_fetch",
2133 args_json,
2134 "",
2135 approved,
2136 false,
2137 &[],
2138 CALLER,
2139 "",
2140 "workspace-write",
2141 if approved { "looks fine" } else { "no" },
2142 "",
2143 CONV,
2144 "approval-nonce-1",
2145 &approval_signer,
2146 );
2147 verify_signed_response(&payload).expect("approval signature verifies")
2148 }
2149
2150 fn chain_prefix(signer: &Signer) -> (Vec<u8>, Vec<u8>, String) {
2152 let (intent, _s, _p) = sign_intent_mandate(&intent_fields("1000000"), signer);
2153 let intent_hash = mandate_hash(&intent);
2154 let (cart, _s, _p) = sign_cart_mandate(
2155 &CartFields {
2156 intent_hash: &intent_hash,
2157 caller: CALLER,
2158 conversation_id: CONV,
2159 merchant_host: HOST,
2160 currency: USD,
2161 amount_base_units: "500000",
2162 issued_at_unix: 100,
2163 expires_at_unix: 9_000,
2164 nonce: "n-cart",
2165 },
2166 signer,
2167 );
2168 (intent, cart, intent_hash)
2169 }
2170
2171 #[test]
2172 fn hitl_approval_mints_a_payment_mandate_that_completes_the_chain() {
2173 let signer = issuer();
2174 let (intent, cart, _ih) = chain_prefix(&signer);
2175
2176 let approved = hitl_approval(true, ARGS);
2179 let payment = payment_mandate_from_approval(
2180 &approved,
2181 "req-1",
2182 "paid_fetch",
2183 ARGS,
2184 &cart,
2185 200,
2186 8_000,
2187 "n-payment",
2188 &signer,
2189 )
2190 .expect("the approval authorizes this exact call");
2191
2192 let chain = MandateChain {
2195 intent,
2196 cart,
2197 payment,
2198 };
2199 let verified = chain
2200 .verify(CONV, &signer.public_key_bytes(), None, 1_000)
2201 .expect("the minted payment chains to the cart");
2202 assert_eq!(verified.authorized_amount_base_units, 500_000);
2203 assert_eq!(verified.caller, CALLER);
2204 verified
2205 .authorize(500_000, HOST, ARGS)
2206 .expect("authorizes the approved call");
2207 assert!(matches!(
2208 verified.authorize(1, HOST, r#"{"url":"https://evil.example.com/x"}"#),
2209 Err(MandateError::ArgsBindingMismatch)
2210 ));
2211 }
2212
2213 #[test]
2214 fn hitl_bridge_refuses_denials_and_mismatched_scopes() {
2215 let signer = issuer();
2216 let (_intent, cart, intent_hash) = chain_prefix(&signer);
2217
2218 let denied = hitl_approval(false, ARGS);
2220 assert_eq!(
2221 payment_mandate_from_approval(
2222 &denied,
2223 "req-1",
2224 "paid_fetch",
2225 ARGS,
2226 &cart,
2227 200,
2228 8_000,
2229 "n",
2230 &signer
2231 )
2232 .unwrap_err(),
2233 MandateError::ApprovalDoesNotAuthorizeCall
2234 );
2235
2236 let approved = hitl_approval(true, ARGS);
2238 assert_eq!(
2239 payment_mandate_from_approval(
2240 &approved,
2241 "req-1",
2242 "paid_fetch",
2243 r#"{"url":"https://evil.example.com/x"}"#,
2244 &cart,
2245 200,
2246 8_000,
2247 "n",
2248 &signer
2249 )
2250 .unwrap_err(),
2251 MandateError::ApprovalDoesNotAuthorizeCall
2252 );
2253
2254 let (foreign_cart, _s, _p) = sign_cart_mandate(
2257 &CartFields {
2258 intent_hash: &intent_hash,
2259 caller: CALLER,
2260 conversation_id: "conv-OTHER",
2261 merchant_host: HOST,
2262 currency: USD,
2263 amount_base_units: "500000",
2264 issued_at_unix: 100,
2265 expires_at_unix: 9_000,
2266 nonce: "n-cart",
2267 },
2268 &signer,
2269 );
2270 assert_eq!(
2271 payment_mandate_from_approval(
2272 &approved,
2273 "req-1",
2274 "paid_fetch",
2275 ARGS,
2276 &foreign_cart,
2277 200,
2278 8_000,
2279 "n",
2280 &signer
2281 )
2282 .unwrap_err(),
2283 MandateError::ConversationMismatch("cart")
2284 );
2285
2286 let (foreign_caller_cart, _s, _p) = sign_cart_mandate(
2288 &CartFields {
2289 intent_hash: &intent_hash,
2290 caller: "slack:T1:USOMEONE",
2291 conversation_id: CONV,
2292 merchant_host: HOST,
2293 currency: USD,
2294 amount_base_units: "500000",
2295 issued_at_unix: 100,
2296 expires_at_unix: 9_000,
2297 nonce: "n-cart",
2298 },
2299 &signer,
2300 );
2301 assert_eq!(
2302 payment_mandate_from_approval(
2303 &approved,
2304 "req-1",
2305 "paid_fetch",
2306 ARGS,
2307 &foreign_caller_cart,
2308 200,
2309 8_000,
2310 "n",
2311 &signer
2312 )
2313 .unwrap_err(),
2314 MandateError::CallerMismatch
2315 );
2316 }
2317}
2318
2319#[cfg(test)]
2320mod canonical_freeze {
2321 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
2346
2347 use super::*;
2348
2349 fn frozen(label: &str, got: &[u8], want: &str) {
2351 assert_eq!(
2352 String::from_utf8(got.to_vec()).unwrap(),
2353 want,
2354 "{label}: canonical bytes moved — every signature over the old bytes is now unverifiable"
2355 );
2356 }
2357
2358 const ARGS_JSON: &str = r#"{"url":"https://shop.example/item","max":"25000"}"#;
2359
2360 fn intent() -> IntentFields<'static> {
2361 IntentFields {
2362 caller: "slack:T1:U9",
2363 conversation_id: "conv-1",
2364 scope_description: "groceries for the week",
2365 currency: "0xToken",
2366 max_total_base_units: "100000",
2367 issued_at_unix: 1_750_000_000,
2368 expires_at_unix: 1_750_086_400,
2369 nonce: "nonce-intent",
2370 }
2371 }
2372
2373 fn cart() -> CartFields<'static> {
2374 CartFields {
2375 intent_hash: "abcd1234",
2376 caller: "slack:T1:U9",
2377 conversation_id: "conv-1",
2378 merchant_host: "shop.example",
2379 currency: "0xToken",
2380 amount_base_units: "25000",
2381 issued_at_unix: 1_750_000_000,
2382 expires_at_unix: 1_750_086_400,
2383 nonce: "nonce-cart",
2384 }
2385 }
2386
2387 fn payment() -> PaymentFields<'static> {
2388 PaymentFields {
2389 cart_hash: "beef5678",
2390 caller: "slack:T1:U9",
2391 conversation_id: "conv-1",
2392 args_json: ARGS_JSON,
2393 currency: "0xToken",
2394 amount_base_units: "25000",
2395 issued_at_unix: 1_750_000_000,
2396 expires_at_unix: 1_750_086_400,
2397 nonce: "nonce-payment",
2398 }
2399 }
2400
2401 #[test]
2402 fn intent_mandate_is_frozen() {
2403 frozen(
2404 "IntentFields::canonical_json",
2405 &canonical_bytes(&intent().canonical_json()),
2406 INTENT_CANONICAL,
2407 );
2408 let (full, sig, _) = sign_intent_mandate(&intent(), &Signer::from_seed(99));
2409 frozen("sign_intent_mandate", &full, INTENT_PAYLOAD);
2410 assert_eq!(crate::hex::lower(&sig), INTENT_SIG);
2411 }
2412
2413 #[test]
2414 fn cart_mandate_is_frozen() {
2415 frozen(
2416 "CartFields::canonical_json",
2417 &canonical_bytes(&cart().canonical_json()),
2418 CART_CANONICAL,
2419 );
2420 let (full, sig, _) = sign_cart_mandate(&cart(), &Signer::from_seed(99));
2421 frozen("sign_cart_mandate", &full, CART_PAYLOAD);
2422 assert_eq!(crate::hex::lower(&sig), CART_SIG);
2423 }
2424
2425 #[test]
2426 fn payment_mandate_is_frozen() {
2427 frozen(
2428 "PaymentFields::canonical_json",
2429 &canonical_bytes(&payment().canonical_json()),
2430 PAYMENT_CANONICAL,
2431 );
2432 let (full, sig, _) = sign_payment_mandate(&payment(), &Signer::from_seed(99));
2433 frozen("sign_payment_mandate", &full, PAYMENT_PAYLOAD);
2434 assert_eq!(crate::hex::lower(&sig), PAYMENT_SIG);
2435 }
2436
2437 const INTENT_CANONICAL: &str = r#"{"kind":"ap2.intent.v1","caller":"slack:T1:U9","conversation_id":"conv-1","scope_description":"groceries for the week","currency":"0xToken","max_total_base_units":"100000","issued_at_unix":1750000000,"expires_at_unix":1750086400,"nonce":"nonce-intent"}"#;
2438 const INTENT_PAYLOAD: &str = r#"{"kind":"ap2.intent.v1","caller":"slack:T1:U9","conversation_id":"conv-1","scope_description":"groceries for the week","currency":"0xToken","max_total_base_units":"100000","issued_at_unix":1750000000,"expires_at_unix":1750086400,"nonce":"nonce-intent","signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"85089b532b35c7ad7689c4f7f830959c08d7db71a9a3c45eb53db40289445494e125122bf45c4367aab60cb727c30fc5e4fd3fdc43abd6b0e7a0b2d3f2642703"}"#;
2439 const INTENT_SIG: &str = "85089b532b35c7ad7689c4f7f830959c08d7db71a9a3c45eb53db40289445494e125122bf45c4367aab60cb727c30fc5e4fd3fdc43abd6b0e7a0b2d3f2642703";
2440 const CART_CANONICAL: &str = r#"{"kind":"ap2.cart.v1","intent_hash":"abcd1234","caller":"slack:T1:U9","conversation_id":"conv-1","merchant_host":"shop.example","currency":"0xToken","amount_base_units":"25000","issued_at_unix":1750000000,"expires_at_unix":1750086400,"nonce":"nonce-cart"}"#;
2441 const CART_PAYLOAD: &str = r#"{"kind":"ap2.cart.v1","intent_hash":"abcd1234","caller":"slack:T1:U9","conversation_id":"conv-1","merchant_host":"shop.example","currency":"0xToken","amount_base_units":"25000","issued_at_unix":1750000000,"expires_at_unix":1750086400,"nonce":"nonce-cart","signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"01c9ed068ef6f7f7724391fb2adffccab3db18e447e2514c7e07a233333534cef73ce2301204ca9f4beafb8907b992d9757e951a827765125a2639926afe2907"}"#;
2442 const CART_SIG: &str = "01c9ed068ef6f7f7724391fb2adffccab3db18e447e2514c7e07a233333534cef73ce2301204ca9f4beafb8907b992d9757e951a827765125a2639926afe2907";
2443 const PAYMENT_CANONICAL: &str = r#"{"kind":"ap2.payment.v1","cart_hash":"beef5678","caller":"slack:T1:U9","conversation_id":"conv-1","args_json":"{\"url\":\"https://shop.example/item\",\"max\":\"25000\"}","currency":"0xToken","amount_base_units":"25000","issued_at_unix":1750000000,"expires_at_unix":1750086400,"nonce":"nonce-payment"}"#;
2444 const PAYMENT_PAYLOAD: &str = r#"{"kind":"ap2.payment.v1","cart_hash":"beef5678","caller":"slack:T1:U9","conversation_id":"conv-1","args_json":"{\"url\":\"https://shop.example/item\",\"max\":\"25000\"}","currency":"0xToken","amount_base_units":"25000","issued_at_unix":1750000000,"expires_at_unix":1750086400,"nonce":"nonce-payment","signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"95c1f52b6b4f3433a78884d7f65caafbaa8423a091632ad4b6e04b95966ca71afdf7819a5399c8f8dc1c0eb2c80b3b3eaa4fe59551eb9eb6a4492f36e9e07e06"}"#;
2445 const PAYMENT_SIG: &str = "95c1f52b6b4f3433a78884d7f65caafbaa8423a091632ad4b6e04b95966ca71afdf7819a5399c8f8dc1c0eb2c80b3b3eaa4fe59551eb9eb6a4492f36e9e07e06";
2446}