1use serde_json::Value;
50use sha2::{Digest, Sha256};
51
52use crate::approval::VerifiedResponse;
53use crate::canon::canon_args;
54use crate::{Signer, verify};
55
56const KIND_INTENT: &str = "ap2.intent.v1";
58const KIND_CART: &str = "ap2.cart.v1";
60const KIND_PAYMENT: &str = "ap2.payment.v1";
62
63#[must_use]
69pub fn mandate_hash(signed_payload: &[u8]) -> String {
70 let mut hasher = Sha256::new();
71 hasher.update(signed_payload);
72 crate::hex::lower(&hasher.finalize())
73}
74
75#[derive(Debug, Clone, Copy)]
81pub struct IntentFields<'a> {
82 pub caller: &'a str,
85 pub conversation_id: &'a str,
87 pub scope_description: &'a str,
90 pub currency: &'a str,
93 pub max_total_base_units: &'a str,
98 pub issued_at_unix: u64,
100 pub expires_at_unix: u64,
103 pub nonce: &'a str,
105}
106
107impl IntentFields<'_> {
108 fn canonical_json(&self) -> Value {
109 serde_json::json!({
110 "kind": KIND_INTENT,
111 "caller": self.caller,
112 "conversation_id": self.conversation_id,
113 "scope_description": self.scope_description,
114 "currency": self.currency,
115 "max_total_base_units": self.max_total_base_units,
116 "issued_at_unix": self.issued_at_unix,
117 "expires_at_unix": self.expires_at_unix,
118 "nonce": self.nonce,
119 })
120 }
121}
122
123#[derive(Debug, Clone)]
125pub struct VerifiedIntentMandate {
126 pub caller: String,
128 pub conversation_id: String,
130 pub scope_description: String,
132 pub currency: String,
134 pub max_total_base_units: String,
136 pub issued_at_unix: u64,
138 pub expires_at_unix: u64,
140 pub nonce: String,
142 pub signer_public_key: Vec<u8>,
144}
145
146#[must_use]
152pub fn sign_intent_mandate(
153 fields: &IntentFields<'_>,
154 signer: &Signer,
155) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
156 sign_envelope(fields.canonical_json(), signer)
157}
158
159#[must_use]
166pub fn verify_signed_intent_mandate(payload: &[u8]) -> Option<VerifiedIntentMandate> {
167 let v: Value = serde_json::from_slice(payload).ok()?;
168 if v.get("kind")?.as_str()? != KIND_INTENT {
169 return None;
170 }
171 let caller = v.get("caller")?.as_str()?.to_owned();
172 let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
173 let scope_description = v.get("scope_description")?.as_str()?.to_owned();
174 let currency = v.get("currency")?.as_str()?.to_owned();
175 let max_total_base_units = v.get("max_total_base_units")?.as_str()?.to_owned();
176 let issued_at_unix = v.get("issued_at_unix")?.as_u64()?;
177 let expires_at_unix = v.get("expires_at_unix")?.as_u64()?;
178 let nonce = v.get("nonce")?.as_str()?.to_owned();
179 let (pk, sig) = envelope_signature(&v)?;
180
181 let fields = IntentFields {
182 caller: &caller,
183 conversation_id: &conversation_id,
184 scope_description: &scope_description,
185 currency: ¤cy,
186 max_total_base_units: &max_total_base_units,
187 issued_at_unix,
188 expires_at_unix,
189 nonce: &nonce,
190 };
191 if verify(&pk, &fields.canonical_json().to_string().into_bytes(), &sig) {
192 Some(VerifiedIntentMandate {
193 caller,
194 conversation_id,
195 scope_description,
196 currency,
197 max_total_base_units,
198 issued_at_unix,
199 expires_at_unix,
200 nonce,
201 signer_public_key: pk,
202 })
203 } else {
204 None
205 }
206}
207
208#[derive(Debug, Clone, Copy)]
211pub struct CartFields<'a> {
212 pub intent_hash: &'a str,
214 pub caller: &'a str,
217 pub conversation_id: &'a str,
219 pub merchant_host: &'a str,
221 pub currency: &'a str,
223 pub amount_base_units: &'a str,
225 pub issued_at_unix: u64,
227 pub expires_at_unix: u64,
229 pub nonce: &'a str,
231}
232
233impl CartFields<'_> {
234 fn canonical_json(&self) -> Value {
235 serde_json::json!({
236 "kind": KIND_CART,
237 "intent_hash": self.intent_hash,
238 "caller": self.caller,
239 "conversation_id": self.conversation_id,
240 "merchant_host": self.merchant_host,
241 "currency": self.currency,
242 "amount_base_units": self.amount_base_units,
243 "issued_at_unix": self.issued_at_unix,
244 "expires_at_unix": self.expires_at_unix,
245 "nonce": self.nonce,
246 })
247 }
248}
249
250#[derive(Debug, Clone)]
252pub struct VerifiedCartMandate {
253 pub intent_hash: String,
255 pub caller: String,
257 pub conversation_id: String,
259 pub merchant_host: String,
261 pub currency: String,
263 pub amount_base_units: String,
265 pub issued_at_unix: u64,
267 pub expires_at_unix: u64,
269 pub nonce: String,
271 pub signer_public_key: Vec<u8>,
273}
274
275#[must_use]
278pub fn sign_cart_mandate(fields: &CartFields<'_>, signer: &Signer) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
279 sign_envelope(fields.canonical_json(), signer)
280}
281
282#[must_use]
285pub fn verify_signed_cart_mandate(payload: &[u8]) -> Option<VerifiedCartMandate> {
286 let v: Value = serde_json::from_slice(payload).ok()?;
287 if v.get("kind")?.as_str()? != KIND_CART {
288 return None;
289 }
290 let intent_hash = v.get("intent_hash")?.as_str()?.to_owned();
291 let caller = v.get("caller")?.as_str()?.to_owned();
292 let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
293 let merchant_host = v.get("merchant_host")?.as_str()?.to_owned();
294 let currency = v.get("currency")?.as_str()?.to_owned();
295 let amount_base_units = v.get("amount_base_units")?.as_str()?.to_owned();
296 let issued_at_unix = v.get("issued_at_unix")?.as_u64()?;
297 let expires_at_unix = v.get("expires_at_unix")?.as_u64()?;
298 let nonce = v.get("nonce")?.as_str()?.to_owned();
299 let (pk, sig) = envelope_signature(&v)?;
300
301 let fields = CartFields {
302 intent_hash: &intent_hash,
303 caller: &caller,
304 conversation_id: &conversation_id,
305 merchant_host: &merchant_host,
306 currency: ¤cy,
307 amount_base_units: &amount_base_units,
308 issued_at_unix,
309 expires_at_unix,
310 nonce: &nonce,
311 };
312 if verify(&pk, &fields.canonical_json().to_string().into_bytes(), &sig) {
313 Some(VerifiedCartMandate {
314 intent_hash,
315 caller,
316 conversation_id,
317 merchant_host,
318 currency,
319 amount_base_units,
320 issued_at_unix,
321 expires_at_unix,
322 nonce,
323 signer_public_key: pk,
324 })
325 } else {
326 None
327 }
328}
329
330#[derive(Debug, Clone, Copy)]
333pub struct PaymentFields<'a> {
334 pub cart_hash: &'a str,
336 pub caller: &'a str,
338 pub conversation_id: &'a str,
340 pub args_json: &'a str,
344 pub currency: &'a str,
346 pub amount_base_units: &'a str,
348 pub issued_at_unix: u64,
350 pub expires_at_unix: u64,
352 pub nonce: &'a str,
354}
355
356impl PaymentFields<'_> {
357 fn canonical_json(&self) -> Value {
358 serde_json::json!({
359 "kind": KIND_PAYMENT,
360 "cart_hash": self.cart_hash,
361 "caller": self.caller,
362 "conversation_id": self.conversation_id,
363 "args_json": self.args_json,
364 "currency": self.currency,
365 "amount_base_units": self.amount_base_units,
366 "issued_at_unix": self.issued_at_unix,
367 "expires_at_unix": self.expires_at_unix,
368 "nonce": self.nonce,
369 })
370 }
371}
372
373#[derive(Debug, Clone)]
375pub struct VerifiedPaymentMandate {
376 pub cart_hash: String,
378 pub caller: String,
380 pub conversation_id: String,
382 pub args_json: String,
384 pub currency: String,
386 pub amount_base_units: String,
388 pub issued_at_unix: u64,
390 pub expires_at_unix: u64,
392 pub nonce: String,
394 pub signer_public_key: Vec<u8>,
396}
397
398#[must_use]
401pub fn sign_payment_mandate(
402 fields: &PaymentFields<'_>,
403 signer: &Signer,
404) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
405 sign_envelope(fields.canonical_json(), signer)
406}
407
408#[must_use]
411pub fn verify_signed_payment_mandate(payload: &[u8]) -> Option<VerifiedPaymentMandate> {
412 let v: Value = serde_json::from_slice(payload).ok()?;
413 if v.get("kind")?.as_str()? != KIND_PAYMENT {
414 return None;
415 }
416 let cart_hash = v.get("cart_hash")?.as_str()?.to_owned();
417 let caller = v.get("caller")?.as_str()?.to_owned();
418 let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
419 let args_json = v.get("args_json")?.as_str()?.to_owned();
420 let currency = v.get("currency")?.as_str()?.to_owned();
421 let amount_base_units = v.get("amount_base_units")?.as_str()?.to_owned();
422 let issued_at_unix = v.get("issued_at_unix")?.as_u64()?;
423 let expires_at_unix = v.get("expires_at_unix")?.as_u64()?;
424 let nonce = v.get("nonce")?.as_str()?.to_owned();
425 let (pk, sig) = envelope_signature(&v)?;
426
427 let fields = PaymentFields {
428 cart_hash: &cart_hash,
429 caller: &caller,
430 conversation_id: &conversation_id,
431 args_json: &args_json,
432 currency: ¤cy,
433 amount_base_units: &amount_base_units,
434 issued_at_unix,
435 expires_at_unix,
436 nonce: &nonce,
437 };
438 if verify(&pk, &fields.canonical_json().to_string().into_bytes(), &sig) {
439 Some(VerifiedPaymentMandate {
440 cart_hash,
441 caller,
442 conversation_id,
443 args_json,
444 currency,
445 amount_base_units,
446 issued_at_unix,
447 expires_at_unix,
448 nonce,
449 signer_public_key: pk,
450 })
451 } else {
452 None
453 }
454}
455
456fn sign_envelope(mut canonical: Value, signer: &Signer) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
461 let canonical_bytes = canonical.to_string().into_bytes();
462 let signature = signer.sign(&canonical_bytes);
463 let pk = signer.public_key_bytes();
464 if let Value::Object(map) = &mut canonical {
465 map.insert(
466 "signed_by".to_owned(),
467 Value::String(crate::hex::lower(&pk)),
468 );
469 map.insert(
470 "signature_hex".to_owned(),
471 Value::String(crate::hex::lower(&signature)),
472 );
473 }
474 (canonical.to_string().into_bytes(), signature, pk)
475}
476
477fn envelope_signature(v: &Value) -> Option<(Vec<u8>, Vec<u8>)> {
480 let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
481 let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
482 Some((pk, sig))
483}
484
485#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
495pub enum MandateError {
496 #[error("intent mandate is malformed or its signature does not verify")]
498 InvalidIntent,
499 #[error("cart mandate is malformed or its signature does not verify")]
501 InvalidCart,
502 #[error("payment mandate is malformed or its signature does not verify")]
504 InvalidPayment,
505 #[error("{0} mandate is not signed by a trusted key")]
509 UntrustedSigner(&'static str),
510 #[error("{0} mandate is bound to a different conversation")]
513 ConversationMismatch(&'static str),
514 #[error("mandate chain is not bound to one consistent caller")]
516 CallerMismatch,
517 #[error("{kind} mandate expired at {expires_at_unix}")]
519 Expired {
520 kind: &'static str,
522 expires_at_unix: u64,
524 },
525 #[error("cart mandate does not chain to the presented intent mandate")]
527 CartNotChainedToIntent,
528 #[error("payment mandate does not chain to the presented cart mandate")]
530 PaymentNotChainedToCart,
531 #[error("{0} mandate carries an unparseable base-unit amount")]
535 MalformedAmount(&'static str),
536 #[error("cart amount {cart} exceeds the intent ceiling {intent}")]
538 AmountWidensAtCart {
539 cart: u128,
541 intent: u128,
543 },
544 #[error("payment amount {payment} exceeds the cart amount {cart}")]
546 AmountWidensAtPayment {
547 payment: u128,
549 cart: u128,
551 },
552 #[error("mandate currency does not match across the chain")]
554 CurrencyMismatch,
555 #[error("cart mandate carries no merchant scope")]
558 EmptyScope,
559 #[error(
562 "mandate authorizes at most {authorized} base units against {authorized_host}; \
563 requested {requested} base units against {requested_host}"
564 )]
565 ExceedsAuthorization {
566 authorized: u128,
568 authorized_host: String,
570 requested: u128,
572 requested_host: String,
574 },
575 #[error("the payment mandate is bound to a different tool call's args")]
579 ArgsBindingMismatch,
580 #[error("the HITL approval does not authorize this exact call; no mandate was minted")]
583 ApprovalDoesNotAuthorizeCall,
584}
585
586#[derive(Debug, Clone, Default)]
589pub struct MandateChain {
590 pub intent: Vec<u8>,
592 pub cart: Vec<u8>,
594 pub payment: Vec<u8>,
596}
597
598const PERSONA_TRUST_MAX_AGE_SECS: u64 = 300;
605
606#[derive(Debug, Clone, Copy)]
624pub struct PersonaSignerTrust<'a> {
625 pub signing_public_key: &'a [u8],
628 pub revoked: bool,
631 pub checked_at_unix: u64,
633}
634
635impl PersonaSignerTrust<'_> {
636 const fn is_trustworthy(&self, now_unix: u64) -> bool {
642 !self.revoked
643 && self.checked_at_unix <= now_unix
644 && now_unix.saturating_sub(self.checked_at_unix) <= PERSONA_TRUST_MAX_AGE_SECS
645 }
646}
647
648impl MandateChain {
649 pub fn verify(
679 &self,
680 conversation_id: &str,
681 issuer_public_key: &[u8],
682 persona_signer: Option<PersonaSignerTrust<'_>>,
683 now_unix: u64,
684 ) -> Result<VerifiedMandateChain, MandateError> {
685 let intent =
686 verify_signed_intent_mandate(&self.intent).ok_or(MandateError::InvalidIntent)?;
687 let cart = verify_signed_cart_mandate(&self.cart).ok_or(MandateError::InvalidCart)?;
688 let payment =
689 verify_signed_payment_mandate(&self.payment).ok_or(MandateError::InvalidPayment)?;
690
691 let links: [(&'static str, &str, u64); 3] = [
694 ("intent", &intent.conversation_id, intent.expires_at_unix),
695 ("cart", &cart.conversation_id, cart.expires_at_unix),
696 ("payment", &payment.conversation_id, payment.expires_at_unix),
697 ];
698 for (label, conv, expires_at_unix) in links {
699 if conv != conversation_id {
700 return Err(MandateError::ConversationMismatch(label));
701 }
702 if expires_at_unix <= now_unix {
703 return Err(MandateError::Expired {
704 kind: label,
705 expires_at_unix,
706 });
707 }
708 }
709
710 let intent_user_signed = persona_signer.is_some_and(|trust| {
718 trust.is_trustworthy(now_unix) && intent.signer_public_key == trust.signing_public_key
719 });
720 if !intent_user_signed && intent.signer_public_key != issuer_public_key {
721 return Err(MandateError::UntrustedSigner("intent"));
722 }
723 if cart.signer_public_key != issuer_public_key {
724 return Err(MandateError::UntrustedSigner("cart"));
725 }
726 if payment.signer_public_key != issuer_public_key {
727 return Err(MandateError::UntrustedSigner("payment"));
728 }
729 if intent.caller != cart.caller || cart.caller != payment.caller {
730 return Err(MandateError::CallerMismatch);
731 }
732
733 if cart.intent_hash != mandate_hash(&self.intent) {
738 return Err(MandateError::CartNotChainedToIntent);
739 }
740 if payment.cart_hash != mandate_hash(&self.cart) {
741 return Err(MandateError::PaymentNotChainedToCart);
742 }
743
744 let cart_amount: u128 = cart
749 .amount_base_units
750 .parse()
751 .map_err(|_| MandateError::MalformedAmount("cart"))?;
752 let payment_amount: u128 = payment
753 .amount_base_units
754 .parse()
755 .map_err(|_| MandateError::MalformedAmount("payment"))?;
756 if !intent.max_total_base_units.is_empty() {
757 let ceiling: u128 = intent
758 .max_total_base_units
759 .parse()
760 .map_err(|_| MandateError::MalformedAmount("intent"))?;
761 if cart_amount > ceiling {
762 return Err(MandateError::AmountWidensAtCart {
763 cart: cart_amount,
764 intent: ceiling,
765 });
766 }
767 }
768 if payment_amount > cart_amount {
769 return Err(MandateError::AmountWidensAtPayment {
770 payment: payment_amount,
771 cart: cart_amount,
772 });
773 }
774
775 if intent.currency != cart.currency || cart.currency != payment.currency {
776 return Err(MandateError::CurrencyMismatch);
777 }
778 if cart.merchant_host.is_empty() {
779 return Err(MandateError::EmptyScope);
780 }
781
782 Ok(VerifiedMandateChain {
783 authorized_amount_base_units: payment_amount,
784 merchant_host: cart.merchant_host.clone(),
785 currency: payment.currency.clone(),
786 caller: payment.caller.clone(),
787 conversation_id: payment.conversation_id.clone(),
788 args_json: payment.args_json.clone(),
789 intent,
790 cart,
791 payment,
792 })
793 }
794}
795
796#[derive(Debug, Clone)]
799pub struct VerifiedMandateChain {
800 pub authorized_amount_base_units: u128,
803 pub merchant_host: String,
806 pub currency: String,
808 pub caller: String,
810 pub conversation_id: String,
812 pub args_json: String,
814 pub intent: VerifiedIntentMandate,
816 pub cart: VerifiedCartMandate,
818 pub payment: VerifiedPaymentMandate,
820}
821
822impl VerifiedMandateChain {
823 pub fn authorize(
846 &self,
847 requested_base_units: u128,
848 requested_host: &str,
849 args_json: &str,
850 ) -> Result<(), MandateError> {
851 if canon_args(args_json) != canon_args(&self.args_json) {
852 return Err(MandateError::ArgsBindingMismatch);
853 }
854 let host_ok = self.merchant_host.eq_ignore_ascii_case(requested_host);
855 if !host_ok || requested_base_units > self.authorized_amount_base_units {
856 return Err(MandateError::ExceedsAuthorization {
857 authorized: self.authorized_amount_base_units,
858 authorized_host: self.merchant_host.clone(),
859 requested: requested_base_units,
860 requested_host: requested_host.to_owned(),
861 });
862 }
863 Ok(())
864 }
865}
866
867pub fn resolve(
889 chain: Option<&MandateChain>,
890 issuer_public_key: Option<&[u8]>,
891 persona_signer: Option<PersonaSignerTrust<'_>>,
892 conversation_id: &str,
893 now_unix: u64,
894) -> Result<Option<VerifiedMandateChain>, MandateError> {
895 match (chain, issuer_public_key) {
896 (Some(c), Some(key)) => c
897 .verify(conversation_id, key, persona_signer, now_unix)
898 .map(Some),
899 _ => Ok(None),
900 }
901}
902
903#[allow(clippy::too_many_arguments)] pub fn payment_mandate_from_approval(
935 approved: &VerifiedResponse,
936 request_id: &str,
937 tool_name: &str,
938 args_json: &str,
939 cart_payload: &[u8],
940 issued_at_unix: u64,
941 expires_at_unix: u64,
942 nonce: &str,
943 signer: &Signer,
944) -> Result<Vec<u8>, MandateError> {
945 if !approved.authorizes_call(request_id, tool_name, args_json) {
946 return Err(MandateError::ApprovalDoesNotAuthorizeCall);
947 }
948 let cart = verify_signed_cart_mandate(cart_payload).ok_or(MandateError::InvalidCart)?;
949 if cart.caller != approved.caller {
950 return Err(MandateError::CallerMismatch);
951 }
952 if cart.conversation_id != approved.conversation_id {
953 return Err(MandateError::ConversationMismatch("cart"));
954 }
955 let fields = PaymentFields {
956 cart_hash: &mandate_hash(cart_payload),
957 caller: &approved.caller,
958 conversation_id: &approved.conversation_id,
959 args_json,
960 currency: &cart.currency,
961 amount_base_units: &cart.amount_base_units,
962 issued_at_unix,
963 expires_at_unix,
964 nonce,
965 };
966 let (payload, _sig, _pk) = sign_payment_mandate(&fields, signer);
967 Ok(payload)
968}
969
970#[cfg(test)]
971mod tests {
972 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
973
974 use super::*;
975 use crate::approval::{ApprovalSigner, response_payload, verify_signed_response};
976
977 fn active_persona_trust(signing_public_key: &[u8], now_unix: u64) -> PersonaSignerTrust<'_> {
981 PersonaSignerTrust {
982 signing_public_key,
983 revoked: false,
984 checked_at_unix: now_unix,
985 }
986 }
987
988 fn revoked_persona_trust(signing_public_key: &[u8], now_unix: u64) -> PersonaSignerTrust<'_> {
993 PersonaSignerTrust {
994 signing_public_key,
995 revoked: true,
996 checked_at_unix: now_unix,
997 }
998 }
999
1000 fn intent(signer: &Signer) -> (Vec<u8>, IntentFields<'static>) {
1001 let fields = IntentFields {
1002 caller: "slack:T1:U9",
1003 conversation_id: "conv-1",
1004 scope_description: "research report purchases",
1005 currency: "0xUSD",
1006 max_total_base_units: "1000000",
1007 issued_at_unix: 1_000,
1008 expires_at_unix: 10_000,
1009 nonce: "intent-nonce-1",
1010 };
1011 let (payload, _sig, _pk) = sign_intent_mandate(&fields, signer);
1012 (payload, fields)
1013 }
1014
1015 #[test]
1016 fn intent_mandate_round_trips() {
1017 let signer = Signer::from_seed(1);
1018 let (payload, fields) = intent(&signer);
1019 let verified = verify_signed_intent_mandate(&payload).expect("verifies");
1020 assert_eq!(verified.caller, fields.caller);
1021 assert_eq!(verified.conversation_id, fields.conversation_id);
1022 assert_eq!(verified.max_total_base_units, fields.max_total_base_units);
1023 assert_eq!(verified.signer_public_key, signer.public_key_bytes());
1024 }
1025
1026 #[test]
1027 fn intent_mandate_tampered_amount_fails() {
1028 let signer = Signer::from_seed(1);
1029 let (payload, _fields) = intent(&signer);
1030 let mut v: Value = serde_json::from_slice(&payload).unwrap();
1031 v["max_total_base_units"] = Value::String("999999999".to_owned());
1032 assert!(verify_signed_intent_mandate(&v.to_string().into_bytes()).is_none());
1033 }
1034
1035 #[test]
1036 fn intent_mandate_wrong_kind_rejected() {
1037 let signer = Signer::from_seed(1);
1040 let cart_fields = CartFields {
1041 intent_hash: "deadbeef",
1042 caller: "slack:T1:U9",
1043 conversation_id: "conv-1",
1044 merchant_host: "api.example.com",
1045 currency: "0xUSD",
1046 amount_base_units: "500000",
1047 issued_at_unix: 1_000,
1048 expires_at_unix: 5_000,
1049 nonce: "cart-nonce-1",
1050 };
1051 let (cart_payload, _sig, _pk) = sign_cart_mandate(&cart_fields, &signer);
1052 assert!(verify_signed_intent_mandate(&cart_payload).is_none());
1053 }
1054
1055 #[test]
1056 fn cart_mandate_round_trips_and_chains_by_hash() {
1057 let signer = Signer::from_seed(2);
1058 let (intent_payload, _fields) = intent(&signer);
1059 let intent_hash = mandate_hash(&intent_payload);
1060
1061 let cart_fields = CartFields {
1062 intent_hash: &intent_hash,
1063 caller: "slack:T1:U9",
1064 conversation_id: "conv-1",
1065 merchant_host: "api.example.com",
1066 currency: "0xUSD",
1067 amount_base_units: "500000",
1068 issued_at_unix: 1_000,
1069 expires_at_unix: 5_000,
1070 nonce: "cart-nonce-1",
1071 };
1072 let (cart_payload, _sig, _pk) = sign_cart_mandate(&cart_fields, &signer);
1073 let verified = verify_signed_cart_mandate(&cart_payload).expect("cart verifies");
1074 assert_eq!(verified.intent_hash, intent_hash);
1075 assert_eq!(verified.merchant_host, "api.example.com");
1076 }
1077
1078 #[test]
1079 fn cart_mandate_tampered_intent_hash_fails() {
1080 let signer = Signer::from_seed(2);
1081 let (intent_payload, _fields) = intent(&signer);
1082 let intent_hash = mandate_hash(&intent_payload);
1083 let cart_fields = CartFields {
1084 intent_hash: &intent_hash,
1085 caller: "slack:T1:U9",
1086 conversation_id: "conv-1",
1087 merchant_host: "api.example.com",
1088 currency: "0xUSD",
1089 amount_base_units: "500000",
1090 issued_at_unix: 1_000,
1091 expires_at_unix: 5_000,
1092 nonce: "cart-nonce-1",
1093 };
1094 let (payload, _sig, _pk) = sign_cart_mandate(&cart_fields, &signer);
1095 let mut v: Value = serde_json::from_slice(&payload).unwrap();
1096 v["intent_hash"] = Value::String("0".repeat(64));
1097 assert!(verify_signed_cart_mandate(&v.to_string().into_bytes()).is_none());
1098 }
1099
1100 #[test]
1101 fn payment_mandate_round_trips_and_chains_by_hash() {
1102 let signer = Signer::from_seed(3);
1103 let (intent_payload, _fields) = intent(&signer);
1104 let intent_hash = mandate_hash(&intent_payload);
1105 let cart_fields = CartFields {
1106 intent_hash: &intent_hash,
1107 caller: "slack:T1:U9",
1108 conversation_id: "conv-1",
1109 merchant_host: "api.example.com",
1110 currency: "0xUSD",
1111 amount_base_units: "500000",
1112 issued_at_unix: 1_000,
1113 expires_at_unix: 5_000,
1114 nonce: "cart-nonce-1",
1115 };
1116 let (cart_payload, _sig, _pk) = sign_cart_mandate(&cart_fields, &signer);
1117 let cart_hash = mandate_hash(&cart_payload);
1118
1119 let payment_fields = PaymentFields {
1120 cart_hash: &cart_hash,
1121 caller: "slack:T1:U9",
1122 conversation_id: "conv-1",
1123 args_json: r#"{"url":"https://api.example.com/report"}"#,
1124 currency: "0xUSD",
1125 amount_base_units: "250000",
1126 issued_at_unix: 1_000,
1127 expires_at_unix: 4_000,
1128 nonce: "payment-nonce-1",
1129 };
1130 let (payment_payload, _sig, _pk) = sign_payment_mandate(&payment_fields, &signer);
1131 let verified = verify_signed_payment_mandate(&payment_payload).expect("payment verifies");
1132 assert_eq!(verified.cart_hash, cart_hash);
1133 assert_eq!(verified.amount_base_units, "250000");
1134 assert_eq!(verified.args_json, payment_fields.args_json);
1135 }
1136
1137 #[test]
1138 fn payment_mandate_tampered_args_json_fails() {
1139 let signer = Signer::from_seed(3);
1140 let payment_fields = PaymentFields {
1141 cart_hash: "deadbeef",
1142 caller: "slack:T1:U9",
1143 conversation_id: "conv-1",
1144 args_json: r#"{"url":"https://api.example.com/report"}"#,
1145 currency: "0xUSD",
1146 amount_base_units: "250000",
1147 issued_at_unix: 1_000,
1148 expires_at_unix: 4_000,
1149 nonce: "payment-nonce-1",
1150 };
1151 let (payload, _sig, _pk) = sign_payment_mandate(&payment_fields, &signer);
1152 let mut v: Value = serde_json::from_slice(&payload).unwrap();
1153 v["args_json"] = Value::String(r#"{"url":"https://evil.example.com/steal"}"#.to_owned());
1154 assert!(verify_signed_payment_mandate(&v.to_string().into_bytes()).is_none());
1155 }
1156
1157 #[test]
1158 fn mandate_hash_is_stable_and_sensitive_to_signature() {
1159 let signer_a = Signer::from_seed(9);
1160 let signer_b = Signer::from_seed(10);
1161 let fields = IntentFields {
1162 caller: "slack:T1:U9",
1163 conversation_id: "conv-1",
1164 scope_description: "x",
1165 currency: "0xUSD",
1166 max_total_base_units: "1000",
1167 issued_at_unix: 1,
1168 expires_at_unix: 2,
1169 nonce: "n",
1170 };
1171 let (payload_a, _s, _p) = sign_intent_mandate(&fields, &signer_a);
1172 let (payload_b, _s, _p) = sign_intent_mandate(&fields, &signer_b);
1173 assert_eq!(mandate_hash(&payload_a), mandate_hash(&payload_a));
1175 assert_ne!(mandate_hash(&payload_a), mandate_hash(&payload_b));
1178 }
1179
1180 #[test]
1181 fn garbage_payload_returns_none_not_panic() {
1182 assert!(verify_signed_intent_mandate(b"not json").is_none());
1183 assert!(verify_signed_cart_mandate(b"{}").is_none());
1184 assert!(verify_signed_payment_mandate(b"[]").is_none());
1185 }
1186
1187 const CONV: &str = "conv-1";
1188 const CALLER: &str = "slack:T1:U9";
1189 const HOST: &str = "api.example.com";
1190 const USD: &str = "0x20c0000000000000000000000000000000000000";
1191 const ARGS: &str = r#"{"url":"https://api.example.com/report"}"#;
1192
1193 fn issuer() -> Signer {
1194 Signer::from_seed(99)
1195 }
1196
1197 fn intent_fields(max_total: &str) -> IntentFields<'_> {
1198 IntentFields {
1199 caller: CALLER,
1200 conversation_id: CONV,
1201 scope_description: "research report purchases",
1202 currency: USD,
1203 max_total_base_units: max_total,
1204 issued_at_unix: 100,
1205 expires_at_unix: 10_000,
1206 nonce: "n-intent",
1207 }
1208 }
1209
1210 fn valid_chain(signer: &Signer) -> MandateChain {
1213 let (intent, _s, _p) = sign_intent_mandate(&intent_fields("1000000"), signer);
1214 let intent_hash = mandate_hash(&intent);
1215 let (cart, _s, _p) = sign_cart_mandate(
1216 &CartFields {
1217 intent_hash: &intent_hash,
1218 caller: CALLER,
1219 conversation_id: CONV,
1220 merchant_host: HOST,
1221 currency: USD,
1222 amount_base_units: "500000",
1223 issued_at_unix: 100,
1224 expires_at_unix: 9_000,
1225 nonce: "n-cart",
1226 },
1227 signer,
1228 );
1229 let cart_hash = mandate_hash(&cart);
1230 let (payment, _s, _p) = sign_payment_mandate(
1231 &PaymentFields {
1232 cart_hash: &cart_hash,
1233 caller: CALLER,
1234 conversation_id: CONV,
1235 args_json: ARGS,
1236 currency: USD,
1237 amount_base_units: "500000",
1238 issued_at_unix: 100,
1239 expires_at_unix: 8_000,
1240 nonce: "n-payment",
1241 },
1242 signer,
1243 );
1244 MandateChain {
1245 intent,
1246 cart,
1247 payment,
1248 }
1249 }
1250
1251 #[test]
1252 fn valid_chain_verifies_and_authorizes_within_bounds() {
1253 let signer = issuer();
1254 let chain = valid_chain(&signer);
1255 let verified = chain
1256 .verify(CONV, &signer.public_key_bytes(), None, 1_000)
1257 .expect("a mutually consistent, unexpired chain must verify");
1258 assert_eq!(verified.authorized_amount_base_units, 500_000);
1259 assert_eq!(verified.merchant_host, HOST);
1260 assert_eq!(verified.caller, CALLER);
1261
1262 verified.authorize(500_000, HOST, ARGS).expect("at-cap ok");
1264 verified.authorize(1, HOST, ARGS).expect("under-cap ok");
1265 verified
1267 .authorize(500_000, "API.Example.COM", ARGS)
1268 .expect("case-insensitive host");
1269
1270 assert!(matches!(
1272 verified.authorize(500_001, HOST, ARGS),
1273 Err(MandateError::ExceedsAuthorization { .. })
1274 ));
1275 assert!(matches!(
1277 verified.authorize(1, "evil.example.com", ARGS),
1278 Err(MandateError::ExceedsAuthorization { .. })
1279 ));
1280 assert!(matches!(
1283 verified.authorize(1, HOST, r#"{"url":"https://api.example.com/OTHER"}"#),
1284 Err(MandateError::ArgsBindingMismatch)
1285 ));
1286 }
1287
1288 #[test]
1289 fn args_binding_matches_by_value_not_key_order() {
1290 let signer = issuer();
1294 let (intent, _s, _p) = sign_intent_mandate(&intent_fields(""), &signer);
1295 let intent_hash = mandate_hash(&intent);
1296 let (cart, _s, _p) = sign_cart_mandate(
1297 &CartFields {
1298 intent_hash: &intent_hash,
1299 caller: CALLER,
1300 conversation_id: CONV,
1301 merchant_host: HOST,
1302 currency: USD,
1303 amount_base_units: "500000",
1304 issued_at_unix: 100,
1305 expires_at_unix: 9_000,
1306 nonce: "n-cart",
1307 },
1308 &signer,
1309 );
1310 let (payment, _s, _p) = sign_payment_mandate(
1311 &PaymentFields {
1312 cart_hash: &mandate_hash(&cart),
1313 caller: CALLER,
1314 conversation_id: CONV,
1315 args_json: r#"{"max_spend":"0.10","url":"https://api.example.com/r"}"#,
1316 currency: USD,
1317 amount_base_units: "500000",
1318 issued_at_unix: 100,
1319 expires_at_unix: 8_000,
1320 nonce: "n-payment",
1321 },
1322 &signer,
1323 );
1324 let verified = MandateChain {
1325 intent,
1326 cart,
1327 payment,
1328 }
1329 .verify(CONV, &signer.public_key_bytes(), None, 1_000)
1330 .expect("chain verifies");
1331 verified
1332 .authorize(
1333 1,
1334 HOST,
1335 r#"{"url":"https://api.example.com/r","max_spend":"0.10"}"#,
1336 )
1337 .expect("reordered-but-equal args must pass the binding");
1338 }
1339
1340 #[test]
1341 fn tampered_link_fails_signature_verification() {
1342 let signer = issuer();
1343 let mut chain = valid_chain(&signer);
1344 let mut v: serde_json::Value = serde_json::from_slice(&chain.cart).unwrap();
1347 v["amount_base_units"] = serde_json::Value::String("999999999".to_owned());
1348 chain.cart = v.to_string().into_bytes();
1349 assert_eq!(
1350 chain
1351 .verify(CONV, &signer.public_key_bytes(), None, 1_000)
1352 .unwrap_err(),
1353 MandateError::InvalidCart
1354 );
1355 }
1356
1357 #[test]
1358 fn validly_signed_but_unchained_links_are_rejected() {
1359 let signer = issuer();
1363 let chain = valid_chain(&signer);
1364 let (other_intent, _s, _p) = sign_intent_mandate(&intent_fields("2000000"), &signer);
1365 let other_hash = mandate_hash(&other_intent);
1366 let (unchained_cart, _s, _p) = sign_cart_mandate(
1367 &CartFields {
1368 intent_hash: &other_hash, caller: CALLER,
1370 conversation_id: CONV,
1371 merchant_host: HOST,
1372 currency: USD,
1373 amount_base_units: "500000",
1374 issued_at_unix: 100,
1375 expires_at_unix: 9_000,
1376 nonce: "n-cart",
1377 },
1378 &signer,
1379 );
1380 let broken = MandateChain {
1381 intent: chain.intent.clone(),
1382 cart: unchained_cart.clone(),
1383 payment: chain.payment.clone(),
1384 };
1385 assert_eq!(
1386 broken
1387 .verify(CONV, &signer.public_key_bytes(), None, 1_000)
1388 .unwrap_err(),
1389 MandateError::CartNotChainedToIntent
1390 );
1391
1392 let (payment_for_other, _s, _p) = sign_payment_mandate(
1395 &PaymentFields {
1396 cart_hash: &mandate_hash(&unchained_cart),
1397 caller: CALLER,
1398 conversation_id: CONV,
1399 args_json: ARGS,
1400 currency: USD,
1401 amount_base_units: "500000",
1402 issued_at_unix: 100,
1403 expires_at_unix: 8_000,
1404 nonce: "n-payment",
1405 },
1406 &signer,
1407 );
1408 let broken = MandateChain {
1409 intent: chain.intent,
1410 cart: chain.cart,
1411 payment: payment_for_other,
1412 };
1413 assert_eq!(
1414 broken
1415 .verify(CONV, &signer.public_key_bytes(), None, 1_000)
1416 .unwrap_err(),
1417 MandateError::PaymentNotChainedToCart
1418 );
1419 }
1420
1421 #[test]
1422 fn widened_amounts_are_rejected() {
1423 let signer = issuer();
1424 let (intent, _s, _p) = sign_intent_mandate(&intent_fields("100"), &signer);
1426 let intent_hash = mandate_hash(&intent);
1427 let (cart, _s, _p) = sign_cart_mandate(
1428 &CartFields {
1429 intent_hash: &intent_hash,
1430 caller: CALLER,
1431 conversation_id: CONV,
1432 merchant_host: HOST,
1433 currency: USD,
1434 amount_base_units: "999", issued_at_unix: 100,
1436 expires_at_unix: 9_000,
1437 nonce: "n-cart",
1438 },
1439 &signer,
1440 );
1441 let (payment, _s, _p) = sign_payment_mandate(
1442 &PaymentFields {
1443 cart_hash: &mandate_hash(&cart),
1444 caller: CALLER,
1445 conversation_id: CONV,
1446 args_json: ARGS,
1447 currency: USD,
1448 amount_base_units: "999",
1449 issued_at_unix: 100,
1450 expires_at_unix: 8_000,
1451 nonce: "n-payment",
1452 },
1453 &signer,
1454 );
1455 let chain = MandateChain {
1456 intent,
1457 cart,
1458 payment,
1459 };
1460 assert_eq!(
1461 chain
1462 .verify(CONV, &signer.public_key_bytes(), None, 1_000)
1463 .unwrap_err(),
1464 MandateError::AmountWidensAtCart {
1465 cart: 999,
1466 intent: 100
1467 }
1468 );
1469
1470 let base = valid_chain(&signer);
1472 let (over_payment, _s, _p) = sign_payment_mandate(
1473 &PaymentFields {
1474 cart_hash: &mandate_hash(&base.cart),
1475 caller: CALLER,
1476 conversation_id: CONV,
1477 args_json: ARGS,
1478 currency: USD,
1479 amount_base_units: "500001", issued_at_unix: 100,
1481 expires_at_unix: 8_000,
1482 nonce: "n-payment",
1483 },
1484 &signer,
1485 );
1486 let chain = MandateChain {
1487 intent: base.intent,
1488 cart: base.cart,
1489 payment: over_payment,
1490 };
1491 assert_eq!(
1492 chain
1493 .verify(CONV, &signer.public_key_bytes(), None, 1_000)
1494 .unwrap_err(),
1495 MandateError::AmountWidensAtPayment {
1496 payment: 500_001,
1497 cart: 500_000
1498 }
1499 );
1500 }
1501
1502 #[test]
1503 fn unbounded_intent_ceiling_admits_any_cart_amount() {
1504 let signer = issuer();
1507 let (intent, _s, _p) = sign_intent_mandate(&intent_fields(""), &signer);
1508 let intent_hash = mandate_hash(&intent);
1509 let (cart, _s, _p) = sign_cart_mandate(
1510 &CartFields {
1511 intent_hash: &intent_hash,
1512 caller: CALLER,
1513 conversation_id: CONV,
1514 merchant_host: HOST,
1515 currency: USD,
1516 amount_base_units: "123456789",
1517 issued_at_unix: 100,
1518 expires_at_unix: 9_000,
1519 nonce: "n-cart",
1520 },
1521 &signer,
1522 );
1523 let (payment, _s, _p) = sign_payment_mandate(
1524 &PaymentFields {
1525 cart_hash: &mandate_hash(&cart),
1526 caller: CALLER,
1527 conversation_id: CONV,
1528 args_json: ARGS,
1529 currency: USD,
1530 amount_base_units: "123456789",
1531 issued_at_unix: 100,
1532 expires_at_unix: 8_000,
1533 nonce: "n-payment",
1534 },
1535 &signer,
1536 );
1537 let chain = MandateChain {
1538 intent,
1539 cart,
1540 payment,
1541 };
1542 let verified = chain
1543 .verify(CONV, &signer.public_key_bytes(), None, 1_000)
1544 .expect("empty intent ceiling is unbounded");
1545 assert_eq!(verified.authorized_amount_base_units, 123_456_789);
1546 }
1547
1548 #[test]
1549 fn expired_link_is_rejected() {
1550 let signer = issuer();
1551 let chain = valid_chain(&signer);
1552 assert_eq!(
1555 chain
1556 .verify(CONV, &signer.public_key_bytes(), None, 8_000)
1557 .unwrap_err(),
1558 MandateError::Expired {
1559 kind: "payment",
1560 expires_at_unix: 8_000
1561 }
1562 );
1563 assert_eq!(
1565 chain
1566 .verify(CONV, &signer.public_key_bytes(), None, 50_000)
1567 .unwrap_err(),
1568 MandateError::Expired {
1569 kind: "intent",
1570 expires_at_unix: 10_000
1571 }
1572 );
1573 assert!(
1575 chain
1576 .verify(CONV, &signer.public_key_bytes(), None, 1)
1577 .is_ok()
1578 );
1579 }
1580
1581 #[test]
1582 fn untrusted_issuer_is_rejected() {
1583 let signer = issuer();
1584 let chain = valid_chain(&signer);
1585 let wrong_key = Signer::from_seed(1).public_key_bytes();
1586 assert_eq!(
1587 chain.verify(CONV, &wrong_key, None, 1_000).unwrap_err(),
1588 MandateError::UntrustedSigner("intent")
1589 );
1590 }
1591
1592 fn chain_with_user_signed_intent(
1598 issuer_signer: &Signer,
1599 persona_signer: &Signer,
1600 ) -> MandateChain {
1601 let (intent, _s, _p) = sign_intent_mandate(&intent_fields("1000000"), persona_signer);
1602 let intent_hash = mandate_hash(&intent);
1603 let (cart, _s, _p) = sign_cart_mandate(
1604 &CartFields {
1605 intent_hash: &intent_hash,
1606 caller: CALLER,
1607 conversation_id: CONV,
1608 merchant_host: HOST,
1609 currency: USD,
1610 amount_base_units: "500000",
1611 issued_at_unix: 100,
1612 expires_at_unix: 9_000,
1613 nonce: "n-cart",
1614 },
1615 issuer_signer,
1616 );
1617 let cart_hash = mandate_hash(&cart);
1618 let (payment, _s, _p) = sign_payment_mandate(
1619 &PaymentFields {
1620 cart_hash: &cart_hash,
1621 caller: CALLER,
1622 conversation_id: CONV,
1623 args_json: ARGS,
1624 currency: USD,
1625 amount_base_units: "500000",
1626 issued_at_unix: 100,
1627 expires_at_unix: 8_000,
1628 nonce: "n-payment",
1629 },
1630 issuer_signer,
1631 );
1632 MandateChain {
1633 intent,
1634 cart,
1635 payment,
1636 }
1637 }
1638
1639 #[test]
1640 fn user_signed_intent_with_platform_cart_and_payment_verifies() {
1641 let issuer_signer = issuer();
1646 let persona_signer = Signer::from_seed(555);
1647 let persona_key = persona_signer.public_key_bytes();
1648 let chain = chain_with_user_signed_intent(&issuer_signer, &persona_signer);
1649 let verified = chain
1650 .verify(
1651 CONV,
1652 &issuer_signer.public_key_bytes(),
1653 Some(active_persona_trust(&persona_key, 1_000)),
1654 1_000,
1655 )
1656 .expect("a user-signed intent with platform cart/payment must verify");
1657 assert_eq!(verified.intent.signer_public_key, persona_key);
1658 assert_eq!(verified.authorized_amount_base_units, 500_000);
1659 }
1660
1661 #[test]
1662 fn platform_signed_intent_still_verifies_when_persona_has_an_active_credential() {
1663 let issuer_signer = issuer();
1668 let persona_signer = Signer::from_seed(555);
1669 let persona_key = persona_signer.public_key_bytes();
1670 let chain = valid_chain(&issuer_signer); assert!(
1672 chain
1673 .verify(
1674 CONV,
1675 &issuer_signer.public_key_bytes(),
1676 Some(active_persona_trust(&persona_key, 1_000)),
1677 1_000,
1678 )
1679 .is_ok()
1680 );
1681 }
1682
1683 #[test]
1684 fn intent_signed_by_a_non_recorded_key_is_refused() {
1685 let issuer_signer = issuer();
1689 let persona_signer = Signer::from_seed(555);
1690 let persona_key = persona_signer.public_key_bytes();
1691 let stranger = Signer::from_seed(556);
1692 let chain = chain_with_user_signed_intent(&issuer_signer, &stranger);
1693 assert_eq!(
1694 chain
1695 .verify(
1696 CONV,
1697 &issuer_signer.public_key_bytes(),
1698 Some(active_persona_trust(&persona_key, 1_000)),
1699 1_000,
1700 )
1701 .unwrap_err(),
1702 MandateError::UntrustedSigner("intent")
1703 );
1704 }
1705
1706 #[test]
1707 fn user_signed_intent_is_refused_when_no_persona_key_is_resolved() {
1708 let issuer_signer = issuer();
1713 let persona_signer = Signer::from_seed(555);
1714 let chain = chain_with_user_signed_intent(&issuer_signer, &persona_signer);
1715 assert_eq!(
1716 chain
1717 .verify(CONV, &issuer_signer.public_key_bytes(), None, 1_000)
1718 .unwrap_err(),
1719 MandateError::UntrustedSigner("intent")
1720 );
1721 }
1722
1723 #[test]
1724 fn user_signed_intent_is_refused_when_the_credential_is_revoked() {
1725 let issuer_signer = issuer();
1733 let persona_signer = Signer::from_seed(555);
1734 let persona_key = persona_signer.public_key_bytes();
1735 let chain = chain_with_user_signed_intent(&issuer_signer, &persona_signer);
1736 assert_eq!(
1737 chain
1738 .verify(
1739 CONV,
1740 &issuer_signer.public_key_bytes(),
1741 Some(revoked_persona_trust(&persona_key, 1_000)),
1742 1_000,
1743 )
1744 .unwrap_err(),
1745 MandateError::UntrustedSigner("intent")
1746 );
1747 }
1748
1749 #[test]
1750 fn user_signed_intent_is_refused_when_the_revocation_check_is_stale() {
1751 let issuer_signer = issuer();
1758 let persona_signer = Signer::from_seed(555);
1759 let persona_key = persona_signer.public_key_bytes();
1760 let chain = chain_with_user_signed_intent(&issuer_signer, &persona_signer);
1761 let now_unix = 5_000;
1764 let checked_at_unix = now_unix - PERSONA_TRUST_MAX_AGE_SECS - 1;
1765 assert_eq!(
1766 chain
1767 .verify(
1768 CONV,
1769 &issuer_signer.public_key_bytes(),
1770 Some(PersonaSignerTrust {
1771 signing_public_key: &persona_key,
1772 revoked: false,
1773 checked_at_unix,
1774 }),
1775 now_unix,
1776 )
1777 .unwrap_err(),
1778 MandateError::UntrustedSigner("intent")
1779 );
1780 }
1781
1782 #[test]
1783 fn user_signed_intent_is_refused_when_the_revocation_check_is_dated_in_the_future() {
1784 let issuer_signer = issuer();
1788 let persona_signer = Signer::from_seed(555);
1789 let persona_key = persona_signer.public_key_bytes();
1790 let chain = chain_with_user_signed_intent(&issuer_signer, &persona_signer);
1791 assert_eq!(
1792 chain
1793 .verify(
1794 CONV,
1795 &issuer_signer.public_key_bytes(),
1796 Some(PersonaSignerTrust {
1797 signing_public_key: &persona_key,
1798 revoked: false,
1799 checked_at_unix: 1_001,
1800 }),
1801 1_000,
1802 )
1803 .unwrap_err(),
1804 MandateError::UntrustedSigner("intent")
1805 );
1806 }
1807
1808 #[test]
1809 fn cart_and_payment_never_trust_the_persona_key_even_when_intent_does() {
1810 let issuer_signer = issuer();
1815 let persona_signer = Signer::from_seed(555);
1816 let persona_key = persona_signer.public_key_bytes();
1817 let issuer_key = issuer_signer.public_key_bytes();
1818
1819 let (intent, _s, _p) = sign_intent_mandate(&intent_fields("1000000"), &persona_signer);
1820 let intent_hash = mandate_hash(&intent);
1821
1822 let (bad_cart, _s, _p) = sign_cart_mandate(
1824 &CartFields {
1825 intent_hash: &intent_hash,
1826 caller: CALLER,
1827 conversation_id: CONV,
1828 merchant_host: HOST,
1829 currency: USD,
1830 amount_base_units: "500000",
1831 issued_at_unix: 100,
1832 expires_at_unix: 9_000,
1833 nonce: "n-cart",
1834 },
1835 &persona_signer,
1836 );
1837 let (payment_for_bad_cart, _s, _p) = sign_payment_mandate(
1838 &PaymentFields {
1839 cart_hash: &mandate_hash(&bad_cart),
1840 caller: CALLER,
1841 conversation_id: CONV,
1842 args_json: ARGS,
1843 currency: USD,
1844 amount_base_units: "500000",
1845 issued_at_unix: 100,
1846 expires_at_unix: 8_000,
1847 nonce: "n-payment",
1848 },
1849 &issuer_signer,
1850 );
1851 let chain = MandateChain {
1852 intent: intent.clone(),
1853 cart: bad_cart,
1854 payment: payment_for_bad_cart,
1855 };
1856 assert_eq!(
1857 chain
1858 .verify(
1859 CONV,
1860 &issuer_key,
1861 Some(active_persona_trust(&persona_key, 1_000)),
1862 1_000,
1863 )
1864 .unwrap_err(),
1865 MandateError::UntrustedSigner("cart")
1866 );
1867
1868 let (good_cart, _s, _p) = sign_cart_mandate(
1870 &CartFields {
1871 intent_hash: &intent_hash,
1872 caller: CALLER,
1873 conversation_id: CONV,
1874 merchant_host: HOST,
1875 currency: USD,
1876 amount_base_units: "500000",
1877 issued_at_unix: 100,
1878 expires_at_unix: 9_000,
1879 nonce: "n-cart",
1880 },
1881 &issuer_signer,
1882 );
1883 let (bad_payment, _s, _p) = sign_payment_mandate(
1884 &PaymentFields {
1885 cart_hash: &mandate_hash(&good_cart),
1886 caller: CALLER,
1887 conversation_id: CONV,
1888 args_json: ARGS,
1889 currency: USD,
1890 amount_base_units: "500000",
1891 issued_at_unix: 100,
1892 expires_at_unix: 8_000,
1893 nonce: "n-payment",
1894 },
1895 &persona_signer,
1896 );
1897 let chain = MandateChain {
1898 intent,
1899 cart: good_cart,
1900 payment: bad_payment,
1901 };
1902 assert_eq!(
1903 chain
1904 .verify(
1905 CONV,
1906 &issuer_key,
1907 Some(active_persona_trust(&persona_key, 1_000)),
1908 1_000,
1909 )
1910 .unwrap_err(),
1911 MandateError::UntrustedSigner("payment")
1912 );
1913 }
1914
1915 #[test]
1916 fn splicing_a_user_signed_intent_under_a_different_chain_still_breaks_the_hash_link() {
1917 let issuer_signer = issuer();
1923 let persona_signer = Signer::from_seed(555);
1924 let persona_key = persona_signer.public_key_bytes();
1925
1926 let mut foreign_fields = intent_fields("1000000");
1930 foreign_fields.nonce = "n-intent-foreign";
1931 let (foreign_intent, _s, _p) = sign_intent_mandate(&foreign_fields, &persona_signer);
1932
1933 let chain = chain_with_user_signed_intent(&issuer_signer, &persona_signer);
1935
1936 let spliced = MandateChain {
1937 intent: foreign_intent,
1938 cart: chain.cart,
1939 payment: chain.payment,
1940 };
1941 assert_eq!(
1942 spliced
1943 .verify(
1944 CONV,
1945 &issuer_signer.public_key_bytes(),
1946 Some(active_persona_trust(&persona_key, 1_000)),
1947 1_000,
1948 )
1949 .unwrap_err(),
1950 MandateError::CartNotChainedToIntent
1951 );
1952 }
1953
1954 #[test]
1955 fn conversation_and_caller_bindings_are_enforced() {
1956 let signer = issuer();
1957 let chain = valid_chain(&signer);
1958 assert_eq!(
1960 chain
1961 .verify("conv-OTHER", &signer.public_key_bytes(), None, 1_000)
1962 .unwrap_err(),
1963 MandateError::ConversationMismatch("intent")
1964 );
1965
1966 let (payment, _s, _p) = sign_payment_mandate(
1969 &PaymentFields {
1970 cart_hash: &mandate_hash(&chain.cart),
1971 caller: "slack:T1:UATTACKER",
1972 conversation_id: CONV,
1973 args_json: ARGS,
1974 currency: USD,
1975 amount_base_units: "500000",
1976 issued_at_unix: 100,
1977 expires_at_unix: 8_000,
1978 nonce: "n-payment",
1979 },
1980 &signer,
1981 );
1982 let cross_caller = MandateChain {
1983 intent: chain.intent,
1984 cart: chain.cart,
1985 payment,
1986 };
1987 assert_eq!(
1988 cross_caller
1989 .verify(CONV, &signer.public_key_bytes(), None, 1_000)
1990 .unwrap_err(),
1991 MandateError::CallerMismatch
1992 );
1993 }
1994
1995 #[test]
1996 fn currency_mismatch_is_rejected() {
1997 let signer = issuer();
1998 let chain = valid_chain(&signer);
1999 let (payment, _s, _p) = sign_payment_mandate(
2000 &PaymentFields {
2001 cart_hash: &mandate_hash(&chain.cart),
2002 caller: CALLER,
2003 conversation_id: CONV,
2004 args_json: ARGS,
2005 currency: "0xOTHER",
2006 amount_base_units: "500000",
2007 issued_at_unix: 100,
2008 expires_at_unix: 8_000,
2009 nonce: "n-payment",
2010 },
2011 &signer,
2012 );
2013 let cross_currency = MandateChain {
2014 intent: chain.intent,
2015 cart: chain.cart,
2016 payment,
2017 };
2018 assert_eq!(
2019 cross_currency
2020 .verify(CONV, &signer.public_key_bytes(), None, 1_000)
2021 .unwrap_err(),
2022 MandateError::CurrencyMismatch
2023 );
2024 }
2025
2026 #[test]
2027 fn resolve_is_a_noop_when_unconfigured_or_absent() {
2028 let signer = issuer();
2029 let chain = valid_chain(&signer);
2030 let key = signer.public_key_bytes();
2031
2032 assert!(matches!(
2035 resolve(None, Some(&key), None, CONV, 1_000),
2036 Ok(None)
2037 ));
2038 assert!(matches!(resolve(None, None, None, CONV, 1_000), Ok(None)));
2039
2040 assert!(matches!(
2044 resolve(Some(&chain), None, None, CONV, 1_000),
2045 Ok(None)
2046 ));
2047
2048 assert!(matches!(
2050 resolve(Some(&chain), Some(&key), None, CONV, 1_000),
2051 Ok(Some(_))
2052 ));
2053 }
2054
2055 #[test]
2056 fn resolve_fails_closed_on_an_invalid_presented_chain() {
2057 let signer = issuer();
2059 let chain = valid_chain(&signer);
2060 let key = signer.public_key_bytes();
2061 assert!(matches!(
2062 resolve(Some(&chain), Some(&key), None, CONV, 50_000).unwrap_err(),
2063 MandateError::Expired { .. }
2064 ));
2065 }
2066
2067 #[test]
2068 fn resolve_refuses_a_revoked_persona_signer_at_the_same_seam_the_proxy_calls() {
2069 let issuer_signer = issuer();
2075 let persona_signer = Signer::from_seed(555);
2076 let persona_key = persona_signer.public_key_bytes();
2077 let chain = chain_with_user_signed_intent(&issuer_signer, &persona_signer);
2078 let issuer_key = issuer_signer.public_key_bytes();
2079 assert_eq!(
2080 resolve(
2081 Some(&chain),
2082 Some(&issuer_key),
2083 Some(revoked_persona_trust(&persona_key, 1_000)),
2084 CONV,
2085 1_000,
2086 )
2087 .unwrap_err(),
2088 MandateError::UntrustedSigner("intent")
2089 );
2090 }
2091
2092 fn hitl_approval(approved: bool, args_json: &str) -> VerifiedResponse {
2095 let approval_signer = ApprovalSigner::from_seed(7);
2096 let (payload, _sig, _pk) = response_payload(
2097 "req-1",
2098 "paid_fetch",
2099 args_json,
2100 "",
2101 approved,
2102 false,
2103 &[],
2104 CALLER,
2105 "",
2106 "workspace-write",
2107 if approved { "looks fine" } else { "no" },
2108 "",
2109 CONV,
2110 "approval-nonce-1",
2111 &approval_signer,
2112 );
2113 verify_signed_response(&payload).expect("approval signature verifies")
2114 }
2115
2116 fn chain_prefix(signer: &Signer) -> (Vec<u8>, Vec<u8>, String) {
2118 let (intent, _s, _p) = sign_intent_mandate(&intent_fields("1000000"), signer);
2119 let intent_hash = mandate_hash(&intent);
2120 let (cart, _s, _p) = sign_cart_mandate(
2121 &CartFields {
2122 intent_hash: &intent_hash,
2123 caller: CALLER,
2124 conversation_id: CONV,
2125 merchant_host: HOST,
2126 currency: USD,
2127 amount_base_units: "500000",
2128 issued_at_unix: 100,
2129 expires_at_unix: 9_000,
2130 nonce: "n-cart",
2131 },
2132 signer,
2133 );
2134 (intent, cart, intent_hash)
2135 }
2136
2137 #[test]
2138 fn hitl_approval_mints_a_payment_mandate_that_completes_the_chain() {
2139 let signer = issuer();
2140 let (intent, cart, _ih) = chain_prefix(&signer);
2141
2142 let approved = hitl_approval(true, ARGS);
2145 let payment = payment_mandate_from_approval(
2146 &approved,
2147 "req-1",
2148 "paid_fetch",
2149 ARGS,
2150 &cart,
2151 200,
2152 8_000,
2153 "n-payment",
2154 &signer,
2155 )
2156 .expect("the approval authorizes this exact call");
2157
2158 let chain = MandateChain {
2161 intent,
2162 cart,
2163 payment,
2164 };
2165 let verified = chain
2166 .verify(CONV, &signer.public_key_bytes(), None, 1_000)
2167 .expect("the minted payment chains to the cart");
2168 assert_eq!(verified.authorized_amount_base_units, 500_000);
2169 assert_eq!(verified.caller, CALLER);
2170 verified
2171 .authorize(500_000, HOST, ARGS)
2172 .expect("authorizes the approved call");
2173 assert!(matches!(
2174 verified.authorize(1, HOST, r#"{"url":"https://evil.example.com/x"}"#),
2175 Err(MandateError::ArgsBindingMismatch)
2176 ));
2177 }
2178
2179 #[test]
2180 fn hitl_bridge_refuses_denials_and_mismatched_scopes() {
2181 let signer = issuer();
2182 let (_intent, cart, intent_hash) = chain_prefix(&signer);
2183
2184 let denied = hitl_approval(false, ARGS);
2186 assert_eq!(
2187 payment_mandate_from_approval(
2188 &denied,
2189 "req-1",
2190 "paid_fetch",
2191 ARGS,
2192 &cart,
2193 200,
2194 8_000,
2195 "n",
2196 &signer
2197 )
2198 .unwrap_err(),
2199 MandateError::ApprovalDoesNotAuthorizeCall
2200 );
2201
2202 let approved = hitl_approval(true, ARGS);
2204 assert_eq!(
2205 payment_mandate_from_approval(
2206 &approved,
2207 "req-1",
2208 "paid_fetch",
2209 r#"{"url":"https://evil.example.com/x"}"#,
2210 &cart,
2211 200,
2212 8_000,
2213 "n",
2214 &signer
2215 )
2216 .unwrap_err(),
2217 MandateError::ApprovalDoesNotAuthorizeCall
2218 );
2219
2220 let (foreign_cart, _s, _p) = sign_cart_mandate(
2223 &CartFields {
2224 intent_hash: &intent_hash,
2225 caller: CALLER,
2226 conversation_id: "conv-OTHER",
2227 merchant_host: HOST,
2228 currency: USD,
2229 amount_base_units: "500000",
2230 issued_at_unix: 100,
2231 expires_at_unix: 9_000,
2232 nonce: "n-cart",
2233 },
2234 &signer,
2235 );
2236 assert_eq!(
2237 payment_mandate_from_approval(
2238 &approved,
2239 "req-1",
2240 "paid_fetch",
2241 ARGS,
2242 &foreign_cart,
2243 200,
2244 8_000,
2245 "n",
2246 &signer
2247 )
2248 .unwrap_err(),
2249 MandateError::ConversationMismatch("cart")
2250 );
2251
2252 let (foreign_caller_cart, _s, _p) = sign_cart_mandate(
2254 &CartFields {
2255 intent_hash: &intent_hash,
2256 caller: "slack:T1:USOMEONE",
2257 conversation_id: CONV,
2258 merchant_host: HOST,
2259 currency: USD,
2260 amount_base_units: "500000",
2261 issued_at_unix: 100,
2262 expires_at_unix: 9_000,
2263 nonce: "n-cart",
2264 },
2265 &signer,
2266 );
2267 assert_eq!(
2268 payment_mandate_from_approval(
2269 &approved,
2270 "req-1",
2271 "paid_fetch",
2272 ARGS,
2273 &foreign_caller_cart,
2274 200,
2275 8_000,
2276 "n",
2277 &signer
2278 )
2279 .unwrap_err(),
2280 MandateError::CallerMismatch
2281 );
2282 }
2283}