Skip to main content

polyc_crypto/
question.rs

1//! `ask_question` (`#1660`) signed-answer canonical, verification, and the
2//! per-question correlation-token contract.
3//!
4//! A **sibling to the HITL approval flow's `approval_response` machinery in
5//! [`crate::approval`], not a reuse of it** — see issue #1660 and its parent
6//! PRD #1659. The trust shape is identical (THIN signing: an edge sends an
7//! *unsigned* answer intent, the control plane resolves and signs
8//! server-side, the harness re-verifies before trusting the answer) but the
9//! signed canonical is its own, with its own field set and its own key names
10//! (`question_call_id`/`question_index`, not `request_id`/`tool_name`), so an
11//! approval signature can never verify as a question answer and vice versa.
12//! The [`ApprovalSigner`] role intentionally covers this human-decision
13//! answer family as well as tool approvals. Session, turn-read, web-session
14//! grants, and journal roots use different roles.
15
16use std::collections::HashSet;
17
18use serde::Serialize;
19use serde_json::Value;
20
21use crate::approval::ApprovalSigner;
22use crate::signed::{Envelope, canonical_bytes};
23use crate::verify;
24
25/// The signed `state` value for a question a human explicitly answered by
26/// picking an option.
27pub const ANSWERED_STATE: &str = "answered";
28/// The signed `state` value for a question a human explicitly declined to
29/// choose ("use your own judgment") — distinct from [`ANSWERED_STATE`] so the
30/// agent never mistakes a decline for a real answer.
31pub const DECLINED_STATE: &str = "declined";
32/// The signed `state` value for a question nobody answered before its idle
33/// window elapsed.
34///
35/// The control plane auto-resolved it to the recommended option (or the
36/// first option if none was marked recommended). Signed and unforgeable, so
37/// the agent is told this was an assumption, not a genuine human answer.
38pub const AUTO_RESOLVED_STATE: &str = "auto_resolved";
39
40/// TTL for a minted answer token (`#1660`).
41///
42/// Mirrors [`crate::approval::RESOLVE_TOKEN_TTL_MS`]'s reasoning: generous
43/// enough that a human has time to see and answer the question card (which
44/// can sit in a chat thread for hours or days), short enough that a token
45/// captured off a stale card cannot answer the question indefinitely.
46pub const ANSWER_TOKEN_TTL_MS: u64 = 24 * 60 * 60 * 1000;
47
48/// The single source for the signed `question_response` canonical JSON. Both
49/// the signing path ([`answer_payload`]) and the verifying paths
50/// ([`verify_signed_answer`], [`verify_wire_answer`]) route through this so
51/// the covered field set/order cannot drift. Adding/renaming/reordering here
52/// is a signed-contract change.
53///
54/// Deliberately uses `question_call_id`/`question_index` rather than the
55/// approval canonical's `request_id`/`tool_name` key names: even though both
56/// canonicals are JSON objects an attacker might try to splice together, no
57/// approval signature can ever verify against this shape (the signed BYTES
58/// differ) and no answer signature can ever verify as an approval.
59///
60/// `question_args_json` is the `ask_question` call's full raw arguments (every
61/// question in the call, not just this one) — the audit binding a verifier
62/// matches against [`VerifiedQuestionAnswer::binds_question`], mirroring how
63/// the approval canonical binds `args_json`. `conversation_id` and `nonce`
64/// make the answer a single-use, conversation-bound capability, exactly like
65/// `#370`'s approval-response token.
66#[allow(clippy::too_many_arguments)] // each arg is a distinct signed field of the canonical contract
67fn answer_canonical(
68    call_id: &str,
69    index: u32,
70    question_args_json: &str,
71    state: &str,
72    selected_index: Option<u32>,
73    selected_label: &str,
74    answered_by: &str,
75    conversation_id: &str,
76    nonce: &str,
77) -> Vec<u8> {
78    canonical_bytes(&answer_fields(
79        call_id,
80        index,
81        question_args_json,
82        state,
83        selected_index,
84        selected_label,
85        answered_by,
86        conversation_id,
87        nonce,
88    ))
89}
90
91/// The signed `question_response` field set, in its frozen order.
92///
93/// Declaration order IS the signed contract — see [`canonical_bytes`] and ADR
94/// 0009. `selected_index` is serialized as an explicit `null` for a decline
95/// rather than skipped, which is what the `json!` object this replaced emitted
96/// and therefore what every already-signed `question_response` in a
97/// deployment's log covers.
98#[derive(Serialize)]
99struct AnswerCanonical<'a> {
100    question_call_id: &'a str,
101    question_index: u32,
102    question_args_json: &'a str,
103    state: &'a str,
104    selected_index: Option<u32>,
105    selected_label: &'a str,
106    answered_by: &'a str,
107    conversation_id: &'a str,
108    nonce: &'a str,
109}
110
111/// The answer body both [`answer_canonical`] and [`answer_payload`] build, so
112/// the signed field list exists once rather than in two hand-maintained copies
113/// (`#1845`).
114#[allow(clippy::too_many_arguments)] // each arg is a distinct signed field of the canonical contract
115const fn answer_fields<'a>(
116    call_id: &'a str,
117    index: u32,
118    question_args_json: &'a str,
119    state: &'a str,
120    selected_index: Option<u32>,
121    selected_label: &'a str,
122    answered_by: &'a str,
123    conversation_id: &'a str,
124    nonce: &'a str,
125) -> AnswerCanonical<'a> {
126    AnswerCanonical {
127        question_call_id: call_id,
128        question_index: index,
129        question_args_json,
130        state,
131        selected_index,
132        selected_label,
133        answered_by,
134        conversation_id,
135        nonce,
136    }
137}
138
139/// JSON payload for a `question_response` event.
140///
141/// The signature commits to the canonical (unsigned) JSON form: the question
142/// identity (`call_id`, `index`, `question_args_json`), the resolution
143/// (`state`/`selected_index`/`selected_label`), who resolved it
144/// (`answered_by`, empty for [`AUTO_RESOLVED_STATE`]), and the
145/// conversation/nonce binding. `signed_by`/`signature_hex` are populated
146/// *after* the signer runs and are NOT covered by the signature.
147///
148/// Returns `(full_payload_bytes, signature_bytes, public_key_bytes)`.
149#[must_use]
150#[allow(clippy::too_many_arguments)] // each arg is a distinct signed field of the canonical contract
151pub fn answer_payload(
152    call_id: &str,
153    index: u32,
154    question_args_json: &str,
155    state: &str,
156    selected_index: Option<u32>,
157    selected_label: &str,
158    answered_by: &str,
159    conversation_id: &str,
160    nonce: &str,
161    signer: &ApprovalSigner,
162) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
163    Envelope::seal(
164        answer_fields(
165            call_id,
166            index,
167            question_args_json,
168            state,
169            selected_index,
170            selected_label,
171            answered_by,
172            conversation_id,
173            nonce,
174        ),
175        signer.as_signer(),
176    )
177}
178
179/// A verified, decoded `question_response` payload.
180#[derive(Debug, Clone, PartialEq, Eq)]
181pub struct VerifiedQuestionAnswer {
182    /// The `ask_question` tool-call id this answer resolves.
183    pub call_id: String,
184    /// This answer's question index within its call's `questions` array.
185    pub index: u32,
186    /// The bound raw `ask_question` call arguments (every question in the
187    /// call) — the audit binding [`Self::binds_question`] matches
188    /// byte-for-byte.
189    pub question_args_json: String,
190    /// One of [`ANSWERED_STATE`], [`DECLINED_STATE`], or
191    /// [`AUTO_RESOLVED_STATE`].
192    pub state: String,
193    /// The chosen option's index, when `state == ANSWERED_STATE` or
194    /// `AUTO_RESOLVED_STATE`. `None` for a decline.
195    pub selected_index: Option<u32>,
196    /// The chosen option's label, mirrored alongside the index so a consumer
197    /// never has to re-resolve it against the (possibly since-compacted)
198    /// original question payload.
199    pub selected_label: String,
200    /// Who answered — empty for [`AUTO_RESOLVED_STATE`] (nobody did).
201    pub answered_by: String,
202    /// The conversation this answer was signed for, covered by the
203    /// signature so a token signed for one conversation cannot be replayed
204    /// into another.
205    pub conversation_id: String,
206    /// Per-answer unique value, covered by the signature. A consumer
207    /// records it on use so the answer cannot be re-applied once spent
208    /// (single-use, mirrors `#370`).
209    pub nonce: String,
210    /// The verified signer's public key (encoded).
211    pub signer_public_key: Vec<u8>,
212    /// Hex-encoded ed25519 public key of the signer — the same bytes as
213    /// [`Self::signer_public_key`], kept alongside it in the wire-ready
214    /// encoding so a consumer forwarding this answer onward (the control
215    /// plane's outstanding-answer collector, to the harness) never
216    /// re-derives the hex itself. Mirrors
217    /// `polyc_crypto::approval::DecodedResponse::signer_pk_hex`.
218    pub signer_pk_hex: String,
219    /// Hex-encoded ed25519 signature over the canonical answer tuple —
220    /// forwarded onward so the harness can independently re-verify (THIN
221    /// signing) rather than trust the control plane's own verification.
222    /// Mirrors `polyc_crypto::approval::DecodedResponse::signature_hex`.
223    pub signature_hex: String,
224}
225
226impl VerifiedQuestionAnswer {
227    /// Whether this verified answer is bound to the EXACT question
228    /// `(call_id, index, question_args_json)` — the args-binding a resume
229    /// path must check before applying the answer, mirroring
230    /// [`crate::approval::VerifiedResponse::authorizes_call`].
231    #[must_use]
232    pub fn binds_question(&self, call_id: &str, index: u32, question_args_json: &str) -> bool {
233        self.call_id == call_id
234            && self.index == index
235            && self.question_args_json == question_args_json
236    }
237}
238
239/// Verify a persisted `question_response` payload.
240///
241/// Returns `Some(record)` if the signature checks out against the embedded
242/// public key (the caller is responsible for trusting that public key — see
243/// [`verify_signed_answer_pinned`]). Returns `None` if the payload is
244/// malformed, the hex fields don't decode, or the signature doesn't verify.
245#[must_use]
246pub fn verify_signed_answer(payload: &[u8]) -> Option<VerifiedQuestionAnswer> {
247    let v: Value = serde_json::from_slice(payload).ok()?;
248    let call_id = v.get("question_call_id")?.as_str()?.to_owned();
249    let index = u32::try_from(v.get("question_index")?.as_u64()?).ok()?;
250    let question_args_json = v.get("question_args_json")?.as_str()?.to_owned();
251    let state = v.get("state")?.as_str()?.to_owned();
252    let selected_index = match v.get("selected_index") {
253        Some(Value::Null) | None => None,
254        Some(x) => Some(u32::try_from(x.as_u64()?).ok()?),
255    };
256    let selected_label = v.get("selected_label")?.as_str()?.to_owned();
257    let answered_by = v.get("answered_by")?.as_str()?.to_owned();
258    let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
259    let nonce = v.get("nonce")?.as_str()?.to_owned();
260    let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
261    let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
262
263    let canonical = answer_canonical(
264        &call_id,
265        index,
266        &question_args_json,
267        &state,
268        selected_index,
269        &selected_label,
270        &answered_by,
271        &conversation_id,
272        &nonce,
273    );
274    if verify(&pk, &canonical, &sig) {
275        Some(VerifiedQuestionAnswer {
276            call_id,
277            index,
278            question_args_json,
279            state,
280            selected_index,
281            selected_label,
282            answered_by,
283            conversation_id,
284            nonce,
285            signer_pk_hex: crate::hex::lower(&pk),
286            signature_hex: crate::hex::lower(&sig),
287            signer_public_key: pk,
288        })
289    } else {
290        None
291    }
292}
293
294/// Verify a persisted `question_response` payload against a **trusted-signer
295/// allow-list**.
296///
297/// Mirrors [`crate::approval::verify_signed_response_pinned`]: without this
298/// gate an attacker could sign a well-formed answer with their own key,
299/// embed it, and have it honored. Returns `None` (fail closed) on a
300/// malformed payload, an untrusted signer, or a bad signature.
301#[must_use]
302pub fn verify_signed_answer_pinned(
303    payload: &[u8],
304    trusted_signers: &[Vec<u8>],
305) -> Option<VerifiedQuestionAnswer> {
306    let verified = verify_signed_answer(payload)?;
307    if !trusted_signers
308        .iter()
309        .any(|k| k.as_slice() == verified.signer_public_key)
310    {
311        return None;
312    }
313    Some(verified)
314}
315
316/// Verify a persisted `question_response` as a SINGLE-USE, conversation-bound
317/// capability (invariant I3/I7), gated on a trusted-signer allow-list.
318///
319/// Returns the verified answer only when ALL hold: the embedded signer is
320/// trusted; the signature verifies; the signed `conversation_id` equals
321/// `conversation_id`; the signed `nonce` is non-empty AND not already in
322/// `consumed`. The caller MUST record the returned nonce into its `consumed`
323/// set before honoring the answer, so a second presentation is rejected.
324#[must_use]
325pub fn verify_answer_capability<S: std::hash::BuildHasher>(
326    payload: &[u8],
327    conversation_id: &str,
328    consumed: &HashSet<String, S>,
329    trusted_signers: &[Vec<u8>],
330) -> Option<VerifiedQuestionAnswer> {
331    let verified = verify_signed_answer_pinned(payload, trusted_signers)?;
332    if verified.conversation_id != conversation_id {
333        return None;
334    }
335    if verified.nonce.is_empty() || consumed.contains(&verified.nonce) {
336        return None;
337    }
338    Some(verified)
339}
340
341/// Verify a wire-form `question_response`, binding the answer to its full
342/// signed identity.
343///
344/// Used by the harness when it receives an answer over the wire and must
345/// confirm provenance AND identity before feeding it back into the turn as
346/// the `ask_question` call's result — the trust boundary this exists for:
347/// silent acceptance would let a compromised control plane forge a user's
348/// answer. Returns `true` only if `signer_pk_hex + signature_hex` validates
349/// against the canonical.
350#[must_use]
351#[allow(clippy::too_many_arguments)] // each arg is a distinct signed field of the canonical contract
352pub fn verify_wire_answer(
353    call_id: &str,
354    index: u32,
355    question_args_json: &str,
356    state: &str,
357    selected_index: Option<u32>,
358    selected_label: &str,
359    answered_by: &str,
360    conversation_id: &str,
361    nonce: &str,
362    signer_pk_hex: &str,
363    signature_hex: &str,
364) -> bool {
365    let Some(pk) = crate::hex::decode(signer_pk_hex) else {
366        return false;
367    };
368    let Some(sig) = crate::hex::decode(signature_hex) else {
369        return false;
370    };
371    let canonical = answer_canonical(
372        call_id,
373        index,
374        question_args_json,
375        state,
376        selected_index,
377        selected_label,
378        answered_by,
379        conversation_id,
380        nonce,
381    );
382    verify(&pk, &canonical, &sig)
383}
384
385// ── Per-question correlation token ──────────────────────────────────────────
386
387/// The canonical (signature-covered) form of an answer token (`#1660`).
388///
389/// Deliberately uses the SAME `question_call_id`/`question_index` key names
390/// [`answer_canonical`] uses, but a DIFFERENT key set than
391/// [`crate::approval`]'s `resolve_token_canonical` (`request_id` vs
392/// `question_call_id`) — an approval resolve token must never verify as an
393/// answer token and vice versa; [`verify_answer_token`]'s own test pins this
394/// cross-domain rejection.
395fn answer_token_canonical(
396    call_id: &str,
397    index: u32,
398    conversation_id: &str,
399    minted_at_ms: u64,
400) -> Vec<u8> {
401    canonical_bytes(&AnswerTokenCanonical {
402        question_call_id: call_id,
403        question_index: index,
404        conversation_id,
405        minted_at_ms,
406    })
407}
408
409/// The signed answer-token field set, in its frozen order. See ADR 0009.
410#[derive(Serialize)]
411struct AnswerTokenCanonical<'a> {
412    question_call_id: &'a str,
413    question_index: u32,
414    conversation_id: &'a str,
415    minted_at_ms: u64,
416}
417
418/// A minted answer token: the canonical body plus its signature.
419///
420/// The token carries no `signed_by` — it is verified against the control
421/// plane's own key, supplied by the caller of [`verify_answer_token`], never
422/// one carried on the token itself. Mirrors
423/// `polyc_crypto::approval::mint_resolve_token`'s shape, so
424/// [`crate::signed::Envelope`] (which always writes both provenance fields)
425/// does not apply here.
426#[derive(Serialize)]
427struct AnswerToken<'a> {
428    #[serde(flatten)]
429    body: AnswerTokenCanonical<'a>,
430    signature_hex: String,
431}
432
433/// Mint a short-lived, signed answer token scoped to one pending question.
434///
435/// Scoped to `(call_id, index, conversation_id)` — the correlation-token
436/// contract every downstream edge slice (#1661-#1664) consumes unchanged.
437/// Mirrors [`crate::approval::mint_resolve_token`]'s shape and
438/// unforgeability argument: opaque to, and unforgeable by, anything
439/// downstream of the mint point. `minted_at_ms` is the caller's wall clock
440/// at mint time; pass a real timestamp in production, an injected one in
441/// tests.
442///
443/// Returns the token as a lowercase-hex opaque string, safe to carry on any
444/// wire surface (a button value, a proto field) alongside `call_id`/`index`.
445#[must_use]
446pub fn mint_answer_token(
447    call_id: &str,
448    index: u32,
449    conversation_id: &str,
450    minted_at_ms: u64,
451    signer: &ApprovalSigner,
452) -> String {
453    // Built once and reused for both the signature and the envelope. Writing the
454    // field list a second time here would be a second hand-maintained copy of a
455    // signed field set — the drift this module's `answer_fields` exists to stop.
456    let body = AnswerTokenCanonical {
457        question_call_id: call_id,
458        question_index: index,
459        conversation_id,
460        minted_at_ms,
461    };
462    let signature = signer.sign(&canonical_bytes(&body));
463    let full = AnswerToken {
464        body,
465        signature_hex: crate::hex::lower(&signature),
466    };
467    crate::hex::lower(&canonical_bytes(&full))
468}
469
470/// Verify an answer token minted by [`mint_answer_token`] against the
471/// `(call_id, index, conversation_id)` a `Respond` call presents it for.
472///
473/// Returns `true` only when ALL hold: the token decodes; its embedded
474/// signature verifies against `signer`'s public key; its bound identity
475/// equals the ones supplied; and `now_ms - minted_at_ms` is within
476/// [`ANSWER_TOKEN_TTL_MS`] (a token minted in the future, by clock skew
477/// beyond the TTL, also fails — fail closed rather than trust an
478/// out-of-bounds clock). Fails closed on any malformed field.
479#[must_use]
480pub fn verify_answer_token(
481    token: &str,
482    call_id: &str,
483    index: u32,
484    conversation_id: &str,
485    now_ms: u64,
486    signer: &ApprovalSigner,
487) -> bool {
488    let Some(bytes) = crate::hex::decode(token) else {
489        return false;
490    };
491    let Ok(v) = serde_json::from_slice::<Value>(&bytes) else {
492        return false;
493    };
494    let (
495        Some(bound_call_id),
496        Some(bound_index),
497        Some(bound_conversation_id),
498        Some(minted_at_ms),
499        Some(signature_hex),
500    ) = (
501        v.get("question_call_id").and_then(Value::as_str),
502        v.get("question_index").and_then(Value::as_u64),
503        v.get("conversation_id").and_then(Value::as_str),
504        v.get("minted_at_ms").and_then(Value::as_u64),
505        v.get("signature_hex").and_then(Value::as_str),
506    )
507    else {
508        return false;
509    };
510    let Ok(bound_index) = u32::try_from(bound_index) else {
511        return false;
512    };
513    if bound_call_id != call_id || bound_index != index || bound_conversation_id != conversation_id
514    {
515        return false;
516    }
517    let elapsed = now_ms.abs_diff(minted_at_ms);
518    if elapsed > ANSWER_TOKEN_TTL_MS {
519        return false;
520    }
521    let Some(sig) = crate::hex::decode(signature_hex) else {
522        return false;
523    };
524    let canonical = answer_token_canonical(
525        bound_call_id,
526        bound_index,
527        bound_conversation_id,
528        minted_at_ms,
529    );
530    verify(&signer.public_key_bytes(), &canonical, &sig)
531}
532
533#[cfg(test)]
534mod tests {
535    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
536    use super::*;
537
538    fn valid_answer_payload(signer: &ApprovalSigner) -> Vec<u8> {
539        answer_payload(
540            "call-1",
541            0,
542            r#"{"questions":[]}"#,
543            ANSWERED_STATE,
544            Some(1),
545            "Production",
546            "slack:T1:U9",
547            "conv-A",
548            "nonce-A",
549            signer,
550        )
551        .0
552    }
553
554    #[test]
555    fn signed_answer_round_trips() {
556        let signer = ApprovalSigner::from_seed(1);
557        let payload = valid_answer_payload(&signer);
558        let verified = verify_signed_answer(&payload).expect("signature verifies");
559        assert_eq!(verified.call_id, "call-1");
560        assert_eq!(verified.index, 0);
561        assert_eq!(verified.state, ANSWERED_STATE);
562        assert_eq!(verified.selected_index, Some(1));
563        assert_eq!(verified.selected_label, "Production");
564        assert_eq!(verified.answered_by, "slack:T1:U9");
565        assert!(verified.binds_question("call-1", 0, r#"{"questions":[]}"#));
566        assert!(!verified.binds_question("call-2", 0, r#"{"questions":[]}"#));
567    }
568
569    /// `#1660` Step 7: the decoded record carries the RAW signer/signature
570    /// hex alongside the verified fields — not just `signer_public_key`
571    /// bytes — so a consumer (the control plane's outstanding-answer
572    /// collector) can re-forward the exact signed bytes to the harness for
573    /// its own independent re-verification (THIN signing), mirroring
574    /// `polyc_crypto::approval::DecodedResponse::{signer_pk_hex,
575    /// signature_hex}`.
576    #[test]
577    fn verified_answer_carries_raw_signer_and_signature_hex() {
578        let signer = ApprovalSigner::from_seed(1);
579        let payload = valid_answer_payload(&signer);
580        let verified = verify_signed_answer(&payload).expect("signature verifies");
581        assert_eq!(
582            verified.signer_pk_hex,
583            crate::hex::lower(&signer.public_key_bytes())
584        );
585        assert!(!verified.signature_hex.is_empty());
586        // The hex must actually verify against the canonical this payload
587        // signs — not just be non-empty — so re-derive the signature bytes
588        // and check them against `verify` directly.
589        let sig_bytes = crate::hex::decode(&verified.signature_hex).expect("valid hex");
590        let canonical = answer_canonical(
591            &verified.call_id,
592            verified.index,
593            &verified.question_args_json,
594            &verified.state,
595            verified.selected_index,
596            &verified.selected_label,
597            &verified.answered_by,
598            &verified.conversation_id,
599            &verified.nonce,
600        );
601        assert!(verify(&signer.public_key_bytes(), &canonical, &sig_bytes));
602    }
603
604    #[test]
605    fn declined_and_auto_resolved_states_round_trip_with_no_selection_or_answerer() {
606        let signer = ApprovalSigner::from_seed(1);
607        let (payload, ..) = answer_payload(
608            "call-1",
609            0,
610            "{}",
611            DECLINED_STATE,
612            None,
613            "",
614            "slack:T1:U9",
615            "conv-A",
616            "nonce-B",
617            &signer,
618        );
619        let v = verify_signed_answer(&payload).expect("verifies");
620        assert_eq!(v.state, DECLINED_STATE);
621        assert_eq!(v.selected_index, None);
622
623        let (payload, ..) = answer_payload(
624            "call-1",
625            0,
626            "{}",
627            AUTO_RESOLVED_STATE,
628            Some(0),
629            "Staging",
630            "",
631            "conv-A",
632            "nonce-C",
633            &signer,
634        );
635        let v = verify_signed_answer(&payload).expect("verifies");
636        assert_eq!(v.state, AUTO_RESOLVED_STATE);
637        assert_eq!(
638            v.answered_by, "",
639            "nobody answered an auto-resolved question"
640        );
641    }
642
643    /// Invariant I3: every signed field is covered — tampering with any one
644    /// (the state, the selection, who answered, the conversation, or the
645    /// nonce) must fail verification.
646    #[test]
647    fn tampered_state_or_selection_fails_verification() {
648        let signer = ApprovalSigner::from_seed(1);
649        let payload = valid_answer_payload(&signer);
650        let mut v: Value = serde_json::from_slice(&payload).unwrap();
651        for (field, val) in [
652            ("state", Value::String(DECLINED_STATE.to_owned())),
653            ("selected_index", Value::Number(2.into())),
654            ("selected_label", Value::String("Staging".into())),
655            ("answered_by", Value::String("slack:T1:U0".into())),
656            ("conversation_id", Value::String("conv-B".into())),
657            ("nonce", Value::String("nonce-Z".into())),
658            ("question_call_id", Value::String("call-2".into())),
659            ("question_index", Value::Number(1.into())),
660        ] {
661            let mut tampered = v.clone();
662            tampered[field] = val;
663            let bytes = serde_json::to_vec(&tampered).unwrap();
664            assert!(
665                verify_signed_answer(&bytes).is_none(),
666                "tampering `{field}` must invalidate the signature"
667            );
668        }
669        // Sanity: the untampered payload still verifies.
670        assert!(verify_signed_answer(&serde_json::to_vec(&v.take()).unwrap()).is_some());
671    }
672
673    /// Invariant I3: a signature from a signer outside the trusted allow-list
674    /// authorizes nothing, even though it is internally self-consistent.
675    #[test]
676    fn answer_from_non_allowlisted_signer_is_rejected() {
677        let trusted = ApprovalSigner::from_seed(1);
678        let attacker = ApprovalSigner::from_seed(666);
679        let payload = valid_answer_payload(&attacker);
680        assert!(
681            verify_signed_answer_pinned(&payload, &[trusted.public_key_bytes()]).is_none(),
682            "an untrusted signer's answer must never verify"
683        );
684        assert!(
685            verify_signed_answer_pinned(&payload, &[attacker.public_key_bytes()]).is_some(),
686            "sanity: the attacker's own key does verify its own signature"
687        );
688    }
689
690    /// Invariant I3/`#370`-style replay guard: a token signed for one
691    /// conversation must be rejected when presented for another, even though
692    /// every other field is byte-identical.
693    #[test]
694    fn answer_rejected_across_conversations() {
695        let signer = ApprovalSigner::from_seed(1);
696        let payload = valid_answer_payload(&signer);
697        let consumed = HashSet::new();
698        assert!(
699            verify_answer_capability(&payload, "conv-A", &consumed, &[signer.public_key_bytes()])
700                .is_some()
701        );
702        assert!(
703            verify_answer_capability(&payload, "conv-B", &consumed, &[signer.public_key_bytes()])
704                .is_none(),
705            "an answer signed for conv-A must be rejected when presented for conv-B"
706        );
707    }
708
709    /// Invariant I2/I7: a single-use answer is rejected on a second
710    /// presentation, mirroring `capability_is_single_use` for approvals.
711    #[test]
712    fn question_answer_capability_is_single_use() {
713        let signer = ApprovalSigner::from_seed(1);
714        let payload = valid_answer_payload(&signer);
715        let mut consumed = HashSet::new();
716        let v =
717            verify_answer_capability(&payload, "conv-A", &consumed, &[signer.public_key_bytes()])
718                .expect("first use honored");
719        assert_eq!(v.nonce, "nonce-A");
720        consumed.insert(v.nonce.clone());
721        assert!(
722            verify_answer_capability(&payload, "conv-A", &consumed, &[signer.public_key_bytes()])
723                .is_none(),
724            "a spent answer must be rejected on re-presentation"
725        );
726    }
727
728    /// Cross-domain canonical separation: an approval's own signed
729    /// `approval_response`/`resolve_token` bytes must never verify as a
730    /// question answer/token, and vice versa — the two canonicals use
731    /// different key names specifically so this can never accidentally work.
732    #[test]
733    fn approval_payloads_never_verify_as_question_answers() {
734        let signer = ApprovalSigner::from_seed(1);
735        let (approval_payload, ..) = crate::approval::response_payload(
736            "call-1",
737            "ask_question",
738            r#"{"questions":[]}"#,
739            "",
740            true,
741            false,
742            &[],
743            "slack:T1:U9",
744            "",
745            "",
746            "",
747            "",
748            "conv-A",
749            "nonce-A",
750            &signer,
751        );
752        assert!(
753            verify_signed_answer(&approval_payload).is_none(),
754            "an approval_response payload must never decode+verify as a question_response"
755        );
756
757        let resolve_token = crate::approval::mint_resolve_token("call-1", "conv-A", 1_000, &signer);
758        assert!(
759            !verify_answer_token(&resolve_token, "call-1", 0, "conv-A", 1_000, &signer),
760            "an approval resolve_token must never verify as an answer token"
761        );
762    }
763
764    /// `polyc_proto::tool_display::question_answered_text` duplicates
765    /// `ANSWERED_STATE`/`DECLINED_STATE`/`AUTO_RESOLVED_STATE` as local
766    /// string literals (`polyc-proto` can't depend on `polyc-crypto` for the
767    /// real consts — see that function's own doc for the layer reason).
768    /// `polyc-crypto` depends on `polyc-proto`, so this crate is the one
769    /// that CAN cross-check them: feed this crate's own real consts in and
770    /// confirm each produces its OWN distinct sentence shape, not the
771    /// declined-shaped fallback every unrecognized string renders as.
772    #[test]
773    fn proto_question_answered_text_recognizes_this_crates_real_state_consts() {
774        let answered = polyc_proto::question_answered_text(
775            "Deploy target",
776            ANSWERED_STATE,
777            "@ada",
778            "Production",
779        );
780        assert!(
781            answered.contains("@ada answered"),
782            "ANSWERED_STATE must render as a real answer, not the declined fallback: {answered}"
783        );
784
785        let auto_resolved = polyc_proto::question_answered_text(
786            "Deploy target",
787            AUTO_RESOLVED_STATE,
788            "",
789            "Production",
790        );
791        assert!(
792            auto_resolved.contains("Nobody answered"),
793            "AUTO_RESOLVED_STATE must render as an assumption, not the declined fallback: {auto_resolved}"
794        );
795
796        let declined =
797            polyc_proto::question_answered_text("Deploy target", DECLINED_STATE, "@ada", "");
798        assert!(
799            declined.contains("@ada declined"),
800            "DECLINED_STATE must render as a real decline: {declined}"
801        );
802    }
803}
804
805#[cfg(test)]
806mod answer_token_tests {
807    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
808    use super::*;
809
810    #[test]
811    fn answer_token_verifies_for_its_own_question() {
812        let signer = ApprovalSigner::from_seed(11);
813        let token = mint_answer_token("call-1", 0, "conv-A", 1_000, &signer);
814        assert!(verify_answer_token(
815            &token, "call-1", 0, "conv-A", 1_000, &signer
816        ));
817    }
818
819    #[test]
820    fn answer_token_rejects_a_different_question_index() {
821        let signer = ApprovalSigner::from_seed(11);
822        let token = mint_answer_token("call-1", 0, "conv-A", 1_000, &signer);
823        assert!(!verify_answer_token(
824            &token, "call-1", 1, "conv-A", 1_000, &signer
825        ));
826    }
827
828    #[test]
829    fn answer_token_rejects_a_different_call_id() {
830        let signer = ApprovalSigner::from_seed(11);
831        let token = mint_answer_token("call-1", 0, "conv-A", 1_000, &signer);
832        assert!(!verify_answer_token(
833            &token, "call-2", 0, "conv-A", 1_000, &signer
834        ));
835    }
836
837    #[test]
838    fn answer_token_rejects_a_different_conversation() {
839        let signer = ApprovalSigner::from_seed(11);
840        let token = mint_answer_token("call-1", 0, "conv-A", 1_000, &signer);
841        assert!(!verify_answer_token(
842            &token, "call-1", 0, "conv-B", 1_000, &signer
843        ));
844    }
845
846    #[test]
847    fn answer_token_rejects_wrong_signer() {
848        let signer = ApprovalSigner::from_seed(11);
849        let other = ApprovalSigner::from_seed(12);
850        let token = mint_answer_token("call-1", 0, "conv-A", 1_000, &signer);
851        assert!(!verify_answer_token(
852            &token, "call-1", 0, "conv-A", 1_000, &other
853        ));
854    }
855
856    #[test]
857    fn answer_token_rejects_after_ttl_elapses() {
858        let signer = ApprovalSigner::from_seed(11);
859        let token = mint_answer_token("call-1", 0, "conv-A", 0, &signer);
860        assert!(verify_answer_token(
861            &token,
862            "call-1",
863            0,
864            "conv-A",
865            ANSWER_TOKEN_TTL_MS,
866            &signer
867        ));
868        assert!(!verify_answer_token(
869            &token,
870            "call-1",
871            0,
872            "conv-A",
873            ANSWER_TOKEN_TTL_MS + 1,
874            &signer
875        ));
876    }
877
878    #[test]
879    fn answer_token_rejects_garbage() {
880        let signer = ApprovalSigner::from_seed(11);
881        assert!(!verify_answer_token(
882            "not-hex", "call-1", 0, "conv-A", 0, &signer
883        ));
884        assert!(!verify_answer_token("", "call-1", 0, "conv-A", 0, &signer));
885    }
886}
887
888#[cfg(test)]
889mod canonical_freeze {
890    //! Every signed canonical in this module, frozen as literal bytes (`#1845`).
891    //!
892    //! These are the bytes a deployment has already signed and has sitting in
893    //! its log. They are checked in, never regenerated: regenerating one is the
894    //! defect this module exists to catch, because a canonical whose bytes move
895    //! invalidates every signature ever minted over the old ones.
896    //!
897    //! The reason they can be single literals at all is the conversion `#1845`
898    //! made, following `#1842`. Before it, each canonical was a
899    //! [`serde_json::Value`], whose object is a `BTreeMap` (keys sorted) by
900    //! default and an `IndexMap` (insertion order) whenever anything in the
901    //! build graph enables `serde_json/preserve_order` — so the same payload
902    //! signed by two binaries with different dependency sets produced different
903    //! bytes and different signatures. Every literal below is the
904    //! insertion-order form, which is what a control-plane binary (where
905    //! `preserve_order` is unified in) has always signed. Run this module under
906    //! either selection and every literal holds:
907    //!
908    //! ```text
909    //! cargo nextest run -p polyc-crypto                    # no preserve_order
910    //! cargo nextest run -p polyc-crypto -p polyc-payments  # preserve_order on
911    //! ```
912    //!
913    //! See ADR 0009 for the decision these literals enforce.
914    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
915
916    use super::*;
917
918    /// Assert a canonical's bytes are exactly the frozen literal.
919    fn frozen(label: &str, got: &[u8], want: &str) {
920        assert_eq!(
921            String::from_utf8(got.to_vec()).unwrap(),
922            want,
923            "{label}: canonical bytes moved — every signature over the old bytes is now unverifiable"
924        );
925    }
926
927    const QUESTION_ARGS: &str =
928        r#"{"questions":[{"prompt":"Ship it?","options":["Hold","Yes, proceed"]}]}"#;
929    const MINTED_AT_MS: u64 = 1_750_000_000_000;
930
931    #[test]
932    fn answered_canonical_and_payload_are_frozen() {
933        frozen(
934            "answer_canonical (answered)",
935            &answer_canonical(
936                "call-1",
937                0,
938                QUESTION_ARGS,
939                ANSWERED_STATE,
940                Some(1),
941                "Yes, proceed",
942                "persona:alice",
943                "conv-1",
944                "nonce-answer",
945            ),
946            ANSWERED_CANONICAL,
947        );
948        let (full, sig, _) = answer_payload(
949            "call-1",
950            0,
951            QUESTION_ARGS,
952            ANSWERED_STATE,
953            Some(1),
954            "Yes, proceed",
955            "persona:alice",
956            "conv-1",
957            "nonce-answer",
958            &ApprovalSigner::from_seed(99),
959        );
960        frozen("answer_payload (answered)", &full, ANSWERED_PAYLOAD);
961        assert_eq!(crate::hex::lower(&sig), ANSWERED_SIG);
962    }
963
964    // A decline signs `selected_index` as an explicit `null`, not an omitted
965    // key — the shape the `json!` object this replaced emitted, and therefore
966    // what every already-signed decline in a deployment's log covers.
967    #[test]
968    fn declined_canonical_and_payload_are_frozen() {
969        frozen(
970            "answer_canonical (declined)",
971            &answer_canonical(
972                "call-1",
973                0,
974                QUESTION_ARGS,
975                DECLINED_STATE,
976                None,
977                "",
978                "persona:alice",
979                "conv-1",
980                "nonce-answer",
981            ),
982            DECLINED_CANONICAL,
983        );
984        let (full, sig, _) = answer_payload(
985            "call-1",
986            0,
987            QUESTION_ARGS,
988            DECLINED_STATE,
989            None,
990            "",
991            "persona:alice",
992            "conv-1",
993            "nonce-answer",
994            &ApprovalSigner::from_seed(99),
995        );
996        frozen("answer_payload (declined)", &full, DECLINED_PAYLOAD);
997        assert_eq!(crate::hex::lower(&sig), DECLINED_SIG);
998    }
999
1000    #[test]
1001    fn answer_token_is_frozen() {
1002        frozen(
1003            "answer_token_canonical",
1004            &answer_token_canonical("call-1", 0, "conv-1", MINTED_AT_MS),
1005            TOKEN_CANONICAL,
1006        );
1007        assert_eq!(
1008            mint_answer_token(
1009                "call-1",
1010                0,
1011                "conv-1",
1012                MINTED_AT_MS,
1013                &ApprovalSigner::from_seed(99)
1014            ),
1015            TOKEN_MINTED,
1016            "a minted answer token's bytes are frozen — a token is hex of the whole object"
1017        );
1018    }
1019
1020    const ANSWERED_CANONICAL: &str = r#"{"question_call_id":"call-1","question_index":0,"question_args_json":"{\"questions\":[{\"prompt\":\"Ship it?\",\"options\":[\"Hold\",\"Yes, proceed\"]}]}","state":"answered","selected_index":1,"selected_label":"Yes, proceed","answered_by":"persona:alice","conversation_id":"conv-1","nonce":"nonce-answer"}"#;
1021    const ANSWERED_PAYLOAD: &str = r#"{"question_call_id":"call-1","question_index":0,"question_args_json":"{\"questions\":[{\"prompt\":\"Ship it?\",\"options\":[\"Hold\",\"Yes, proceed\"]}]}","state":"answered","selected_index":1,"selected_label":"Yes, proceed","answered_by":"persona:alice","conversation_id":"conv-1","nonce":"nonce-answer","signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"e7b877065d853887c0188eff5fb971a9d8e531e2c36e3ffa8d02dc5da2b3cb17a8dc140bfb1d6b4c6c6529d0b4890851516d03b1d633d96b1496d6713b08a705"}"#;
1022    const ANSWERED_SIG: &str = "e7b877065d853887c0188eff5fb971a9d8e531e2c36e3ffa8d02dc5da2b3cb17a8dc140bfb1d6b4c6c6529d0b4890851516d03b1d633d96b1496d6713b08a705";
1023    const DECLINED_CANONICAL: &str = r#"{"question_call_id":"call-1","question_index":0,"question_args_json":"{\"questions\":[{\"prompt\":\"Ship it?\",\"options\":[\"Hold\",\"Yes, proceed\"]}]}","state":"declined","selected_index":null,"selected_label":"","answered_by":"persona:alice","conversation_id":"conv-1","nonce":"nonce-answer"}"#;
1024    const DECLINED_PAYLOAD: &str = r#"{"question_call_id":"call-1","question_index":0,"question_args_json":"{\"questions\":[{\"prompt\":\"Ship it?\",\"options\":[\"Hold\",\"Yes, proceed\"]}]}","state":"declined","selected_index":null,"selected_label":"","answered_by":"persona:alice","conversation_id":"conv-1","nonce":"nonce-answer","signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"5961a4cc427c84674a0a60b0f51552143b511751c2295705dbd323e062b504858de0f2209e67bb0c7a6826e80ec9831c2864c9f910cf7b4e137656da273fa30d"}"#;
1025    const DECLINED_SIG: &str = "5961a4cc427c84674a0a60b0f51552143b511751c2295705dbd323e062b504858de0f2209e67bb0c7a6826e80ec9831c2864c9f910cf7b4e137656da273fa30d";
1026    const TOKEN_CANONICAL: &str = r#"{"question_call_id":"call-1","question_index":0,"conversation_id":"conv-1","minted_at_ms":1750000000000}"#;
1027    const TOKEN_MINTED: &str = "7b227175657374696f6e5f63616c6c5f6964223a2263616c6c2d31222c227175657374696f6e5f696e646578223a302c22636f6e766572736174696f6e5f6964223a22636f6e762d31222c226d696e7465645f61745f6d73223a313735303030303030303030302c227369676e61747572655f686578223a223337633731353962313761313236643935323466313766616636323766623565396461626133313535616638316163666539313966633634363833653634626530333833386539363033383165313931366533643033626437333338653830353762636636326565313365643166353639363866616366323034373936643065227d";
1028}