1use 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
25pub const ANSWERED_STATE: &str = "answered";
28pub const DECLINED_STATE: &str = "declined";
32pub const AUTO_RESOLVED_STATE: &str = "auto_resolved";
39
40pub const ANSWER_TOKEN_TTL_MS: u64 = 24 * 60 * 60 * 1000;
47
48#[allow(clippy::too_many_arguments)] fn answer_canonical(
71 turn_id: &str,
72 call_id: &str,
73 index: u32,
74 question_args_json: &str,
75 state: &str,
76 selected_index: Option<u32>,
77 selected_label: &str,
78 answered_by: &str,
79 conversation_id: &str,
80 nonce: &str,
81) -> Vec<u8> {
82 canonical_bytes(&answer_fields(
83 turn_id,
84 call_id,
85 index,
86 question_args_json,
87 state,
88 selected_index,
89 selected_label,
90 answered_by,
91 conversation_id,
92 nonce,
93 ))
94}
95
96#[derive(Serialize)]
104struct AnswerCanonical<'a> {
105 #[serde(skip_serializing_if = "str::is_empty")]
109 turn_id: &'a str,
110 question_call_id: &'a str,
111 question_index: u32,
112 question_args_json: &'a str,
113 state: &'a str,
114 selected_index: Option<u32>,
115 selected_label: &'a str,
116 answered_by: &'a str,
117 conversation_id: &'a str,
118 nonce: &'a str,
119}
120
121#[allow(clippy::too_many_arguments)] const fn answer_fields<'a>(
126 turn_id: &'a str,
127 call_id: &'a str,
128 index: u32,
129 question_args_json: &'a str,
130 state: &'a str,
131 selected_index: Option<u32>,
132 selected_label: &'a str,
133 answered_by: &'a str,
134 conversation_id: &'a str,
135 nonce: &'a str,
136) -> AnswerCanonical<'a> {
137 AnswerCanonical {
138 turn_id,
139 question_call_id: call_id,
140 question_index: index,
141 question_args_json,
142 state,
143 selected_index,
144 selected_label,
145 answered_by,
146 conversation_id,
147 nonce,
148 }
149}
150
151#[must_use]
162#[allow(clippy::too_many_arguments)] pub fn answer_payload(
164 turn_id: &str,
165 call_id: &str,
166 index: u32,
167 question_args_json: &str,
168 state: &str,
169 selected_index: Option<u32>,
170 selected_label: &str,
171 answered_by: &str,
172 conversation_id: &str,
173 nonce: &str,
174 signer: &ApprovalSigner,
175) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
176 Envelope::seal(
177 answer_fields(
178 turn_id,
179 call_id,
180 index,
181 question_args_json,
182 state,
183 selected_index,
184 selected_label,
185 answered_by,
186 conversation_id,
187 nonce,
188 ),
189 signer.as_signer(),
190 )
191}
192
193#[derive(Debug, Clone, PartialEq, Eq)]
195pub struct VerifiedQuestionAnswer {
196 pub turn_id: String,
199 pub call_id: String,
201 pub index: u32,
203 pub question_args_json: String,
207 pub state: String,
210 pub selected_index: Option<u32>,
213 pub selected_label: String,
217 pub answered_by: String,
219 pub conversation_id: String,
223 pub nonce: String,
227 pub signer_public_key: Vec<u8>,
229 pub signer_pk_hex: String,
236 pub signature_hex: String,
241}
242
243impl VerifiedQuestionAnswer {
244 #[must_use]
249 pub fn binds_question(&self, call_id: &str, index: u32, question_args_json: &str) -> bool {
250 self.call_id == call_id
251 && self.index == index
252 && self.question_args_json == question_args_json
253 }
254}
255
256#[must_use]
263pub fn verify_signed_answer(payload: &[u8]) -> Option<VerifiedQuestionAnswer> {
264 let v: Value = serde_json::from_slice(payload).ok()?;
265 let turn_id = v
268 .get("turn_id")
269 .and_then(Value::as_str)
270 .unwrap_or_default()
271 .to_owned();
272 let call_id = v.get("question_call_id")?.as_str()?.to_owned();
273 let index = u32::try_from(v.get("question_index")?.as_u64()?).ok()?;
274 let question_args_json = v.get("question_args_json")?.as_str()?.to_owned();
275 let state = v.get("state")?.as_str()?.to_owned();
276 let selected_index = match v.get("selected_index") {
277 Some(Value::Null) | None => None,
278 Some(x) => Some(u32::try_from(x.as_u64()?).ok()?),
279 };
280 let selected_label = v.get("selected_label")?.as_str()?.to_owned();
281 let answered_by = v.get("answered_by")?.as_str()?.to_owned();
282 let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
283 let nonce = v.get("nonce")?.as_str()?.to_owned();
284 let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
285 let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
286
287 let canonical = answer_canonical(
288 &turn_id,
289 &call_id,
290 index,
291 &question_args_json,
292 &state,
293 selected_index,
294 &selected_label,
295 &answered_by,
296 &conversation_id,
297 &nonce,
298 );
299 if verify(&pk, &canonical, &sig) {
300 Some(VerifiedQuestionAnswer {
301 turn_id,
302 call_id,
303 index,
304 question_args_json,
305 state,
306 selected_index,
307 selected_label,
308 answered_by,
309 conversation_id,
310 nonce,
311 signer_pk_hex: crate::hex::lower(&pk),
312 signature_hex: crate::hex::lower(&sig),
313 signer_public_key: pk,
314 })
315 } else {
316 None
317 }
318}
319
320#[must_use]
328pub fn verify_signed_answer_pinned(
329 payload: &[u8],
330 trusted_signers: &[Vec<u8>],
331) -> Option<VerifiedQuestionAnswer> {
332 let verified = verify_signed_answer(payload)?;
333 if !trusted_signers
334 .iter()
335 .any(|k| k.as_slice() == verified.signer_public_key)
336 {
337 return None;
338 }
339 Some(verified)
340}
341
342#[must_use]
359pub fn verify_answer_capability<S: std::hash::BuildHasher>(
360 payload: &[u8],
361 conversation_id: &str,
362 turn_id: &str,
363 consumed: &HashSet<String, S>,
364 trusted_signers: &[Vec<u8>],
365) -> Option<VerifiedQuestionAnswer> {
366 let verified = verify_signed_answer_pinned(payload, trusted_signers)?;
367 if verified.conversation_id != conversation_id {
368 return None;
369 }
370 if !verified.turn_id.is_empty() && verified.turn_id != turn_id {
371 return None;
372 }
373 if verified.nonce.is_empty() || consumed.contains(&verified.nonce) {
374 return None;
375 }
376 Some(verified)
377}
378
379#[must_use]
389#[allow(clippy::too_many_arguments)] pub fn verify_wire_answer(
391 turn_id: &str,
392 call_id: &str,
393 index: u32,
394 question_args_json: &str,
395 state: &str,
396 selected_index: Option<u32>,
397 selected_label: &str,
398 answered_by: &str,
399 conversation_id: &str,
400 nonce: &str,
401 signer_pk_hex: &str,
402 signature_hex: &str,
403) -> bool {
404 let Some(pk) = crate::hex::decode(signer_pk_hex) else {
405 return false;
406 };
407 let Some(sig) = crate::hex::decode(signature_hex) else {
408 return false;
409 };
410 let canonical = answer_canonical(
411 turn_id,
412 call_id,
413 index,
414 question_args_json,
415 state,
416 selected_index,
417 selected_label,
418 answered_by,
419 conversation_id,
420 nonce,
421 );
422 verify(&pk, &canonical, &sig)
423}
424
425fn answer_token_canonical(
436 turn_id: &str,
437 call_id: &str,
438 index: u32,
439 conversation_id: &str,
440 minted_at_ms: u64,
441) -> Vec<u8> {
442 canonical_bytes(&AnswerTokenCanonical {
443 turn_id,
444 question_call_id: call_id,
445 question_index: index,
446 conversation_id,
447 minted_at_ms,
448 })
449}
450
451#[derive(Serialize)]
459struct AnswerTokenCanonical<'a> {
460 turn_id: &'a str,
461 question_call_id: &'a str,
462 question_index: u32,
463 conversation_id: &'a str,
464 minted_at_ms: u64,
465}
466
467#[derive(Serialize)]
476struct AnswerToken<'a> {
477 #[serde(flatten)]
478 body: AnswerTokenCanonical<'a>,
479 signature_hex: String,
480}
481
482#[must_use]
496pub fn mint_answer_token(
497 turn_id: &str,
498 call_id: &str,
499 index: u32,
500 conversation_id: &str,
501 minted_at_ms: u64,
502 signer: &ApprovalSigner,
503) -> String {
504 let body = AnswerTokenCanonical {
508 turn_id,
509 question_call_id: call_id,
510 question_index: index,
511 conversation_id,
512 minted_at_ms,
513 };
514 let signature = signer.sign(&canonical_bytes(&body));
515 let full = AnswerToken {
516 body,
517 signature_hex: crate::hex::lower(&signature),
518 };
519 crate::hex::lower(&canonical_bytes(&full))
520}
521
522#[must_use]
533pub fn verify_answer_token(
534 token: &str,
535 turn_id: &str,
536 call_id: &str,
537 index: u32,
538 conversation_id: &str,
539 now_ms: u64,
540 signer: &ApprovalSigner,
541) -> bool {
542 let Some(bytes) = crate::hex::decode(token) else {
543 return false;
544 };
545 let Ok(v) = serde_json::from_slice::<Value>(&bytes) else {
546 return false;
547 };
548 let (
549 Some(bound_turn_id),
550 Some(bound_call_id),
551 Some(bound_index),
552 Some(bound_conversation_id),
553 Some(minted_at_ms),
554 Some(signature_hex),
555 ) = (
556 v.get("turn_id").and_then(Value::as_str),
557 v.get("question_call_id").and_then(Value::as_str),
558 v.get("question_index").and_then(Value::as_u64),
559 v.get("conversation_id").and_then(Value::as_str),
560 v.get("minted_at_ms").and_then(Value::as_u64),
561 v.get("signature_hex").and_then(Value::as_str),
562 )
563 else {
564 return false;
565 };
566 let Ok(bound_index) = u32::try_from(bound_index) else {
567 return false;
568 };
569 if bound_turn_id != turn_id
570 || bound_call_id != call_id
571 || bound_index != index
572 || bound_conversation_id != conversation_id
573 {
574 return false;
575 }
576 let elapsed = now_ms.abs_diff(minted_at_ms);
577 if elapsed > ANSWER_TOKEN_TTL_MS {
578 return false;
579 }
580 let Some(sig) = crate::hex::decode(signature_hex) else {
581 return false;
582 };
583 let canonical = answer_token_canonical(
584 bound_turn_id,
585 bound_call_id,
586 bound_index,
587 bound_conversation_id,
588 minted_at_ms,
589 );
590 verify(&signer.public_key_bytes(), &canonical, &sig)
591}
592
593#[cfg(test)]
594mod tests {
595 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
596 use super::*;
597
598 const TURN: &str = "018f47f0-5f70-7cc5-98df-123456789abc";
599 const OTHER_TURN: &str = "018f47f0-5f70-7cc5-98df-123456789abd";
600
601 #[allow(clippy::too_many_arguments)]
606 fn answer_payload(
607 call_id: &str,
608 index: u32,
609 question_args_json: &str,
610 state: &str,
611 selected_index: Option<u32>,
612 selected_label: &str,
613 answered_by: &str,
614 conversation_id: &str,
615 nonce: &str,
616 signer: &ApprovalSigner,
617 ) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
618 super::answer_payload(
619 "",
620 call_id,
621 index,
622 question_args_json,
623 state,
624 selected_index,
625 selected_label,
626 answered_by,
627 conversation_id,
628 nonce,
629 signer,
630 )
631 }
632
633 fn verify_answer_capability<S: std::hash::BuildHasher>(
636 payload: &[u8],
637 conversation_id: &str,
638 consumed: &HashSet<String, S>,
639 trusted_signers: &[Vec<u8>],
640 ) -> Option<VerifiedQuestionAnswer> {
641 super::verify_answer_capability(payload, conversation_id, TURN, consumed, trusted_signers)
642 }
643
644 fn valid_answer_payload(signer: &ApprovalSigner) -> Vec<u8> {
645 answer_payload(
646 "call-1",
647 0,
648 r#"{"questions":[]}"#,
649 ANSWERED_STATE,
650 Some(1),
651 "Production",
652 "slack:T1:U9",
653 "conv-A",
654 "nonce-A",
655 signer,
656 )
657 .0
658 }
659
660 #[test]
661 fn signed_answer_round_trips() {
662 let signer = ApprovalSigner::from_seed(1);
663 let payload = valid_answer_payload(&signer);
664 let verified = verify_signed_answer(&payload).expect("signature verifies");
665 assert_eq!(verified.call_id, "call-1");
666 assert_eq!(verified.index, 0);
667 assert_eq!(verified.state, ANSWERED_STATE);
668 assert_eq!(verified.selected_index, Some(1));
669 assert_eq!(verified.selected_label, "Production");
670 assert_eq!(verified.answered_by, "slack:T1:U9");
671 assert!(verified.binds_question("call-1", 0, r#"{"questions":[]}"#));
672 assert!(!verified.binds_question("call-2", 0, r#"{"questions":[]}"#));
673 }
674
675 #[test]
683 fn verified_answer_carries_raw_signer_and_signature_hex() {
684 let signer = ApprovalSigner::from_seed(1);
685 let payload = valid_answer_payload(&signer);
686 let verified = verify_signed_answer(&payload).expect("signature verifies");
687 assert_eq!(
688 verified.signer_pk_hex,
689 crate::hex::lower(&signer.public_key_bytes())
690 );
691 assert!(!verified.signature_hex.is_empty());
692 let sig_bytes = crate::hex::decode(&verified.signature_hex).expect("valid hex");
696 let canonical = answer_canonical(
697 &verified.turn_id,
698 &verified.call_id,
699 verified.index,
700 &verified.question_args_json,
701 &verified.state,
702 verified.selected_index,
703 &verified.selected_label,
704 &verified.answered_by,
705 &verified.conversation_id,
706 &verified.nonce,
707 );
708 assert!(verify(&signer.public_key_bytes(), &canonical, &sig_bytes));
709 }
710
711 #[test]
712 fn declined_and_auto_resolved_states_round_trip_with_no_selection_or_answerer() {
713 let signer = ApprovalSigner::from_seed(1);
714 let (payload, ..) = answer_payload(
715 "call-1",
716 0,
717 "{}",
718 DECLINED_STATE,
719 None,
720 "",
721 "slack:T1:U9",
722 "conv-A",
723 "nonce-B",
724 &signer,
725 );
726 let v = verify_signed_answer(&payload).expect("verifies");
727 assert_eq!(v.state, DECLINED_STATE);
728 assert_eq!(v.selected_index, None);
729
730 let (payload, ..) = answer_payload(
731 "call-1",
732 0,
733 "{}",
734 AUTO_RESOLVED_STATE,
735 Some(0),
736 "Staging",
737 "",
738 "conv-A",
739 "nonce-C",
740 &signer,
741 );
742 let v = verify_signed_answer(&payload).expect("verifies");
743 assert_eq!(v.state, AUTO_RESOLVED_STATE);
744 assert_eq!(
745 v.answered_by, "",
746 "nobody answered an auto-resolved question"
747 );
748 }
749
750 #[test]
754 fn tampered_state_or_selection_fails_verification() {
755 let signer = ApprovalSigner::from_seed(1);
756 let payload = valid_answer_payload(&signer);
757 let mut v: Value = serde_json::from_slice(&payload).unwrap();
758 for (field, val) in [
759 ("state", Value::String(DECLINED_STATE.to_owned())),
760 ("selected_index", Value::Number(2.into())),
761 ("selected_label", Value::String("Staging".into())),
762 ("answered_by", Value::String("slack:T1:U0".into())),
763 ("conversation_id", Value::String("conv-B".into())),
764 ("nonce", Value::String("nonce-Z".into())),
765 ("question_call_id", Value::String("call-2".into())),
766 ("question_index", Value::Number(1.into())),
767 ] {
768 let mut tampered = v.clone();
769 tampered[field] = val;
770 let bytes = serde_json::to_vec(&tampered).unwrap();
771 assert!(
772 verify_signed_answer(&bytes).is_none(),
773 "tampering `{field}` must invalidate the signature"
774 );
775 }
776 assert!(verify_signed_answer(&serde_json::to_vec(&v.take()).unwrap()).is_some());
778 }
779
780 #[test]
783 fn answer_from_non_allowlisted_signer_is_rejected() {
784 let trusted = ApprovalSigner::from_seed(1);
785 let attacker = ApprovalSigner::from_seed(666);
786 let payload = valid_answer_payload(&attacker);
787 assert!(
788 verify_signed_answer_pinned(&payload, &[trusted.public_key_bytes()]).is_none(),
789 "an untrusted signer's answer must never verify"
790 );
791 assert!(
792 verify_signed_answer_pinned(&payload, &[attacker.public_key_bytes()]).is_some(),
793 "sanity: the attacker's own key does verify its own signature"
794 );
795 }
796
797 #[test]
801 fn answer_signature_binds_the_turn_occurrence() {
802 let signer = ApprovalSigner::from_seed(7);
803 let (payload, ..) = super::answer_payload(
804 TURN,
805 "call-0",
806 0,
807 r#"{"questions":[]}"#,
808 ANSWERED_STATE,
809 Some(1),
810 "Production",
811 "persona:alice",
812 "conv-A",
813 "nonce-occurrence",
814 &signer,
815 );
816 let v = verify_signed_answer(&payload).expect("the occurrence answer verifies");
817 assert_eq!(v.turn_id, TURN);
818 let wire = |turn: &str| {
819 verify_wire_answer(
820 turn,
821 &v.call_id,
822 v.index,
823 &v.question_args_json,
824 &v.state,
825 v.selected_index,
826 &v.selected_label,
827 &v.answered_by,
828 &v.conversation_id,
829 &v.nonce,
830 &v.signer_pk_hex,
831 &v.signature_hex,
832 )
833 };
834 assert!(wire(TURN));
835 assert!(
836 !wire(OTHER_TURN),
837 "an answer for one occurrence must not verify as another's"
838 );
839 assert!(
840 !wire(""),
841 "an occurrence answer must not verify as a turn-less historical one"
842 );
843 }
844
845 #[test]
850 fn historical_answer_verifies_and_redeems_under_its_event_kind_turn() {
851 let signer = ApprovalSigner::from_seed(7);
852 let trusted = [signer.public_key_bytes()];
853 let consumed = HashSet::new();
854
855 let historical = valid_answer_payload(&signer);
856 let decoded =
857 verify_signed_answer(&historical).expect("a historical answer still verifies");
858 assert!(
859 decoded.turn_id.is_empty(),
860 "a pre-occurrence answer signs no turn"
861 );
862 for turn in [TURN, OTHER_TURN] {
863 assert!(
864 super::verify_answer_capability(&historical, "conv-A", turn, &consumed, &trusted)
865 .is_some(),
866 "a historical answer's turn comes from its event kind, not its signature"
867 );
868 }
869
870 let (current, ..) = super::answer_payload(
871 TURN,
872 "call-1",
873 0,
874 r#"{"questions":[]}"#,
875 ANSWERED_STATE,
876 Some(1),
877 "Production",
878 "slack:T1:U9",
879 "conv-A",
880 "nonce-current",
881 &signer,
882 );
883 assert!(
884 super::verify_answer_capability(¤t, "conv-A", TURN, &consumed, &trusted)
885 .is_some()
886 );
887 assert!(
888 super::verify_answer_capability(¤t, "conv-A", OTHER_TURN, &consumed, &trusted)
889 .is_none(),
890 "a newly minted answer must name the occurrence it is applied to"
891 );
892 }
893
894 #[test]
898 fn answer_rejected_across_conversations() {
899 let signer = ApprovalSigner::from_seed(1);
900 let payload = valid_answer_payload(&signer);
901 let consumed = HashSet::new();
902 assert!(
903 verify_answer_capability(&payload, "conv-A", &consumed, &[signer.public_key_bytes()])
904 .is_some()
905 );
906 assert!(
907 verify_answer_capability(&payload, "conv-B", &consumed, &[signer.public_key_bytes()])
908 .is_none(),
909 "an answer signed for conv-A must be rejected when presented for conv-B"
910 );
911 }
912
913 #[test]
916 fn question_answer_capability_is_single_use() {
917 let signer = ApprovalSigner::from_seed(1);
918 let payload = valid_answer_payload(&signer);
919 let mut consumed = HashSet::new();
920 let v =
921 verify_answer_capability(&payload, "conv-A", &consumed, &[signer.public_key_bytes()])
922 .expect("first use honored");
923 assert_eq!(v.nonce, "nonce-A");
924 consumed.insert(v.nonce.clone());
925 assert!(
926 verify_answer_capability(&payload, "conv-A", &consumed, &[signer.public_key_bytes()])
927 .is_none(),
928 "a spent answer must be rejected on re-presentation"
929 );
930 }
931
932 #[test]
937 fn approval_payloads_never_verify_as_question_answers() {
938 let signer = ApprovalSigner::from_seed(1);
939 let (approval_payload, ..) = crate::approval::response_payload(
940 "call-1",
941 "ask_question",
942 r#"{"questions":[]}"#,
943 "",
944 true,
945 false,
946 &[],
947 "slack:T1:U9",
948 "",
949 "",
950 "",
951 "",
952 "conv-A",
953 "nonce-A",
954 "",
955 &signer,
956 );
957 assert!(
958 verify_signed_answer(&approval_payload).is_none(),
959 "an approval_response payload must never decode+verify as a question_response"
960 );
961
962 let resolve_token = crate::approval::mint_resolve_token(
963 "018f47f0-5f70-7cc5-98df-123456789abc",
964 "call-1",
965 "conv-A",
966 1_000,
967 &signer,
968 );
969 assert!(
970 !verify_answer_token(&resolve_token, TURN, "call-1", 0, "conv-A", 1_000, &signer),
971 "an approval resolve_token must never verify as an answer token"
972 );
973 }
974
975 #[test]
984 fn proto_question_answered_text_recognizes_this_crates_real_state_consts() {
985 let answered = polyc_proto::question_answered_text(
986 "Deploy target",
987 ANSWERED_STATE,
988 "@ada",
989 "Production",
990 );
991 assert!(
992 answered.contains("@ada answered"),
993 "ANSWERED_STATE must render as a real answer, not the declined fallback: {answered}"
994 );
995
996 let auto_resolved = polyc_proto::question_answered_text(
997 "Deploy target",
998 AUTO_RESOLVED_STATE,
999 "",
1000 "Production",
1001 );
1002 assert!(
1003 auto_resolved.contains("Nobody answered"),
1004 "AUTO_RESOLVED_STATE must render as an assumption, not the declined fallback: {auto_resolved}"
1005 );
1006
1007 let declined =
1008 polyc_proto::question_answered_text("Deploy target", DECLINED_STATE, "@ada", "");
1009 assert!(
1010 declined.contains("@ada declined"),
1011 "DECLINED_STATE must render as a real decline: {declined}"
1012 );
1013 }
1014}
1015
1016#[cfg(test)]
1017mod answer_token_tests {
1018 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
1019 use super::*;
1020
1021 const TURN: &str = "018f47f0-5f70-7cc5-98df-123456789abc";
1022 const OTHER_TURN: &str = "018f47f0-5f70-7cc5-98df-123456789abd";
1023
1024 #[test]
1025 fn answer_token_verifies_for_its_own_question() {
1026 let signer = ApprovalSigner::from_seed(11);
1027 let token = mint_answer_token(TURN, "call-1", 0, "conv-A", 1_000, &signer);
1028 assert!(verify_answer_token(
1029 &token, TURN, "call-1", 0, "conv-A", 1_000, &signer
1030 ));
1031 }
1032
1033 #[test]
1037 fn answer_token_rejects_a_different_occurrence_turn() {
1038 let signer = ApprovalSigner::from_seed(11);
1039 let token = mint_answer_token(TURN, "call-1", 0, "conv-A", 1_000, &signer);
1040 assert!(!verify_answer_token(
1041 &token, OTHER_TURN, "call-1", 0, "conv-A", 1_000, &signer
1042 ));
1043 }
1044
1045 #[test]
1050 fn pre_occurrence_token_without_a_turn_fails_closed() {
1051 let signer = ApprovalSigner::from_seed(11);
1052 let old_body = serde_json::json!({
1053 "question_call_id": "call-1",
1054 "question_index": 0,
1055 "conversation_id": "conv-A",
1056 "minted_at_ms": 1_000,
1057 });
1058 let old_signature = signer.sign(&canonical_bytes(&old_body));
1059 let old_token = crate::hex::lower(
1060 &serde_json::to_vec(&serde_json::json!({
1061 "question_call_id": "call-1",
1062 "question_index": 0,
1063 "conversation_id": "conv-A",
1064 "minted_at_ms": 1_000,
1065 "signature_hex": crate::hex::lower(&old_signature),
1066 }))
1067 .expect("the pre-occurrence token serializes"),
1068 );
1069 assert!(!verify_answer_token(
1070 &old_token, TURN, "call-1", 0, "conv-A", 1_000, &signer,
1071 ));
1072 }
1073
1074 #[test]
1075 fn answer_token_rejects_a_different_question_index() {
1076 let signer = ApprovalSigner::from_seed(11);
1077 let token = mint_answer_token(TURN, "call-1", 0, "conv-A", 1_000, &signer);
1078 assert!(!verify_answer_token(
1079 &token, TURN, "call-1", 1, "conv-A", 1_000, &signer
1080 ));
1081 }
1082
1083 #[test]
1084 fn answer_token_rejects_a_different_call_id() {
1085 let signer = ApprovalSigner::from_seed(11);
1086 let token = mint_answer_token(TURN, "call-1", 0, "conv-A", 1_000, &signer);
1087 assert!(!verify_answer_token(
1088 &token, TURN, "call-2", 0, "conv-A", 1_000, &signer
1089 ));
1090 }
1091
1092 #[test]
1093 fn answer_token_rejects_a_different_conversation() {
1094 let signer = ApprovalSigner::from_seed(11);
1095 let token = mint_answer_token(TURN, "call-1", 0, "conv-A", 1_000, &signer);
1096 assert!(!verify_answer_token(
1097 &token, TURN, "call-1", 0, "conv-B", 1_000, &signer
1098 ));
1099 }
1100
1101 #[test]
1102 fn answer_token_rejects_wrong_signer() {
1103 let signer = ApprovalSigner::from_seed(11);
1104 let other = ApprovalSigner::from_seed(12);
1105 let token = mint_answer_token(TURN, "call-1", 0, "conv-A", 1_000, &signer);
1106 assert!(!verify_answer_token(
1107 &token, TURN, "call-1", 0, "conv-A", 1_000, &other
1108 ));
1109 }
1110
1111 #[test]
1112 fn answer_token_rejects_after_ttl_elapses() {
1113 let signer = ApprovalSigner::from_seed(11);
1114 let token = mint_answer_token(TURN, "call-1", 0, "conv-A", 0, &signer);
1115 assert!(verify_answer_token(
1116 &token,
1117 TURN,
1118 "call-1",
1119 0,
1120 "conv-A",
1121 ANSWER_TOKEN_TTL_MS,
1122 &signer
1123 ));
1124 assert!(!verify_answer_token(
1125 &token,
1126 TURN,
1127 "call-1",
1128 0,
1129 "conv-A",
1130 ANSWER_TOKEN_TTL_MS + 1,
1131 &signer
1132 ));
1133 }
1134
1135 #[test]
1136 fn answer_token_rejects_garbage() {
1137 let signer = ApprovalSigner::from_seed(11);
1138 assert!(!verify_answer_token(
1139 "not-hex", TURN, "call-1", 0, "conv-A", 0, &signer
1140 ));
1141 assert!(!verify_answer_token(
1142 "", TURN, "call-1", 0, "conv-A", 0, &signer
1143 ));
1144 }
1145}
1146
1147#[cfg(test)]
1148mod canonical_freeze {
1149 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
1174
1175 use super::*;
1176
1177 fn frozen(label: &str, got: &[u8], want: &str) {
1179 assert_eq!(
1180 String::from_utf8(got.to_vec()).unwrap(),
1181 want,
1182 "{label}: canonical bytes moved — every signature over the old bytes is now unverifiable"
1183 );
1184 }
1185
1186 const QUESTION_ARGS: &str =
1187 r#"{"questions":[{"prompt":"Ship it?","options":["Hold","Yes, proceed"]}]}"#;
1188 const MINTED_AT_MS: u64 = 1_750_000_000_000;
1189 const TURN: &str = "018f47f0-5f70-7cc5-98df-123456789abc";
1190
1191 #[test]
1192 fn answered_canonical_and_payload_are_frozen() {
1193 frozen(
1194 "answer_canonical (answered)",
1195 &answer_canonical(
1196 "",
1197 "call-1",
1198 0,
1199 QUESTION_ARGS,
1200 ANSWERED_STATE,
1201 Some(1),
1202 "Yes, proceed",
1203 "persona:alice",
1204 "conv-1",
1205 "nonce-answer",
1206 ),
1207 ANSWERED_CANONICAL,
1208 );
1209 let (full, sig, _) = answer_payload(
1210 "",
1211 "call-1",
1212 0,
1213 QUESTION_ARGS,
1214 ANSWERED_STATE,
1215 Some(1),
1216 "Yes, proceed",
1217 "persona:alice",
1218 "conv-1",
1219 "nonce-answer",
1220 &ApprovalSigner::from_seed(99),
1221 );
1222 frozen("answer_payload (answered)", &full, ANSWERED_PAYLOAD);
1223 assert_eq!(crate::hex::lower(&sig), ANSWERED_SIG);
1224 }
1225
1226 #[test]
1230 fn declined_canonical_and_payload_are_frozen() {
1231 frozen(
1232 "answer_canonical (declined)",
1233 &answer_canonical(
1234 "",
1235 "call-1",
1236 0,
1237 QUESTION_ARGS,
1238 DECLINED_STATE,
1239 None,
1240 "",
1241 "persona:alice",
1242 "conv-1",
1243 "nonce-answer",
1244 ),
1245 DECLINED_CANONICAL,
1246 );
1247 let (full, sig, _) = answer_payload(
1248 "",
1249 "call-1",
1250 0,
1251 QUESTION_ARGS,
1252 DECLINED_STATE,
1253 None,
1254 "",
1255 "persona:alice",
1256 "conv-1",
1257 "nonce-answer",
1258 &ApprovalSigner::from_seed(99),
1259 );
1260 frozen("answer_payload (declined)", &full, DECLINED_PAYLOAD);
1261 assert_eq!(crate::hex::lower(&sig), DECLINED_SIG);
1262 }
1263
1264 #[test]
1265 fn answer_token_is_frozen() {
1266 frozen(
1267 "answer_token_canonical",
1268 &answer_token_canonical(TURN, "call-1", 0, "conv-1", MINTED_AT_MS),
1269 TOKEN_CANONICAL,
1270 );
1271 assert_eq!(
1272 mint_answer_token(
1273 TURN,
1274 "call-1",
1275 0,
1276 "conv-1",
1277 MINTED_AT_MS,
1278 &ApprovalSigner::from_seed(99)
1279 ),
1280 TOKEN_MINTED,
1281 "a minted answer token's bytes are frozen — a token is hex of the whole object"
1282 );
1283 }
1284
1285 #[test]
1292 fn occurrence_canonical_and_payload_are_frozen() {
1293 frozen(
1294 "answer_canonical (occurrence)",
1295 &answer_canonical(
1296 TURN,
1297 "call-1",
1298 0,
1299 QUESTION_ARGS,
1300 ANSWERED_STATE,
1301 Some(1),
1302 "Yes, proceed",
1303 "persona:alice",
1304 "conv-1",
1305 "nonce-answer",
1306 ),
1307 OCCURRENCE_CANONICAL,
1308 );
1309 let (full, sig, _) = answer_payload(
1310 TURN,
1311 "call-1",
1312 0,
1313 QUESTION_ARGS,
1314 ANSWERED_STATE,
1315 Some(1),
1316 "Yes, proceed",
1317 "persona:alice",
1318 "conv-1",
1319 "nonce-answer",
1320 &ApprovalSigner::from_seed(99),
1321 );
1322 frozen("answer_payload (occurrence)", &full, OCCURRENCE_PAYLOAD);
1323 assert_eq!(crate::hex::lower(&sig), OCCURRENCE_SIG);
1324 }
1325
1326 const OCCURRENCE_CANONICAL: &str = r#"{"turn_id":"018f47f0-5f70-7cc5-98df-123456789abc","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"}"#;
1327 const OCCURRENCE_PAYLOAD: &str = r#"{"turn_id":"018f47f0-5f70-7cc5-98df-123456789abc","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":"4a99c6e0846437d5f15717355a835544dab85676bdf30d593956c6f3535ec49dc921c7994a4d4cf5c6d0f553e4d058bb2c5cd9c9608130e564c983dce09c1f0b"}"#;
1328 const OCCURRENCE_SIG: &str = "4a99c6e0846437d5f15717355a835544dab85676bdf30d593956c6f3535ec49dc921c7994a4d4cf5c6d0f553e4d058bb2c5cd9c9608130e564c983dce09c1f0b";
1329 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"}"#;
1330 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"}"#;
1331 const ANSWERED_SIG: &str = "e7b877065d853887c0188eff5fb971a9d8e531e2c36e3ffa8d02dc5da2b3cb17a8dc140bfb1d6b4c6c6529d0b4890851516d03b1d633d96b1496d6713b08a705";
1332 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"}"#;
1333 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"}"#;
1334 const DECLINED_SIG: &str = "5961a4cc427c84674a0a60b0f51552143b511751c2295705dbd323e062b504858de0f2209e67bb0c7a6826e80ec9831c2864c9f910cf7b4e137656da273fa30d";
1335 const TOKEN_CANONICAL: &str = r#"{"turn_id":"018f47f0-5f70-7cc5-98df-123456789abc","question_call_id":"call-1","question_index":0,"conversation_id":"conv-1","minted_at_ms":1750000000000}"#;
1336 const TOKEN_MINTED: &str = "7b227475726e5f6964223a2230313866343766302d356637302d376363352d393864662d313233343536373839616263222c227175657374696f6e5f63616c6c5f6964223a2263616c6c2d31222c227175657374696f6e5f696e646578223a302c22636f6e766572736174696f6e5f6964223a22636f6e762d31222c226d696e7465645f61745f6d73223a313735303030303030303030302c227369676e61747572655f686578223a223461306239656333303930623666323662633362623436636238316564363764613935303365386235353533323937396661626237646330626234366538336135306436356236643664656231363734363739636134643437643265333836303533636663336133303061623865643330636362353465623739616363653035227d";
1337}