use std::sync::Arc;
use serde_json::Value;
use crate::{Signer, verify};
#[derive(Clone)]
pub struct ApprovalSigner {
inner: Arc<Signer>,
}
impl Default for ApprovalSigner {
fn default() -> Self {
Self::from_seed(1)
}
}
impl ApprovalSigner {
#[must_use]
pub fn from_seed(seed: u64) -> Self {
Self {
inner: Arc::new(Signer::from_seed(seed)),
}
}
#[must_use]
pub fn public_key_bytes(&self) -> Vec<u8> {
self.inner.public_key_bytes()
}
#[must_use]
pub fn sign(&self, canonical_bytes: &[u8]) -> Vec<u8> {
self.inner.sign(canonical_bytes)
}
}
#[must_use]
pub fn request_payload(request_id: &str, tool_name: &str, args_json: &str) -> Vec<u8> {
serde_json::json!({
"tool_name": tool_name,
"args_json": args_json,
"request_id": request_id,
})
.to_string()
.into_bytes()
}
pub const RESPONSE_VERSION: u64 = 2;
fn response_canonical_v2(
request_id: &str,
tool_name: &str,
args_json: &str,
approved: bool,
reason: &str,
) -> Vec<u8> {
serde_json::json!({
"version": RESPONSE_VERSION,
"request_id": request_id,
"tool_name": tool_name,
"args_json": args_json,
"approved": approved,
"reason": reason,
})
.to_string()
.into_bytes()
}
fn response_canonical_v1(request_id: &str, approved: bool, reason: &str) -> Vec<u8> {
serde_json::json!({
"request_id": request_id,
"approved": approved,
"reason": reason,
})
.to_string()
.into_bytes()
}
#[must_use]
pub fn response_payload(
request_id: &str,
tool_name: &str,
args_json: &str,
approved: bool,
reason: &str,
signer: &ApprovalSigner,
) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
let canonical_bytes = response_canonical_v2(request_id, tool_name, args_json, approved, reason);
let signature = signer.sign(&canonical_bytes);
let pk = signer.public_key_bytes();
let full = serde_json::json!({
"version": RESPONSE_VERSION,
"request_id": request_id,
"tool_name": tool_name,
"args_json": args_json,
"approved": approved,
"reason": reason,
"signed_by": hex_lower(&pk),
"signature_hex": hex_lower(&signature),
});
(full.to_string().into_bytes(), signature, pk)
}
#[derive(Debug, Clone)]
pub struct VerifiedResponse {
pub request_id: String,
pub tool_name: Option<String>,
pub args_json: Option<String>,
pub approved: bool,
pub reason: String,
pub signer_public_key: Vec<u8>,
}
impl VerifiedResponse {
#[must_use]
pub const fn is_executable_approval(&self) -> bool {
self.approved && self.tool_name.is_some() && self.args_json.is_some()
}
}
#[must_use]
pub fn verify_signed_response(payload: &[u8]) -> Option<VerifiedResponse> {
let v: Value = serde_json::from_slice(payload).ok()?;
let request_id = v.get("request_id")?.as_str()?.to_owned();
let approved = v.get("approved")?.as_bool()?;
let reason = v.get("reason")?.as_str()?.to_owned();
let signed_by_hex = v.get("signed_by")?.as_str()?;
let signature_hex = v.get("signature_hex")?.as_str()?;
let pk = hex_decode(signed_by_hex)?;
let sig = hex_decode(signature_hex)?;
let version = match v.get("version") {
None => 1,
Some(n) if n.as_u64() == Some(RESPONSE_VERSION) => RESPONSE_VERSION,
Some(_) => return None,
};
if version == RESPONSE_VERSION {
let tool_name = v.get("tool_name")?.as_str()?.to_owned();
let args_json = v.get("args_json")?.as_str()?.to_owned();
let canonical =
response_canonical_v2(&request_id, &tool_name, &args_json, approved, &reason);
if verify(&pk, &canonical, &sig) {
return Some(VerifiedResponse {
request_id,
tool_name: Some(tool_name),
args_json: Some(args_json),
approved,
reason,
signer_public_key: pk,
});
}
return None;
}
let canonical = response_canonical_v1(&request_id, approved, &reason);
if verify(&pk, &canonical, &sig) {
Some(VerifiedResponse {
request_id,
tool_name: None,
args_json: None,
approved,
reason,
signer_public_key: pk,
})
} else {
None
}
}
#[must_use]
pub fn verify_wire_response(
request_id: &str,
tool_name: &str,
args_json: &str,
approved: bool,
reason: &str,
signer_pk_hex: &str,
signature_hex: &str,
) -> bool {
let Some(pk) = hex_decode(signer_pk_hex) else {
return false;
};
let Some(sig) = hex_decode(signature_hex) else {
return false;
};
let canonical = response_canonical_v2(request_id, tool_name, args_json, approved, reason);
verify(&pk, &canonical, &sig)
}
#[must_use]
pub fn decode_response_minimal(payload: &[u8]) -> Option<(String, bool)> {
let v: Value = serde_json::from_slice(payload).ok()?;
let request_id = v.get("request_id")?.as_str()?.to_owned();
let approved = v.get("approved")?.as_bool()?;
Some((request_id, approved))
}
#[derive(Debug, Clone)]
pub struct DecodedResponse {
pub request_id: String,
pub tool_name: Option<String>,
pub args_json: Option<String>,
pub approved: bool,
pub reason: String,
pub signer_pk_hex: String,
pub signature_hex: String,
}
#[must_use]
pub fn decode_response_full(payload: &[u8]) -> Option<DecodedResponse> {
let v: Value = serde_json::from_slice(payload).ok()?;
Some(DecodedResponse {
request_id: v.get("request_id")?.as_str()?.to_owned(),
tool_name: v
.get("tool_name")
.and_then(Value::as_str)
.map(str::to_owned),
args_json: v
.get("args_json")
.and_then(Value::as_str)
.map(str::to_owned),
approved: v.get("approved")?.as_bool()?,
reason: v.get("reason")?.as_str()?.to_owned(),
signer_pk_hex: v.get("signed_by")?.as_str()?.to_owned(),
signature_hex: v.get("signature_hex")?.as_str()?.to_owned(),
})
}
pub const RECEIPT_VERSION: u64 = 2;
#[derive(Debug, Clone, Copy)]
pub struct ReceiptPayload<'a> {
pub kind: &'a str,
pub reference: &'a str,
pub amount: &'a str,
pub currency: &'a str,
pub recipient: &'a str,
pub method: &'a str,
pub timestamp: &'a str,
pub tool_call_id: &'a str,
pub approval_pos: &'a str,
pub approved_args_hash: &'a str,
pub subject: &'a str,
}
impl ReceiptPayload<'_> {
#[must_use]
fn canonical_json(&self) -> Value {
serde_json::json!({
"version": RECEIPT_VERSION,
"kind": self.kind,
"reference": self.reference,
"amount": self.amount,
"currency": self.currency,
"recipient": self.recipient,
"method": self.method,
"timestamp": self.timestamp,
"tool_call_id": self.tool_call_id,
"approval_pos": self.approval_pos,
"approved_args_hash": self.approved_args_hash,
"subject": self.subject,
})
}
#[must_use]
fn canonical_json_v1(&self) -> Value {
serde_json::json!({
"reference": self.reference,
"amount": self.amount,
"currency": self.currency,
"recipient": self.recipient,
"method": self.method,
"timestamp": self.timestamp,
})
}
}
#[must_use]
pub fn receipt_payload(
fields: &ReceiptPayload<'_>,
signer: &ApprovalSigner,
) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
let mut full = fields.canonical_json();
let canonical_bytes = full.to_string().into_bytes();
let signature = signer.sign(&canonical_bytes);
let pk = signer.public_key_bytes();
if let Value::Object(map) = &mut full {
map.insert("signed_by".to_owned(), Value::String(hex_lower(&pk)));
map.insert(
"signature_hex".to_owned(),
Value::String(hex_lower(&signature)),
);
}
(full.to_string().into_bytes(), signature, pk)
}
#[derive(Debug, Clone)]
pub struct VerifiedReceipt {
pub reference: String,
pub amount: String,
pub currency: String,
pub recipient: String,
pub method: String,
pub timestamp: String,
pub version: u64,
pub kind: String,
pub tool_call_id: String,
pub approval_pos: String,
pub approved_args_hash: String,
pub subject: String,
pub signer_public_key: Vec<u8>,
}
#[must_use]
pub fn verify_signed_receipt(payload: &[u8]) -> Option<VerifiedReceipt> {
let v: Value = serde_json::from_slice(payload).ok()?;
let reference = v.get("reference")?.as_str()?.to_owned();
let amount = v.get("amount")?.as_str()?.to_owned();
let currency = v.get("currency")?.as_str()?.to_owned();
let recipient = v.get("recipient")?.as_str()?.to_owned();
let method = v.get("method")?.as_str()?.to_owned();
let timestamp = v.get("timestamp")?.as_str()?.to_owned();
let signed_by_hex = v.get("signed_by")?.as_str()?;
let signature_hex = v.get("signature_hex")?.as_str()?;
let pk = hex_decode(signed_by_hex)?;
let sig = hex_decode(signature_hex)?;
let version = match v.get("version") {
None => 1,
Some(n) if n.as_u64() == Some(RECEIPT_VERSION) => RECEIPT_VERSION,
Some(_) => return None,
};
let (kind, tool_call_id, approval_pos, approved_args_hash, subject) =
if version == RECEIPT_VERSION {
(
v.get("kind")?.as_str()?.to_owned(),
v.get("tool_call_id")?.as_str()?.to_owned(),
v.get("approval_pos")?.as_str()?.to_owned(),
v.get("approved_args_hash")?.as_str()?.to_owned(),
v.get("subject")?.as_str()?.to_owned(),
)
} else {
(
String::new(),
String::new(),
String::new(),
String::new(),
String::new(),
)
};
let fields = ReceiptPayload {
kind: &kind,
reference: &reference,
amount: &amount,
currency: ¤cy,
recipient: &recipient,
method: &method,
timestamp: ×tamp,
tool_call_id: &tool_call_id,
approval_pos: &approval_pos,
approved_args_hash: &approved_args_hash,
subject: &subject,
};
let canonical_bytes = if version == RECEIPT_VERSION {
fields.canonical_json()
} else {
fields.canonical_json_v1()
}
.to_string()
.into_bytes();
if verify(&pk, &canonical_bytes, &sig) {
Some(VerifiedReceipt {
reference,
amount,
currency,
recipient,
method,
timestamp,
version,
kind,
tool_call_id,
approval_pos,
approved_args_hash,
subject,
signer_public_key: pk,
})
} else {
None
}
}
#[must_use]
pub fn decode_request_id(payload: &[u8]) -> Option<String> {
let v: Value = serde_json::from_slice(payload).ok()?;
Some(v.get("request_id")?.as_str()?.to_owned())
}
#[must_use]
pub fn decode_request_fields(payload: &[u8]) -> Option<(String, String, String)> {
let v: Value = serde_json::from_slice(payload).ok()?;
Some((
v.get("request_id")?.as_str()?.to_owned(),
v.get("tool_name")?.as_str()?.to_owned(),
v.get("args_json")?.as_str()?.to_owned(),
))
}
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::*;
#[test]
fn signed_response_round_trips() {
let signer = ApprovalSigner::from_seed(42);
let (payload, _sig, _pk) = response_payload(
"req-1",
"rm",
r#"{"path":"/etc"}"#,
true,
"looks fine",
&signer,
);
let verified =
verify_signed_response(&payload).expect("signature verifies on untampered payload");
assert!(verified.approved);
assert_eq!(verified.reason, "looks fine");
assert_eq!(verified.request_id, "req-1");
assert_eq!(verified.tool_name.as_deref(), Some("rm"));
assert_eq!(verified.args_json.as_deref(), Some(r#"{"path":"/etc"}"#));
assert!(verified.is_executable_approval());
}
#[test]
fn tampered_response_fails_verification() {
let signer = ApprovalSigner::from_seed(42);
let (payload, _sig, _pk) =
response_payload("req-1", "rm", "{}", true, "looks fine", &signer);
for field in ["approved", "tool_name", "args_json"] {
let mut v: Value = serde_json::from_slice(&payload).unwrap();
v[field] = if field == "approved" {
Value::Bool(false)
} else {
Value::String("EVIL".to_owned())
};
assert!(
verify_signed_response(&v.to_string().into_bytes()).is_none(),
"tampering with {field} must fail verification"
);
}
}
#[test]
fn wire_verification_round_trips_with_hex_fields() {
let signer = ApprovalSigner::from_seed(7);
let (payload, _sig, _pk) =
response_payload("req-x", "fetch", r#"{"u":"x"}"#, true, "go", &signer);
let d = decode_response_full(&payload).expect("decoded payload");
let (name, args) = (d.tool_name.unwrap(), d.args_json.unwrap());
assert!(verify_wire_response(
&d.request_id,
&name,
&args,
d.approved,
&d.reason,
&d.signer_pk_hex,
&d.signature_hex
));
assert!(!verify_wire_response(
&d.request_id,
&name,
r#"{"u":"EVIL"}"#,
d.approved,
&d.reason,
&d.signer_pk_hex,
&d.signature_hex
));
}
#[test]
fn legacy_v1_response_verifies_but_is_not_executable() {
let signer = ApprovalSigner::from_seed(7);
let canonical = response_canonical_v1("req-old", true, "ok");
let sig = signer.sign(&canonical);
let pk = signer.public_key_bytes();
let v1 = serde_json::json!({
"request_id": "req-old",
"approved": true,
"reason": "ok",
"signed_by": hex_lower(&pk),
"signature_hex": hex_lower(&sig),
})
.to_string()
.into_bytes();
let verified = verify_signed_response(&v1).expect("a valid v1 signature still verifies");
assert!(verified.approved, "v1 decision is readable for forensics");
assert!(verified.tool_name.is_none());
assert!(
!verified.is_executable_approval(),
"v1 must NOT authorize execution (re-prompt, fail closed)"
);
assert!(!verify_wire_response(
"req-old",
"any",
"{}",
true,
"ok",
&hex_lower(&pk),
&hex_lower(&sig)
));
}
#[test]
fn injected_version_on_v1_signed_response_fails() {
let signer = ApprovalSigner::from_seed(7);
let canonical = response_canonical_v1("req-old", true, "ok");
let sig = signer.sign(&canonical);
let pk = signer.public_key_bytes();
let full = serde_json::json!({
"request_id": "req-old",
"approved": true,
"reason": "ok",
"signed_by": hex_lower(&pk),
"signature_hex": hex_lower(&sig),
});
for injected in [
Value::from(99_u64), Value::from(1_u64), Value::String("2".to_owned()), ] {
let mut tampered = full.clone();
tampered["version"] = injected;
assert!(
verify_signed_response(&tampered.to_string().into_bytes()).is_none(),
"a writer-chosen version key must never verify"
);
}
assert!(verify_signed_response(&full.to_string().into_bytes()).is_some());
}
#[test]
fn request_payload_round_trips_id() {
let bytes = request_payload("call-7", "rm", r#"{"path":"/etc"}"#);
assert_eq!(decode_request_id(&bytes).as_deref(), Some("call-7"));
}
#[test]
fn receipt_payload_pins_v2_canonical_shape() {
let signer = ApprovalSigner::from_seed(99);
let (reference, amount, currency, recipient, method, timestamp) = (
"tx-abc",
"0.01",
"USDC",
"0xrecipient",
"tempo",
"2026-06-02T00:00:00Z",
);
let (kind, tool_call_id, approval_pos, approved_args_hash, subject) = (
"outbound_payment_receipt",
"call-1",
"42",
"abcd1234",
"conv-xyz",
);
let expected_canonical = serde_json::json!({
"version": RECEIPT_VERSION,
"kind": kind,
"reference": reference,
"amount": amount,
"currency": currency,
"recipient": recipient,
"method": method,
"timestamp": timestamp,
"tool_call_id": tool_call_id,
"approval_pos": approval_pos,
"approved_args_hash": approved_args_hash,
"subject": subject,
});
let expected_sig = signer.sign(expected_canonical.to_string().as_bytes());
let expected_pk = signer.public_key_bytes();
let expected_full = serde_json::json!({
"version": RECEIPT_VERSION,
"kind": kind,
"reference": reference,
"amount": amount,
"currency": currency,
"recipient": recipient,
"method": method,
"timestamp": timestamp,
"tool_call_id": tool_call_id,
"approval_pos": approval_pos,
"approved_args_hash": approved_args_hash,
"subject": subject,
"signed_by": hex_lower(&expected_pk),
"signature_hex": hex_lower(&expected_sig),
})
.to_string();
let (payload, sig, pk) = receipt_payload(
&ReceiptPayload {
kind,
reference,
amount,
currency,
recipient,
method,
timestamp,
tool_call_id,
approval_pos,
approved_args_hash,
subject,
},
&signer,
);
assert_eq!(
String::from_utf8(payload).unwrap(),
expected_full,
"v2 receipt payload must be byte-identical to the pinned v2 shape"
);
assert_eq!(
sig, expected_sig,
"signature must match the pinned v2 shape"
);
assert_eq!(pk, expected_pk, "public key must be unchanged");
}
#[test]
fn receipt_sign_and_verify_share_one_canonical_source() {
let signer = ApprovalSigner::from_seed(99);
let fields = ReceiptPayload {
kind: "outbound_payment_receipt",
reference: "tx-abc",
amount: "0.01",
currency: "USDC",
recipient: "0xrecipient",
method: "tempo",
timestamp: "2026-06-02T00:00:00Z",
tool_call_id: "call-1",
approval_pos: "42",
approved_args_hash: "abcd1234",
subject: "conv-xyz",
};
let canonical_bytes = fields.canonical_json().to_string().into_bytes();
let expected_sig = signer.sign(&canonical_bytes);
let (_payload, sig, _pk) = receipt_payload(&fields, &signer);
assert_eq!(
sig, expected_sig,
"receipt_payload must sign exactly ReceiptPayload::canonical_json"
);
let (payload, _sig, _pk) = receipt_payload(&fields, &signer);
let verified = verify_signed_receipt(&payload).expect("verifies");
let verified_canonical = ReceiptPayload {
kind: &verified.kind,
reference: &verified.reference,
amount: &verified.amount,
currency: &verified.currency,
recipient: &verified.recipient,
method: &verified.method,
timestamp: &verified.timestamp,
tool_call_id: &verified.tool_call_id,
approval_pos: &verified.approval_pos,
approved_args_hash: &verified.approved_args_hash,
subject: &verified.subject,
}
.canonical_json()
.to_string()
.into_bytes();
assert_eq!(
verified_canonical, canonical_bytes,
"verify path must derive canonical JSON from the same single source"
);
}
#[test]
fn crypto_receipt_payload_signs_and_verifies() {
let signer = ApprovalSigner::from_seed(99);
let (payload, _sig, _pk) = receipt_payload(
&ReceiptPayload {
kind: "outbound_payment_receipt",
reference: "tx-abc",
amount: "0.01",
currency: "USDC",
recipient: "0xrecipient",
method: "tempo",
timestamp: "2026-06-02T00:00:00Z",
tool_call_id: "call-1",
approval_pos: "42",
approved_args_hash: "abcd1234",
subject: "conv-xyz",
},
&signer,
);
let verified =
verify_signed_receipt(&payload).expect("signature verifies on untampered receipt");
assert_eq!(verified.reference, "tx-abc");
assert_eq!(verified.amount, "0.01");
assert_eq!(verified.currency, "USDC");
assert_eq!(verified.recipient, "0xrecipient");
assert_eq!(verified.method, "tempo");
assert_eq!(verified.timestamp, "2026-06-02T00:00:00Z");
assert_eq!(verified.version, RECEIPT_VERSION);
assert_eq!(verified.kind, "outbound_payment_receipt");
assert_eq!(verified.tool_call_id, "call-1");
assert_eq!(verified.approval_pos, "42");
assert_eq!(verified.approved_args_hash, "abcd1234");
assert_eq!(verified.subject, "conv-xyz");
}
#[test]
fn tampered_receipt_fails_verification() {
let signer = ApprovalSigner::from_seed(99);
let (payload, _sig, _pk) = receipt_payload(
&ReceiptPayload {
kind: "outbound_payment_receipt",
reference: "tx-abc",
amount: "0.01",
currency: "USDC",
recipient: "0xrecipient",
method: "tempo",
timestamp: "2026-06-02T00:00:00Z",
tool_call_id: "call-1",
approval_pos: "42",
approved_args_hash: "abcd1234",
subject: "conv-xyz",
},
&signer,
);
let mut v: Value = serde_json::from_slice(&payload).unwrap();
v["amount"] = Value::String("9999.00".to_owned());
let tampered = v.to_string().into_bytes();
assert!(verify_signed_receipt(&tampered).is_none());
}
#[test]
fn tampered_receipt_binding_field_fails_verification() {
let signer = ApprovalSigner::from_seed(99);
let (payload, _sig, _pk) = receipt_payload(
&ReceiptPayload {
kind: "outbound_payment_receipt",
reference: "tx-abc",
amount: "0.01",
currency: "USDC",
recipient: "0xrecipient",
method: "tempo",
timestamp: "2026-06-02T00:00:00Z",
tool_call_id: "call-1",
approval_pos: "42",
approved_args_hash: "abcd1234",
subject: "conv-xyz",
},
&signer,
);
let mut v: Value = serde_json::from_slice(&payload).unwrap();
v["approval_pos"] = Value::String("7".to_owned());
let tampered = v.to_string().into_bytes();
assert!(verify_signed_receipt(&tampered).is_none());
let mut v: Value = serde_json::from_slice(&payload).unwrap();
v["kind"] = Value::String("payment_receipt".to_owned());
let refiled = v.to_string().into_bytes();
assert!(verify_signed_receipt(&refiled).is_none());
}
#[test]
fn legacy_v1_receipt_still_verifies() {
let signer = ApprovalSigner::from_seed(99);
let canonical = serde_json::json!({
"reference": "tx-old",
"amount": "0.02",
"currency": "USDC",
"recipient": "0xr",
"method": "tempo",
"timestamp": "2026-06-01T00:00:00Z",
});
let sig = signer.sign(canonical.to_string().as_bytes());
let pk = signer.public_key_bytes();
let v1 = serde_json::json!({
"reference": "tx-old",
"amount": "0.02",
"currency": "USDC",
"recipient": "0xr",
"method": "tempo",
"timestamp": "2026-06-01T00:00:00Z",
"signed_by": hex_lower(&pk),
"signature_hex": hex_lower(&sig),
})
.to_string()
.into_bytes();
let verified = verify_signed_receipt(&v1).expect("a valid v1 receipt still verifies");
assert_eq!(verified.version, 1);
assert_eq!(verified.reference, "tx-old");
assert!(verified.kind.is_empty());
assert!(verified.tool_call_id.is_empty());
assert!(verified.approval_pos.is_empty());
assert!(verified.subject.is_empty());
}
#[test]
fn injected_version_on_v1_signed_receipt_fails() {
let signer = ApprovalSigner::from_seed(99);
let canonical = serde_json::json!({
"reference": "tx-old",
"amount": "0.02",
"currency": "USDC",
"recipient": "0xr",
"method": "tempo",
"timestamp": "2026-06-01T00:00:00Z",
});
let sig = signer.sign(canonical.to_string().as_bytes());
let pk = signer.public_key_bytes();
let mut full = canonical;
full["signed_by"] = Value::String(hex_lower(&pk));
full["signature_hex"] = Value::String(hex_lower(&sig));
for injected in [
Value::from(7_u64),
Value::from(1_u64),
Value::String("2".to_owned()),
] {
let mut tampered = full.clone();
tampered["version"] = injected;
assert!(
verify_signed_receipt(&tampered.to_string().into_bytes()).is_none(),
"a writer-chosen version key must never verify"
);
}
assert!(verify_signed_receipt(&full.to_string().into_bytes()).is_some());
}
}