use serde_json::Value;
use sha2::{Digest, Sha256};
use crate::{Signer, verify};
const KIND_INTENT: &str = "ap2.intent.v1";
const KIND_CART: &str = "ap2.cart.v1";
const KIND_PAYMENT: &str = "ap2.payment.v1";
#[must_use]
pub fn mandate_hash(signed_payload: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(signed_payload);
hex_lower(&hasher.finalize())
}
#[derive(Debug, Clone, Copy)]
pub struct IntentFields<'a> {
pub caller: &'a str,
pub conversation_id: &'a str,
pub scope_description: &'a str,
pub currency: &'a str,
pub max_total_base_units: &'a str,
pub issued_at_unix: u64,
pub expires_at_unix: u64,
pub nonce: &'a str,
}
impl IntentFields<'_> {
fn canonical_json(&self) -> Value {
serde_json::json!({
"kind": KIND_INTENT,
"caller": self.caller,
"conversation_id": self.conversation_id,
"scope_description": self.scope_description,
"currency": self.currency,
"max_total_base_units": self.max_total_base_units,
"issued_at_unix": self.issued_at_unix,
"expires_at_unix": self.expires_at_unix,
"nonce": self.nonce,
})
}
}
#[derive(Debug, Clone)]
pub struct VerifiedIntentMandate {
pub caller: String,
pub conversation_id: String,
pub scope_description: String,
pub currency: String,
pub max_total_base_units: String,
pub issued_at_unix: u64,
pub expires_at_unix: u64,
pub nonce: String,
pub signer_public_key: Vec<u8>,
}
#[must_use]
pub fn sign_intent_mandate(
fields: &IntentFields<'_>,
signer: &Signer,
) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
sign_envelope(fields.canonical_json(), signer)
}
#[must_use]
pub fn verify_signed_intent_mandate(payload: &[u8]) -> Option<VerifiedIntentMandate> {
let v: Value = serde_json::from_slice(payload).ok()?;
if v.get("kind")?.as_str()? != KIND_INTENT {
return None;
}
let caller = v.get("caller")?.as_str()?.to_owned();
let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
let scope_description = v.get("scope_description")?.as_str()?.to_owned();
let currency = v.get("currency")?.as_str()?.to_owned();
let max_total_base_units = v.get("max_total_base_units")?.as_str()?.to_owned();
let issued_at_unix = v.get("issued_at_unix")?.as_u64()?;
let expires_at_unix = v.get("expires_at_unix")?.as_u64()?;
let nonce = v.get("nonce")?.as_str()?.to_owned();
let (pk, sig) = envelope_signature(&v)?;
let fields = IntentFields {
caller: &caller,
conversation_id: &conversation_id,
scope_description: &scope_description,
currency: ¤cy,
max_total_base_units: &max_total_base_units,
issued_at_unix,
expires_at_unix,
nonce: &nonce,
};
if verify(&pk, &fields.canonical_json().to_string().into_bytes(), &sig) {
Some(VerifiedIntentMandate {
caller,
conversation_id,
scope_description,
currency,
max_total_base_units,
issued_at_unix,
expires_at_unix,
nonce,
signer_public_key: pk,
})
} else {
None
}
}
#[derive(Debug, Clone, Copy)]
pub struct CartFields<'a> {
pub intent_hash: &'a str,
pub caller: &'a str,
pub conversation_id: &'a str,
pub merchant_host: &'a str,
pub currency: &'a str,
pub amount_base_units: &'a str,
pub issued_at_unix: u64,
pub expires_at_unix: u64,
pub nonce: &'a str,
}
impl CartFields<'_> {
fn canonical_json(&self) -> Value {
serde_json::json!({
"kind": KIND_CART,
"intent_hash": self.intent_hash,
"caller": self.caller,
"conversation_id": self.conversation_id,
"merchant_host": self.merchant_host,
"currency": self.currency,
"amount_base_units": self.amount_base_units,
"issued_at_unix": self.issued_at_unix,
"expires_at_unix": self.expires_at_unix,
"nonce": self.nonce,
})
}
}
#[derive(Debug, Clone)]
pub struct VerifiedCartMandate {
pub intent_hash: String,
pub caller: String,
pub conversation_id: String,
pub merchant_host: String,
pub currency: String,
pub amount_base_units: String,
pub issued_at_unix: u64,
pub expires_at_unix: u64,
pub nonce: String,
pub signer_public_key: Vec<u8>,
}
#[must_use]
pub fn sign_cart_mandate(fields: &CartFields<'_>, signer: &Signer) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
sign_envelope(fields.canonical_json(), signer)
}
#[must_use]
pub fn verify_signed_cart_mandate(payload: &[u8]) -> Option<VerifiedCartMandate> {
let v: Value = serde_json::from_slice(payload).ok()?;
if v.get("kind")?.as_str()? != KIND_CART {
return None;
}
let intent_hash = v.get("intent_hash")?.as_str()?.to_owned();
let caller = v.get("caller")?.as_str()?.to_owned();
let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
let merchant_host = v.get("merchant_host")?.as_str()?.to_owned();
let currency = v.get("currency")?.as_str()?.to_owned();
let amount_base_units = v.get("amount_base_units")?.as_str()?.to_owned();
let issued_at_unix = v.get("issued_at_unix")?.as_u64()?;
let expires_at_unix = v.get("expires_at_unix")?.as_u64()?;
let nonce = v.get("nonce")?.as_str()?.to_owned();
let (pk, sig) = envelope_signature(&v)?;
let fields = CartFields {
intent_hash: &intent_hash,
caller: &caller,
conversation_id: &conversation_id,
merchant_host: &merchant_host,
currency: ¤cy,
amount_base_units: &amount_base_units,
issued_at_unix,
expires_at_unix,
nonce: &nonce,
};
if verify(&pk, &fields.canonical_json().to_string().into_bytes(), &sig) {
Some(VerifiedCartMandate {
intent_hash,
caller,
conversation_id,
merchant_host,
currency,
amount_base_units,
issued_at_unix,
expires_at_unix,
nonce,
signer_public_key: pk,
})
} else {
None
}
}
#[derive(Debug, Clone, Copy)]
pub struct PaymentFields<'a> {
pub cart_hash: &'a str,
pub caller: &'a str,
pub conversation_id: &'a str,
pub args_json: &'a str,
pub currency: &'a str,
pub amount_base_units: &'a str,
pub issued_at_unix: u64,
pub expires_at_unix: u64,
pub nonce: &'a str,
}
impl PaymentFields<'_> {
fn canonical_json(&self) -> Value {
serde_json::json!({
"kind": KIND_PAYMENT,
"cart_hash": self.cart_hash,
"caller": self.caller,
"conversation_id": self.conversation_id,
"args_json": self.args_json,
"currency": self.currency,
"amount_base_units": self.amount_base_units,
"issued_at_unix": self.issued_at_unix,
"expires_at_unix": self.expires_at_unix,
"nonce": self.nonce,
})
}
}
#[derive(Debug, Clone)]
pub struct VerifiedPaymentMandate {
pub cart_hash: String,
pub caller: String,
pub conversation_id: String,
pub args_json: String,
pub currency: String,
pub amount_base_units: String,
pub issued_at_unix: u64,
pub expires_at_unix: u64,
pub nonce: String,
pub signer_public_key: Vec<u8>,
}
#[must_use]
pub fn sign_payment_mandate(
fields: &PaymentFields<'_>,
signer: &Signer,
) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
sign_envelope(fields.canonical_json(), signer)
}
#[must_use]
pub fn verify_signed_payment_mandate(payload: &[u8]) -> Option<VerifiedPaymentMandate> {
let v: Value = serde_json::from_slice(payload).ok()?;
if v.get("kind")?.as_str()? != KIND_PAYMENT {
return None;
}
let cart_hash = v.get("cart_hash")?.as_str()?.to_owned();
let caller = v.get("caller")?.as_str()?.to_owned();
let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
let args_json = v.get("args_json")?.as_str()?.to_owned();
let currency = v.get("currency")?.as_str()?.to_owned();
let amount_base_units = v.get("amount_base_units")?.as_str()?.to_owned();
let issued_at_unix = v.get("issued_at_unix")?.as_u64()?;
let expires_at_unix = v.get("expires_at_unix")?.as_u64()?;
let nonce = v.get("nonce")?.as_str()?.to_owned();
let (pk, sig) = envelope_signature(&v)?;
let fields = PaymentFields {
cart_hash: &cart_hash,
caller: &caller,
conversation_id: &conversation_id,
args_json: &args_json,
currency: ¤cy,
amount_base_units: &amount_base_units,
issued_at_unix,
expires_at_unix,
nonce: &nonce,
};
if verify(&pk, &fields.canonical_json().to_string().into_bytes(), &sig) {
Some(VerifiedPaymentMandate {
cart_hash,
caller,
conversation_id,
args_json,
currency,
amount_base_units,
issued_at_unix,
expires_at_unix,
nonce,
signer_public_key: pk,
})
} else {
None
}
}
fn sign_envelope(mut canonical: Value, signer: &Signer) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
let canonical_bytes = canonical.to_string().into_bytes();
let signature = signer.sign(&canonical_bytes);
let pk = signer.public_key_bytes();
if let Value::Object(map) = &mut canonical {
map.insert("signed_by".to_owned(), Value::String(hex_lower(&pk)));
map.insert(
"signature_hex".to_owned(),
Value::String(hex_lower(&signature)),
);
}
(canonical.to_string().into_bytes(), signature, pk)
}
fn envelope_signature(v: &Value) -> Option<(Vec<u8>, Vec<u8>)> {
let pk = hex_decode(v.get("signed_by")?.as_str()?)?;
let sig = hex_decode(v.get("signature_hex")?.as_str()?)?;
Some((pk, sig))
}
fn hex_lower(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
use std::fmt::Write as _;
let _ = write!(&mut s, "{b:02x}");
}
s
}
fn hex_decode(s: &str) -> Option<Vec<u8>> {
if !s.len().is_multiple_of(2) {
return None;
}
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
.collect()
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
fn intent(signer: &Signer) -> (Vec<u8>, IntentFields<'static>) {
let fields = IntentFields {
caller: "slack:T1:U9",
conversation_id: "conv-1",
scope_description: "research report purchases",
currency: "0xUSD",
max_total_base_units: "1000000",
issued_at_unix: 1_000,
expires_at_unix: 10_000,
nonce: "intent-nonce-1",
};
let (payload, _sig, _pk) = sign_intent_mandate(&fields, signer);
(payload, fields)
}
#[test]
fn intent_mandate_round_trips() {
let signer = Signer::from_seed(1);
let (payload, fields) = intent(&signer);
let verified = verify_signed_intent_mandate(&payload).expect("verifies");
assert_eq!(verified.caller, fields.caller);
assert_eq!(verified.conversation_id, fields.conversation_id);
assert_eq!(verified.max_total_base_units, fields.max_total_base_units);
assert_eq!(verified.signer_public_key, signer.public_key_bytes());
}
#[test]
fn intent_mandate_tampered_amount_fails() {
let signer = Signer::from_seed(1);
let (payload, _fields) = intent(&signer);
let mut v: Value = serde_json::from_slice(&payload).unwrap();
v["max_total_base_units"] = Value::String("999999999".to_owned());
assert!(verify_signed_intent_mandate(&v.to_string().into_bytes()).is_none());
}
#[test]
fn intent_mandate_wrong_kind_rejected() {
let signer = Signer::from_seed(1);
let cart_fields = CartFields {
intent_hash: "deadbeef",
caller: "slack:T1:U9",
conversation_id: "conv-1",
merchant_host: "api.example.com",
currency: "0xUSD",
amount_base_units: "500000",
issued_at_unix: 1_000,
expires_at_unix: 5_000,
nonce: "cart-nonce-1",
};
let (cart_payload, _sig, _pk) = sign_cart_mandate(&cart_fields, &signer);
assert!(verify_signed_intent_mandate(&cart_payload).is_none());
}
#[test]
fn cart_mandate_round_trips_and_chains_by_hash() {
let signer = Signer::from_seed(2);
let (intent_payload, _fields) = intent(&signer);
let intent_hash = mandate_hash(&intent_payload);
let cart_fields = CartFields {
intent_hash: &intent_hash,
caller: "slack:T1:U9",
conversation_id: "conv-1",
merchant_host: "api.example.com",
currency: "0xUSD",
amount_base_units: "500000",
issued_at_unix: 1_000,
expires_at_unix: 5_000,
nonce: "cart-nonce-1",
};
let (cart_payload, _sig, _pk) = sign_cart_mandate(&cart_fields, &signer);
let verified = verify_signed_cart_mandate(&cart_payload).expect("cart verifies");
assert_eq!(verified.intent_hash, intent_hash);
assert_eq!(verified.merchant_host, "api.example.com");
}
#[test]
fn cart_mandate_tampered_intent_hash_fails() {
let signer = Signer::from_seed(2);
let (intent_payload, _fields) = intent(&signer);
let intent_hash = mandate_hash(&intent_payload);
let cart_fields = CartFields {
intent_hash: &intent_hash,
caller: "slack:T1:U9",
conversation_id: "conv-1",
merchant_host: "api.example.com",
currency: "0xUSD",
amount_base_units: "500000",
issued_at_unix: 1_000,
expires_at_unix: 5_000,
nonce: "cart-nonce-1",
};
let (payload, _sig, _pk) = sign_cart_mandate(&cart_fields, &signer);
let mut v: Value = serde_json::from_slice(&payload).unwrap();
v["intent_hash"] = Value::String("0".repeat(64));
assert!(verify_signed_cart_mandate(&v.to_string().into_bytes()).is_none());
}
#[test]
fn payment_mandate_round_trips_and_chains_by_hash() {
let signer = Signer::from_seed(3);
let (intent_payload, _fields) = intent(&signer);
let intent_hash = mandate_hash(&intent_payload);
let cart_fields = CartFields {
intent_hash: &intent_hash,
caller: "slack:T1:U9",
conversation_id: "conv-1",
merchant_host: "api.example.com",
currency: "0xUSD",
amount_base_units: "500000",
issued_at_unix: 1_000,
expires_at_unix: 5_000,
nonce: "cart-nonce-1",
};
let (cart_payload, _sig, _pk) = sign_cart_mandate(&cart_fields, &signer);
let cart_hash = mandate_hash(&cart_payload);
let payment_fields = PaymentFields {
cart_hash: &cart_hash,
caller: "slack:T1:U9",
conversation_id: "conv-1",
args_json: r#"{"url":"https://api.example.com/report"}"#,
currency: "0xUSD",
amount_base_units: "250000",
issued_at_unix: 1_000,
expires_at_unix: 4_000,
nonce: "payment-nonce-1",
};
let (payment_payload, _sig, _pk) = sign_payment_mandate(&payment_fields, &signer);
let verified = verify_signed_payment_mandate(&payment_payload).expect("payment verifies");
assert_eq!(verified.cart_hash, cart_hash);
assert_eq!(verified.amount_base_units, "250000");
assert_eq!(verified.args_json, payment_fields.args_json);
}
#[test]
fn payment_mandate_tampered_args_json_fails() {
let signer = Signer::from_seed(3);
let payment_fields = PaymentFields {
cart_hash: "deadbeef",
caller: "slack:T1:U9",
conversation_id: "conv-1",
args_json: r#"{"url":"https://api.example.com/report"}"#,
currency: "0xUSD",
amount_base_units: "250000",
issued_at_unix: 1_000,
expires_at_unix: 4_000,
nonce: "payment-nonce-1",
};
let (payload, _sig, _pk) = sign_payment_mandate(&payment_fields, &signer);
let mut v: Value = serde_json::from_slice(&payload).unwrap();
v["args_json"] = Value::String(r#"{"url":"https://evil.example.com/steal"}"#.to_owned());
assert!(verify_signed_payment_mandate(&v.to_string().into_bytes()).is_none());
}
#[test]
fn mandate_hash_is_stable_and_sensitive_to_signature() {
let signer_a = Signer::from_seed(9);
let signer_b = Signer::from_seed(10);
let fields = IntentFields {
caller: "slack:T1:U9",
conversation_id: "conv-1",
scope_description: "x",
currency: "0xUSD",
max_total_base_units: "1000",
issued_at_unix: 1,
expires_at_unix: 2,
nonce: "n",
};
let (payload_a, _s, _p) = sign_intent_mandate(&fields, &signer_a);
let (payload_b, _s, _p) = sign_intent_mandate(&fields, &signer_b);
assert_eq!(mandate_hash(&payload_a), mandate_hash(&payload_a));
assert_ne!(mandate_hash(&payload_a), mandate_hash(&payload_b));
}
#[test]
fn garbage_payload_returns_none_not_panic() {
assert!(verify_signed_intent_mandate(b"not json").is_none());
assert!(verify_signed_cart_mandate(b"{}").is_none());
assert!(verify_signed_payment_mandate(b"[]").is_none());
}
}