polyc_crypto/approval.rs
1//! HITL approval payload encoders/decoders and signer.
2//!
3//! The approval flow persists two event payloads in the conversation eventlog:
4//!
5//! * `approval_request` — minted when `polyc_agent::run_turn` surfaces a
6//! pending tool call that needs human consent.
7//! * `approval_response` — minted by `ApprovalService::Respond` (or the
8//! `POLYCHROME_APPROVE_ALL` auto-approve fast path) carrying a signed
9//! decision.
10//!
11//! The encoders / decoders live in `polyc-crypto` (this crate) rather
12//! than the control-plane binary so the harness pod can verify inbound
13//! `approval_response` payloads on its own — the harness sits in a sandbox
14//! and shouldn't trust the wire blindly.
15
16use std::sync::Arc;
17
18use serde_json::Value;
19
20use crate::{Signer, verify};
21
22/// Ed25519 signer that mints provenance signatures for `approval_response`
23/// payloads. Wraps [`Signer`] in an `Arc` so the gRPC service and the
24/// `connect` path share one instance.
25#[derive(Clone)]
26pub struct ApprovalSigner {
27 inner: Arc<Signer>,
28}
29
30impl Default for ApprovalSigner {
31 /// Deterministic seed (V1 only). Production wires this to a secret
32 /// store; the `Default` is here purely so callers can compose.
33 fn default() -> Self {
34 Self::from_seed(1)
35 }
36}
37
38impl ApprovalSigner {
39 /// Build a signer from a deterministic seed (dev / tests). Production
40 /// keys come from a secret store; that wiring is a follow-up.
41 #[must_use]
42 pub fn from_seed(seed: u64) -> Self {
43 Self {
44 inner: Arc::new(Signer::from_seed(seed)),
45 }
46 }
47
48 /// Encoded public key bytes; clients verify approval signatures against
49 /// these.
50 #[must_use]
51 pub fn public_key_bytes(&self) -> Vec<u8> {
52 self.inner.public_key_bytes()
53 }
54
55 /// Sign the canonical bytes of a response payload. The canonical bytes
56 /// are the JSON encoding with the `signature_hex` and `signed_by` fields
57 /// cleared — same shape as [`crate::toolcall`] (signature commits to
58 /// everything except itself).
59 #[must_use]
60 pub fn sign(&self, canonical_bytes: &[u8]) -> Vec<u8> {
61 self.inner.sign(canonical_bytes)
62 }
63}
64
65/// JSON payload for an `approval_request` event.
66///
67/// `request_id` is the model's tool-call id — stable for the lifetime of the
68/// turn and used by `ApprovalService::Respond` to address the matching
69/// response.
70#[must_use]
71pub fn request_payload(request_id: &str, tool_name: &str, args_json: &str) -> Vec<u8> {
72 serde_json::json!({
73 "tool_name": tool_name,
74 "args_json": args_json,
75 "request_id": request_id,
76 })
77 .to_string()
78 .into_bytes()
79}
80
81/// Current signed `approval_response` schema version.
82///
83/// **v2** binds the approval to the request identity by covering `tool_name` +
84/// `args_json` in the signature, closing the call-id-only inheritance gap (#141).
85/// **v1** (legacy, no `version` field) signed only `{request_id, approved,
86/// reason}` and so cannot authorize execution under v2 rules — it verifies for
87/// forensics/display but is treated as **not approved** by the execution gate
88/// (fail closed, re-prompt).
89pub const RESPONSE_VERSION: u64 = 2;
90
91/// The single source for the **v2** signed canonical JSON. Both the signing path
92/// ([`response_payload`]) and the verifying paths ([`verify_signed_response`],
93/// [`verify_wire_response`]) route through this so the covered field set/order
94/// cannot drift. Adding/renaming/reordering here is a signed-contract change.
95fn response_canonical_v2(
96 request_id: &str,
97 tool_name: &str,
98 args_json: &str,
99 approved: bool,
100 reason: &str,
101) -> Vec<u8> {
102 serde_json::json!({
103 "version": RESPONSE_VERSION,
104 "request_id": request_id,
105 "tool_name": tool_name,
106 "args_json": args_json,
107 "approved": approved,
108 "reason": reason,
109 })
110 .to_string()
111 .into_bytes()
112}
113
114/// Legacy **v1** signed canonical (no `version`, no `tool_name`/`args_json`).
115/// Retained only so previously-persisted responses still *verify* (for forensics
116/// / display); v1 never authorizes execution under the v2 binding.
117fn response_canonical_v1(request_id: &str, approved: bool, reason: &str) -> Vec<u8> {
118 serde_json::json!({
119 "request_id": request_id,
120 "approved": approved,
121 "reason": reason,
122 })
123 .to_string()
124 .into_bytes()
125}
126
127/// JSON payload for an `approval_response` event (schema **v2**).
128///
129/// The signature commits to the canonical (unsigned) JSON form — now including
130/// `version`, `tool_name`, and `args_json` so an approval is bound to the exact
131/// `(request_id, tool_name, args_json)` the human authorized; a re-emitted
132/// same-id call with different args/tool no longer inherits it. `signed_by` and
133/// `signature_hex` are populated *after* the signer runs and are NOT covered by
134/// the signature.
135///
136/// Returns `(full_payload_bytes, signature_bytes, public_key_bytes)`.
137#[must_use]
138pub fn response_payload(
139 request_id: &str,
140 tool_name: &str,
141 args_json: &str,
142 approved: bool,
143 reason: &str,
144 signer: &ApprovalSigner,
145) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
146 let canonical_bytes = response_canonical_v2(request_id, tool_name, args_json, approved, reason);
147 let signature = signer.sign(&canonical_bytes);
148 let pk = signer.public_key_bytes();
149 let full = serde_json::json!({
150 "version": RESPONSE_VERSION,
151 "request_id": request_id,
152 "tool_name": tool_name,
153 "args_json": args_json,
154 "approved": approved,
155 "reason": reason,
156 "signed_by": hex_lower(&pk),
157 "signature_hex": hex_lower(&signature),
158 });
159 (full.to_string().into_bytes(), signature, pk)
160}
161
162/// A decoded `approval_response` payload after signature verification.
163#[derive(Debug, Clone)]
164pub struct VerifiedResponse {
165 /// The tool-call id this response answers.
166 pub request_id: String,
167 /// The bound tool name (v2 only; `None` for legacy v1 — not executable).
168 pub tool_name: Option<String>,
169 /// The bound `args_json` (v2 only; `None` for legacy v1 — not executable).
170 pub args_json: Option<String>,
171 /// Whether the request was approved.
172 pub approved: bool,
173 /// Free-form human-supplied reason.
174 pub reason: String,
175 /// The verified signer's public key (encoded).
176 pub signer_public_key: Vec<u8>,
177}
178
179impl VerifiedResponse {
180 /// Whether this response can authorize **execution**: it must be v2 (carry a
181 /// signed `tool_name` + `args_json`) and be approved. Legacy v1 responses
182 /// return `false` so the gate re-prompts (fail closed).
183 #[must_use]
184 pub const fn is_executable_approval(&self) -> bool {
185 self.approved && self.tool_name.is_some() && self.args_json.is_some()
186 }
187}
188
189/// Verify a persisted `approval_response` payload.
190///
191/// Returns `Some(record)` if the signature checks out against the embedded
192/// public key (caller is responsible for trusting that public key — in V1
193/// we trust any signed response; in production a key allow-list lives
194/// alongside this).
195///
196/// Returns `None` if the payload is malformed, the hex fields don't decode,
197/// or the signature doesn't verify.
198#[must_use]
199pub fn verify_signed_response(payload: &[u8]) -> Option<VerifiedResponse> {
200 let v: Value = serde_json::from_slice(payload).ok()?;
201 let request_id = v.get("request_id")?.as_str()?.to_owned();
202 let approved = v.get("approved")?.as_bool()?;
203 let reason = v.get("reason")?.as_str()?.to_owned();
204 let signed_by_hex = v.get("signed_by")?.as_str()?;
205 let signature_hex = v.get("signature_hex")?.as_str()?;
206 let pk = hex_decode(signed_by_hex)?;
207 let sig = hex_decode(signature_hex)?;
208
209 // Version-aware: v2 binds tool_name + args_json into the signature; legacy v1
210 // (no version) signed only id/approved/reason. Verify against the matching
211 // canonical so old persisted responses still verify (for forensics), but only
212 // v2 carries the executable (tool_name, args_json) binding.
213 //
214 // A legacy response carries no `version` key; treat it as v1. A present
215 // `version` must equal the exact current version — any other value (an
216 // explicit `1`, a future `3`, or a non-integer) is refused outright rather
217 // than verified against a guessed canonical. The v1 canonical does not cover
218 // the `version` key, so dispatching an unknown claimed version to v1 would
219 // let a valid v1 signature verify under the weaker (unbound) rules while the
220 // output echoed an unsigned, writer-chosen version — fail closed instead,
221 // exactly as `verify_signed_receipt` does.
222 let version = match v.get("version") {
223 None => 1,
224 Some(n) if n.as_u64() == Some(RESPONSE_VERSION) => RESPONSE_VERSION,
225 Some(_) => return None,
226 };
227 if version == RESPONSE_VERSION {
228 let tool_name = v.get("tool_name")?.as_str()?.to_owned();
229 let args_json = v.get("args_json")?.as_str()?.to_owned();
230 let canonical =
231 response_canonical_v2(&request_id, &tool_name, &args_json, approved, &reason);
232 if verify(&pk, &canonical, &sig) {
233 return Some(VerifiedResponse {
234 request_id,
235 tool_name: Some(tool_name),
236 args_json: Some(args_json),
237 approved,
238 reason,
239 signer_public_key: pk,
240 });
241 }
242 return None;
243 }
244 // Legacy v1.
245 let canonical = response_canonical_v1(&request_id, approved, &reason);
246 if verify(&pk, &canonical, &sig) {
247 Some(VerifiedResponse {
248 request_id,
249 tool_name: None,
250 args_json: None,
251 approved,
252 reason,
253 signer_public_key: pk,
254 })
255 } else {
256 None
257 }
258}
259
260/// Verify a wire-form **v2** `approval_response`, binding the approval to its
261/// `(request_id, tool_name, args_json)` identity.
262///
263/// Used by the harness when it receives `HarnessMessage.approval_responses` over
264/// the wire and must confirm provenance AND identity before executing the paused
265/// tool. Returns `true` only if `signer_pk_hex + signature_hex` validates against
266/// the v2 canonical `(version, request_id, tool_name, args_json, approved,
267/// reason)`. Legacy v1 responses (which carry no signed tool/args) never validate
268/// here, so the gate re-prompts for them — fail closed.
269#[must_use]
270pub fn verify_wire_response(
271 request_id: &str,
272 tool_name: &str,
273 args_json: &str,
274 approved: bool,
275 reason: &str,
276 signer_pk_hex: &str,
277 signature_hex: &str,
278) -> bool {
279 let Some(pk) = hex_decode(signer_pk_hex) else {
280 return false;
281 };
282 let Some(sig) = hex_decode(signature_hex) else {
283 return false;
284 };
285 let canonical = response_canonical_v2(request_id, tool_name, args_json, approved, reason);
286 verify(&pk, &canonical, &sig)
287}
288
289/// Extract `(request_id, approved)` from an `approval_response` payload.
290///
291/// Used by replay to find which pending requests have been answered. Skips
292/// signature verification on the assumption the caller has already accepted
293/// the entry — pair with [`verify_signed_response`] when trust matters.
294#[must_use]
295pub fn decode_response_minimal(payload: &[u8]) -> Option<(String, bool)> {
296 let v: Value = serde_json::from_slice(payload).ok()?;
297 let request_id = v.get("request_id")?.as_str()?.to_owned();
298 let approved = v.get("approved")?.as_bool()?;
299 Some((request_id, approved))
300}
301
302/// Every decoded field of an `approval_response` payload (unverified). `tool_name`
303/// / `args_json` are present only for v2 payloads.
304#[derive(Debug, Clone)]
305pub struct DecodedResponse {
306 /// Tool-call id this response answers.
307 pub request_id: String,
308 /// Bound tool name (v2 only).
309 pub tool_name: Option<String>,
310 /// Bound `args_json` (v2 only).
311 pub args_json: Option<String>,
312 /// Approve / deny decision.
313 pub approved: bool,
314 /// Human-supplied reason.
315 pub reason: String,
316 /// Signer public key, hex.
317 pub signer_pk_hex: String,
318 /// Signature, hex.
319 pub signature_hex: String,
320}
321
322/// Decode every field of an `approval_response` payload without verifying.
323///
324/// Used by the control plane to forward signed responses onto the harness wire;
325/// the harness re-verifies on receipt. For v1 payloads, `tool_name`/`args_json`
326/// are `None`.
327#[must_use]
328pub fn decode_response_full(payload: &[u8]) -> Option<DecodedResponse> {
329 let v: Value = serde_json::from_slice(payload).ok()?;
330 Some(DecodedResponse {
331 request_id: v.get("request_id")?.as_str()?.to_owned(),
332 tool_name: v
333 .get("tool_name")
334 .and_then(Value::as_str)
335 .map(str::to_owned),
336 args_json: v
337 .get("args_json")
338 .and_then(Value::as_str)
339 .map(str::to_owned),
340 approved: v.get("approved")?.as_bool()?,
341 reason: v.get("reason")?.as_str()?.to_owned(),
342 signer_pk_hex: v.get("signed_by")?.as_str()?.to_owned(),
343 signature_hex: v.get("signature_hex")?.as_str()?.to_owned(),
344 })
345}
346
347/// Current signed `payment_receipt` schema version.
348///
349/// **v2** makes a settled receipt self-describing: alongside the original
350/// settlement facts it covers the event `kind` (direction — without it an
351/// inbound payload could be re-filed under the outbound kind, or vice versa,
352/// since the stored `Event.kind` is not itself signed), the binding fields
353/// (`tool_call_id`, `approval_pos`, `approved_args_hash`), and an opaque
354/// `subject` — so an auditor can bind the receipt to the exact approved tool
355/// call it answered without walking the log to the separate
356/// `outbound_payment_attempt` event. **v1** (legacy, no `version` field) signed
357/// only the six settlement facts; it still *verifies* for forensics but carries
358/// no kind or binding tuple.
359pub const RECEIPT_VERSION: u64 = 2;
360
361/// The receipt body fields [`receipt_payload`] signs, named at the call site.
362///
363/// Replaces positional `&str` arguments: with the positional form, two
364/// same-typed fields (e.g. `recipient` and `method`) could be swapped at a call
365/// site and still compile, silently signing a corrupt receipt. Naming the
366/// fields here makes such a swap a compile error.
367///
368/// The field set, names, and the order they are serialized in
369/// [`receipt_payload`] are the signed-payload contract: they must NOT change
370/// without a version bump, or previously-persisted receipts stop verifying.
371///
372/// `kind` and the trailing four fields are **v2** additions: the event kind
373/// (direction) plus the binding tuple and an opaque `subject`. Inbound (the
374/// server was *paid*) receipts have no approved tool call, so they pass empty
375/// strings for the binding tuple and subject; outbound (the control plane
376/// *paid* a 402 service) receipts populate them. Both directions sign their
377/// `kind`.
378#[derive(Debug, Clone, Copy)]
379pub struct ReceiptPayload<'a> {
380 /// The event kind the payload is stored under (`payment_receipt` for
381 /// inbound, `outbound_payment_receipt` for outbound). Signed so a payload
382 /// cannot be re-filed under the other direction's kind (v2; empty for
383 /// legacy v1).
384 pub kind: &'a str,
385 /// Chain/transaction reference (e.g. tx id) the receipt settles.
386 pub reference: &'a str,
387 /// Decimal amount as a string (avoids float drift).
388 pub amount: &'a str,
389 /// Currency / asset symbol.
390 pub currency: &'a str,
391 /// Recipient address.
392 pub recipient: &'a str,
393 /// Settlement method (e.g. `tempo`).
394 pub method: &'a str,
395 /// RFC3339 settlement timestamp.
396 pub timestamp: &'a str,
397 /// The `paid_fetch` tool-call id this payment answered (v2; empty for
398 /// inbound and legacy v1).
399 pub tool_call_id: &'a str,
400 /// Decimal string of the `approval_request` log position the payment
401 /// answered (v2; empty for inbound and legacy v1).
402 pub approval_pos: &'a str,
403 /// sha256 hex of the approved `args_json` — the same idempotency-key
404 /// component the `outbound_payment_attempt` marker carries, so a verifier
405 /// can cross-check the receipt against the attempt (v2; empty otherwise).
406 pub approved_args_hash: &'a str,
407 /// Opaque principal the spend is attributed to. Currently the conversation
408 /// id; the structured agent/tenant identity is supplied later by the
409 /// declarative-catalog identity model. Treat as opaque (v2; empty otherwise).
410 pub subject: &'a str,
411}
412
413impl ReceiptPayload<'_> {
414 /// Build the canonical (unsigned) **v2** JSON the signature commits to.
415 ///
416 /// This is the SINGLE source for the signed v2 receipt field set and order:
417 /// both the signing path ([`receipt_payload`]) and the verifying path
418 /// ([`verify_signed_receipt`]) route their canonical bytes through here,
419 /// so adding, renaming, or reordering a covered field is a one-line edit
420 /// and the two paths cannot drift. The key set/order is the on-wire
421 /// signed contract and must NOT change without a version bump.
422 #[must_use]
423 fn canonical_json(&self) -> Value {
424 serde_json::json!({
425 "version": RECEIPT_VERSION,
426 "kind": self.kind,
427 "reference": self.reference,
428 "amount": self.amount,
429 "currency": self.currency,
430 "recipient": self.recipient,
431 "method": self.method,
432 "timestamp": self.timestamp,
433 "tool_call_id": self.tool_call_id,
434 "approval_pos": self.approval_pos,
435 "approved_args_hash": self.approved_args_hash,
436 "subject": self.subject,
437 })
438 }
439
440 /// Legacy **v1** canonical (the original six settlement fields, no
441 /// `version`). Retained only so receipts persisted before the v2 binding
442 /// still *verify* for forensics; new receipts always sign v2.
443 #[must_use]
444 fn canonical_json_v1(&self) -> Value {
445 serde_json::json!({
446 "reference": self.reference,
447 "amount": self.amount,
448 "currency": self.currency,
449 "recipient": self.recipient,
450 "method": self.method,
451 "timestamp": self.timestamp,
452 })
453 }
454}
455
456/// JSON payload for a `payment_receipt` event.
457///
458/// Mirrors [`response_payload`] exactly: the signature commits to the
459/// canonical (unsigned) JSON form of the receipt body
460/// (`reference`, `amount`, `currency`, `recipient`, `method`, `timestamp`).
461/// `signed_by` and `signature_hex` are populated *after* the signer runs and
462/// are NOT covered by the signature itself. Tampering with any body field
463/// invalidates the signature.
464///
465/// The fields arrive as a single named [`ReceiptPayload`] (rather than six
466/// positional strings) so a call site cannot silently swap two same-typed
467/// fields; the serialized key set/order is unchanged and remains the signed
468/// contract.
469///
470/// Returns `(full_payload_bytes, signature_bytes, public_key_bytes)`.
471#[must_use]
472pub fn receipt_payload(
473 fields: &ReceiptPayload<'_>,
474 signer: &ApprovalSigner,
475) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
476 let mut full = fields.canonical_json();
477 let canonical_bytes = full.to_string().into_bytes();
478 let signature = signer.sign(&canonical_bytes);
479 let pk = signer.public_key_bytes();
480 // The full payload is the canonical body plus the two signature fields,
481 // which are NOT covered by the signature. Append them to the single-source
482 // canonical object so the body field set still lives only in
483 // `ReceiptPayload::canonical_json`.
484 if let Value::Object(map) = &mut full {
485 map.insert("signed_by".to_owned(), Value::String(hex_lower(&pk)));
486 map.insert(
487 "signature_hex".to_owned(),
488 Value::String(hex_lower(&signature)),
489 );
490 }
491 (full.to_string().into_bytes(), signature, pk)
492}
493
494/// A decoded `payment_receipt` payload after signature verification.
495#[derive(Debug, Clone)]
496pub struct VerifiedReceipt {
497 /// Chain/transaction reference (e.g. tx id) the receipt settles.
498 pub reference: String,
499 /// Decimal amount, as a string (avoids float drift).
500 pub amount: String,
501 /// Currency / asset symbol.
502 pub currency: String,
503 /// Recipient address.
504 pub recipient: String,
505 /// Settlement method (e.g. `tempo`).
506 pub method: String,
507 /// RFC3339 settlement timestamp.
508 pub timestamp: String,
509 /// Schema version (`1` = legacy settlement-only, `2` = kind + binding
510 /// tuple present).
511 pub version: u64,
512 /// The signed event kind (`payment_receipt` or `outbound_payment_receipt`).
513 /// Callers should check it matches the kind the event was stored under —
514 /// the stored kind itself is not signed (v2; empty for v1).
515 pub kind: String,
516 /// The `paid_fetch` tool-call id this payment answered (v2; empty for v1).
517 pub tool_call_id: String,
518 /// Decimal string of the `approval_request` log position (v2; empty for v1).
519 pub approval_pos: String,
520 /// sha256 hex of the approved `args_json` (v2; empty for v1).
521 pub approved_args_hash: String,
522 /// Opaque principal the spend is attributed to (v2; empty for v1).
523 pub subject: String,
524 /// The verified signer's public key (encoded).
525 pub signer_public_key: Vec<u8>,
526}
527
528/// Verify a persisted `payment_receipt` payload.
529///
530/// Returns `Some(record)` if the signature checks out against the embedded
531/// public key (caller trusts that key; V1 trusts any signed receipt, like
532/// [`verify_signed_response`] — a production key allow-list is a follow-up).
533///
534/// Returns `None` if the payload is malformed, the hex fields don't decode,
535/// or the signature doesn't verify.
536#[must_use]
537pub fn verify_signed_receipt(payload: &[u8]) -> Option<VerifiedReceipt> {
538 let v: Value = serde_json::from_slice(payload).ok()?;
539 let reference = v.get("reference")?.as_str()?.to_owned();
540 let amount = v.get("amount")?.as_str()?.to_owned();
541 let currency = v.get("currency")?.as_str()?.to_owned();
542 let recipient = v.get("recipient")?.as_str()?.to_owned();
543 let method = v.get("method")?.as_str()?.to_owned();
544 let timestamp = v.get("timestamp")?.as_str()?.to_owned();
545 let signed_by_hex = v.get("signed_by")?.as_str()?;
546 let signature_hex = v.get("signature_hex")?.as_str()?;
547 let pk = hex_decode(signed_by_hex)?;
548 let sig = hex_decode(signature_hex)?;
549 // A legacy receipt carries no `version` key; treat it as v1. A present
550 // `version` must equal the exact current version — any other value
551 // (including an explicit `1`, a future `3`, or a non-integer) is refused
552 // outright rather than verified against a guessed canonical. The v1
553 // canonical does not cover the `version` key, so dispatching an unknown
554 // claimed version to v1 would let a valid v1 signature verify while the
555 // output echoed an unsigned, writer-chosen version — fail closed instead.
556 let version = match v.get("version") {
557 None => 1,
558 Some(n) if n.as_u64() == Some(RECEIPT_VERSION) => RECEIPT_VERSION,
559 Some(_) => return None,
560 };
561 let (kind, tool_call_id, approval_pos, approved_args_hash, subject) =
562 if version == RECEIPT_VERSION {
563 (
564 v.get("kind")?.as_str()?.to_owned(),
565 v.get("tool_call_id")?.as_str()?.to_owned(),
566 v.get("approval_pos")?.as_str()?.to_owned(),
567 v.get("approved_args_hash")?.as_str()?.to_owned(),
568 v.get("subject")?.as_str()?.to_owned(),
569 )
570 } else {
571 (
572 String::new(),
573 String::new(),
574 String::new(),
575 String::new(),
576 String::new(),
577 )
578 };
579 // Rebuild the canonical bytes via the SAME single source the signer used,
580 // so the verify path can never check a different field set/order.
581 let fields = ReceiptPayload {
582 kind: &kind,
583 reference: &reference,
584 amount: &amount,
585 currency: ¤cy,
586 recipient: &recipient,
587 method: &method,
588 timestamp: ×tamp,
589 tool_call_id: &tool_call_id,
590 approval_pos: &approval_pos,
591 approved_args_hash: &approved_args_hash,
592 subject: &subject,
593 };
594 let canonical_bytes = if version == RECEIPT_VERSION {
595 fields.canonical_json()
596 } else {
597 fields.canonical_json_v1()
598 }
599 .to_string()
600 .into_bytes();
601 if verify(&pk, &canonical_bytes, &sig) {
602 Some(VerifiedReceipt {
603 reference,
604 amount,
605 currency,
606 recipient,
607 method,
608 timestamp,
609 version,
610 kind,
611 tool_call_id,
612 approval_pos,
613 approved_args_hash,
614 subject,
615 signer_public_key: pk,
616 })
617 } else {
618 None
619 }
620}
621
622/// Extract `request_id` from an `approval_request` payload.
623#[must_use]
624pub fn decode_request_id(payload: &[u8]) -> Option<String> {
625 let v: Value = serde_json::from_slice(payload).ok()?;
626 Some(v.get("request_id")?.as_str()?.to_owned())
627}
628
629/// Extract `(request_id, tool_name, args_json)` from an `approval_request`
630/// payload — the fields a v2 `approval_response` must sign to bind the approval
631/// to the request identity.
632#[must_use]
633pub fn decode_request_fields(payload: &[u8]) -> Option<(String, String, String)> {
634 let v: Value = serde_json::from_slice(payload).ok()?;
635 Some((
636 v.get("request_id")?.as_str()?.to_owned(),
637 v.get("tool_name")?.as_str()?.to_owned(),
638 v.get("args_json")?.as_str()?.to_owned(),
639 ))
640}
641
642fn hex_lower(bytes: &[u8]) -> String {
643 let mut s = String::with_capacity(bytes.len() * 2);
644 for b in bytes {
645 use std::fmt::Write as _;
646 let _ = write!(&mut s, "{b:02x}");
647 }
648 s
649}
650
651fn hex_decode(s: &str) -> Option<Vec<u8>> {
652 if !s.len().is_multiple_of(2) {
653 return None;
654 }
655 (0..s.len())
656 .step_by(2)
657 .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
658 .collect()
659}
660
661#[cfg(test)]
662mod tests {
663 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
664
665 use super::*;
666
667 #[test]
668 fn signed_response_round_trips() {
669 let signer = ApprovalSigner::from_seed(42);
670 let (payload, _sig, _pk) = response_payload(
671 "req-1",
672 "rm",
673 r#"{"path":"/etc"}"#,
674 true,
675 "looks fine",
676 &signer,
677 );
678 let verified =
679 verify_signed_response(&payload).expect("signature verifies on untampered payload");
680 assert!(verified.approved);
681 assert_eq!(verified.reason, "looks fine");
682 assert_eq!(verified.request_id, "req-1");
683 assert_eq!(verified.tool_name.as_deref(), Some("rm"));
684 assert_eq!(verified.args_json.as_deref(), Some(r#"{"path":"/etc"}"#));
685 assert!(verified.is_executable_approval());
686 }
687
688 #[test]
689 fn tampered_response_fails_verification() {
690 let signer = ApprovalSigner::from_seed(42);
691 let (payload, _sig, _pk) =
692 response_payload("req-1", "rm", "{}", true, "looks fine", &signer);
693 // Tampering with any signed field (approved, tool_name, or args_json)
694 // invalidates the signature.
695 for field in ["approved", "tool_name", "args_json"] {
696 let mut v: Value = serde_json::from_slice(&payload).unwrap();
697 v[field] = if field == "approved" {
698 Value::Bool(false)
699 } else {
700 Value::String("EVIL".to_owned())
701 };
702 assert!(
703 verify_signed_response(&v.to_string().into_bytes()).is_none(),
704 "tampering with {field} must fail verification"
705 );
706 }
707 }
708
709 #[test]
710 fn wire_verification_round_trips_with_hex_fields() {
711 let signer = ApprovalSigner::from_seed(7);
712 let (payload, _sig, _pk) =
713 response_payload("req-x", "fetch", r#"{"u":"x"}"#, true, "go", &signer);
714 let d = decode_response_full(&payload).expect("decoded payload");
715 let (name, args) = (d.tool_name.unwrap(), d.args_json.unwrap());
716 assert!(verify_wire_response(
717 &d.request_id,
718 &name,
719 &args,
720 d.approved,
721 &d.reason,
722 &d.signer_pk_hex,
723 &d.signature_hex
724 ));
725 // Tampering with the bound args over the wire invalidates the sig.
726 assert!(!verify_wire_response(
727 &d.request_id,
728 &name,
729 r#"{"u":"EVIL"}"#,
730 d.approved,
731 &d.reason,
732 &d.signer_pk_hex,
733 &d.signature_hex
734 ));
735 }
736
737 /// Migration: a legacy **v1** `approval_response` (no version/tool/args) still
738 /// VERIFIES (for forensics/display) but is **not executable** — the execution
739 /// gate must re-prompt rather than inherit it.
740 #[test]
741 fn legacy_v1_response_verifies_but_is_not_executable() {
742 let signer = ApprovalSigner::from_seed(7);
743 // Reconstruct the exact v1 payload the old code produced.
744 let canonical = response_canonical_v1("req-old", true, "ok");
745 let sig = signer.sign(&canonical);
746 let pk = signer.public_key_bytes();
747 let v1 = serde_json::json!({
748 "request_id": "req-old",
749 "approved": true,
750 "reason": "ok",
751 "signed_by": hex_lower(&pk),
752 "signature_hex": hex_lower(&sig),
753 })
754 .to_string()
755 .into_bytes();
756
757 let verified = verify_signed_response(&v1).expect("a valid v1 signature still verifies");
758 assert!(verified.approved, "v1 decision is readable for forensics");
759 assert!(verified.tool_name.is_none());
760 assert!(
761 !verified.is_executable_approval(),
762 "v1 must NOT authorize execution (re-prompt, fail closed)"
763 );
764 // And a v1 payload never satisfies the v2 wire verification.
765 assert!(!verify_wire_response(
766 "req-old",
767 "any",
768 "{}",
769 true,
770 "ok",
771 &hex_lower(&pk),
772 &hex_lower(&sig)
773 ));
774 }
775
776 #[test]
777 fn injected_version_on_v1_signed_response_fails() {
778 // A writer-chosen `version` on an otherwise-valid v1 response must be
779 // refused outright (fail closed), never verified against the v1
780 // canonical — mirrors `injected_version_on_v1_signed_receipt_fails`, so
781 // the two signed-schema verifiers can't drift in strictness.
782 let signer = ApprovalSigner::from_seed(7);
783 let canonical = response_canonical_v1("req-old", true, "ok");
784 let sig = signer.sign(&canonical);
785 let pk = signer.public_key_bytes();
786 let full = serde_json::json!({
787 "request_id": "req-old",
788 "approved": true,
789 "reason": "ok",
790 "signed_by": hex_lower(&pk),
791 "signature_hex": hex_lower(&sig),
792 });
793
794 for injected in [
795 Value::from(99_u64), // unknown future version
796 Value::from(1_u64), // explicit v1 (still must be absent)
797 Value::String("2".to_owned()), // non-integer claiming current
798 ] {
799 let mut tampered = full.clone();
800 tampered["version"] = injected;
801 assert!(
802 verify_signed_response(&tampered.to_string().into_bytes()).is_none(),
803 "a writer-chosen version key must never verify"
804 );
805 }
806 // Sanity: without the injected key the same payload verifies as v1.
807 assert!(verify_signed_response(&full.to_string().into_bytes()).is_some());
808 }
809
810 #[test]
811 fn request_payload_round_trips_id() {
812 let bytes = request_payload("call-7", "rm", r#"{"path":"/etc"}"#);
813 assert_eq!(decode_request_id(&bytes).as_deref(), Some("call-7"));
814 }
815
816 /// Golden test: `receipt_payload` must produce the EXACT **v2** signed-payload
817 /// bytes — `version` + the six settlement facts + the four binding fields,
818 /// in that order — plus the two uncovered signature fields. We recompute the
819 /// expected canonical+full JSON inline and assert the struct form is
820 /// byte-identical, pinning the v2 key set, order, and signature so a future
821 /// edit cannot silently reorder, rename, or swap a covered field. Recomputing
822 /// inline (rather than hardcoding bytes) keeps the golden stable across
823 /// `serde_json` feature unification (e.g. `preserve_order`), which only flips
824 /// key order — what matters is that both forms agree under whatever ordering
825 /// is in effect.
826 #[test]
827 fn receipt_payload_pins_v2_canonical_shape() {
828 let signer = ApprovalSigner::from_seed(99);
829 let (reference, amount, currency, recipient, method, timestamp) = (
830 "tx-abc",
831 "0.01",
832 "USDC",
833 "0xrecipient",
834 "tempo",
835 "2026-06-02T00:00:00Z",
836 );
837 let (kind, tool_call_id, approval_pos, approved_args_hash, subject) = (
838 "outbound_payment_receipt",
839 "call-1",
840 "42",
841 "abcd1234",
842 "conv-xyz",
843 );
844
845 // The exact v2 JSON the implementation must build, recomputed here.
846 let expected_canonical = serde_json::json!({
847 "version": RECEIPT_VERSION,
848 "kind": kind,
849 "reference": reference,
850 "amount": amount,
851 "currency": currency,
852 "recipient": recipient,
853 "method": method,
854 "timestamp": timestamp,
855 "tool_call_id": tool_call_id,
856 "approval_pos": approval_pos,
857 "approved_args_hash": approved_args_hash,
858 "subject": subject,
859 });
860 let expected_sig = signer.sign(expected_canonical.to_string().as_bytes());
861 let expected_pk = signer.public_key_bytes();
862 let expected_full = serde_json::json!({
863 "version": RECEIPT_VERSION,
864 "kind": kind,
865 "reference": reference,
866 "amount": amount,
867 "currency": currency,
868 "recipient": recipient,
869 "method": method,
870 "timestamp": timestamp,
871 "tool_call_id": tool_call_id,
872 "approval_pos": approval_pos,
873 "approved_args_hash": approved_args_hash,
874 "subject": subject,
875 "signed_by": hex_lower(&expected_pk),
876 "signature_hex": hex_lower(&expected_sig),
877 })
878 .to_string();
879
880 let (payload, sig, pk) = receipt_payload(
881 &ReceiptPayload {
882 kind,
883 reference,
884 amount,
885 currency,
886 recipient,
887 method,
888 timestamp,
889 tool_call_id,
890 approval_pos,
891 approved_args_hash,
892 subject,
893 },
894 &signer,
895 );
896
897 assert_eq!(
898 String::from_utf8(payload).unwrap(),
899 expected_full,
900 "v2 receipt payload must be byte-identical to the pinned v2 shape"
901 );
902 assert_eq!(
903 sig, expected_sig,
904 "signature must match the pinned v2 shape"
905 );
906 assert_eq!(pk, expected_pk, "public key must be unchanged");
907 }
908
909 /// Single-source guard: both the signing path (`receipt_payload`) and the
910 /// verifying path (`verify_signed_receipt`) MUST derive their canonical
911 /// signed JSON from the one [`ReceiptPayload::canonical_json`] builder, so
912 /// the signed field set/order cannot drift between sign and verify.
913 ///
914 /// We assert the canonical bytes the signer commits to are exactly the
915 /// bytes `canonical_json` produces for the same fields, and that a receipt
916 /// reconstructed from `VerifiedReceipt` (the verify path's owned form)
917 /// yields the identical canonical bytes. If a future edit added a field to
918 /// one json! block but not the other, those bytes would differ and this
919 /// (plus the round-trip) would fail.
920 #[test]
921 fn receipt_sign_and_verify_share_one_canonical_source() {
922 let signer = ApprovalSigner::from_seed(99);
923 let fields = ReceiptPayload {
924 kind: "outbound_payment_receipt",
925 reference: "tx-abc",
926 amount: "0.01",
927 currency: "USDC",
928 recipient: "0xrecipient",
929 method: "tempo",
930 timestamp: "2026-06-02T00:00:00Z",
931 tool_call_id: "call-1",
932 approval_pos: "42",
933 approved_args_hash: "abcd1234",
934 subject: "conv-xyz",
935 };
936
937 // The signed bytes the producer commits to.
938 let canonical_bytes = fields.canonical_json().to_string().into_bytes();
939 let expected_sig = signer.sign(&canonical_bytes);
940 let (_payload, sig, _pk) = receipt_payload(&fields, &signer);
941 assert_eq!(
942 sig, expected_sig,
943 "receipt_payload must sign exactly ReceiptPayload::canonical_json"
944 );
945
946 // The verify path reconstructs the same canonical bytes from its owned
947 // form before checking the signature.
948 let (payload, _sig, _pk) = receipt_payload(&fields, &signer);
949 let verified = verify_signed_receipt(&payload).expect("verifies");
950 let verified_canonical = ReceiptPayload {
951 kind: &verified.kind,
952 reference: &verified.reference,
953 amount: &verified.amount,
954 currency: &verified.currency,
955 recipient: &verified.recipient,
956 method: &verified.method,
957 timestamp: &verified.timestamp,
958 tool_call_id: &verified.tool_call_id,
959 approval_pos: &verified.approval_pos,
960 approved_args_hash: &verified.approved_args_hash,
961 subject: &verified.subject,
962 }
963 .canonical_json()
964 .to_string()
965 .into_bytes();
966 assert_eq!(
967 verified_canonical, canonical_bytes,
968 "verify path must derive canonical JSON from the same single source"
969 );
970 }
971
972 #[test]
973 fn crypto_receipt_payload_signs_and_verifies() {
974 let signer = ApprovalSigner::from_seed(99);
975 let (payload, _sig, _pk) = receipt_payload(
976 &ReceiptPayload {
977 kind: "outbound_payment_receipt",
978 reference: "tx-abc",
979 amount: "0.01",
980 currency: "USDC",
981 recipient: "0xrecipient",
982 method: "tempo",
983 timestamp: "2026-06-02T00:00:00Z",
984 tool_call_id: "call-1",
985 approval_pos: "42",
986 approved_args_hash: "abcd1234",
987 subject: "conv-xyz",
988 },
989 &signer,
990 );
991 let verified =
992 verify_signed_receipt(&payload).expect("signature verifies on untampered receipt");
993 assert_eq!(verified.reference, "tx-abc");
994 assert_eq!(verified.amount, "0.01");
995 assert_eq!(verified.currency, "USDC");
996 assert_eq!(verified.recipient, "0xrecipient");
997 assert_eq!(verified.method, "tempo");
998 assert_eq!(verified.timestamp, "2026-06-02T00:00:00Z");
999 // The v2 kind + binding tuple + opaque subject round-trip and are
1000 // covered by the signature.
1001 assert_eq!(verified.version, RECEIPT_VERSION);
1002 assert_eq!(verified.kind, "outbound_payment_receipt");
1003 assert_eq!(verified.tool_call_id, "call-1");
1004 assert_eq!(verified.approval_pos, "42");
1005 assert_eq!(verified.approved_args_hash, "abcd1234");
1006 assert_eq!(verified.subject, "conv-xyz");
1007 }
1008
1009 #[test]
1010 fn tampered_receipt_fails_verification() {
1011 let signer = ApprovalSigner::from_seed(99);
1012 let (payload, _sig, _pk) = receipt_payload(
1013 &ReceiptPayload {
1014 kind: "outbound_payment_receipt",
1015 reference: "tx-abc",
1016 amount: "0.01",
1017 currency: "USDC",
1018 recipient: "0xrecipient",
1019 method: "tempo",
1020 timestamp: "2026-06-02T00:00:00Z",
1021 tool_call_id: "call-1",
1022 approval_pos: "42",
1023 approved_args_hash: "abcd1234",
1024 subject: "conv-xyz",
1025 },
1026 &signer,
1027 );
1028 // Tamper with a covered field (amount).
1029 let mut v: Value = serde_json::from_slice(&payload).unwrap();
1030 v["amount"] = Value::String("9999.00".to_owned());
1031 let tampered = v.to_string().into_bytes();
1032 assert!(verify_signed_receipt(&tampered).is_none());
1033 }
1034
1035 #[test]
1036 fn tampered_receipt_binding_field_fails_verification() {
1037 let signer = ApprovalSigner::from_seed(99);
1038 let (payload, _sig, _pk) = receipt_payload(
1039 &ReceiptPayload {
1040 kind: "outbound_payment_receipt",
1041 reference: "tx-abc",
1042 amount: "0.01",
1043 currency: "USDC",
1044 recipient: "0xrecipient",
1045 method: "tempo",
1046 timestamp: "2026-06-02T00:00:00Z",
1047 tool_call_id: "call-1",
1048 approval_pos: "42",
1049 approved_args_hash: "abcd1234",
1050 subject: "conv-xyz",
1051 },
1052 &signer,
1053 );
1054 // Re-pointing the receipt at a different approval position invalidates
1055 // the signature — the binding tuple is covered, not advisory.
1056 let mut v: Value = serde_json::from_slice(&payload).unwrap();
1057 v["approval_pos"] = Value::String("7".to_owned());
1058 let tampered = v.to_string().into_bytes();
1059 assert!(verify_signed_receipt(&tampered).is_none());
1060
1061 // Re-filing the payload under the other direction's kind likewise
1062 // fails — the signed `kind` is what makes direction trustworthy
1063 // independent of the (unsigned) stored event kind.
1064 let mut v: Value = serde_json::from_slice(&payload).unwrap();
1065 v["kind"] = Value::String("payment_receipt".to_owned());
1066 let refiled = v.to_string().into_bytes();
1067 assert!(verify_signed_receipt(&refiled).is_none());
1068 }
1069
1070 /// A receipt persisted before the v2 binding (no `version`, six fields only)
1071 /// must still verify for forensics, surfacing as `version == 1` with empty
1072 /// binding fields. Mirrors the legacy approval-response path.
1073 #[test]
1074 fn legacy_v1_receipt_still_verifies() {
1075 let signer = ApprovalSigner::from_seed(99);
1076 let canonical = serde_json::json!({
1077 "reference": "tx-old",
1078 "amount": "0.02",
1079 "currency": "USDC",
1080 "recipient": "0xr",
1081 "method": "tempo",
1082 "timestamp": "2026-06-01T00:00:00Z",
1083 });
1084 let sig = signer.sign(canonical.to_string().as_bytes());
1085 let pk = signer.public_key_bytes();
1086 let v1 = serde_json::json!({
1087 "reference": "tx-old",
1088 "amount": "0.02",
1089 "currency": "USDC",
1090 "recipient": "0xr",
1091 "method": "tempo",
1092 "timestamp": "2026-06-01T00:00:00Z",
1093 "signed_by": hex_lower(&pk),
1094 "signature_hex": hex_lower(&sig),
1095 })
1096 .to_string()
1097 .into_bytes();
1098
1099 let verified = verify_signed_receipt(&v1).expect("a valid v1 receipt still verifies");
1100 assert_eq!(verified.version, 1);
1101 assert_eq!(verified.reference, "tx-old");
1102 assert!(verified.kind.is_empty());
1103 assert!(verified.tool_call_id.is_empty());
1104 assert!(verified.approval_pos.is_empty());
1105 assert!(verified.subject.is_empty());
1106 }
1107
1108 /// Injecting a `version` key into a validly-signed legacy receipt must not
1109 /// verify: the v1 canonical does not cover `version`, so dispatching the
1110 /// claimed version to the v1 canonical would let the signature check pass
1111 /// while `VerifiedReceipt.version` echoed an unsigned, writer-chosen value.
1112 /// Only an absent `version` (⇒ 1) or the exact current version is accepted.
1113 #[test]
1114 fn injected_version_on_v1_signed_receipt_fails() {
1115 let signer = ApprovalSigner::from_seed(99);
1116 let canonical = serde_json::json!({
1117 "reference": "tx-old",
1118 "amount": "0.02",
1119 "currency": "USDC",
1120 "recipient": "0xr",
1121 "method": "tempo",
1122 "timestamp": "2026-06-01T00:00:00Z",
1123 });
1124 let sig = signer.sign(canonical.to_string().as_bytes());
1125 let pk = signer.public_key_bytes();
1126 let mut full = canonical;
1127 full["signed_by"] = Value::String(hex_lower(&pk));
1128 full["signature_hex"] = Value::String(hex_lower(&sig));
1129
1130 // A claimed future version, an explicit "1", and a non-integer are all
1131 // refused outright (fail closed) — never verified against a guessed
1132 // canonical.
1133 for injected in [
1134 Value::from(7_u64),
1135 Value::from(1_u64),
1136 Value::String("2".to_owned()),
1137 ] {
1138 let mut tampered = full.clone();
1139 tampered["version"] = injected;
1140 assert!(
1141 verify_signed_receipt(&tampered.to_string().into_bytes()).is_none(),
1142 "a writer-chosen version key must never verify"
1143 );
1144 }
1145 // Sanity: without the injected key the same payload verifies as v1.
1146 assert!(verify_signed_receipt(&full.to_string().into_bytes()).is_some());
1147 }
1148}