Skip to main content

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: &currency,
586        recipient: &recipient,
587        method: &method,
588        timestamp: &timestamp,
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}