1use serde_json::Value;
27use sha2::{Digest, Sha256};
28
29use crate::{Signer, verify};
30
31const KIND_INTENT: &str = "ap2.intent.v1";
33const KIND_CART: &str = "ap2.cart.v1";
35const KIND_PAYMENT: &str = "ap2.payment.v1";
37
38#[must_use]
44pub fn mandate_hash(signed_payload: &[u8]) -> String {
45 let mut hasher = Sha256::new();
46 hasher.update(signed_payload);
47 hex_lower(&hasher.finalize())
48}
49
50#[derive(Debug, Clone, Copy)]
56pub struct IntentFields<'a> {
57 pub caller: &'a str,
60 pub conversation_id: &'a str,
62 pub scope_description: &'a str,
65 pub currency: &'a str,
68 pub max_total_base_units: &'a str,
73 pub issued_at_unix: u64,
75 pub expires_at_unix: u64,
78 pub nonce: &'a str,
80}
81
82impl IntentFields<'_> {
83 fn canonical_json(&self) -> Value {
84 serde_json::json!({
85 "kind": KIND_INTENT,
86 "caller": self.caller,
87 "conversation_id": self.conversation_id,
88 "scope_description": self.scope_description,
89 "currency": self.currency,
90 "max_total_base_units": self.max_total_base_units,
91 "issued_at_unix": self.issued_at_unix,
92 "expires_at_unix": self.expires_at_unix,
93 "nonce": self.nonce,
94 })
95 }
96}
97
98#[derive(Debug, Clone)]
100pub struct VerifiedIntentMandate {
101 pub caller: String,
103 pub conversation_id: String,
105 pub scope_description: String,
107 pub currency: String,
109 pub max_total_base_units: String,
111 pub issued_at_unix: u64,
113 pub expires_at_unix: u64,
115 pub nonce: String,
117 pub signer_public_key: Vec<u8>,
119}
120
121#[must_use]
127pub fn sign_intent_mandate(
128 fields: &IntentFields<'_>,
129 signer: &Signer,
130) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
131 sign_envelope(fields.canonical_json(), signer)
132}
133
134#[must_use]
141pub fn verify_signed_intent_mandate(payload: &[u8]) -> Option<VerifiedIntentMandate> {
142 let v: Value = serde_json::from_slice(payload).ok()?;
143 if v.get("kind")?.as_str()? != KIND_INTENT {
144 return None;
145 }
146 let caller = v.get("caller")?.as_str()?.to_owned();
147 let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
148 let scope_description = v.get("scope_description")?.as_str()?.to_owned();
149 let currency = v.get("currency")?.as_str()?.to_owned();
150 let max_total_base_units = v.get("max_total_base_units")?.as_str()?.to_owned();
151 let issued_at_unix = v.get("issued_at_unix")?.as_u64()?;
152 let expires_at_unix = v.get("expires_at_unix")?.as_u64()?;
153 let nonce = v.get("nonce")?.as_str()?.to_owned();
154 let (pk, sig) = envelope_signature(&v)?;
155
156 let fields = IntentFields {
157 caller: &caller,
158 conversation_id: &conversation_id,
159 scope_description: &scope_description,
160 currency: ¤cy,
161 max_total_base_units: &max_total_base_units,
162 issued_at_unix,
163 expires_at_unix,
164 nonce: &nonce,
165 };
166 if verify(&pk, &fields.canonical_json().to_string().into_bytes(), &sig) {
167 Some(VerifiedIntentMandate {
168 caller,
169 conversation_id,
170 scope_description,
171 currency,
172 max_total_base_units,
173 issued_at_unix,
174 expires_at_unix,
175 nonce,
176 signer_public_key: pk,
177 })
178 } else {
179 None
180 }
181}
182
183#[derive(Debug, Clone, Copy)]
186pub struct CartFields<'a> {
187 pub intent_hash: &'a str,
189 pub caller: &'a str,
192 pub conversation_id: &'a str,
194 pub merchant_host: &'a str,
196 pub currency: &'a str,
198 pub amount_base_units: &'a str,
200 pub issued_at_unix: u64,
202 pub expires_at_unix: u64,
204 pub nonce: &'a str,
206}
207
208impl CartFields<'_> {
209 fn canonical_json(&self) -> Value {
210 serde_json::json!({
211 "kind": KIND_CART,
212 "intent_hash": self.intent_hash,
213 "caller": self.caller,
214 "conversation_id": self.conversation_id,
215 "merchant_host": self.merchant_host,
216 "currency": self.currency,
217 "amount_base_units": self.amount_base_units,
218 "issued_at_unix": self.issued_at_unix,
219 "expires_at_unix": self.expires_at_unix,
220 "nonce": self.nonce,
221 })
222 }
223}
224
225#[derive(Debug, Clone)]
227pub struct VerifiedCartMandate {
228 pub intent_hash: String,
230 pub caller: String,
232 pub conversation_id: String,
234 pub merchant_host: String,
236 pub currency: String,
238 pub amount_base_units: String,
240 pub issued_at_unix: u64,
242 pub expires_at_unix: u64,
244 pub nonce: String,
246 pub signer_public_key: Vec<u8>,
248}
249
250#[must_use]
253pub fn sign_cart_mandate(fields: &CartFields<'_>, signer: &Signer) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
254 sign_envelope(fields.canonical_json(), signer)
255}
256
257#[must_use]
260pub fn verify_signed_cart_mandate(payload: &[u8]) -> Option<VerifiedCartMandate> {
261 let v: Value = serde_json::from_slice(payload).ok()?;
262 if v.get("kind")?.as_str()? != KIND_CART {
263 return None;
264 }
265 let intent_hash = v.get("intent_hash")?.as_str()?.to_owned();
266 let caller = v.get("caller")?.as_str()?.to_owned();
267 let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
268 let merchant_host = v.get("merchant_host")?.as_str()?.to_owned();
269 let currency = v.get("currency")?.as_str()?.to_owned();
270 let amount_base_units = v.get("amount_base_units")?.as_str()?.to_owned();
271 let issued_at_unix = v.get("issued_at_unix")?.as_u64()?;
272 let expires_at_unix = v.get("expires_at_unix")?.as_u64()?;
273 let nonce = v.get("nonce")?.as_str()?.to_owned();
274 let (pk, sig) = envelope_signature(&v)?;
275
276 let fields = CartFields {
277 intent_hash: &intent_hash,
278 caller: &caller,
279 conversation_id: &conversation_id,
280 merchant_host: &merchant_host,
281 currency: ¤cy,
282 amount_base_units: &amount_base_units,
283 issued_at_unix,
284 expires_at_unix,
285 nonce: &nonce,
286 };
287 if verify(&pk, &fields.canonical_json().to_string().into_bytes(), &sig) {
288 Some(VerifiedCartMandate {
289 intent_hash,
290 caller,
291 conversation_id,
292 merchant_host,
293 currency,
294 amount_base_units,
295 issued_at_unix,
296 expires_at_unix,
297 nonce,
298 signer_public_key: pk,
299 })
300 } else {
301 None
302 }
303}
304
305#[derive(Debug, Clone, Copy)]
308pub struct PaymentFields<'a> {
309 pub cart_hash: &'a str,
311 pub caller: &'a str,
313 pub conversation_id: &'a str,
315 pub args_json: &'a str,
319 pub currency: &'a str,
321 pub amount_base_units: &'a str,
323 pub issued_at_unix: u64,
325 pub expires_at_unix: u64,
327 pub nonce: &'a str,
329}
330
331impl PaymentFields<'_> {
332 fn canonical_json(&self) -> Value {
333 serde_json::json!({
334 "kind": KIND_PAYMENT,
335 "cart_hash": self.cart_hash,
336 "caller": self.caller,
337 "conversation_id": self.conversation_id,
338 "args_json": self.args_json,
339 "currency": self.currency,
340 "amount_base_units": self.amount_base_units,
341 "issued_at_unix": self.issued_at_unix,
342 "expires_at_unix": self.expires_at_unix,
343 "nonce": self.nonce,
344 })
345 }
346}
347
348#[derive(Debug, Clone)]
350pub struct VerifiedPaymentMandate {
351 pub cart_hash: String,
353 pub caller: String,
355 pub conversation_id: String,
357 pub args_json: String,
359 pub currency: String,
361 pub amount_base_units: String,
363 pub issued_at_unix: u64,
365 pub expires_at_unix: u64,
367 pub nonce: String,
369 pub signer_public_key: Vec<u8>,
371}
372
373#[must_use]
376pub fn sign_payment_mandate(
377 fields: &PaymentFields<'_>,
378 signer: &Signer,
379) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
380 sign_envelope(fields.canonical_json(), signer)
381}
382
383#[must_use]
386pub fn verify_signed_payment_mandate(payload: &[u8]) -> Option<VerifiedPaymentMandate> {
387 let v: Value = serde_json::from_slice(payload).ok()?;
388 if v.get("kind")?.as_str()? != KIND_PAYMENT {
389 return None;
390 }
391 let cart_hash = v.get("cart_hash")?.as_str()?.to_owned();
392 let caller = v.get("caller")?.as_str()?.to_owned();
393 let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
394 let args_json = v.get("args_json")?.as_str()?.to_owned();
395 let currency = v.get("currency")?.as_str()?.to_owned();
396 let amount_base_units = v.get("amount_base_units")?.as_str()?.to_owned();
397 let issued_at_unix = v.get("issued_at_unix")?.as_u64()?;
398 let expires_at_unix = v.get("expires_at_unix")?.as_u64()?;
399 let nonce = v.get("nonce")?.as_str()?.to_owned();
400 let (pk, sig) = envelope_signature(&v)?;
401
402 let fields = PaymentFields {
403 cart_hash: &cart_hash,
404 caller: &caller,
405 conversation_id: &conversation_id,
406 args_json: &args_json,
407 currency: ¤cy,
408 amount_base_units: &amount_base_units,
409 issued_at_unix,
410 expires_at_unix,
411 nonce: &nonce,
412 };
413 if verify(&pk, &fields.canonical_json().to_string().into_bytes(), &sig) {
414 Some(VerifiedPaymentMandate {
415 cart_hash,
416 caller,
417 conversation_id,
418 args_json,
419 currency,
420 amount_base_units,
421 issued_at_unix,
422 expires_at_unix,
423 nonce,
424 signer_public_key: pk,
425 })
426 } else {
427 None
428 }
429}
430
431fn sign_envelope(mut canonical: Value, signer: &Signer) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
436 let canonical_bytes = canonical.to_string().into_bytes();
437 let signature = signer.sign(&canonical_bytes);
438 let pk = signer.public_key_bytes();
439 if let Value::Object(map) = &mut canonical {
440 map.insert("signed_by".to_owned(), Value::String(hex_lower(&pk)));
441 map.insert(
442 "signature_hex".to_owned(),
443 Value::String(hex_lower(&signature)),
444 );
445 }
446 (canonical.to_string().into_bytes(), signature, pk)
447}
448
449fn envelope_signature(v: &Value) -> Option<(Vec<u8>, Vec<u8>)> {
452 let pk = hex_decode(v.get("signed_by")?.as_str()?)?;
453 let sig = hex_decode(v.get("signature_hex")?.as_str()?)?;
454 Some((pk, sig))
455}
456
457fn hex_lower(bytes: &[u8]) -> String {
458 let mut s = String::with_capacity(bytes.len() * 2);
459 for b in bytes {
460 use std::fmt::Write as _;
461 let _ = write!(&mut s, "{b:02x}");
462 }
463 s
464}
465
466fn hex_decode(s: &str) -> Option<Vec<u8>> {
467 if !s.len().is_multiple_of(2) {
468 return None;
469 }
470 (0..s.len())
471 .step_by(2)
472 .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
473 .collect()
474}
475
476#[cfg(test)]
477mod tests {
478 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
479
480 use super::*;
481
482 fn intent(signer: &Signer) -> (Vec<u8>, IntentFields<'static>) {
483 let fields = IntentFields {
484 caller: "slack:T1:U9",
485 conversation_id: "conv-1",
486 scope_description: "research report purchases",
487 currency: "0xUSD",
488 max_total_base_units: "1000000",
489 issued_at_unix: 1_000,
490 expires_at_unix: 10_000,
491 nonce: "intent-nonce-1",
492 };
493 let (payload, _sig, _pk) = sign_intent_mandate(&fields, signer);
494 (payload, fields)
495 }
496
497 #[test]
498 fn intent_mandate_round_trips() {
499 let signer = Signer::from_seed(1);
500 let (payload, fields) = intent(&signer);
501 let verified = verify_signed_intent_mandate(&payload).expect("verifies");
502 assert_eq!(verified.caller, fields.caller);
503 assert_eq!(verified.conversation_id, fields.conversation_id);
504 assert_eq!(verified.max_total_base_units, fields.max_total_base_units);
505 assert_eq!(verified.signer_public_key, signer.public_key_bytes());
506 }
507
508 #[test]
509 fn intent_mandate_tampered_amount_fails() {
510 let signer = Signer::from_seed(1);
511 let (payload, _fields) = intent(&signer);
512 let mut v: Value = serde_json::from_slice(&payload).unwrap();
513 v["max_total_base_units"] = Value::String("999999999".to_owned());
514 assert!(verify_signed_intent_mandate(&v.to_string().into_bytes()).is_none());
515 }
516
517 #[test]
518 fn intent_mandate_wrong_kind_rejected() {
519 let signer = Signer::from_seed(1);
522 let cart_fields = CartFields {
523 intent_hash: "deadbeef",
524 caller: "slack:T1:U9",
525 conversation_id: "conv-1",
526 merchant_host: "api.example.com",
527 currency: "0xUSD",
528 amount_base_units: "500000",
529 issued_at_unix: 1_000,
530 expires_at_unix: 5_000,
531 nonce: "cart-nonce-1",
532 };
533 let (cart_payload, _sig, _pk) = sign_cart_mandate(&cart_fields, &signer);
534 assert!(verify_signed_intent_mandate(&cart_payload).is_none());
535 }
536
537 #[test]
538 fn cart_mandate_round_trips_and_chains_by_hash() {
539 let signer = Signer::from_seed(2);
540 let (intent_payload, _fields) = intent(&signer);
541 let intent_hash = mandate_hash(&intent_payload);
542
543 let cart_fields = CartFields {
544 intent_hash: &intent_hash,
545 caller: "slack:T1:U9",
546 conversation_id: "conv-1",
547 merchant_host: "api.example.com",
548 currency: "0xUSD",
549 amount_base_units: "500000",
550 issued_at_unix: 1_000,
551 expires_at_unix: 5_000,
552 nonce: "cart-nonce-1",
553 };
554 let (cart_payload, _sig, _pk) = sign_cart_mandate(&cart_fields, &signer);
555 let verified = verify_signed_cart_mandate(&cart_payload).expect("cart verifies");
556 assert_eq!(verified.intent_hash, intent_hash);
557 assert_eq!(verified.merchant_host, "api.example.com");
558 }
559
560 #[test]
561 fn cart_mandate_tampered_intent_hash_fails() {
562 let signer = Signer::from_seed(2);
563 let (intent_payload, _fields) = intent(&signer);
564 let intent_hash = mandate_hash(&intent_payload);
565 let cart_fields = CartFields {
566 intent_hash: &intent_hash,
567 caller: "slack:T1:U9",
568 conversation_id: "conv-1",
569 merchant_host: "api.example.com",
570 currency: "0xUSD",
571 amount_base_units: "500000",
572 issued_at_unix: 1_000,
573 expires_at_unix: 5_000,
574 nonce: "cart-nonce-1",
575 };
576 let (payload, _sig, _pk) = sign_cart_mandate(&cart_fields, &signer);
577 let mut v: Value = serde_json::from_slice(&payload).unwrap();
578 v["intent_hash"] = Value::String("0".repeat(64));
579 assert!(verify_signed_cart_mandate(&v.to_string().into_bytes()).is_none());
580 }
581
582 #[test]
583 fn payment_mandate_round_trips_and_chains_by_hash() {
584 let signer = Signer::from_seed(3);
585 let (intent_payload, _fields) = intent(&signer);
586 let intent_hash = mandate_hash(&intent_payload);
587 let cart_fields = CartFields {
588 intent_hash: &intent_hash,
589 caller: "slack:T1:U9",
590 conversation_id: "conv-1",
591 merchant_host: "api.example.com",
592 currency: "0xUSD",
593 amount_base_units: "500000",
594 issued_at_unix: 1_000,
595 expires_at_unix: 5_000,
596 nonce: "cart-nonce-1",
597 };
598 let (cart_payload, _sig, _pk) = sign_cart_mandate(&cart_fields, &signer);
599 let cart_hash = mandate_hash(&cart_payload);
600
601 let payment_fields = PaymentFields {
602 cart_hash: &cart_hash,
603 caller: "slack:T1:U9",
604 conversation_id: "conv-1",
605 args_json: r#"{"url":"https://api.example.com/report"}"#,
606 currency: "0xUSD",
607 amount_base_units: "250000",
608 issued_at_unix: 1_000,
609 expires_at_unix: 4_000,
610 nonce: "payment-nonce-1",
611 };
612 let (payment_payload, _sig, _pk) = sign_payment_mandate(&payment_fields, &signer);
613 let verified = verify_signed_payment_mandate(&payment_payload).expect("payment verifies");
614 assert_eq!(verified.cart_hash, cart_hash);
615 assert_eq!(verified.amount_base_units, "250000");
616 assert_eq!(verified.args_json, payment_fields.args_json);
617 }
618
619 #[test]
620 fn payment_mandate_tampered_args_json_fails() {
621 let signer = Signer::from_seed(3);
622 let payment_fields = PaymentFields {
623 cart_hash: "deadbeef",
624 caller: "slack:T1:U9",
625 conversation_id: "conv-1",
626 args_json: r#"{"url":"https://api.example.com/report"}"#,
627 currency: "0xUSD",
628 amount_base_units: "250000",
629 issued_at_unix: 1_000,
630 expires_at_unix: 4_000,
631 nonce: "payment-nonce-1",
632 };
633 let (payload, _sig, _pk) = sign_payment_mandate(&payment_fields, &signer);
634 let mut v: Value = serde_json::from_slice(&payload).unwrap();
635 v["args_json"] = Value::String(r#"{"url":"https://evil.example.com/steal"}"#.to_owned());
636 assert!(verify_signed_payment_mandate(&v.to_string().into_bytes()).is_none());
637 }
638
639 #[test]
640 fn mandate_hash_is_stable_and_sensitive_to_signature() {
641 let signer_a = Signer::from_seed(9);
642 let signer_b = Signer::from_seed(10);
643 let fields = IntentFields {
644 caller: "slack:T1:U9",
645 conversation_id: "conv-1",
646 scope_description: "x",
647 currency: "0xUSD",
648 max_total_base_units: "1000",
649 issued_at_unix: 1,
650 expires_at_unix: 2,
651 nonce: "n",
652 };
653 let (payload_a, _s, _p) = sign_intent_mandate(&fields, &signer_a);
654 let (payload_b, _s, _p) = sign_intent_mandate(&fields, &signer_b);
655 assert_eq!(mandate_hash(&payload_a), mandate_hash(&payload_a));
657 assert_ne!(mandate_hash(&payload_a), mandate_hash(&payload_b));
660 }
661
662 #[test]
663 fn garbage_payload_returns_none_not_panic() {
664 assert!(verify_signed_intent_mandate(b"not json").is_none());
665 assert!(verify_signed_cart_mandate(b"{}").is_none());
666 assert!(verify_signed_payment_mandate(b"[]").is_none());
667 }
668}