1use serde::Serialize;
29use serde_json::Value;
30use std::collections::HashSet;
31
32use crate::signed::{Envelope, canonical_bytes};
33use crate::verify;
34
35pub use crate::signing_role::ApprovalSigner;
36
37#[must_use]
74pub fn request_payload(
75 request_id: &str,
76 tool_name: &str,
77 args_json: &str,
78 sandbox_mode: &str,
79 reason: &str,
80 missing_capabilities: &[String],
81 preview_json: &str,
82) -> Vec<u8> {
83 let preview: Value = if preview_json.is_empty() {
89 Value::Null
90 } else {
91 serde_json::from_str(preview_json).unwrap_or(Value::Null)
92 };
93 serde_json::json!({
99 "tool_name": tool_name,
100 "args_json": args_json,
101 "request_id": request_id,
102 "sandbox_mode": sandbox_mode,
103 "reason": reason,
104 "missing_capabilities": missing_capabilities,
105 "preview": preview,
106 })
107 .to_string()
108 .into_bytes()
109}
110
111#[allow(clippy::too_many_arguments)] fn response_canonical(
158 request_id: &str,
159 tool_name: &str,
160 args_json: &str,
161 modified_args_json: &str,
162 approved: bool,
163 approved_for_session: bool,
164 covered_capabilities: &[String],
165 caller: &str,
166 approver_id: &str,
167 sandbox_mode: &str,
168 reason: &str,
169 injected_context: &str,
170 conversation_id: &str,
171 nonce: &str,
172) -> Vec<u8> {
173 canonical_bytes(&ResponseCanonical {
174 body: ResponseBody {
175 request_id,
176 tool_name,
177 args_json,
178 modified_args_json,
179 approved,
180 approved_for_session,
181 covered_capabilities,
182 caller,
183 sandbox_mode,
184 reason,
185 injected_context,
186 conversation_id,
187 nonce,
188 },
189 approver: approver_id,
190 })
191}
192
193#[derive(Serialize)]
202struct ResponseBody<'a> {
203 request_id: &'a str,
204 tool_name: &'a str,
205 args_json: &'a str,
206 modified_args_json: &'a str,
207 approved: bool,
208 approved_for_session: bool,
209 covered_capabilities: &'a [String],
210 caller: &'a str,
211 sandbox_mode: &'a str,
212 reason: &'a str,
213 injected_context: &'a str,
214 conversation_id: &'a str,
215 nonce: &'a str,
216}
217
218#[derive(Serialize)]
225struct ResponseCanonical<'a> {
226 #[serde(flatten)]
227 body: ResponseBody<'a>,
228 #[serde(skip_serializing_if = "str::is_empty")]
229 approver: &'a str,
230}
231
232#[derive(Serialize)]
239struct ResponseFull<'a> {
240 #[serde(flatten)]
241 body: ResponseBody<'a>,
242 signed_by: String,
243 signature_hex: String,
244 #[serde(skip_serializing_if = "str::is_empty")]
245 approver: &'a str,
246}
247
248#[must_use]
266#[allow(clippy::too_many_arguments)] pub fn response_payload(
268 request_id: &str,
269 tool_name: &str,
270 args_json: &str,
271 modified_args_json: &str,
272 approved: bool,
273 approved_for_session: bool,
274 covered_capabilities: &[String],
275 caller: &str,
276 approver_id: &str,
277 sandbox_mode: &str,
278 reason: &str,
279 injected_context: &str,
280 conversation_id: &str,
281 nonce: &str,
282 signer: &ApprovalSigner,
283) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
284 let canonical = response_canonical(
285 request_id,
286 tool_name,
287 args_json,
288 modified_args_json,
289 approved,
290 approved_for_session,
291 covered_capabilities,
292 caller,
293 approver_id,
294 sandbox_mode,
295 reason,
296 injected_context,
297 conversation_id,
298 nonce,
299 );
300 let signature = signer.sign(&canonical);
301 let pk = signer.public_key_bytes();
302 let full = ResponseFull {
305 body: ResponseBody {
306 request_id,
307 tool_name,
308 args_json,
309 modified_args_json,
310 approved,
311 approved_for_session,
312 covered_capabilities,
313 caller,
314 sandbox_mode,
315 reason,
316 injected_context,
317 conversation_id,
318 nonce,
319 },
320 signed_by: crate::hex::lower(&pk),
321 signature_hex: crate::hex::lower(&signature),
322 approver: approver_id,
323 };
324 (canonical_bytes(&full), signature, pk)
325}
326
327pub const EXCISION_SCOPE_CASCADE: &str = "cascade";
337pub const EXCISION_SCOPE_SOURCE_ONLY: &str = "source-only";
339
340fn excision_canonical(
348 conversation_id: &str,
349 scope: &str,
350 positions: &[u64],
351 requested_by: &str,
352 reason: &str,
353) -> Vec<u8> {
354 canonical_bytes(&ExcisionCanonical {
355 conversation_id,
356 scope,
357 positions,
358 requested_by,
359 reason,
360 })
361}
362
363#[derive(Serialize)]
365struct ExcisionCanonical<'a> {
366 conversation_id: &'a str,
367 scope: &'a str,
368 positions: &'a [u64],
369 requested_by: &'a str,
370 reason: &'a str,
371}
372
373#[must_use]
377pub fn excision_payload(
378 conversation_id: &str,
379 scope: &str,
380 positions: &[u64],
381 requested_by: &str,
382 reason: &str,
383 signer: &ApprovalSigner,
384) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
385 Envelope::seal(
386 ExcisionCanonical {
387 conversation_id,
388 scope,
389 positions,
390 requested_by,
391 reason,
392 },
393 signer.as_signer(),
394 )
395}
396
397fn grant_replay_canonical(
407 conversation_id: &str,
408 turn_id: &str,
409 tool: &str,
410 grant_ref: &str,
411 covered_capabilities: &[String],
412 coverage_hash: &str,
413) -> Vec<u8> {
414 canonical_bytes(&GrantReplayCanonical {
415 conversation_id,
416 turn_id,
417 tool,
418 grant_ref,
419 covered_capabilities,
420 coverage_hash,
421 })
422}
423
424#[derive(Serialize)]
426struct GrantReplayCanonical<'a> {
427 conversation_id: &'a str,
428 turn_id: &'a str,
429 tool: &'a str,
430 grant_ref: &'a str,
431 covered_capabilities: &'a [String],
432 coverage_hash: &'a str,
433}
434
435#[must_use]
445#[allow(clippy::too_many_arguments)] pub fn grant_replay_payload(
447 conversation_id: &str,
448 turn_id: &str,
449 tool: &str,
450 grant_ref: &str,
451 covered_capabilities: &[String],
452 coverage_hash: &str,
453 signer: &ApprovalSigner,
454) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
455 Envelope::seal(
456 GrantReplayCanonical {
457 conversation_id,
458 turn_id,
459 tool,
460 grant_ref,
461 covered_capabilities,
462 coverage_hash,
463 },
464 signer.as_signer(),
465 )
466}
467
468#[must_use]
476pub fn verify_grant_replay(payload: &[u8]) -> bool {
477 let Ok(v) = serde_json::from_slice::<serde_json::Value>(payload) else {
478 return false;
479 };
480 let (
481 Some(conversation_id),
482 Some(turn_id),
483 Some(tool),
484 Some(grant_ref),
485 Some(covered),
486 Some(coverage_hash),
487 Some(signed_by),
488 Some(signature_hex),
489 ) = (
490 v.get("conversation_id").and_then(Value::as_str),
491 v.get("turn_id").and_then(Value::as_str),
492 v.get("tool").and_then(Value::as_str),
493 v.get("grant_ref").and_then(Value::as_str),
494 v.get("covered_capabilities").and_then(Value::as_array),
495 v.get("coverage_hash").and_then(Value::as_str),
496 v.get("signed_by").and_then(Value::as_str),
497 v.get("signature_hex").and_then(Value::as_str),
498 )
499 else {
500 return false;
501 };
502 let Some(covered_capabilities) = covered
503 .iter()
504 .map(|c| c.as_str().map(str::to_owned))
505 .collect::<Option<Vec<_>>>()
506 else {
507 return false;
508 };
509 let (Some(pk), Some(sig)) = (
510 crate::hex::decode(signed_by),
511 crate::hex::decode(signature_hex),
512 ) else {
513 return false;
514 };
515 let canonical = grant_replay_canonical(
516 conversation_id,
517 turn_id,
518 tool,
519 grant_ref,
520 &covered_capabilities,
521 coverage_hash,
522 );
523 crate::verify(&pk, &canonical, &sig)
524}
525
526fn signer_is_trusted(signer_pk: &[u8], trusted_signers: &[Vec<u8>]) -> bool {
538 trusted_signers.iter().any(|k| k.as_slice() == signer_pk)
539}
540
541#[must_use]
555pub fn verify_grant_replay_pinned(payload: &[u8], trusted_signers: &[Vec<u8>]) -> bool {
556 let Some(pk) = serde_json::from_slice::<Value>(payload).ok().and_then(|v| {
560 v.get("signed_by")
561 .and_then(Value::as_str)
562 .and_then(crate::hex::decode)
563 }) else {
564 return false;
565 };
566 if !signer_is_trusted(&pk, trusted_signers) {
567 return false;
568 }
569 verify_grant_replay(payload)
570}
571
572#[derive(Debug, Clone, PartialEq, Eq)]
574pub struct VerifiedExcision {
575 pub conversation_id: String,
577 pub scope: String,
579 pub positions: Vec<u64>,
581 pub requested_by: String,
583 pub reason: String,
585 pub signer_public_key: Vec<u8>,
587}
588
589impl VerifiedExcision {
590 #[must_use]
592 pub fn is_cascade(&self) -> bool {
593 self.scope == EXCISION_SCOPE_CASCADE
594 }
595}
596
597#[must_use]
603pub fn verify_signed_excision(payload: &[u8]) -> Option<VerifiedExcision> {
604 let v: Value = serde_json::from_slice(payload).ok()?;
605 let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
606 let scope = v.get("scope")?.as_str()?.to_owned();
607 if scope != EXCISION_SCOPE_CASCADE && scope != EXCISION_SCOPE_SOURCE_ONLY {
608 return None;
609 }
610 let positions: Vec<u64> = v
611 .get("positions")?
612 .as_array()?
613 .iter()
614 .map(serde_json::Value::as_u64)
615 .collect::<Option<Vec<_>>>()?;
616 let requested_by = v.get("requested_by")?.as_str()?.to_owned();
617 let reason = v.get("reason")?.as_str()?.to_owned();
618 let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
619 let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
620 let canonical =
621 excision_canonical(&conversation_id, &scope, &positions, &requested_by, &reason);
622 if verify(&pk, &canonical, &sig) {
623 Some(VerifiedExcision {
624 conversation_id,
625 scope,
626 positions,
627 requested_by,
628 reason,
629 signer_public_key: pk,
630 })
631 } else {
632 None
633 }
634}
635
636#[must_use]
647pub fn deferred_payload(
648 request_id: &str,
649 conversation_id: &str,
650 reason: &str,
651 signer: &ApprovalSigner,
652) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
653 Envelope::seal(
654 DeferredCanonical {
655 request_id,
656 conversation_id,
657 reason,
658 },
659 signer.as_signer(),
660 )
661}
662
663#[derive(Serialize)]
665struct DeferredCanonical<'a> {
666 request_id: &'a str,
667 conversation_id: &'a str,
668 reason: &'a str,
669}
670
671#[must_use]
676pub fn verify_deferred(payload: &[u8]) -> Option<(String, String, String)> {
677 let v: Value = serde_json::from_slice(payload).ok()?;
678 let request_id = v.get("request_id")?.as_str()?.to_owned();
679 let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
680 let reason = v.get("reason")?.as_str()?.to_owned();
681 let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
682 let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
683 let canonical = canonical_bytes(&DeferredCanonical {
684 request_id: &request_id,
685 conversation_id: &conversation_id,
686 reason: &reason,
687 });
688 verify(&pk, &canonical, &sig).then_some((request_id, conversation_id, reason))
689}
690
691#[must_use]
704#[allow(clippy::too_many_arguments)] pub fn mutation_payload(
706 kind: &str,
707 tool_call_id: &str,
708 tool_name: &str,
709 conversation_id: &str,
710 before: &str,
711 after: &str,
712 signer: &ApprovalSigner,
713) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
714 Envelope::seal(
715 MutationCanonical {
716 kind,
717 tool_call_id,
718 tool_name,
719 conversation_id,
720 before,
721 after,
722 },
723 signer.as_signer(),
724 )
725}
726
727#[derive(Serialize)]
729struct MutationCanonical<'a> {
730 kind: &'a str,
731 tool_call_id: &'a str,
732 tool_name: &'a str,
733 conversation_id: &'a str,
734 before: &'a str,
735 after: &'a str,
736}
737
738#[must_use]
743pub fn verify_mutation(payload: &[u8]) -> Option<(String, String, String, String, String, String)> {
744 let v: Value = serde_json::from_slice(payload).ok()?;
745 let kind = v.get("kind")?.as_str()?.to_owned();
746 let tool_call_id = v.get("tool_call_id")?.as_str()?.to_owned();
747 let tool_name = v.get("tool_name")?.as_str()?.to_owned();
748 let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
749 let before = v.get("before")?.as_str()?.to_owned();
750 let after = v.get("after")?.as_str()?.to_owned();
751 let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
752 let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
753 let canonical = canonical_bytes(&MutationCanonical {
754 kind: &kind,
755 tool_call_id: &tool_call_id,
756 tool_name: &tool_name,
757 conversation_id: &conversation_id,
758 before: &before,
759 after: &after,
760 });
761 verify(&pk, &canonical, &sig).then_some((
762 kind,
763 tool_call_id,
764 tool_name,
765 conversation_id,
766 before,
767 after,
768 ))
769}
770
771pub const AUTO_REVIEW_REASON_PREFIX: &str = "auto-review:";
787
788#[must_use]
796pub fn auto_review_reason(tier: &str) -> String {
797 format!("{AUTO_REVIEW_REASON_PREFIX}{tier}")
798}
799
800#[must_use]
806pub fn is_auto_review_reason(reason: &str) -> bool {
807 reason.starts_with(AUTO_REVIEW_REASON_PREFIX)
808}
809
810pub const APPROVE_ALL_DANGEROUS_REASON: &str = "approve-all-dangerous: blanket machine approval";
820
821#[derive(Debug, Clone)]
823pub struct VerifiedResponse {
824 pub request_id: String,
826 pub tool_name: String,
828 pub args_json: String,
833 pub modified_args_json: String,
839 pub approved: bool,
841 pub approved_for_session: bool,
844 pub covered_capabilities: Vec<String>,
851 pub caller: String,
856 pub approver: String,
864 pub sandbox_mode: String,
867 pub reason: String,
869 pub injected_context: String,
873 pub conversation_id: String,
877 pub nonce: String,
880 pub signer_public_key: Vec<u8>,
882}
883
884impl VerifiedResponse {
885 #[must_use]
893 pub fn authorizes_call(&self, request_id: &str, tool_name: &str, args_json: &str) -> bool {
894 self.approved
895 && self.request_id == request_id
896 && self.tool_name == tool_name
897 && self.args_json == args_json
898 }
899}
900
901#[must_use]
908pub fn verify_signed_response(payload: &[u8]) -> Option<VerifiedResponse> {
909 let v: Value = serde_json::from_slice(payload).ok()?;
910 let request_id = v.get("request_id")?.as_str()?.to_owned();
911 let tool_name = v.get("tool_name")?.as_str()?.to_owned();
912 let args_json = v.get("args_json")?.as_str()?.to_owned();
913 let modified_args_json = v.get("modified_args_json")?.as_str()?.to_owned();
914 let approved = v.get("approved")?.as_bool()?;
915 let approved_for_session = v.get("approved_for_session")?.as_bool()?;
916 let covered_capabilities: Vec<String> = v
917 .get("covered_capabilities")?
918 .as_array()?
919 .iter()
920 .map(|c| c.as_str().map(str::to_owned))
921 .collect::<Option<Vec<_>>>()?;
922 let caller = v.get("caller")?.as_str()?.to_owned();
923 let approver_id = v
927 .get("approver")
928 .and_then(|x| x.as_str())
929 .unwrap_or_default()
930 .to_owned();
931 let sandbox_mode = v.get("sandbox_mode")?.as_str()?.to_owned();
932 let reason = v.get("reason")?.as_str()?.to_owned();
933 let injected_context = v.get("injected_context")?.as_str()?.to_owned();
934 let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
935 let nonce = v.get("nonce")?.as_str()?.to_owned();
936 let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
937 let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
938
939 let canonical = response_canonical(
940 &request_id,
941 &tool_name,
942 &args_json,
943 &modified_args_json,
944 approved,
945 approved_for_session,
946 &covered_capabilities,
947 &caller,
948 &approver_id,
949 &sandbox_mode,
950 &reason,
951 &injected_context,
952 &conversation_id,
953 &nonce,
954 );
955 if verify(&pk, &canonical, &sig) {
956 Some(VerifiedResponse {
957 request_id,
958 tool_name,
959 args_json,
960 modified_args_json,
961 approved,
962 approved_for_session,
963 covered_capabilities,
964 caller,
965 approver: approver_id,
966 sandbox_mode,
967 reason,
968 injected_context,
969 conversation_id,
970 nonce,
971 signer_public_key: pk,
972 })
973 } else {
974 None
975 }
976}
977
978#[must_use]
991pub fn verify_signed_response_pinned(
992 payload: &[u8],
993 trusted_signers: &[Vec<u8>],
994) -> Option<VerifiedResponse> {
995 let verified = verify_signed_response(payload)?;
996 if !signer_is_trusted(&verified.signer_public_key, trusted_signers) {
999 return None;
1000 }
1001 Some(verified)
1002}
1003
1004#[must_use]
1025pub fn verify_capability<S: std::hash::BuildHasher>(
1026 payload: &[u8],
1027 conversation_id: &str,
1028 consumed: &HashSet<String, S>,
1029 trusted_signers: &[Vec<u8>],
1030) -> Option<VerifiedResponse> {
1031 let verified = verify_signed_response_pinned(payload, trusted_signers)?;
1032 if verified.conversation_id != conversation_id {
1033 return None;
1034 }
1035 if verified.nonce.is_empty() || consumed.contains(&verified.nonce) {
1036 return None;
1037 }
1038 Some(verified)
1039}
1040
1041#[must_use]
1052#[allow(clippy::too_many_arguments)] pub fn verify_wire_response(
1054 request_id: &str,
1055 tool_name: &str,
1056 args_json: &str,
1057 modified_args_json: &str,
1058 approved: bool,
1059 approved_for_session: bool,
1060 covered_capabilities: &[String],
1061 caller: &str,
1062 approver_id: &str,
1063 sandbox_mode: &str,
1064 reason: &str,
1065 injected_context: &str,
1066 conversation_id: &str,
1067 nonce: &str,
1068 signer_pk_hex: &str,
1069 signature_hex: &str,
1070) -> bool {
1071 let Some(pk) = crate::hex::decode(signer_pk_hex) else {
1072 return false;
1073 };
1074 let Some(sig) = crate::hex::decode(signature_hex) else {
1075 return false;
1076 };
1077 let canonical = response_canonical(
1078 request_id,
1079 tool_name,
1080 args_json,
1081 modified_args_json,
1082 approved,
1083 approved_for_session,
1084 covered_capabilities,
1085 caller,
1086 approver_id,
1087 sandbox_mode,
1088 reason,
1089 injected_context,
1090 conversation_id,
1091 nonce,
1092 );
1093 verify(&pk, &canonical, &sig)
1094}
1095
1096#[must_use]
1109pub fn is_session_grant_for(
1110 approved: bool,
1111 approved_for_session: bool,
1112 caller: &str,
1113 current_caller: &str,
1114) -> bool {
1115 approved && approved_for_session && !caller.is_empty() && caller == current_caller
1116}
1117
1118#[must_use]
1124pub fn decode_response_minimal(payload: &[u8]) -> Option<(String, bool)> {
1125 let v: Value = serde_json::from_slice(payload).ok()?;
1126 let request_id = v.get("request_id")?.as_str()?.to_owned();
1127 let approved = v.get("approved")?.as_bool()?;
1128 Some((request_id, approved))
1129}
1130
1131#[derive(Debug, Clone)]
1133pub struct DecodedResponse {
1134 pub request_id: String,
1136 pub tool_name: String,
1138 pub args_json: String,
1140 pub modified_args_json: String,
1142 pub approved: bool,
1144 pub approved_for_session: bool,
1146 pub covered_capabilities: Vec<String>,
1148 pub caller: String,
1151 pub approver: String,
1155 pub sandbox_mode: String,
1157 pub reason: String,
1159 pub injected_context: String,
1161 pub conversation_id: String,
1163 pub nonce: String,
1165 pub signer_pk_hex: String,
1167 pub signature_hex: String,
1169}
1170
1171#[must_use]
1176pub fn decode_response_full(payload: &[u8]) -> Option<DecodedResponse> {
1177 let v: Value = serde_json::from_slice(payload).ok()?;
1178 Some(DecodedResponse {
1179 request_id: v.get("request_id")?.as_str()?.to_owned(),
1180 tool_name: v.get("tool_name")?.as_str()?.to_owned(),
1181 args_json: v.get("args_json")?.as_str()?.to_owned(),
1182 modified_args_json: v.get("modified_args_json")?.as_str()?.to_owned(),
1183 approved: v.get("approved")?.as_bool()?,
1184 approved_for_session: v.get("approved_for_session")?.as_bool()?,
1185 covered_capabilities: v
1186 .get("covered_capabilities")
1187 .and_then(Value::as_array)
1188 .map(|a| {
1189 a.iter()
1190 .filter_map(|c| c.as_str().map(str::to_owned))
1191 .collect()
1192 })
1193 .unwrap_or_default(),
1194 caller: v.get("caller")?.as_str()?.to_owned(),
1195 approver: v
1198 .get("approver")
1199 .and_then(|x| x.as_str())
1200 .unwrap_or_default()
1201 .to_owned(),
1202 sandbox_mode: v.get("sandbox_mode")?.as_str()?.to_owned(),
1203 reason: v.get("reason")?.as_str()?.to_owned(),
1204 injected_context: v.get("injected_context")?.as_str()?.to_owned(),
1205 conversation_id: v.get("conversation_id")?.as_str()?.to_owned(),
1206 nonce: v.get("nonce")?.as_str()?.to_owned(),
1207 signer_pk_hex: v.get("signed_by")?.as_str()?.to_owned(),
1208 signature_hex: v.get("signature_hex")?.as_str()?.to_owned(),
1209 })
1210}
1211
1212pub const RECEIPT_VERSION: u64 = 3;
1229
1230const _: () = assert!(
1239 RECEIPT_VERSION == 3,
1240 "RECEIPT_VERSION changed. A bump is five edits, not one: (1) add \
1241 ReceiptPayload::canonical_json_v<N> beside the frozen builders; (2) add \
1242 ReceiptSchema::V<N> and answer number(), covers_binding() — say which \
1243 fields the new canonical actually covers — and canonical(); (3) give \
1244 ReceiptSchema::resolve an arm mapping the claimed number to it; (4) \
1245 repoint receipt_payload at the new builder; (5) in the tests, add a \
1246 golden v<N> fixture beside GOLDEN_V2_RECEIPT and re-freeze the pinned \
1247 writer shape, leaving every already-frozen v2 literal untouched"
1248);
1249
1250#[derive(Debug, Clone, Copy)]
1268pub struct ReceiptPayload<'a> {
1269 pub kind: &'a str,
1274 pub reference: &'a str,
1276 pub amount: &'a str,
1287 pub currency: &'a str,
1291 pub recipient: &'a str,
1293 pub method: &'a str,
1295 pub timestamp: &'a str,
1297 pub tool_call_id: &'a str,
1300 pub approval_pos: &'a str,
1303 pub approved_args_hash: &'a str,
1307 pub subject: &'a str,
1311 pub payer_kind: &'a str,
1316 pub paying_account: &'a str,
1321}
1322
1323impl ReceiptPayload<'_> {
1324 #[must_use]
1342 const fn canonical_json_v3(&self) -> ReceiptCanonicalV3<'_> {
1343 ReceiptCanonicalV3 {
1344 version: 3,
1345 kind: self.kind,
1346 reference: self.reference,
1347 amount: self.amount,
1348 currency: self.currency,
1349 recipient: self.recipient,
1350 method: self.method,
1351 timestamp: self.timestamp,
1352 tool_call_id: self.tool_call_id,
1353 approval_pos: self.approval_pos,
1354 approved_args_hash: self.approved_args_hash,
1355 subject: self.subject,
1356 payer_kind: self.payer_kind,
1357 paying_account: self.paying_account,
1358 }
1359 }
1360
1361 #[must_use]
1379 const fn canonical_json_v2(&self) -> ReceiptCanonicalV2<'_> {
1380 ReceiptCanonicalV2 {
1381 version: 2,
1382 kind: self.kind,
1383 reference: self.reference,
1384 amount: self.amount,
1385 currency: self.currency,
1386 recipient: self.recipient,
1387 method: self.method,
1388 timestamp: self.timestamp,
1389 tool_call_id: self.tool_call_id,
1390 approval_pos: self.approval_pos,
1391 approved_args_hash: self.approved_args_hash,
1392 subject: self.subject,
1393 }
1394 }
1395
1396 #[must_use]
1400 const fn canonical_json_v1(&self) -> ReceiptCanonicalV1<'_> {
1401 ReceiptCanonicalV1 {
1402 reference: self.reference,
1403 amount: self.amount,
1404 currency: self.currency,
1405 recipient: self.recipient,
1406 method: self.method,
1407 timestamp: self.timestamp,
1408 }
1409 }
1410}
1411
1412#[derive(Serialize)]
1419struct ReceiptCanonicalV3<'a> {
1420 version: u8,
1421 kind: &'a str,
1422 reference: &'a str,
1423 amount: &'a str,
1424 currency: &'a str,
1425 recipient: &'a str,
1426 method: &'a str,
1427 timestamp: &'a str,
1428 tool_call_id: &'a str,
1429 approval_pos: &'a str,
1430 approved_args_hash: &'a str,
1431 subject: &'a str,
1432 payer_kind: &'a str,
1433 paying_account: &'a str,
1434}
1435
1436#[derive(Serialize)]
1443struct ReceiptCanonicalV2<'a> {
1444 version: u8,
1445 kind: &'a str,
1446 reference: &'a str,
1447 amount: &'a str,
1448 currency: &'a str,
1449 recipient: &'a str,
1450 method: &'a str,
1451 timestamp: &'a str,
1452 tool_call_id: &'a str,
1453 approval_pos: &'a str,
1454 approved_args_hash: &'a str,
1455 subject: &'a str,
1456}
1457
1458#[derive(Serialize)]
1461struct ReceiptCanonicalV1<'a> {
1462 reference: &'a str,
1463 amount: &'a str,
1464 currency: &'a str,
1465 recipient: &'a str,
1466 method: &'a str,
1467 timestamp: &'a str,
1468}
1469
1470#[must_use]
1486pub fn receipt_payload(
1487 fields: &ReceiptPayload<'_>,
1488 signer: &ApprovalSigner,
1489) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
1490 Envelope::seal(fields.canonical_json_v3(), signer.as_signer())
1499}
1500
1501#[derive(Debug, Clone)]
1503pub struct VerifiedReceipt {
1504 pub reference: String,
1506 pub amount: String,
1516 pub currency: String,
1519 pub recipient: String,
1521 pub method: String,
1523 pub timestamp: String,
1525 pub version: u64,
1528 pub kind: String,
1532 pub tool_call_id: String,
1534 pub approval_pos: String,
1536 pub approved_args_hash: String,
1538 pub subject: String,
1540 pub payer_kind: String,
1544 pub paying_account: String,
1547 pub signer_public_key: Vec<u8>,
1549}
1550
1551#[derive(Debug, Clone, Copy)]
1570enum ReceiptSchema {
1571 V1,
1573 V2,
1576 V3,
1578}
1579
1580impl ReceiptSchema {
1581 #[must_use]
1596 fn resolve(claimed: Option<&Value>) -> Option<Self> {
1597 let Some(claimed) = claimed else {
1598 return Some(Self::V1);
1599 };
1600 match claimed.as_u64() {
1604 Some(2) => Some(Self::V2),
1605 Some(3) => Some(Self::V3),
1606 _ => None,
1607 }
1608 }
1609
1610 #[must_use]
1612 const fn number(self) -> u64 {
1613 match self {
1614 Self::V1 => 1,
1615 Self::V2 => 2,
1616 Self::V3 => 3,
1617 }
1618 }
1619
1620 #[must_use]
1630 const fn covers_binding(self) -> bool {
1631 match self {
1632 Self::V1 => false,
1633 Self::V2 | Self::V3 => true,
1634 }
1635 }
1636
1637 #[must_use]
1646 const fn covers_payer(self) -> bool {
1647 match self {
1648 Self::V1 | Self::V2 => false,
1649 Self::V3 => true,
1650 }
1651 }
1652
1653 #[must_use]
1656 fn canonical(self, fields: &ReceiptPayload<'_>) -> Vec<u8> {
1657 match self {
1658 Self::V1 => canonical_bytes(&fields.canonical_json_v1()),
1659 Self::V2 => canonical_bytes(&fields.canonical_json_v2()),
1660 Self::V3 => canonical_bytes(&fields.canonical_json_v3()),
1661 }
1662 }
1663}
1664
1665#[must_use]
1689pub fn verify_signed_receipt(
1690 payload: &[u8],
1691 trusted_signers: &[Vec<u8>],
1692) -> Option<VerifiedReceipt> {
1693 let v: Value = serde_json::from_slice(payload).ok()?;
1694 let reference = v.get("reference")?.as_str()?.to_owned();
1695 let amount = v.get("amount")?.as_str()?.to_owned();
1696 let currency = v.get("currency")?.as_str()?.to_owned();
1697 let recipient = v.get("recipient")?.as_str()?.to_owned();
1698 let method = v.get("method")?.as_str()?.to_owned();
1699 let timestamp = v.get("timestamp")?.as_str()?.to_owned();
1700 let signed_by_hex = v.get("signed_by")?.as_str()?;
1701 let signature_hex = v.get("signature_hex")?.as_str()?;
1702 let pk = crate::hex::decode(signed_by_hex)?;
1703 let sig = crate::hex::decode(signature_hex)?;
1704 if !signer_is_trusted(&pk, trusted_signers) {
1709 return None;
1710 }
1711 let schema = ReceiptSchema::resolve(v.get("version"))?;
1717 let (kind, tool_call_id, approval_pos, approved_args_hash, subject) = if schema.covers_binding()
1718 {
1719 (
1720 v.get("kind")?.as_str()?.to_owned(),
1721 v.get("tool_call_id")?.as_str()?.to_owned(),
1722 v.get("approval_pos")?.as_str()?.to_owned(),
1723 v.get("approved_args_hash")?.as_str()?.to_owned(),
1724 v.get("subject")?.as_str()?.to_owned(),
1725 )
1726 } else {
1727 (
1731 String::new(),
1732 String::new(),
1733 String::new(),
1734 String::new(),
1735 String::new(),
1736 )
1737 };
1738 let (payer_kind, paying_account) = if schema.covers_payer() {
1739 (
1740 v.get("payer_kind")?.as_str()?.to_owned(),
1741 v.get("paying_account")?.as_str()?.to_owned(),
1742 )
1743 } else {
1744 (String::new(), String::new())
1748 };
1749 let fields = ReceiptPayload {
1752 kind: &kind,
1753 reference: &reference,
1754 amount: &amount,
1755 currency: ¤cy,
1756 recipient: &recipient,
1757 method: &method,
1758 timestamp: ×tamp,
1759 tool_call_id: &tool_call_id,
1760 approval_pos: &approval_pos,
1761 approved_args_hash: &approved_args_hash,
1762 subject: &subject,
1763 payer_kind: &payer_kind,
1764 paying_account: &paying_account,
1765 };
1766 let canonical = schema.canonical(&fields);
1767 if verify(&pk, &canonical, &sig) {
1768 Some(VerifiedReceipt {
1769 reference,
1770 amount,
1771 currency,
1772 recipient,
1773 method,
1774 timestamp,
1775 version: schema.number(),
1776 kind,
1777 tool_call_id,
1778 approval_pos,
1779 approved_args_hash,
1780 subject,
1781 payer_kind,
1782 paying_account,
1783 signer_public_key: pk,
1784 })
1785 } else {
1786 None
1787 }
1788}
1789
1790#[derive(Debug, Clone, Copy)]
1800pub struct RefusalPayload<'a> {
1801 pub kind: &'a str,
1805 pub reason: &'a str,
1810 pub reason_detail: &'a str,
1814 pub merchant_host: &'a str,
1817 pub requested_base_units: &'a str,
1821 pub permitted_base_units: &'a str,
1824 pub tool_call_id: &'a str,
1826 pub subject: &'a str,
1829 pub timestamp: &'a str,
1838}
1839
1840impl RefusalPayload<'_> {
1841 #[must_use]
1846 const fn canonical(&self) -> RefusalCanonical<'_> {
1847 RefusalCanonical {
1848 kind: self.kind,
1849 reason: self.reason,
1850 reason_detail: self.reason_detail,
1851 merchant_host: self.merchant_host,
1852 requested_base_units: self.requested_base_units,
1853 permitted_base_units: self.permitted_base_units,
1854 tool_call_id: self.tool_call_id,
1855 subject: self.subject,
1856 timestamp: self.timestamp,
1857 }
1858 }
1859}
1860
1861#[derive(Serialize)]
1864struct RefusalCanonical<'a> {
1865 kind: &'a str,
1866 reason: &'a str,
1867 reason_detail: &'a str,
1868 merchant_host: &'a str,
1869 requested_base_units: &'a str,
1870 permitted_base_units: &'a str,
1871 tool_call_id: &'a str,
1872 subject: &'a str,
1873 timestamp: &'a str,
1874}
1875
1876#[must_use]
1885pub fn refusal_payload(
1886 fields: &RefusalPayload<'_>,
1887 signer: &ApprovalSigner,
1888) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
1889 Envelope::seal(fields.canonical(), signer.as_signer())
1890}
1891
1892#[derive(Debug, Clone)]
1894pub struct VerifiedRefusal {
1895 pub kind: String,
1899 pub reason: String,
1901 pub reason_detail: String,
1903 pub merchant_host: String,
1905 pub requested_base_units: String,
1907 pub permitted_base_units: String,
1909 pub tool_call_id: String,
1911 pub subject: String,
1913 pub timestamp: String,
1916 pub signer_public_key: Vec<u8>,
1918}
1919
1920#[must_use]
1932pub fn verify_signed_refusal(
1933 payload: &[u8],
1934 trusted_signers: &[Vec<u8>],
1935) -> Option<VerifiedRefusal> {
1936 let v: Value = serde_json::from_slice(payload).ok()?;
1937 let kind = v.get("kind")?.as_str()?.to_owned();
1938 let reason = v.get("reason")?.as_str()?.to_owned();
1939 let reason_detail = v.get("reason_detail")?.as_str()?.to_owned();
1940 let merchant_host = v.get("merchant_host")?.as_str()?.to_owned();
1941 let requested_base_units = v.get("requested_base_units")?.as_str()?.to_owned();
1942 let permitted_base_units = v.get("permitted_base_units")?.as_str()?.to_owned();
1943 let tool_call_id = v.get("tool_call_id")?.as_str()?.to_owned();
1944 let subject = v.get("subject")?.as_str()?.to_owned();
1945 let timestamp = v.get("timestamp")?.as_str()?.to_owned();
1946 let signed_by_hex = v.get("signed_by")?.as_str()?;
1947 let signature_hex = v.get("signature_hex")?.as_str()?;
1948 let pk = crate::hex::decode(signed_by_hex)?;
1949 let sig = crate::hex::decode(signature_hex)?;
1950 if !signer_is_trusted(&pk, trusted_signers) {
1951 return None;
1952 }
1953 let fields = RefusalPayload {
1954 kind: &kind,
1955 reason: &reason,
1956 reason_detail: &reason_detail,
1957 merchant_host: &merchant_host,
1958 requested_base_units: &requested_base_units,
1959 permitted_base_units: &permitted_base_units,
1960 tool_call_id: &tool_call_id,
1961 subject: &subject,
1962 timestamp: ×tamp,
1963 };
1964 let canonical = canonical_bytes(&fields.canonical());
1965 if verify(&pk, &canonical, &sig) {
1966 Some(VerifiedRefusal {
1967 kind,
1968 reason,
1969 reason_detail,
1970 merchant_host,
1971 requested_base_units,
1972 permitted_base_units,
1973 tool_call_id,
1974 subject,
1975 timestamp,
1976 signer_public_key: pk,
1977 })
1978 } else {
1979 None
1980 }
1981}
1982
1983#[derive(Debug, Clone, Copy)]
1991pub struct WalletLinkLifecyclePayload<'a> {
1992 pub kind: &'a str,
1995 pub transition: &'a str,
2001 pub subject: &'a str,
2004 pub wallet_address: &'a str,
2006 pub currency: &'a str,
2008 pub chain_id: &'a str,
2010 pub limit_base_units: &'a str,
2013 pub limit_human: &'a str,
2015 pub period_secs: &'a str,
2020 pub expiry_unix: &'a str,
2025 pub recipients: &'a str,
2028 pub conversation_id: &'a str,
2032 pub timestamp: &'a str,
2037}
2038
2039impl WalletLinkLifecyclePayload<'_> {
2040 #[must_use]
2046 const fn canonical(&self) -> WalletLinkLifecycleCanonical<'_> {
2047 WalletLinkLifecycleCanonical {
2048 kind: self.kind,
2049 transition: self.transition,
2050 subject: self.subject,
2051 wallet_address: self.wallet_address,
2052 currency: self.currency,
2053 chain_id: self.chain_id,
2054 limit_base_units: self.limit_base_units,
2055 limit_human: self.limit_human,
2056 period_secs: self.period_secs,
2057 expiry_unix: self.expiry_unix,
2058 recipients: self.recipients,
2059 conversation_id: self.conversation_id,
2060 timestamp: self.timestamp,
2061 }
2062 }
2063}
2064
2065#[derive(Serialize)]
2068struct WalletLinkLifecycleCanonical<'a> {
2069 kind: &'a str,
2070 transition: &'a str,
2071 subject: &'a str,
2072 wallet_address: &'a str,
2073 currency: &'a str,
2074 chain_id: &'a str,
2075 limit_base_units: &'a str,
2076 limit_human: &'a str,
2077 period_secs: &'a str,
2078 expiry_unix: &'a str,
2079 recipients: &'a str,
2080 conversation_id: &'a str,
2081 timestamp: &'a str,
2082}
2083
2084#[must_use]
2094pub fn wallet_link_lifecycle_payload(
2095 fields: &WalletLinkLifecyclePayload<'_>,
2096 signer: &ApprovalSigner,
2097) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
2098 Envelope::seal(fields.canonical(), signer.as_signer())
2099}
2100
2101#[derive(Debug, Clone)]
2103pub struct VerifiedWalletLinkLifecycle {
2104 pub kind: String,
2106 pub transition: String,
2111 pub subject: String,
2113 pub wallet_address: String,
2115 pub currency: String,
2117 pub chain_id: String,
2119 pub limit_base_units: String,
2121 pub limit_human: String,
2123 pub period_secs: String,
2125 pub expiry_unix: String,
2127 pub recipients: String,
2129 pub conversation_id: String,
2131 pub timestamp: String,
2134 pub signer_public_key: Vec<u8>,
2136}
2137
2138#[must_use]
2149pub fn verify_signed_wallet_link_lifecycle(
2150 payload: &[u8],
2151 trusted_signers: &[Vec<u8>],
2152) -> Option<VerifiedWalletLinkLifecycle> {
2153 let v: Value = serde_json::from_slice(payload).ok()?;
2154 let kind = v.get("kind")?.as_str()?.to_owned();
2155 let transition = v.get("transition")?.as_str()?.to_owned();
2156 let subject = v.get("subject")?.as_str()?.to_owned();
2157 let wallet_address = v.get("wallet_address")?.as_str()?.to_owned();
2158 let currency = v.get("currency")?.as_str()?.to_owned();
2159 let chain_id = v.get("chain_id")?.as_str()?.to_owned();
2160 let limit_base_units = v.get("limit_base_units")?.as_str()?.to_owned();
2161 let limit_human = v.get("limit_human")?.as_str()?.to_owned();
2162 let period_secs = v.get("period_secs")?.as_str()?.to_owned();
2163 let expiry_unix = v.get("expiry_unix")?.as_str()?.to_owned();
2164 let recipients = v.get("recipients")?.as_str()?.to_owned();
2165 let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
2166 let timestamp = v.get("timestamp")?.as_str()?.to_owned();
2167 let signed_by_hex = v.get("signed_by")?.as_str()?;
2168 let signature_hex = v.get("signature_hex")?.as_str()?;
2169 let pk = crate::hex::decode(signed_by_hex)?;
2170 let sig = crate::hex::decode(signature_hex)?;
2171 if !signer_is_trusted(&pk, trusted_signers) {
2172 return None;
2173 }
2174 let fields = WalletLinkLifecyclePayload {
2175 kind: &kind,
2176 transition: &transition,
2177 subject: &subject,
2178 wallet_address: &wallet_address,
2179 currency: ¤cy,
2180 chain_id: &chain_id,
2181 limit_base_units: &limit_base_units,
2182 limit_human: &limit_human,
2183 period_secs: &period_secs,
2184 expiry_unix: &expiry_unix,
2185 recipients: &recipients,
2186 conversation_id: &conversation_id,
2187 timestamp: ×tamp,
2188 };
2189 let canonical = canonical_bytes(&fields.canonical());
2190 if verify(&pk, &canonical, &sig) {
2191 Some(VerifiedWalletLinkLifecycle {
2192 kind,
2193 transition,
2194 subject,
2195 wallet_address,
2196 currency,
2197 chain_id,
2198 limit_base_units,
2199 limit_human,
2200 period_secs,
2201 expiry_unix,
2202 recipients,
2203 conversation_id,
2204 timestamp,
2205 signer_public_key: pk,
2206 })
2207 } else {
2208 None
2209 }
2210}
2211
2212pub const RESOLVE_TOKEN_TTL_MS: u64 = 24 * 60 * 60 * 1000;
2221
2222fn resolve_token_canonical(request_id: &str, conversation_id: &str, minted_at_ms: u64) -> Vec<u8> {
2229 canonical_bytes(&ResolveTokenCanonical {
2230 request_id,
2231 conversation_id,
2232 minted_at_ms,
2233 })
2234}
2235
2236#[derive(Serialize)]
2238struct ResolveTokenCanonical<'a> {
2239 request_id: &'a str,
2240 conversation_id: &'a str,
2241 minted_at_ms: u64,
2242}
2243
2244#[derive(Serialize)]
2250struct ResolveToken<'a> {
2251 #[serde(flatten)]
2252 body: ResolveTokenCanonical<'a>,
2253 signature_hex: String,
2254}
2255
2256#[must_use]
2273pub fn mint_resolve_token(
2274 request_id: &str,
2275 conversation_id: &str,
2276 minted_at_ms: u64,
2277 signer: &ApprovalSigner,
2278) -> String {
2279 let canonical = resolve_token_canonical(request_id, conversation_id, minted_at_ms);
2280 let signature = signer.sign(&canonical);
2281 let full = ResolveToken {
2282 body: ResolveTokenCanonical {
2283 request_id,
2284 conversation_id,
2285 minted_at_ms,
2286 },
2287 signature_hex: crate::hex::lower(&signature),
2288 };
2289 crate::hex::lower(&canonical_bytes(&full))
2290}
2291
2292#[must_use]
2302pub fn verify_resolve_token(
2303 token: &str,
2304 request_id: &str,
2305 conversation_id: &str,
2306 now_ms: u64,
2307 signer: &ApprovalSigner,
2308) -> bool {
2309 let Some(bytes) = crate::hex::decode(token) else {
2310 return false;
2311 };
2312 let Ok(v) = serde_json::from_slice::<Value>(&bytes) else {
2313 return false;
2314 };
2315 let (
2316 Some(bound_request_id),
2317 Some(bound_conversation_id),
2318 Some(minted_at_ms),
2319 Some(signature_hex),
2320 ) = (
2321 v.get("request_id").and_then(Value::as_str),
2322 v.get("conversation_id").and_then(Value::as_str),
2323 v.get("minted_at_ms").and_then(Value::as_u64),
2324 v.get("signature_hex").and_then(Value::as_str),
2325 )
2326 else {
2327 return false;
2328 };
2329 if bound_request_id != request_id || bound_conversation_id != conversation_id {
2330 return false;
2331 }
2332 let elapsed = now_ms.abs_diff(minted_at_ms);
2333 if elapsed > RESOLVE_TOKEN_TTL_MS {
2334 return false;
2335 }
2336 let Some(sig) = crate::hex::decode(signature_hex) else {
2337 return false;
2338 };
2339 let canonical = resolve_token_canonical(bound_request_id, bound_conversation_id, minted_at_ms);
2340 verify(&signer.public_key_bytes(), &canonical, &sig)
2341}
2342
2343fn admin_model_change_canonical(
2352 principal: &str,
2353 previous_provider: &str,
2354 previous_model: &str,
2355 new_provider: &str,
2356 new_model: &str,
2357 changed_at_ms: u64,
2358) -> Vec<u8> {
2359 canonical_bytes(&AdminModelChangeCanonical {
2360 principal,
2361 previous_provider,
2362 previous_model,
2363 new_provider,
2364 new_model,
2365 changed_at_ms,
2366 })
2367}
2368
2369#[derive(Serialize)]
2371struct AdminModelChangeCanonical<'a> {
2372 principal: &'a str,
2373 previous_provider: &'a str,
2374 previous_model: &'a str,
2375 new_provider: &'a str,
2376 new_model: &'a str,
2377 changed_at_ms: u64,
2378}
2379
2380#[must_use]
2387#[allow(clippy::too_many_arguments)] pub fn admin_model_change_payload(
2389 principal: &str,
2390 previous_provider: &str,
2391 previous_model: &str,
2392 new_provider: &str,
2393 new_model: &str,
2394 changed_at_ms: u64,
2395 signer: &ApprovalSigner,
2396) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
2397 Envelope::seal(
2398 AdminModelChangeCanonical {
2399 principal,
2400 previous_provider,
2401 previous_model,
2402 new_provider,
2403 new_model,
2404 changed_at_ms,
2405 },
2406 signer.as_signer(),
2407 )
2408}
2409
2410#[derive(Debug, Clone, PartialEq, Eq)]
2412pub struct VerifiedAdminModelChange {
2413 pub principal: String,
2415 pub previous_provider: String,
2417 pub previous_model: String,
2419 pub new_provider: String,
2421 pub new_model: String,
2423 pub changed_at_ms: u64,
2425 pub signer_public_key: Vec<u8>,
2427}
2428
2429#[must_use]
2434pub fn verify_admin_model_change(payload: &[u8]) -> Option<VerifiedAdminModelChange> {
2435 let v: Value = serde_json::from_slice(payload).ok()?;
2436 let principal = v.get("principal")?.as_str()?.to_owned();
2437 let previous_provider = v.get("previous_provider")?.as_str()?.to_owned();
2438 let previous_model = v.get("previous_model")?.as_str()?.to_owned();
2439 let new_provider = v.get("new_provider")?.as_str()?.to_owned();
2440 let new_model = v.get("new_model")?.as_str()?.to_owned();
2441 let changed_at_ms = v.get("changed_at_ms")?.as_u64()?;
2442 let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
2443 let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
2444 let canonical = admin_model_change_canonical(
2445 &principal,
2446 &previous_provider,
2447 &previous_model,
2448 &new_provider,
2449 &new_model,
2450 changed_at_ms,
2451 );
2452 if verify(&pk, &canonical, &sig) {
2453 Some(VerifiedAdminModelChange {
2454 principal,
2455 previous_provider,
2456 previous_model,
2457 new_provider,
2458 new_model,
2459 changed_at_ms,
2460 signer_public_key: pk,
2461 })
2462 } else {
2463 None
2464 }
2465}
2466
2467#[derive(Debug, Clone, PartialEq, Eq)]
2474pub struct CredentialKeyTransition {
2475 pub kid: String,
2477 pub from: String,
2479 pub to: String,
2481}
2482
2483fn credential_change_canonical(
2492 change: &str,
2493 principal: &str,
2494 edge_id: &str,
2495 kid: &str,
2496 grants: &str,
2497 transitions: &[CredentialKeyTransition],
2498 changed_at_ms: u64,
2499) -> Vec<u8> {
2500 canonical_bytes(&credential_change_fields(
2501 change,
2502 principal,
2503 edge_id,
2504 kid,
2505 grants,
2506 transitions,
2507 changed_at_ms,
2508 ))
2509}
2510
2511#[derive(Serialize)]
2518struct CredentialChangeCanonical<'a> {
2519 change: &'a str,
2520 principal: &'a str,
2521 edge_id: &'a str,
2522 kid: &'a str,
2523 grants: &'a str,
2524 transitions: Vec<CredentialTransition<'a>>,
2525 changed_at_ms: u64,
2526}
2527
2528#[derive(Serialize)]
2531struct CredentialTransition<'a> {
2532 kid: &'a str,
2533 from: &'a str,
2534 to: &'a str,
2535}
2536
2537#[allow(clippy::too_many_arguments)] fn credential_change_fields<'a>(
2544 change: &'a str,
2545 principal: &'a str,
2546 edge_id: &'a str,
2547 kid: &'a str,
2548 grants: &'a str,
2549 transitions: &'a [CredentialKeyTransition],
2550 changed_at_ms: u64,
2551) -> CredentialChangeCanonical<'a> {
2552 CredentialChangeCanonical {
2553 change,
2554 principal,
2555 edge_id,
2556 kid,
2557 grants,
2558 transitions: transitions
2559 .iter()
2560 .map(|t| CredentialTransition {
2561 kid: &t.kid,
2562 from: &t.from,
2563 to: &t.to,
2564 })
2565 .collect(),
2566 changed_at_ms,
2567 }
2568}
2569
2570#[must_use]
2589#[allow(clippy::too_many_arguments)] pub fn credential_change_payload(
2591 change: &str,
2592 principal: &str,
2593 edge_id: &str,
2594 kid: &str,
2595 grants: &str,
2596 transitions: &[CredentialKeyTransition],
2597 changed_at_ms: u64,
2598 signer: &ApprovalSigner,
2599) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
2600 Envelope::seal(
2601 credential_change_fields(
2602 change,
2603 principal,
2604 edge_id,
2605 kid,
2606 grants,
2607 transitions,
2608 changed_at_ms,
2609 ),
2610 signer.as_signer(),
2611 )
2612}
2613
2614#[derive(Debug, Clone, PartialEq, Eq)]
2616pub struct VerifiedCredentialChange {
2617 pub change: String,
2619 pub principal: String,
2621 pub edge_id: String,
2623 pub kid: String,
2625 pub grants: String,
2633 pub transitions: Vec<CredentialKeyTransition>,
2635 pub changed_at_ms: u64,
2637 pub signer_public_key: Vec<u8>,
2639}
2640
2641#[must_use]
2657pub fn verify_credential_change(payload: &[u8]) -> Option<VerifiedCredentialChange> {
2658 let v: Value = serde_json::from_slice(payload).ok()?;
2659 let change = v.get("change")?.as_str()?.to_owned();
2660 let principal = v.get("principal")?.as_str()?.to_owned();
2661 let edge_id = v.get("edge_id")?.as_str()?.to_owned();
2662 let kid = v.get("kid")?.as_str()?.to_owned();
2663 let grants = v.get("grants")?.as_str()?.to_owned();
2664 let mut transitions = Vec::new();
2665 for item in v.get("transitions")?.as_array()? {
2666 transitions.push(CredentialKeyTransition {
2667 kid: item.get("kid")?.as_str()?.to_owned(),
2668 from: item.get("from")?.as_str()?.to_owned(),
2669 to: item.get("to")?.as_str()?.to_owned(),
2670 });
2671 }
2672 let changed_at_ms = v.get("changed_at_ms")?.as_u64()?;
2673 let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
2674 let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
2675 let canonical = credential_change_canonical(
2676 &change,
2677 &principal,
2678 &edge_id,
2679 &kid,
2680 &grants,
2681 &transitions,
2682 changed_at_ms,
2683 );
2684 if verify(&pk, &canonical, &sig) {
2685 Some(VerifiedCredentialChange {
2686 change,
2687 principal,
2688 edge_id,
2689 kid,
2690 grants,
2691 transitions,
2692 changed_at_ms,
2693 signer_public_key: pk,
2694 })
2695 } else {
2696 None
2697 }
2698}
2699
2700fn routine_created_canonical(
2702 routine: &str,
2703 creator_persona: &str,
2704 conversation_id: &str,
2705 tool_call_id: &str,
2706 args_hash: &str,
2707 created_at_ms: u64,
2708) -> Vec<u8> {
2709 canonical_bytes(&RoutineCreatedCanonical {
2710 routine,
2711 creator_persona,
2712 conversation_id,
2713 tool_call_id,
2714 args_hash,
2715 created_at_ms,
2716 })
2717}
2718
2719#[derive(Serialize)]
2721struct RoutineCreatedCanonical<'a> {
2722 routine: &'a str,
2723 creator_persona: &'a str,
2724 conversation_id: &'a str,
2725 tool_call_id: &'a str,
2726 args_hash: &'a str,
2727 created_at_ms: u64,
2728}
2729
2730#[must_use]
2749pub fn routine_created_payload(
2750 routine: &str,
2751 creator_persona: &str,
2752 conversation_id: &str,
2753 tool_call_id: &str,
2754 args_hash: &str,
2755 created_at_ms: u64,
2756 signer: &ApprovalSigner,
2757) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
2758 Envelope::seal(
2759 RoutineCreatedCanonical {
2760 routine,
2761 creator_persona,
2762 conversation_id,
2763 tool_call_id,
2764 args_hash,
2765 created_at_ms,
2766 },
2767 signer.as_signer(),
2768 )
2769}
2770
2771#[derive(Debug, Clone, PartialEq, Eq)]
2773pub struct VerifiedRoutineCreated {
2774 pub routine: String,
2776 pub creator_persona: String,
2778 pub conversation_id: String,
2780 pub tool_call_id: String,
2782 pub args_hash: String,
2784 pub created_at_ms: u64,
2786 pub signer_public_key: Vec<u8>,
2788}
2789
2790#[must_use]
2795pub fn verify_routine_created(payload: &[u8]) -> Option<VerifiedRoutineCreated> {
2796 let v: Value = serde_json::from_slice(payload).ok()?;
2797 let routine = v.get("routine")?.as_str()?.to_owned();
2798 let creator_persona = v.get("creator_persona")?.as_str()?.to_owned();
2799 let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
2800 let tool_call_id = v.get("tool_call_id")?.as_str()?.to_owned();
2801 let args_hash = v.get("args_hash")?.as_str()?.to_owned();
2802 let created_at_ms = v.get("created_at_ms")?.as_u64()?;
2803 let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
2804 let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
2805 let canonical = routine_created_canonical(
2806 &routine,
2807 &creator_persona,
2808 &conversation_id,
2809 &tool_call_id,
2810 &args_hash,
2811 created_at_ms,
2812 );
2813 if verify(&pk, &canonical, &sig) {
2814 Some(VerifiedRoutineCreated {
2815 routine,
2816 creator_persona,
2817 conversation_id,
2818 tool_call_id,
2819 args_hash,
2820 created_at_ms,
2821 signer_public_key: pk,
2822 })
2823 } else {
2824 None
2825 }
2826}
2827
2828fn routine_paused_canonical(
2830 routine: &str,
2831 actor_persona: &str,
2832 conversation_id: &str,
2833 tool_call_id: &str,
2834 args_hash: &str,
2835 paused_at_ms: u64,
2836 reason: Option<&str>,
2837) -> Vec<u8> {
2838 canonical_bytes(&RoutinePausedCanonical {
2839 routine,
2840 actor_persona,
2841 conversation_id,
2842 tool_call_id,
2843 args_hash,
2844 paused_at_ms,
2845 reason,
2846 })
2847}
2848
2849#[derive(Serialize)]
2855struct RoutinePausedCanonical<'a> {
2856 routine: &'a str,
2857 actor_persona: &'a str,
2858 conversation_id: &'a str,
2859 tool_call_id: &'a str,
2860 args_hash: &'a str,
2861 paused_at_ms: u64,
2862 reason: Option<&'a str>,
2863}
2864
2865#[must_use]
2877#[allow(clippy::too_many_arguments)] pub fn routine_paused_payload(
2879 routine: &str,
2880 actor_persona: &str,
2881 conversation_id: &str,
2882 tool_call_id: &str,
2883 args_hash: &str,
2884 paused_at_ms: u64,
2885 reason: Option<&str>,
2886 signer: &ApprovalSigner,
2887) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
2888 Envelope::seal(
2889 RoutinePausedCanonical {
2890 routine,
2891 actor_persona,
2892 conversation_id,
2893 tool_call_id,
2894 args_hash,
2895 paused_at_ms,
2896 reason,
2897 },
2898 signer.as_signer(),
2899 )
2900}
2901
2902#[derive(Debug, Clone, PartialEq, Eq)]
2904pub struct VerifiedRoutinePaused {
2905 pub routine: String,
2907 pub actor_persona: String,
2909 pub conversation_id: String,
2911 pub tool_call_id: String,
2913 pub args_hash: String,
2915 pub paused_at_ms: u64,
2917 pub reason: Option<String>,
2919 pub signer_public_key: Vec<u8>,
2921}
2922
2923#[must_use]
2928pub fn verify_routine_paused(payload: &[u8]) -> Option<VerifiedRoutinePaused> {
2929 let v: Value = serde_json::from_slice(payload).ok()?;
2930 let routine = v.get("routine")?.as_str()?.to_owned();
2931 let actor_persona = v.get("actor_persona")?.as_str()?.to_owned();
2932 let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
2933 let tool_call_id = v.get("tool_call_id")?.as_str()?.to_owned();
2934 let args_hash = v.get("args_hash")?.as_str()?.to_owned();
2935 let paused_at_ms = v.get("paused_at_ms")?.as_u64()?;
2936 let reason = v.get("reason").and_then(|r| r.as_str()).map(str::to_owned);
2937 let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
2938 let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
2939 let canonical = routine_paused_canonical(
2940 &routine,
2941 &actor_persona,
2942 &conversation_id,
2943 &tool_call_id,
2944 &args_hash,
2945 paused_at_ms,
2946 reason.as_deref(),
2947 );
2948 if verify(&pk, &canonical, &sig) {
2949 Some(VerifiedRoutinePaused {
2950 routine,
2951 actor_persona,
2952 conversation_id,
2953 tool_call_id,
2954 args_hash,
2955 paused_at_ms,
2956 reason,
2957 signer_public_key: pk,
2958 })
2959 } else {
2960 None
2961 }
2962}
2963
2964fn routine_resumed_canonical(
2966 routine: &str,
2967 actor_persona: &str,
2968 conversation_id: &str,
2969 tool_call_id: &str,
2970 args_hash: &str,
2971 resumed_at_ms: u64,
2972) -> Vec<u8> {
2973 canonical_bytes(&RoutineResumedCanonical {
2974 routine,
2975 actor_persona,
2976 conversation_id,
2977 tool_call_id,
2978 args_hash,
2979 resumed_at_ms,
2980 })
2981}
2982
2983#[derive(Serialize)]
2985struct RoutineResumedCanonical<'a> {
2986 routine: &'a str,
2987 actor_persona: &'a str,
2988 conversation_id: &'a str,
2989 tool_call_id: &'a str,
2990 args_hash: &'a str,
2991 resumed_at_ms: u64,
2992}
2993
2994#[must_use]
3002pub fn routine_resumed_payload(
3003 routine: &str,
3004 actor_persona: &str,
3005 conversation_id: &str,
3006 tool_call_id: &str,
3007 args_hash: &str,
3008 resumed_at_ms: u64,
3009 signer: &ApprovalSigner,
3010) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
3011 Envelope::seal(
3012 RoutineResumedCanonical {
3013 routine,
3014 actor_persona,
3015 conversation_id,
3016 tool_call_id,
3017 args_hash,
3018 resumed_at_ms,
3019 },
3020 signer.as_signer(),
3021 )
3022}
3023
3024#[derive(Debug, Clone, PartialEq, Eq)]
3026pub struct VerifiedRoutineResumed {
3027 pub routine: String,
3029 pub actor_persona: String,
3031 pub conversation_id: String,
3033 pub tool_call_id: String,
3035 pub args_hash: String,
3037 pub resumed_at_ms: u64,
3039 pub signer_public_key: Vec<u8>,
3041}
3042
3043#[must_use]
3048pub fn verify_routine_resumed(payload: &[u8]) -> Option<VerifiedRoutineResumed> {
3049 let v: Value = serde_json::from_slice(payload).ok()?;
3050 let routine = v.get("routine")?.as_str()?.to_owned();
3051 let actor_persona = v.get("actor_persona")?.as_str()?.to_owned();
3052 let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
3053 let tool_call_id = v.get("tool_call_id")?.as_str()?.to_owned();
3054 let args_hash = v.get("args_hash")?.as_str()?.to_owned();
3055 let resumed_at_ms = v.get("resumed_at_ms")?.as_u64()?;
3056 let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
3057 let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
3058 let canonical = routine_resumed_canonical(
3059 &routine,
3060 &actor_persona,
3061 &conversation_id,
3062 &tool_call_id,
3063 &args_hash,
3064 resumed_at_ms,
3065 );
3066 if verify(&pk, &canonical, &sig) {
3067 Some(VerifiedRoutineResumed {
3068 routine,
3069 actor_persona,
3070 conversation_id,
3071 tool_call_id,
3072 args_hash,
3073 resumed_at_ms,
3074 signer_public_key: pk,
3075 })
3076 } else {
3077 None
3078 }
3079}
3080
3081fn routine_deleted_canonical(
3083 routine: &str,
3084 actor_persona: &str,
3085 conversation_id: &str,
3086 tool_call_id: &str,
3087 args_hash: &str,
3088 deleted_at_ms: u64,
3089) -> Vec<u8> {
3090 canonical_bytes(&RoutineDeletedCanonical {
3091 routine,
3092 actor_persona,
3093 conversation_id,
3094 tool_call_id,
3095 args_hash,
3096 deleted_at_ms,
3097 })
3098}
3099
3100#[derive(Serialize)]
3102struct RoutineDeletedCanonical<'a> {
3103 routine: &'a str,
3104 actor_persona: &'a str,
3105 conversation_id: &'a str,
3106 tool_call_id: &'a str,
3107 args_hash: &'a str,
3108 deleted_at_ms: u64,
3109}
3110
3111#[must_use]
3120pub fn routine_deleted_payload(
3121 routine: &str,
3122 actor_persona: &str,
3123 conversation_id: &str,
3124 tool_call_id: &str,
3125 args_hash: &str,
3126 deleted_at_ms: u64,
3127 signer: &ApprovalSigner,
3128) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
3129 Envelope::seal(
3130 RoutineDeletedCanonical {
3131 routine,
3132 actor_persona,
3133 conversation_id,
3134 tool_call_id,
3135 args_hash,
3136 deleted_at_ms,
3137 },
3138 signer.as_signer(),
3139 )
3140}
3141
3142#[derive(Debug, Clone, PartialEq, Eq)]
3144pub struct VerifiedRoutineDeleted {
3145 pub routine: String,
3147 pub actor_persona: String,
3149 pub conversation_id: String,
3151 pub tool_call_id: String,
3153 pub args_hash: String,
3155 pub deleted_at_ms: u64,
3157 pub signer_public_key: Vec<u8>,
3159}
3160
3161#[must_use]
3166pub fn verify_routine_deleted(payload: &[u8]) -> Option<VerifiedRoutineDeleted> {
3167 let v: Value = serde_json::from_slice(payload).ok()?;
3168 let routine = v.get("routine")?.as_str()?.to_owned();
3169 let actor_persona = v.get("actor_persona")?.as_str()?.to_owned();
3170 let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
3171 let tool_call_id = v.get("tool_call_id")?.as_str()?.to_owned();
3172 let args_hash = v.get("args_hash")?.as_str()?.to_owned();
3173 let deleted_at_ms = v.get("deleted_at_ms")?.as_u64()?;
3174 let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
3175 let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
3176 let canonical = routine_deleted_canonical(
3177 &routine,
3178 &actor_persona,
3179 &conversation_id,
3180 &tool_call_id,
3181 &args_hash,
3182 deleted_at_ms,
3183 );
3184 if verify(&pk, &canonical, &sig) {
3185 Some(VerifiedRoutineDeleted {
3186 routine,
3187 actor_persona,
3188 conversation_id,
3189 tool_call_id,
3190 args_hash,
3191 deleted_at_ms,
3192 signer_public_key: pk,
3193 })
3194 } else {
3195 None
3196 }
3197}
3198
3199fn routine_scope_changed_canonical(
3201 routine: &str,
3202 actor_persona: &str,
3203 conversation_id: &str,
3204 tool_call_id: &str,
3205 args_hash: &str,
3206 scope: &str,
3207 changed_at_ms: u64,
3208) -> Vec<u8> {
3209 canonical_bytes(&RoutineScopeChangedCanonical {
3210 routine,
3211 actor_persona,
3212 conversation_id,
3213 tool_call_id,
3214 args_hash,
3215 scope,
3216 changed_at_ms,
3217 })
3218}
3219
3220#[derive(Serialize)]
3222struct RoutineScopeChangedCanonical<'a> {
3223 routine: &'a str,
3224 actor_persona: &'a str,
3225 conversation_id: &'a str,
3226 tool_call_id: &'a str,
3227 args_hash: &'a str,
3228 scope: &'a str,
3229 changed_at_ms: u64,
3230}
3231
3232#[must_use]
3246#[allow(clippy::too_many_arguments)] pub fn routine_scope_changed_payload(
3248 routine: &str,
3249 actor_persona: &str,
3250 conversation_id: &str,
3251 tool_call_id: &str,
3252 args_hash: &str,
3253 scope: &str,
3254 changed_at_ms: u64,
3255 signer: &ApprovalSigner,
3256) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
3257 Envelope::seal(
3258 RoutineScopeChangedCanonical {
3259 routine,
3260 actor_persona,
3261 conversation_id,
3262 tool_call_id,
3263 args_hash,
3264 scope,
3265 changed_at_ms,
3266 },
3267 signer.as_signer(),
3268 )
3269}
3270
3271#[derive(Debug, Clone, PartialEq, Eq)]
3273pub struct VerifiedRoutineScopeChanged {
3274 pub routine: String,
3276 pub actor_persona: String,
3278 pub conversation_id: String,
3280 pub tool_call_id: String,
3282 pub args_hash: String,
3284 pub scope: String,
3286 pub changed_at_ms: u64,
3288 pub signer_public_key: Vec<u8>,
3290}
3291
3292#[must_use]
3297pub fn verify_routine_scope_changed(payload: &[u8]) -> Option<VerifiedRoutineScopeChanged> {
3298 let v: Value = serde_json::from_slice(payload).ok()?;
3299 let routine = v.get("routine")?.as_str()?.to_owned();
3300 let actor_persona = v.get("actor_persona")?.as_str()?.to_owned();
3301 let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
3302 let tool_call_id = v.get("tool_call_id")?.as_str()?.to_owned();
3303 let args_hash = v.get("args_hash")?.as_str()?.to_owned();
3304 let scope = v.get("scope")?.as_str()?.to_owned();
3305 let changed_at_ms = v.get("changed_at_ms")?.as_u64()?;
3306 let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
3307 let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
3308 let canonical = routine_scope_changed_canonical(
3309 &routine,
3310 &actor_persona,
3311 &conversation_id,
3312 &tool_call_id,
3313 &args_hash,
3314 &scope,
3315 changed_at_ms,
3316 );
3317 if verify(&pk, &canonical, &sig) {
3318 Some(VerifiedRoutineScopeChanged {
3319 routine,
3320 actor_persona,
3321 conversation_id,
3322 tool_call_id,
3323 args_hash,
3324 scope,
3325 changed_at_ms,
3326 signer_public_key: pk,
3327 })
3328 } else {
3329 None
3330 }
3331}
3332
3333#[must_use]
3335pub fn decode_request_id(payload: &[u8]) -> Option<String> {
3336 let v: Value = serde_json::from_slice(payload).ok()?;
3337 Some(v.get("request_id")?.as_str()?.to_owned())
3338}
3339
3340#[must_use]
3344pub fn decode_request_fields(payload: &[u8]) -> Option<(String, String, String)> {
3345 let v: Value = serde_json::from_slice(payload).ok()?;
3346 Some((
3347 v.get("request_id")?.as_str()?.to_owned(),
3348 v.get("tool_name")?.as_str()?.to_owned(),
3349 v.get("args_json")?.as_str()?.to_owned(),
3350 ))
3351}
3352
3353#[must_use]
3360pub fn decode_request_sandbox_mode(payload: &[u8]) -> String {
3361 serde_json::from_slice::<Value>(payload)
3362 .ok()
3363 .and_then(|v| {
3364 v.get("sandbox_mode")
3365 .and_then(Value::as_str)
3366 .map(str::to_owned)
3367 })
3368 .unwrap_or_default()
3369}
3370
3371#[must_use]
3378pub fn decode_request_reason(payload: &[u8]) -> String {
3379 serde_json::from_slice::<Value>(payload)
3380 .ok()
3381 .and_then(|v| v.get("reason").and_then(Value::as_str).map(str::to_owned))
3382 .unwrap_or_default()
3383}
3384
3385#[must_use]
3393pub fn decode_request_missing_capabilities(payload: &[u8]) -> Vec<String> {
3394 serde_json::from_slice::<Value>(payload)
3395 .ok()
3396 .and_then(|v| {
3397 v.get("missing_capabilities")
3398 .and_then(Value::as_array)
3399 .map(|a| {
3400 a.iter()
3401 .filter_map(|c| c.as_str().map(str::to_owned))
3402 .collect()
3403 })
3404 })
3405 .unwrap_or_default()
3406}
3407
3408#[must_use]
3416pub fn decode_request_preview_json(payload: &[u8]) -> Option<String> {
3417 let v: Value = serde_json::from_slice(payload).ok()?;
3418 let preview = v.get("preview")?;
3419 if preview.is_null() {
3420 None
3421 } else {
3422 Some(preview.to_string())
3423 }
3424}
3425
3426#[cfg(test)]
3427mod tests {
3428 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
3429
3430 use super::*;
3431
3432 #[test]
3439 fn loaded_key_signature_verifies_and_from_seed_signature_does_not() {
3440 let key_bytes = [42u8; 32];
3443 let loaded = ApprovalSigner::from_key_bytes(&key_bytes).expect("valid key material");
3444 let forged = ApprovalSigner::from_seed(1);
3445 assert_ne!(
3446 loaded.public_key_bytes(),
3447 forged.public_key_bytes(),
3448 "a loaded key must not collide with the public, deterministic seed-1 key"
3449 );
3450
3451 let (payload, _sig, _pk) = response_payload(
3452 "req-1",
3453 "web_fetch",
3454 r#"{"url":"https://a"}"#,
3455 "",
3456 true,
3457 false,
3458 &[],
3459 "slack:T1:U9",
3460 "",
3461 "workspace-write",
3462 "",
3463 "",
3464 "conv-1",
3465 "nonce-1",
3466 &loaded,
3467 );
3468 let verified = verify_signed_response(&payload).expect("verifies under the loaded key");
3469 assert_eq!(verified.signer_public_key, loaded.public_key_bytes());
3470
3471 let (forged_payload, _sig, _pk) = response_payload(
3475 "req-1",
3476 "web_fetch",
3477 r#"{"url":"https://a"}"#,
3478 "",
3479 true,
3480 false,
3481 &[],
3482 "slack:T1:U9",
3483 "",
3484 "workspace-write",
3485 "",
3486 "",
3487 "conv-1",
3488 "nonce-1",
3489 &forged,
3490 );
3491 let mut v: serde_json::Value = serde_json::from_slice(&forged_payload).unwrap();
3492 v["signed_by"] = serde_json::json!(crate::hex::lower(&loaded.public_key_bytes()));
3493 assert!(
3494 verify_signed_response(v.to_string().as_bytes()).is_none(),
3495 "a from_seed(1) signature must not verify against the loaded key"
3496 );
3497 }
3498
3499 #[test]
3502 fn grant_replay_audit_is_signed_and_tamper_evident() {
3503 let signer = ApprovalSigner::from_seed(9);
3504 let covered = vec!["arbitrary-egress".to_owned()];
3505 let (payload, _sig, _pk) = grant_replay_payload(
3506 "conv-1",
3507 "turn-7",
3508 "post_summary",
3509 "deadbeef",
3510 &covered,
3511 "sha256:template-abc",
3512 &signer,
3513 );
3514 assert!(verify_grant_replay(&payload), "the genuine record verifies");
3515 for (field, val) in [
3517 ("conversation_id", serde_json::json!("conv-EVIL")),
3518 ("turn_id", serde_json::json!("turn-8")),
3519 ("tool", serde_json::json!("exfiltrate")),
3520 ("grant_ref", serde_json::json!("cafe")),
3521 (
3522 "covered_capabilities",
3523 serde_json::json!(["arbitrary-egress", "mutate-external"]),
3524 ),
3525 ("coverage_hash", serde_json::json!("sha256:other")),
3526 ] {
3527 let mut v: Value = serde_json::from_slice(&payload).unwrap();
3528 v[field] = val;
3529 assert!(
3530 !verify_grant_replay(v.to_string().as_bytes()),
3531 "tampered {field} must fail verification"
3532 );
3533 }
3534 assert!(!verify_grant_replay(b"not json"));
3535 }
3536
3537 #[test]
3540 fn covered_capabilities_are_signed_and_tamper_evident() {
3541 let signer = ApprovalSigner::from_seed(42);
3542 let covered = vec!["arbitrary-egress".to_owned()];
3543 let (payload, _sig, _pk) = response_payload(
3544 "req-1",
3545 "web_fetch",
3546 r#"{"url":"https://a"}"#,
3547 "",
3548 true,
3549 true,
3550 &covered,
3551 "slack:T1:U9",
3552 "",
3553 "workspace-write",
3554 "",
3555 "",
3556 "conv-1",
3557 "nonce-1",
3558 &signer,
3559 );
3560 let verified = verify_signed_response(&payload).expect("verifies untampered");
3561 assert_eq!(verified.covered_capabilities, covered);
3562
3563 let mut v: serde_json::Value = serde_json::from_slice(&payload).unwrap();
3565 v["covered_capabilities"] = serde_json::json!(["arbitrary-egress", "mutate-external"]);
3566 assert!(
3567 verify_signed_response(v.to_string().as_bytes()).is_none(),
3568 "a tampered covered set must fail verification"
3569 );
3570 let mut v: serde_json::Value = serde_json::from_slice(&payload).unwrap();
3572 v["covered_capabilities"] = serde_json::json!([]);
3573 assert!(verify_signed_response(v.to_string().as_bytes()).is_none());
3574 }
3575
3576 #[test]
3579 fn request_missing_capabilities_round_trip() {
3580 let missing = vec!["arbitrary-egress".to_owned(), "mutate-external".to_owned()];
3581 let bytes = request_payload("call-1", "web_fetch", "{}", "", "", &missing, "");
3582 assert_eq!(decode_request_missing_capabilities(&bytes), missing);
3583 let bare = request_payload("call-2", "grep", "{}", "", "", &[], "");
3584 assert_eq!(
3585 decode_request_missing_capabilities(&bare),
3586 Vec::<String>::new()
3587 );
3588 assert_eq!(
3589 decode_request_missing_capabilities(b"{\"nope\":1}"),
3590 Vec::<String>::new()
3591 );
3592 }
3593
3594 #[test]
3598 fn signed_excision_round_trips_and_is_tamper_evident() {
3599 let signer = ApprovalSigner::from_seed(11);
3600 let (payload, _sig, _pk) = excision_payload(
3601 "conv-1",
3602 EXCISION_SCOPE_CASCADE,
3603 &[17, 23],
3604 "persona-9",
3605 "poisoned fetch",
3606 &signer,
3607 );
3608 let v = verify_signed_excision(&payload).expect("verifies untampered");
3609 assert_eq!(v.conversation_id, "conv-1");
3610 assert!(v.is_cascade());
3611 assert_eq!(v.positions, vec![17, 23]);
3612 assert_eq!(v.requested_by, "persona-9");
3613
3614 for (field, value) in [
3615 ("positions", serde_json::json!([17, 23, 40])),
3616 ("scope", serde_json::json!(EXCISION_SCOPE_SOURCE_ONLY)),
3617 ("conversation_id", serde_json::json!("conv-2")),
3618 ("requested_by", serde_json::json!("someone-else")),
3619 ] {
3620 let mut t: serde_json::Value = serde_json::from_slice(&payload).unwrap();
3621 t[field] = value;
3622 assert!(
3623 verify_signed_excision(t.to_string().as_bytes()).is_none(),
3624 "tampered {field} must fail verification"
3625 );
3626 }
3627 let mut t: serde_json::Value = serde_json::from_slice(&payload).unwrap();
3629 t["scope"] = serde_json::json!("everything");
3630 assert!(verify_signed_excision(t.to_string().as_bytes()).is_none());
3631 assert!(verify_signed_excision(b"not json").is_none());
3633 }
3634
3635 #[test]
3636 fn signed_response_round_trips() {
3637 let signer = ApprovalSigner::from_seed(42);
3638 let (payload, _sig, _pk) = response_payload(
3640 "req-1",
3641 "rm",
3642 r#"{"path":"/etc"}"#,
3643 "",
3644 true,
3645 false,
3646 &[],
3647 "slack:T1:U9",
3648 "",
3649 "workspace-write",
3650 "looks fine",
3651 "",
3652 "conv-1",
3653 "nonce-1",
3654 &signer,
3655 );
3656 let verified =
3657 verify_signed_response(&payload).expect("signature verifies on untampered payload");
3658 assert!(verified.approved);
3659 assert_eq!(verified.reason, "looks fine");
3660 assert_eq!(verified.request_id, "req-1");
3661 assert_eq!(verified.tool_name, "rm");
3662 assert_eq!(verified.args_json, r#"{"path":"/etc"}"#);
3663 assert_eq!(verified.caller, "slack:T1:U9");
3664 assert_eq!(verified.conversation_id, "conv-1");
3665 assert_eq!(verified.nonce, "nonce-1");
3666 assert!(verified.approved);
3667 assert!(!verified.approved_for_session);
3669 }
3670
3671 const PRE_APPROVER_RESPONSE_PAYLOAD: &str = r#"{"request_id":"req-1","tool_name":"tool.name","args_json":"{\"a\":1}","modified_args_json":"","approved":true,"approved_for_session":true,"covered_capabilities":["cap.a","cap.b"],"caller":"persona-caller","sandbox_mode":"sandboxed","reason":"looks fine","injected_context":"","conversation_id":"conv-1","nonce":"nonce-1","signed_by":"78eda21ba04a15e2000fe8810fe3e56741d23bb9ae44aa9d5bb21b76675ff34b","signature_hex":"59ffe281c9b866b703eb77ef8a1aff15d96aacf07a3529ae25afb11798317247b7e387343f4fc3e73dccb0b6d4cfac503ca1de8abcefae32fbdce7959f4d490f"}"#;
3690
3691 #[test]
3692 fn pre_approver_field_payload_still_verifies_unchanged() {
3693 let signer = ApprovalSigner::from_seed(42);
3694 let (request_id, tool_name, args_json, modified_args_json) =
3695 ("req-1", "tool.name", r#"{"a":1}"#, "");
3696 let (approved, approved_for_session) = (true, true);
3697 let covered_capabilities = ["cap.a".to_owned(), "cap.b".to_owned()];
3698 let caller = "persona-caller";
3699 let sandbox_mode = "sandboxed";
3700 let reason = "looks fine";
3701 let injected_context = "";
3702 let conversation_id = "conv-1";
3703 let nonce = "nonce-1";
3704
3705 let pre_approver_payload = PRE_APPROVER_RESPONSE_PAYLOAD.as_bytes();
3714 let expected_pk = signer.public_key_bytes();
3715 let expected_sig = crate::hex::decode(
3716 serde_json::from_slice::<Value>(pre_approver_payload)
3717 .expect("the frozen payload is valid JSON")["signature_hex"]
3718 .as_str()
3719 .expect("the frozen payload carries a signature"),
3720 )
3721 .expect("the frozen signature is valid hex");
3722
3723 let verified = verify_signed_response(pre_approver_payload)
3724 .expect("a pre-#1025 payload must still verify");
3725 assert_eq!(verified.request_id, request_id);
3726 assert_eq!(verified.caller, caller);
3727 assert_eq!(
3728 verified.approver, "",
3729 "no approver field existed on this payload — decodes to empty, not an error"
3730 );
3731
3732 let (regenerated, sig, pk) = response_payload(
3736 request_id,
3737 tool_name,
3738 args_json,
3739 modified_args_json,
3740 approved,
3741 approved_for_session,
3742 &covered_capabilities,
3743 caller,
3744 "",
3745 sandbox_mode,
3746 reason,
3747 injected_context,
3748 conversation_id,
3749 nonce,
3750 &signer,
3751 );
3752 assert_eq!(
3753 regenerated, pre_approver_payload,
3754 "an empty approver must produce byte-identical canonical/payload to before #1025"
3755 );
3756 assert_eq!(
3757 sig, expected_sig,
3758 "an empty approver must sign byte-identically to before #1025"
3759 );
3760 assert_eq!(pk, expected_pk, "public key must be unchanged");
3761 }
3762
3763 #[test]
3764 fn session_response_round_trips_with_caller_binding() {
3765 let signer = ApprovalSigner::from_seed(42);
3766 let (payload, _sig, _pk) = response_payload(
3767 "req-1",
3768 "grep",
3769 r#"{"pattern":"x"}"#,
3770 "",
3771 true,
3772 true,
3773 &[],
3774 "slack:T1:U9",
3775 "",
3776 "workspace-write",
3777 "remember it",
3778 "",
3779 "conv-1",
3780 "nonce-1",
3781 &signer,
3782 );
3783 let verified = verify_signed_response(&payload).expect("session signature verifies");
3784 assert!(verified.approved);
3785 assert!(verified.approved_for_session, "carries session scope");
3786 assert_eq!(verified.caller, "slack:T1:U9");
3787 assert_eq!(verified.tool_name, "grep");
3788 assert!(verified.approved);
3789 }
3790
3791 #[test]
3792 fn tampered_session_or_caller_fails_verification() {
3793 let signer = ApprovalSigner::from_seed(42);
3794 let (payload, _sig, _pk) = response_payload(
3795 "req-1",
3796 "grep",
3797 "{}",
3798 "",
3799 true,
3800 true,
3801 &[],
3802 "slack:T1:U9",
3803 "",
3804 "workspace-write",
3805 "ok",
3806 "",
3807 "conv-1",
3808 "nonce-1",
3809 &signer,
3810 );
3811 for (field, val) in [
3815 ("caller", Value::String("slack:T1:ATTACKER".to_owned())),
3816 ("approved_for_session", Value::Bool(false)),
3817 ("tool_name", Value::String("rm".to_owned())),
3818 ("approved", Value::Bool(false)),
3819 ("args_json", Value::String("EVIL".to_owned())),
3820 (
3824 "modified_args_json",
3825 Value::String(r#"{"path":"/EVIL"}"#.to_owned()),
3826 ),
3827 ("injected_context", Value::String("do EVIL".to_owned())),
3828 (
3829 "sandbox_mode",
3830 Value::String("danger-full-access".to_owned()),
3831 ),
3832 ("conversation_id", Value::String("conv-OTHER".to_owned())),
3833 ("nonce", Value::String("nonce-OTHER".to_owned())),
3834 ] {
3835 let mut v: Value = serde_json::from_slice(&payload).unwrap();
3836 v[field] = val;
3837 assert!(
3838 verify_signed_response(&v.to_string().into_bytes()).is_none(),
3839 "tampering with {field} must fail verification"
3840 );
3841 }
3842 }
3843
3844 #[test]
3855 fn edited_response_binds_proposed_and_carries_modified() {
3856 let signer = ApprovalSigner::from_seed(7);
3857 let proposed = r#"{"path":"/etc/shadow"}"#;
3858 let edited = r#"{"path":"/etc/hostname"}"#;
3859 let (payload, _sig, _pk) = response_payload(
3860 "call-1",
3861 "read_file",
3862 proposed,
3863 edited,
3864 true,
3865 false,
3866 &[],
3867 "slack:T1:U9",
3868 "",
3869 "workspace-write",
3870 "narrowed the path",
3871 "",
3872 "conv-1",
3873 "nonce-1",
3874 &signer,
3875 );
3876 let v = verify_signed_response(&payload).expect("edited approval verifies");
3877 assert_eq!(v.args_json, proposed, "identity binds the proposed args");
3878 assert_eq!(
3879 v.modified_args_json, edited,
3880 "the edit is carried and signed"
3881 );
3882 assert!(
3885 v.authorizes_call("call-1", "read_file", proposed),
3886 "the exact proposed call is authorized"
3887 );
3888 assert!(
3889 !v.authorizes_call("call-1", "read_file", edited),
3890 "the edited args are NOT the identity — authorizes_call binds proposed"
3891 );
3892 }
3893
3894 #[test]
3898 fn unedited_response_carries_empty_edit() {
3899 let signer = ApprovalSigner::from_seed(7);
3900 let proposed = r#"{"path":"/tmp/x"}"#;
3901 let (payload, _sig, _pk) = response_payload(
3902 "call-1",
3903 "read_file",
3904 proposed,
3905 "",
3906 true,
3907 false,
3908 &[],
3909 "slack:T1:U9",
3910 "",
3911 "workspace-write",
3912 "ok",
3913 "",
3914 "conv-1",
3915 "nonce-1",
3916 &signer,
3917 );
3918 let v = verify_signed_response(&payload).expect("verifies");
3919 assert!(v.modified_args_json.is_empty(), "no edit ⇒ empty");
3920 assert!(v.injected_context.is_empty(), "no injected context ⇒ empty");
3921 assert!(v.authorizes_call("call-1", "read_file", proposed));
3922 }
3923
3924 #[test]
3927 fn injected_context_round_trips_and_is_signed() {
3928 let signer = ApprovalSigner::from_seed(7);
3929 let (payload, _sig, _pk) = response_payload(
3930 "call-1",
3931 "shell",
3932 r#"{"cmd":"ls"}"#,
3933 "",
3934 true,
3935 false,
3936 &[],
3937 "slack:T1:U9",
3938 "",
3939 "workspace-write",
3940 "ok",
3941 "only touch files under src/",
3942 "conv-1",
3943 "nonce-1",
3944 &signer,
3945 );
3946 let v = verify_signed_response(&payload).expect("verifies");
3947 assert_eq!(v.injected_context, "only touch files under src/");
3948 }
3949
3950 #[test]
3953 fn mutation_round_trips_and_tamper_fails() {
3954 let signer = ApprovalSigner::from_seed(7);
3955 let (payload, _s, _p) = mutation_payload(
3956 "tool_input_rewrite",
3957 "call-1",
3958 "shell",
3959 "conv-1",
3960 r#"{"cmd":"rm -rf /"}"#,
3961 r#"{"cmd":"rm /tmp/x"}"#,
3962 &signer,
3963 );
3964 assert_eq!(
3965 verify_mutation(&payload).map(|t| (t.0, t.4, t.5)),
3966 Some((
3967 "tool_input_rewrite".to_owned(),
3968 r#"{"cmd":"rm -rf /"}"#.to_owned(),
3969 r#"{"cmd":"rm /tmp/x"}"#.to_owned()
3970 ))
3971 );
3972 for field in [
3973 "kind",
3974 "tool_call_id",
3975 "tool_name",
3976 "conversation_id",
3977 "before",
3978 "after",
3979 ] {
3980 let mut v: Value = serde_json::from_slice(&payload).unwrap();
3981 v[field] = Value::String("EVIL".to_owned());
3982 assert!(
3983 verify_mutation(&v.to_string().into_bytes()).is_none(),
3984 "tampering with {field} must fail"
3985 );
3986 }
3987 }
3988
3989 #[test]
3992 fn deferred_round_trips_and_tamper_fails() {
3993 let signer = ApprovalSigner::from_seed(7);
3994 let (payload, _sig, _pk) = deferred_payload("call-1", "conv-1", "need more info", &signer);
3995 assert_eq!(
3996 verify_deferred(&payload),
3997 Some((
3998 "call-1".to_owned(),
3999 "conv-1".to_owned(),
4000 "need more info".to_owned()
4001 ))
4002 );
4003 for field in ["request_id", "conversation_id", "reason"] {
4004 let mut v: Value = serde_json::from_slice(&payload).unwrap();
4005 v[field] = Value::String("EVIL".to_owned());
4006 assert!(
4007 verify_deferred(&v.to_string().into_bytes()).is_none(),
4008 "tampering with {field} must fail"
4009 );
4010 }
4011 }
4012
4013 #[test]
4014 fn wire_verification_round_trips_and_binds_session_and_caller() {
4015 let signer = ApprovalSigner::from_seed(7);
4016 let (payload, _sig, _pk) = response_payload(
4017 "req-x",
4018 "grep",
4019 r#"{"p":"x"}"#,
4020 "",
4021 true,
4022 true,
4023 &[],
4024 "slack:T1:U9",
4025 "",
4026 "workspace-write",
4027 "go",
4028 "",
4029 "conv-7",
4030 "nonce-7",
4031 &signer,
4032 );
4033 let d = decode_response_full(&payload).expect("decoded payload");
4034 assert!(d.approved_for_session);
4036 assert_eq!(d.caller, "slack:T1:U9");
4037 assert_eq!(d.sandbox_mode, "workspace-write");
4038 assert_eq!(d.conversation_id, "conv-7");
4039 assert_eq!(d.nonce, "nonce-7");
4040 assert!(verify_wire_response(
4041 &d.request_id,
4042 &d.tool_name,
4043 &d.args_json,
4044 "",
4045 d.approved,
4046 d.approved_for_session,
4047 &[],
4048 &d.caller,
4049 &d.approver,
4050 &d.sandbox_mode,
4051 &d.reason,
4052 "",
4053 &d.conversation_id,
4054 &d.nonce,
4055 &d.signer_pk_hex,
4056 &d.signature_hex
4057 ));
4058 assert!(!verify_wire_response(
4061 &d.request_id,
4062 &d.tool_name,
4063 &d.args_json,
4064 "",
4065 d.approved,
4066 d.approved_for_session,
4067 &[],
4068 "slack:T1:ATTACKER",
4069 &d.approver,
4070 &d.sandbox_mode,
4071 &d.reason,
4072 "",
4073 &d.conversation_id,
4074 &d.nonce,
4075 &d.signer_pk_hex,
4076 &d.signature_hex
4077 ));
4078 assert!(!verify_wire_response(
4080 &d.request_id,
4081 &d.tool_name,
4082 r#"{"p":"EVIL"}"#,
4083 "",
4084 d.approved,
4085 d.approved_for_session,
4086 &[],
4087 &d.caller,
4088 &d.approver,
4089 &d.sandbox_mode,
4090 &d.reason,
4091 "",
4092 &d.conversation_id,
4093 &d.nonce,
4094 &d.signer_pk_hex,
4095 &d.signature_hex
4096 ));
4097 assert!(!verify_wire_response(
4100 &d.request_id,
4101 &d.tool_name,
4102 &d.args_json,
4103 "",
4104 d.approved,
4105 d.approved_for_session,
4106 &[],
4107 &d.caller,
4108 &d.approver,
4109 &d.sandbox_mode,
4110 &d.reason,
4111 "",
4112 "conv-OTHER",
4113 &d.nonce,
4114 &d.signer_pk_hex,
4115 &d.signature_hex
4116 ));
4117 }
4118
4119 #[test]
4124 fn approver_is_recoverable_and_distinct_from_caller() {
4125 let signer = ApprovalSigner::from_seed(3);
4126
4127 let (self_approved, ..) = response_payload(
4131 "req-1",
4132 "grep",
4133 r#"{"q":"x"}"#,
4134 "",
4135 true,
4136 false,
4137 &[],
4138 "slack:T1:U9",
4139 "slack:T1:U9",
4140 "workspace-write",
4141 "self-approved",
4142 "",
4143 "conv-1",
4144 "nonce-1",
4145 &signer,
4146 );
4147 let self_decoded = decode_response_full(&self_approved).expect("decodes");
4148 assert_eq!(self_decoded.caller, "slack:T1:U9");
4149 assert_eq!(self_decoded.approver, "slack:T1:U9");
4150 let self_verified = verify_signed_response(&self_approved).expect("verifies");
4151 assert_eq!(self_verified.caller, self_verified.approver);
4152
4153 let (admin_approved, ..) = response_payload(
4159 "req-2",
4160 "rm",
4161 r#"{"path":"/tmp/x"}"#,
4162 "",
4163 true,
4164 false,
4165 &[],
4166 "slack:T1:BENEFICIARY",
4167 "slack:T1:ADMIN",
4168 "workspace-write",
4169 "approved on your behalf",
4170 "",
4171 "conv-2",
4172 "nonce-2",
4173 &signer,
4174 );
4175 let admin_decoded = decode_response_full(&admin_approved).expect("decodes");
4176 assert_eq!(admin_decoded.caller, "slack:T1:BENEFICIARY");
4177 assert_eq!(admin_decoded.approver, "slack:T1:ADMIN");
4178 assert_ne!(
4179 admin_decoded.caller, admin_decoded.approver,
4180 "admin-approves-for-someone-else must decode two DISTINCT identities"
4181 );
4182 let admin_verified = verify_signed_response(&admin_approved).expect("verifies");
4183 assert_eq!(admin_verified.caller, "slack:T1:BENEFICIARY");
4184 assert_eq!(admin_verified.approver, "slack:T1:ADMIN");
4185
4186 let mut v: serde_json::Value = serde_json::from_slice(&admin_approved).unwrap();
4190 v["approver"] = serde_json::json!("slack:T1:ATTACKER");
4191 assert!(
4192 verify_signed_response(v.to_string().as_bytes()).is_none(),
4193 "a tampered approver must fail verification"
4194 );
4195 }
4196
4197 #[test]
4219 fn auto_review_signs_byte_identical_canonical_and_is_distinguishable() {
4220 let signer = ApprovalSigner::from_seed(7);
4221 let (rid, tool, args, caller, mode, conv, nonce) = (
4222 "req-9",
4223 "file_read",
4224 r#"{"path":"a.txt"}"#,
4225 "slack:T1:U9",
4226 "read-only",
4227 "conv-9",
4228 "nonce-9",
4229 );
4230
4231 let shared_reason = auto_review_reason("low");
4235 let human_like = response_payload(
4236 rid,
4237 tool,
4238 args,
4239 "",
4240 true,
4241 false,
4242 &[],
4243 caller,
4244 "",
4245 mode,
4246 &shared_reason,
4247 "",
4248 conv,
4249 nonce,
4250 &signer,
4251 );
4252 let reviewer = response_payload(
4253 rid,
4254 tool,
4255 args,
4256 "",
4257 true,
4258 false,
4259 &[],
4260 caller,
4261 "",
4262 mode,
4263 &shared_reason,
4264 "",
4265 conv,
4266 nonce,
4267 &signer,
4268 );
4269 assert_eq!(human_like.0, reviewer.0, "auto path must be byte-identical");
4270 assert_eq!(human_like.1, reviewer.1, "signature must be identical");
4271
4272 let verified = verify_signed_response(&reviewer.0).expect("auto-review verifies");
4274 assert!(verified.approved);
4275 assert!(
4276 !verified.approved_for_session,
4277 "a machine decision is never remembered per-caller"
4278 );
4279 assert!(
4280 is_auto_review_reason(&verified.reason),
4281 "the signed reason marks this as an auto-approval"
4282 );
4283
4284 let (human_payload, _s, _p) = response_payload(
4287 rid,
4288 tool,
4289 args,
4290 "",
4291 true,
4292 false,
4293 &[],
4294 caller,
4295 "",
4296 mode,
4297 "looks fine",
4298 "",
4299 conv,
4300 nonce,
4301 &signer,
4302 );
4303 let human = verify_signed_response(&human_payload).expect("human verifies");
4304 assert!(!is_auto_review_reason(&human.reason));
4305
4306 let a: Value = serde_json::from_slice(&reviewer.0).unwrap();
4310 let h: Value = serde_json::from_slice(&human_payload).unwrap();
4311 for field in [
4312 "request_id",
4313 "tool_name",
4314 "args_json",
4315 "modified_args_json",
4316 "approved",
4317 "approved_for_session",
4318 "caller",
4319 "sandbox_mode",
4320 "injected_context",
4321 "conversation_id",
4322 "nonce",
4323 ] {
4324 assert_eq!(a[field], h[field], "{field} must match the human payload");
4325 }
4326 assert_ne!(a["reason"], h["reason"], "reason is the sole distinguisher");
4327
4328 let base = &reviewer.0;
4338 let base_sig = &reviewer.1;
4339 for (label, variant) in [
4340 (
4341 "tool",
4342 response_payload(
4343 rid,
4344 "file_write",
4345 args,
4346 "",
4347 true,
4348 false,
4349 &[],
4350 caller,
4351 "",
4352 mode,
4353 &shared_reason,
4354 "",
4355 conv,
4356 nonce,
4357 &signer,
4358 ),
4359 ),
4360 (
4361 "args",
4362 response_payload(
4363 rid,
4364 tool,
4365 r#"{"path":"b.txt"}"#,
4366 "",
4367 true,
4368 false,
4369 &[],
4370 caller,
4371 "",
4372 mode,
4373 &shared_reason,
4374 "",
4375 conv,
4376 nonce,
4377 &signer,
4378 ),
4379 ),
4380 (
4381 "caller",
4382 response_payload(
4383 rid,
4384 tool,
4385 args,
4386 "",
4387 true,
4388 false,
4389 &[],
4390 "slack:T1:UEVIL",
4391 "",
4392 mode,
4393 &shared_reason,
4394 "",
4395 conv,
4396 nonce,
4397 &signer,
4398 ),
4399 ),
4400 (
4401 "request_id",
4402 response_payload(
4403 "req-OTHER",
4404 tool,
4405 args,
4406 "",
4407 true,
4408 false,
4409 &[],
4410 caller,
4411 "",
4412 mode,
4413 &shared_reason,
4414 "",
4415 conv,
4416 nonce,
4417 &signer,
4418 ),
4419 ),
4420 (
4421 "conversation_id",
4422 response_payload(
4423 rid,
4424 tool,
4425 args,
4426 "",
4427 true,
4428 false,
4429 &[],
4430 caller,
4431 "",
4432 mode,
4433 &shared_reason,
4434 "",
4435 "conv-OTHER",
4436 nonce,
4437 &signer,
4438 ),
4439 ),
4440 (
4441 "nonce",
4442 response_payload(
4443 rid,
4444 tool,
4445 args,
4446 "",
4447 true,
4448 false,
4449 &[],
4450 caller,
4451 "",
4452 mode,
4453 &shared_reason,
4454 "",
4455 conv,
4456 "nonce-OTHER",
4457 &signer,
4458 ),
4459 ),
4460 ] {
4461 assert_ne!(
4462 &variant.0, base,
4463 "{label}: a different {label} must change the canonical bytes"
4464 );
4465 assert_ne!(
4466 &variant.1, base_sig,
4467 "{label}: a different {label} must change the signature"
4468 );
4469 }
4470 }
4471
4472 #[test]
4480 fn capability_rejected_across_conversations() {
4481 let signer = ApprovalSigner::from_seed(11);
4482 let (payload, _sig, _pk) = response_payload(
4483 "call-0",
4484 "delete_file",
4485 r#"{"path":"/etc/hosts"}"#,
4486 "",
4487 true,
4488 false,
4489 &[],
4490 "slack:T1:U9",
4491 "",
4492 "workspace-write",
4493 "ok",
4494 "",
4495 "conv-A",
4496 "nonce-A",
4497 &signer,
4498 );
4499 let consumed = HashSet::new();
4500 assert!(
4502 verify_capability(&payload, "conv-A", &consumed, &[signer.public_key_bytes()])
4503 .is_some(),
4504 "a token must verify in the conversation it was signed for"
4505 );
4506 assert!(
4509 verify_capability(&payload, "conv-B", &consumed, &[signer.public_key_bytes()])
4510 .is_none(),
4511 "a token signed for conv-A must be rejected when consumed in conv-B"
4512 );
4513 }
4514
4515 #[test]
4519 fn capability_is_single_use() {
4520 let signer = ApprovalSigner::from_seed(11);
4521 let (payload, _sig, _pk) = response_payload(
4522 "call-0",
4523 "delete_file",
4524 r#"{"path":"/etc/hosts"}"#,
4525 "",
4526 true,
4527 false,
4528 &[],
4529 "slack:T1:U9",
4530 "",
4531 "workspace-write",
4532 "ok",
4533 "",
4534 "conv-A",
4535 "nonce-A",
4536 &signer,
4537 );
4538 let mut consumed = HashSet::new();
4539 let v = verify_capability(&payload, "conv-A", &consumed, &[signer.public_key_bytes()])
4541 .expect("first use honored");
4542 assert_eq!(v.nonce, "nonce-A");
4543 consumed.insert(v.nonce.clone());
4544 assert!(
4546 verify_capability(&payload, "conv-A", &consumed, &[signer.public_key_bytes()])
4547 .is_none(),
4548 "a spent token must be rejected on re-presentation"
4549 );
4550 }
4551
4552 #[test]
4557 fn capability_authorizes_only_matching_args() {
4558 let signer = ApprovalSigner::from_seed(11);
4559 let (payload, _sig, _pk) = response_payload(
4560 "call-0",
4561 "delete_file",
4562 r#"{"path":"/tmp/scratch"}"#,
4563 "",
4564 true,
4565 false,
4566 &[],
4567 "slack:T1:U9",
4568 "",
4569 "workspace-write",
4570 "ok",
4571 "",
4572 "conv-A",
4573 "nonce-A",
4574 &signer,
4575 );
4576 let consumed = HashSet::new();
4577 let v = verify_capability(&payload, "conv-A", &consumed, &[signer.public_key_bytes()])
4578 .expect("verifies in conv-A");
4579 assert!(
4581 !v.authorizes_call("call-0", "delete_file", r#"{"path":"/etc/hosts"}"#),
4582 "a token must not authorize a call with different args"
4583 );
4584 assert!(
4586 !v.authorizes_call("call-0", "shell", r#"{"path":"/tmp/scratch"}"#),
4587 "a token must not authorize a different tool"
4588 );
4589 assert!(
4591 v.authorizes_call("call-0", "delete_file", r#"{"path":"/tmp/scratch"}"#),
4592 "the exact signed call must be authorized"
4593 );
4594 }
4595
4596 #[test]
4598 fn denied_capability_authorizes_nothing() {
4599 let signer = ApprovalSigner::from_seed(11);
4600 let (payload, _sig, _pk) = response_payload(
4601 "call-0",
4602 "delete_file",
4603 "{}",
4604 "",
4605 false,
4606 false,
4607 &[],
4608 "slack:T1:U9",
4609 "",
4610 "workspace-write",
4611 "deny",
4612 "",
4613 "conv-A",
4614 "nonce-A",
4615 &signer,
4616 );
4617 let consumed = HashSet::new();
4618 let v = verify_capability(&payload, "conv-A", &consumed, &[signer.public_key_bytes()])
4619 .expect("verifies");
4620 assert!(!v.approved);
4621 assert!(
4622 !v.authorizes_call("call-0", "delete_file", "{}"),
4623 "a denied token authorizes nothing even on an exact identity match"
4624 );
4625 }
4626
4627 #[test]
4628 fn request_payload_round_trips_id() {
4629 let bytes = request_payload(
4630 "call-7",
4631 "rm",
4632 r#"{"path":"/etc"}"#,
4633 "workspace-write",
4634 "",
4635 &[],
4636 "",
4637 );
4638 assert_eq!(decode_request_id(&bytes).as_deref(), Some("call-7"));
4639 assert_eq!(decode_request_reason(&bytes), "");
4641 }
4642
4643 #[test]
4644 fn request_payload_carries_override_reason() {
4645 let reason = "lethal-trifecta / Rule-of-Two: untrusted content is in context";
4648 let bytes = request_payload(
4649 "call-9",
4650 "web_fetch",
4651 r#"{"url":"https://x"}"#,
4652 "",
4653 reason,
4654 &[],
4655 "",
4656 );
4657 assert_eq!(decode_request_reason(&bytes), reason);
4658 assert_eq!(decode_request_id(&bytes).as_deref(), Some("call-9"));
4660 assert_eq!(
4661 decode_request_fields(&bytes),
4662 Some((
4663 "call-9".to_owned(),
4664 "web_fetch".to_owned(),
4665 r#"{"url":"https://x"}"#.to_owned()
4666 ))
4667 );
4668 }
4669
4670 #[test]
4674 fn request_preview_json_round_trips() {
4675 let preview =
4676 r#"{"prompt_text":"hi","next_fires":[],"zone_name":"UTC","zone_is_fallback":true}"#;
4677 let bytes = request_payload("call-10", "routine_create", "{}", "", "", &[], preview);
4678 let decoded = decode_request_preview_json(&bytes).expect("preview present");
4679 let expected: Value = serde_json::from_str(preview).unwrap();
4682 let actual: Value = serde_json::from_str(&decoded).unwrap();
4683 assert_eq!(actual, expected);
4684 }
4685
4686 #[test]
4687 fn request_preview_json_is_absent_when_empty_or_missing() {
4688 let bytes = request_payload("call-11", "grep", "{}", "", "", &[], "");
4689 assert_eq!(decode_request_preview_json(&bytes), None);
4690 assert_eq!(decode_request_preview_json(b"{\"nope\":1}"), None);
4691 }
4692
4693 #[test]
4712 fn receipt_payload_pins_v3_canonical_shape() {
4713 let signer = ApprovalSigner::from_seed(99);
4714 let (reference, amount, currency, recipient, method, timestamp) = (
4715 "tx-abc",
4716 "0.01",
4717 "USDC",
4718 "0xrecipient",
4719 "tempo",
4720 "2026-06-02T00:00:00Z",
4721 );
4722 let (kind, tool_call_id, approval_pos, approved_args_hash, subject) = (
4723 "outbound_payment_receipt",
4724 "call-1",
4725 "42",
4726 "abcd1234",
4727 "conv-xyz",
4728 );
4729 let (payer_kind, paying_account) = ("linked_wallet", "0xpayer");
4730
4731 let expected_canonical = r#"{"version":3,"kind":"outbound_payment_receipt","reference":"tx-abc","amount":"0.01","currency":"USDC","recipient":"0xrecipient","method":"tempo","timestamp":"2026-06-02T00:00:00Z","tool_call_id":"call-1","approval_pos":"42","approved_args_hash":"abcd1234","subject":"conv-xyz","payer_kind":"linked_wallet","paying_account":"0xpayer"}"#;
4740 let expected_sig = signer.sign(expected_canonical.as_bytes());
4741 let expected_pk = signer.public_key_bytes();
4742 let expected_full = format!(
4743 r#"{{{expected_body},"signed_by":"{pk}","signature_hex":"{sig}"}}"#,
4744 expected_body = expected_canonical
4745 .trim_start_matches('{')
4746 .trim_end_matches('}'),
4747 pk = crate::hex::lower(&expected_pk),
4748 sig = crate::hex::lower(&expected_sig),
4749 );
4750
4751 let (payload, sig, pk) = receipt_payload(
4752 &ReceiptPayload {
4753 kind,
4754 reference,
4755 amount,
4756 currency,
4757 recipient,
4758 method,
4759 timestamp,
4760 tool_call_id,
4761 approval_pos,
4762 approved_args_hash,
4763 subject,
4764 payer_kind,
4765 paying_account,
4766 },
4767 &signer,
4768 );
4769
4770 assert_eq!(
4771 String::from_utf8(payload).unwrap(),
4772 expected_full,
4773 "v3 receipt payload must be byte-identical to the pinned v3 shape"
4774 );
4775 assert_eq!(
4776 sig, expected_sig,
4777 "signature must match the pinned v3 shape"
4778 );
4779 assert_eq!(pk, expected_pk, "public key must be unchanged");
4780 }
4781
4782 #[test]
4794 fn receipt_sign_and_verify_share_one_canonical_source() {
4795 let signer = ApprovalSigner::from_seed(99);
4796 let trusted = vec![signer.public_key_bytes()];
4797 let fields = ReceiptPayload {
4798 kind: "outbound_payment_receipt",
4799 reference: "tx-abc",
4800 amount: "0.01",
4801 currency: "USDC",
4802 recipient: "0xrecipient",
4803 method: "tempo",
4804 timestamp: "2026-06-02T00:00:00Z",
4805 tool_call_id: "call-1",
4806 approval_pos: "42",
4807 approved_args_hash: "abcd1234",
4808 subject: "conv-xyz",
4809 payer_kind: "linked_wallet",
4810 paying_account: "0xpayer",
4811 };
4812
4813 let signed_bytes = canonical_bytes(&fields.canonical_json_v3());
4815 let expected_sig = signer.sign(&signed_bytes);
4816 let (_payload, sig, _pk) = receipt_payload(&fields, &signer);
4817 assert_eq!(
4818 sig, expected_sig,
4819 "receipt_payload must sign exactly ReceiptPayload::canonical_json_v3"
4820 );
4821
4822 let (payload, _sig, _pk) = receipt_payload(&fields, &signer);
4825 let verified = verify_signed_receipt(&payload, &trusted).expect("verifies");
4826 let verified_fields = ReceiptPayload {
4827 kind: &verified.kind,
4828 reference: &verified.reference,
4829 amount: &verified.amount,
4830 currency: &verified.currency,
4831 recipient: &verified.recipient,
4832 method: &verified.method,
4833 timestamp: &verified.timestamp,
4834 tool_call_id: &verified.tool_call_id,
4835 approval_pos: &verified.approval_pos,
4836 approved_args_hash: &verified.approved_args_hash,
4837 subject: &verified.subject,
4838 payer_kind: &verified.payer_kind,
4839 paying_account: &verified.paying_account,
4840 };
4841 let verified_canonical = canonical_bytes(&verified_fields.canonical_json_v3());
4842 assert_eq!(
4843 verified_canonical, signed_bytes,
4844 "verify path must derive canonical JSON from the same single source"
4845 );
4846 }
4847
4848 #[test]
4849 fn crypto_receipt_payload_signs_and_verifies() {
4850 let signer = ApprovalSigner::from_seed(99);
4851 let trusted = vec![signer.public_key_bytes()];
4852 let (payload, _sig, _pk) = receipt_payload(
4853 &ReceiptPayload {
4854 kind: "outbound_payment_receipt",
4855 reference: "tx-abc",
4856 amount: "0.01",
4857 currency: "USDC",
4858 recipient: "0xrecipient",
4859 method: "tempo",
4860 timestamp: "2026-06-02T00:00:00Z",
4861 tool_call_id: "call-1",
4862 approval_pos: "42",
4863 approved_args_hash: "abcd1234",
4864 subject: "conv-xyz",
4865 payer_kind: "linked_wallet",
4866 paying_account: "0xpayer",
4867 },
4868 &signer,
4869 );
4870 let verified = verify_signed_receipt(&payload, &trusted)
4871 .expect("signature verifies on untampered receipt");
4872 assert_eq!(verified.reference, "tx-abc");
4873 assert_eq!(verified.amount, "0.01");
4874 assert_eq!(verified.currency, "USDC");
4875 assert_eq!(verified.recipient, "0xrecipient");
4876 assert_eq!(verified.method, "tempo");
4877 assert_eq!(verified.timestamp, "2026-06-02T00:00:00Z");
4878 assert_eq!(verified.version, RECEIPT_VERSION);
4881 assert_eq!(verified.kind, "outbound_payment_receipt");
4882 assert_eq!(verified.tool_call_id, "call-1");
4883 assert_eq!(verified.approval_pos, "42");
4884 assert_eq!(verified.approved_args_hash, "abcd1234");
4885 assert_eq!(verified.subject, "conv-xyz");
4886 assert_eq!(verified.payer_kind, "linked_wallet");
4888 assert_eq!(verified.paying_account, "0xpayer");
4889 }
4890
4891 #[test]
4892 fn tampered_receipt_fails_verification() {
4893 let signer = ApprovalSigner::from_seed(99);
4894 let trusted = vec![signer.public_key_bytes()];
4895 let (payload, _sig, _pk) = receipt_payload(
4896 &ReceiptPayload {
4897 kind: "outbound_payment_receipt",
4898 reference: "tx-abc",
4899 amount: "0.01",
4900 currency: "USDC",
4901 recipient: "0xrecipient",
4902 method: "tempo",
4903 timestamp: "2026-06-02T00:00:00Z",
4904 tool_call_id: "call-1",
4905 approval_pos: "42",
4906 approved_args_hash: "abcd1234",
4907 subject: "conv-xyz",
4908 payer_kind: "linked_wallet",
4909 paying_account: "0xpayer",
4910 },
4911 &signer,
4912 );
4913 let mut v: Value = serde_json::from_slice(&payload).unwrap();
4915 v["amount"] = Value::String("9999.00".to_owned());
4916 let tampered = v.to_string().into_bytes();
4917 assert!(verify_signed_receipt(&tampered, &trusted).is_none());
4918 }
4919
4920 #[test]
4921 fn tampered_receipt_binding_field_fails_verification() {
4922 let signer = ApprovalSigner::from_seed(99);
4923 let trusted = vec![signer.public_key_bytes()];
4924 let (payload, _sig, _pk) = receipt_payload(
4925 &ReceiptPayload {
4926 kind: "outbound_payment_receipt",
4927 reference: "tx-abc",
4928 amount: "0.01",
4929 currency: "USDC",
4930 recipient: "0xrecipient",
4931 method: "tempo",
4932 timestamp: "2026-06-02T00:00:00Z",
4933 tool_call_id: "call-1",
4934 approval_pos: "42",
4935 approved_args_hash: "abcd1234",
4936 subject: "conv-xyz",
4937 payer_kind: "linked_wallet",
4938 paying_account: "0xpayer",
4939 },
4940 &signer,
4941 );
4942 let mut v: Value = serde_json::from_slice(&payload).unwrap();
4945 v["approval_pos"] = Value::String("7".to_owned());
4946 let tampered = v.to_string().into_bytes();
4947 assert!(verify_signed_receipt(&tampered, &trusted).is_none());
4948
4949 let mut v: Value = serde_json::from_slice(&payload).unwrap();
4953 v["kind"] = Value::String("payment_receipt".to_owned());
4954 let refiled = v.to_string().into_bytes();
4955 assert!(verify_signed_receipt(&refiled, &trusted).is_none());
4956
4957 let mut v: Value = serde_json::from_slice(&payload).unwrap();
4962 v["payer_kind"] = Value::String("deployment".to_owned());
4963 let repayered = v.to_string().into_bytes();
4964 assert!(verify_signed_receipt(&repayered, &trusted).is_none());
4965 }
4966
4967 #[test]
4973 fn receipt_from_non_allowlisted_signer_is_rejected() {
4974 let trusted_signer = ApprovalSigner::from_seed(99);
4975 let attacker_signer = ApprovalSigner::from_seed(31337);
4976 let fields = ReceiptPayload {
4977 kind: "outbound_payment_receipt",
4978 reference: "tx-forged",
4979 amount: "100.00",
4980 currency: "USDC",
4981 recipient: "0xattacker",
4982 method: "tempo",
4983 timestamp: "2026-06-02T00:00:00Z",
4984 tool_call_id: "call-1",
4985 approval_pos: "42",
4986 approved_args_hash: "abcd1234",
4987 subject: "conv-xyz",
4988 payer_kind: "linked_wallet",
4989 paying_account: "0xpayer",
4990 };
4991 let (payload, _sig, _pk) = receipt_payload(&fields, &attacker_signer);
4994
4995 let trusted = vec![trusted_signer.public_key_bytes()];
4998 assert!(
4999 verify_signed_receipt(&payload, &trusted).is_none(),
5000 "a receipt signed by a non-allow-listed key must not verify"
5001 );
5002
5003 let trusted_plus_attacker = vec![
5007 trusted_signer.public_key_bytes(),
5008 attacker_signer.public_key_bytes(),
5009 ];
5010 assert!(
5011 verify_signed_receipt(&payload, &trusted_plus_attacker).is_some(),
5012 "the same receipt must verify once its signer is allow-listed"
5013 );
5014
5015 assert!(verify_signed_receipt(&payload, &[]).is_none());
5018 }
5019
5020 #[test]
5027 fn grant_replay_from_non_allowlisted_signer_is_rejected() {
5028 let trusted_signer = ApprovalSigner::from_seed(99);
5029 let attacker_signer = ApprovalSigner::from_seed(31337);
5030 let covered = vec!["arbitrary-egress".to_owned()];
5031 let (payload, _sig, _pk) = grant_replay_payload(
5033 "conv-1",
5034 "turn-7",
5035 "post_summary",
5036 "deadbeef",
5037 &covered,
5038 "sha256:template-abc",
5039 &attacker_signer,
5040 );
5041
5042 assert!(
5045 verify_grant_replay(&payload),
5046 "the unpinned verifier trusts any self-consistent signature"
5047 );
5048
5049 let trusted = vec![trusted_signer.public_key_bytes()];
5051 assert!(
5052 !verify_grant_replay_pinned(&payload, &trusted),
5053 "a grant_replay signed by a non-allow-listed key must not verify"
5054 );
5055
5056 let trusted_plus_attacker = vec![
5059 trusted_signer.public_key_bytes(),
5060 attacker_signer.public_key_bytes(),
5061 ];
5062 assert!(
5063 verify_grant_replay_pinned(&payload, &trusted_plus_attacker),
5064 "the same record must verify once its signer is allow-listed"
5065 );
5066
5067 assert!(!verify_grant_replay_pinned(&payload, &[]));
5069 }
5070
5071 #[test]
5078 fn signed_response_from_non_allowlisted_signer_is_rejected() {
5079 let trusted_signer = ApprovalSigner::from_seed(99);
5080 let attacker_signer = ApprovalSigner::from_seed(31337);
5081 let (payload, _sig, _pk) = response_payload(
5083 "call-0",
5084 "delete_file",
5085 r#"{"path":"/etc/hosts"}"#,
5086 "",
5087 true,
5088 false,
5089 &[],
5090 "slack:T1:U9",
5091 "slack:T1:U9",
5092 "workspace-write",
5093 "ok",
5094 "",
5095 "conv-A",
5096 "nonce-A",
5097 &attacker_signer,
5098 );
5099
5100 assert!(
5102 verify_signed_response(&payload).is_some(),
5103 "the unpinned verifier trusts any self-consistent signature"
5104 );
5105
5106 let trusted = vec![trusted_signer.public_key_bytes()];
5109 let consumed = HashSet::new();
5110 assert!(
5111 verify_signed_response_pinned(&payload, &trusted).is_none(),
5112 "an approval_response signed by a non-allow-listed key must not verify"
5113 );
5114 assert!(
5115 verify_capability(&payload, "conv-A", &consumed, &trusted).is_none(),
5116 "the capability gate must reject a non-allow-listed signer"
5117 );
5118
5119 let trusted_plus_attacker = vec![
5121 trusted_signer.public_key_bytes(),
5122 attacker_signer.public_key_bytes(),
5123 ];
5124 assert!(
5125 verify_signed_response_pinned(&payload, &trusted_plus_attacker).is_some(),
5126 "the same response must verify once its signer is allow-listed"
5127 );
5128 assert!(
5129 verify_capability(&payload, "conv-A", &consumed, &trusted_plus_attacker).is_some(),
5130 "the capability gate honors an allow-listed signer"
5131 );
5132
5133 assert!(verify_signed_response_pinned(&payload, &[]).is_none());
5135 assert!(verify_capability(&payload, "conv-A", &consumed, &[]).is_none());
5136 }
5137
5138 #[test]
5142 fn legacy_v1_receipt_still_verifies() {
5143 let signer = ApprovalSigner::from_seed(99);
5144 let trusted = vec![signer.public_key_bytes()];
5145 let verified = verify_signed_receipt(GOLDEN_V1_RECEIPT.as_bytes(), &trusted)
5146 .expect("a valid v1 receipt still verifies");
5147 assert_eq!(verified.version, 1);
5148 assert_eq!(verified.reference, "tx-old");
5149 assert!(verified.kind.is_empty());
5150 assert!(verified.tool_call_id.is_empty());
5151 assert!(verified.approval_pos.is_empty());
5152 assert!(verified.subject.is_empty());
5153 assert!(verified.payer_kind.is_empty());
5156 assert!(verified.paying_account.is_empty());
5157 }
5158
5159 #[test]
5167 fn injected_version_on_v1_signed_receipt_fails() {
5168 let signer = ApprovalSigner::from_seed(99);
5169 let trusted = vec![signer.public_key_bytes()];
5170 let full: Value =
5171 serde_json::from_str(GOLDEN_V1_RECEIPT).expect("the frozen v1 receipt is valid JSON");
5172
5173 for injected in [
5180 Value::from(7_u64),
5181 Value::from(0_u64),
5182 Value::from(1_u64),
5183 Value::from(2.0_f64),
5184 Value::String("2".to_owned()),
5185 Value::Null,
5186 Value::from(-1_i64),
5187 ] {
5188 let mut tampered = full.clone();
5189 tampered["version"] = injected;
5190 assert!(
5191 verify_signed_receipt(&tampered.to_string().into_bytes(), &trusted).is_none(),
5192 "a writer-chosen version key must never verify"
5193 );
5194 }
5195 assert!(verify_signed_receipt(GOLDEN_V1_RECEIPT.as_bytes(), &trusted).is_some());
5197 }
5198
5199 const GOLDEN_V1_RECEIPT: &str = r#"{"reference":"tx-old","amount":"0.02","currency":"USDC","recipient":"0xr","method":"tempo","timestamp":"2026-06-01T00:00:00Z","signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"0b2d980be185a6154d34da71e1ab6226d6dfc65bcd331fcf87b1b190765c4734301a3adececb1f2a80cf38007bc37d61dd4768eed4460a8326032b026a043407"}"#;
5205
5206 const GOLDEN_V2_RECEIPT: &str = r#"{"version":2,"kind":"outbound_payment_receipt","reference":"tx-frozen-v2","amount":"10000","currency":"0xToken","recipient":"0xrecipient","method":"tempo","timestamp":"2026-06-02T00:00:00Z","tool_call_id":"call-frozen","approval_pos":"42","approved_args_hash":"abcd1234","subject":"conv-frozen","signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"4f73999b3885188a976c4a2da45c0fc5b6917102fd5a17a10efbea7c9dc9c13216602de44b52388f6811d07ad3a9225288528828f05d436e8c1d3241e472c809"}"#;
5228
5229 const fn golden_v2_receipt_fields() -> ReceiptPayload<'static> {
5234 ReceiptPayload {
5235 kind: "outbound_payment_receipt",
5236 reference: "tx-frozen-v2",
5237 amount: "10000",
5238 currency: "0xToken",
5239 recipient: "0xrecipient",
5240 method: "tempo",
5241 timestamp: "2026-06-02T00:00:00Z",
5242 tool_call_id: "call-frozen",
5243 approval_pos: "42",
5244 approved_args_hash: "abcd1234",
5245 subject: "conv-frozen",
5246 payer_kind: "",
5247 paying_account: "",
5248 }
5249 }
5250
5251 #[test]
5264 fn frozen_v2_receipts_survive_a_later_version_bump() {
5265 let signer = ApprovalSigner::from_seed(99);
5266 let trusted = vec![signer.public_key_bytes()];
5267
5268 let verified = verify_signed_receipt(GOLDEN_V2_RECEIPT.as_bytes(), &trusted)
5269 .expect("the frozen v2 canonical must keep verifying receipts already signed under it");
5270
5271 assert_eq!(verified.version, 2);
5274 assert_eq!(verified.reference, "tx-frozen-v2");
5275 assert_eq!(verified.amount, "10000");
5276 assert_eq!(verified.currency, "0xToken");
5277 assert_eq!(verified.recipient, "0xrecipient");
5278 assert_eq!(verified.method, "tempo");
5279 assert_eq!(verified.timestamp, "2026-06-02T00:00:00Z");
5280 assert_eq!(verified.kind, "outbound_payment_receipt");
5283 assert_eq!(verified.tool_call_id, "call-frozen");
5284 assert_eq!(verified.approval_pos, "42");
5285 assert_eq!(verified.approved_args_hash, "abcd1234");
5286 assert_eq!(verified.subject, "conv-frozen");
5287 assert!(verified.payer_kind.is_empty());
5290 assert!(verified.paying_account.is_empty());
5291 assert_eq!(verified.signer_public_key, signer.public_key_bytes());
5292
5293 let mut relabelled: Value = serde_json::from_str(GOLDEN_V2_RECEIPT).unwrap();
5299 for unknown in [Value::from(4_u64), Value::from(5_u64)] {
5300 relabelled["version"] = unknown;
5301 assert!(
5302 verify_signed_receipt(&relabelled.to_string().into_bytes(), &trusted).is_none(),
5303 "a version with no frozen canonical must never be guessed at"
5304 );
5305 }
5306 }
5307
5308 const GOLDEN_V3_RECEIPT: &str = r#"{"version":3,"kind":"outbound_payment_receipt","reference":"tx-frozen-v3","amount":"10000","currency":"0xToken","recipient":"0xrecipient","method":"tempo","timestamp":"2026-06-02T00:00:00Z","tool_call_id":"call-frozen","approval_pos":"42","approved_args_hash":"abcd1234","subject":"conv-frozen","payer_kind":"linked_wallet","paying_account":"0xpayer-frozen","signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"0f5b4e37955044f7ae10ed1e9a57872200ddfc80d9d24a9a2958b9bb0dbe9a0fe6317cba772807a3797beaef84072a17317c2a220d3ba367077547b4dd77520f"}"#;
5312
5313 const fn golden_v3_receipt_fields() -> ReceiptPayload<'static> {
5315 ReceiptPayload {
5316 kind: "outbound_payment_receipt",
5317 reference: "tx-frozen-v3",
5318 amount: "10000",
5319 currency: "0xToken",
5320 recipient: "0xrecipient",
5321 method: "tempo",
5322 timestamp: "2026-06-02T00:00:00Z",
5323 tool_call_id: "call-frozen",
5324 approval_pos: "42",
5325 approved_args_hash: "abcd1234",
5326 subject: "conv-frozen",
5327 payer_kind: "linked_wallet",
5328 paying_account: "0xpayer-frozen",
5329 }
5330 }
5331
5332 #[test]
5335 fn frozen_v3_receipts_survive_a_later_version_bump() {
5336 let signer = ApprovalSigner::from_seed(99);
5337 let trusted = vec![signer.public_key_bytes()];
5338
5339 let verified = verify_signed_receipt(GOLDEN_V3_RECEIPT.as_bytes(), &trusted)
5340 .expect("the frozen v3 canonical must keep verifying receipts already signed under it");
5341
5342 assert_eq!(verified.version, 3);
5343 assert_eq!(verified.reference, "tx-frozen-v3");
5344 assert_eq!(verified.kind, "outbound_payment_receipt");
5345 assert_eq!(verified.tool_call_id, "call-frozen");
5346 assert_eq!(verified.approval_pos, "42");
5347 assert_eq!(verified.approved_args_hash, "abcd1234");
5348 assert_eq!(verified.subject, "conv-frozen");
5349 assert_eq!(verified.payer_kind, "linked_wallet");
5350 assert_eq!(verified.paying_account, "0xpayer-frozen");
5351 assert_eq!(verified.signer_public_key, signer.public_key_bytes());
5352 }
5353
5354 #[test]
5361 fn the_frozen_v2_fixture_reproduces_via_its_own_canonical() {
5362 let signer = ApprovalSigner::from_seed(99);
5363 let fields = golden_v2_receipt_fields();
5364 let canonical = ReceiptSchema::V2.canonical(&fields);
5365 let sig = signer.sign(&canonical);
5366 let full = format!(
5367 r#"{{{body},"signed_by":"{pk}","signature_hex":"{sig}"}}"#,
5368 body = String::from_utf8(canonical)
5369 .unwrap()
5370 .trim_start_matches('{')
5371 .trim_end_matches('}'),
5372 pk = crate::hex::lower(&signer.public_key_bytes()),
5373 sig = crate::hex::lower(&sig),
5374 );
5375 assert_eq!(
5376 full, GOLDEN_V2_RECEIPT,
5377 "the checked-in v2 fixture must be exactly what the frozen v2 canonical produces"
5378 );
5379 }
5380
5381 #[test]
5392 fn the_frozen_v3_fixture_is_what_todays_writer_signs() {
5393 let signer = ApprovalSigner::from_seed(99);
5394 let (payload, _sig, _pk) = receipt_payload(&golden_v3_receipt_fields(), &signer);
5395 assert_eq!(
5396 String::from_utf8(payload).unwrap(),
5397 GOLDEN_V3_RECEIPT,
5398 "the checked-in v3 fixture must be exactly what receipt_payload emits today"
5399 );
5400 }
5401}
5402
5403#[cfg(test)]
5404mod resolve_token_tests {
5405 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
5406 use super::*;
5407
5408 #[test]
5409 fn resolve_token_verifies_for_its_own_request_and_conversation() {
5410 let signer = ApprovalSigner::from_seed(11);
5411 let token = mint_resolve_token("call-1", "conv-a", 1_000, &signer);
5412 assert!(verify_resolve_token(
5413 &token, "call-1", "conv-a", 1_000, &signer
5414 ));
5415 }
5416
5417 #[test]
5418 fn resolve_token_rejects_a_different_request_id() {
5419 let signer = ApprovalSigner::from_seed(11);
5420 let token = mint_resolve_token("call-1", "conv-a", 1_000, &signer);
5421 assert!(!verify_resolve_token(
5422 &token, "call-2", "conv-a", 1_000, &signer
5423 ));
5424 }
5425
5426 #[test]
5427 fn resolve_token_rejects_a_different_conversation() {
5428 let signer = ApprovalSigner::from_seed(11);
5429 let token = mint_resolve_token("call-1", "conv-a", 1_000, &signer);
5430 assert!(!verify_resolve_token(
5431 &token, "call-1", "conv-b", 1_000, &signer
5432 ));
5433 }
5434
5435 #[test]
5436 fn resolve_token_rejects_wrong_signer() {
5437 let signer = ApprovalSigner::from_seed(11);
5438 let other = ApprovalSigner::from_seed(12);
5439 let token = mint_resolve_token("call-1", "conv-a", 1_000, &signer);
5440 assert!(!verify_resolve_token(
5441 &token, "call-1", "conv-a", 1_000, &other
5442 ));
5443 }
5444
5445 #[test]
5446 fn resolve_token_rejects_after_ttl_elapses() {
5447 let signer = ApprovalSigner::from_seed(11);
5448 let token = mint_resolve_token("call-1", "conv-a", 0, &signer);
5449 assert!(verify_resolve_token(
5450 &token,
5451 "call-1",
5452 "conv-a",
5453 RESOLVE_TOKEN_TTL_MS,
5454 &signer
5455 ));
5456 assert!(!verify_resolve_token(
5457 &token,
5458 "call-1",
5459 "conv-a",
5460 RESOLVE_TOKEN_TTL_MS + 1,
5461 &signer
5462 ));
5463 }
5464
5465 #[test]
5466 fn resolve_token_rejects_garbage() {
5467 let signer = ApprovalSigner::from_seed(11);
5468 assert!(!verify_resolve_token(
5469 "not-hex", "call-1", "conv-a", 0, &signer
5470 ));
5471 assert!(!verify_resolve_token("", "call-1", "conv-a", 0, &signer));
5472 }
5473}
5474
5475#[cfg(test)]
5476mod admin_model_change_tests {
5477 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
5478 use super::*;
5479
5480 #[test]
5481 fn admin_model_change_round_trips_and_is_tamper_evident() {
5482 let signer = ApprovalSigner::from_seed(31);
5483 let (payload, _sig, _pk) = admin_model_change_payload(
5484 "team-a",
5485 "vertex",
5486 "old-model",
5487 "vertex",
5488 "new-model",
5489 1_000,
5490 &signer,
5491 );
5492 let verified = verify_admin_model_change(&payload).expect("genuine record verifies");
5493 assert_eq!(verified.principal, "team-a");
5494 assert_eq!(verified.new_model, "new-model");
5495 assert_eq!(verified.signer_public_key, signer.public_key_bytes());
5496
5497 for (field, val) in [
5498 ("principal", serde_json::json!("attacker")),
5499 ("new_model", serde_json::json!("evil-model")),
5500 ("new_provider", serde_json::json!("evil-provider")),
5501 ] {
5502 let mut v: Value = serde_json::from_slice(&payload).unwrap();
5503 v[field] = val;
5504 assert!(
5505 verify_admin_model_change(v.to_string().as_bytes()).is_none(),
5506 "tampered {field} must fail verification"
5507 );
5508 }
5509 }
5510}
5511
5512#[cfg(test)]
5513mod routine_created_tests {
5514 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
5515 use super::*;
5516
5517 #[test]
5520 fn routine_created_round_trips_and_is_tamper_evident() {
5521 let signer = ApprovalSigner::from_seed(41);
5522 let (payload, _sig, _pk) = routine_created_payload(
5523 "daily-standup-a1b2",
5524 "persona-1",
5525 "conv-1",
5526 "call-1",
5527 "hash-1",
5528 1_000,
5529 &signer,
5530 );
5531 let verified = verify_routine_created(&payload).expect("genuine record verifies");
5532 assert_eq!(verified.routine, "daily-standup-a1b2");
5533 assert_eq!(verified.creator_persona, "persona-1");
5534 assert_eq!(verified.conversation_id, "conv-1");
5535 assert_eq!(verified.tool_call_id, "call-1");
5536 assert_eq!(verified.args_hash, "hash-1");
5537 assert_eq!(verified.created_at_ms, 1_000);
5538 assert_eq!(verified.signer_public_key, signer.public_key_bytes());
5539
5540 for (field, val) in [
5541 ("routine", serde_json::json!("someone-elses-routine")),
5542 ("creator_persona", serde_json::json!("attacker")),
5543 ("conversation_id", serde_json::json!("conv-other")),
5544 ("tool_call_id", serde_json::json!("call-other")),
5545 ("args_hash", serde_json::json!("hash-other")),
5546 ] {
5547 let mut v: Value = serde_json::from_slice(&payload).unwrap();
5548 v[field] = val;
5549 assert!(
5550 verify_routine_created(v.to_string().as_bytes()).is_none(),
5551 "tampered {field} must fail verification"
5552 );
5553 }
5554 }
5555
5556 #[test]
5557 fn malformed_routine_created_payload_fails_closed() {
5558 assert!(verify_routine_created(b"not json").is_none());
5559 assert!(verify_routine_created(b"{}").is_none());
5560 }
5561}
5562
5563#[cfg(test)]
5564mod routine_paused_tests {
5565 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
5566 use super::*;
5567
5568 #[test]
5571 fn routine_paused_round_trips_and_is_tamper_evident() {
5572 let signer = ApprovalSigner::from_seed(51);
5573 let (payload, _sig, _pk) = routine_paused_payload(
5574 "daily-standup-a1b2",
5575 "persona-1",
5576 "conv-1",
5577 "call-1",
5578 "hash-1",
5579 1_000,
5580 Some("rotating content"),
5581 &signer,
5582 );
5583 let verified = verify_routine_paused(&payload).expect("genuine record verifies");
5584 assert_eq!(verified.routine, "daily-standup-a1b2");
5585 assert_eq!(verified.actor_persona, "persona-1");
5586 assert_eq!(verified.conversation_id, "conv-1");
5587 assert_eq!(verified.tool_call_id, "call-1");
5588 assert_eq!(verified.args_hash, "hash-1");
5589 assert_eq!(verified.paused_at_ms, 1_000);
5590 assert_eq!(verified.reason.as_deref(), Some("rotating content"));
5591 assert_eq!(verified.signer_public_key, signer.public_key_bytes());
5592
5593 for (field, val) in [
5594 ("routine", serde_json::json!("someone-elses-routine")),
5595 ("actor_persona", serde_json::json!("attacker")),
5596 ("conversation_id", serde_json::json!("conv-other")),
5597 ("tool_call_id", serde_json::json!("call-other")),
5598 ("args_hash", serde_json::json!("hash-other")),
5599 ("reason", serde_json::json!("a different reason")),
5600 ] {
5601 let mut v: Value = serde_json::from_slice(&payload).unwrap();
5602 v[field] = val;
5603 assert!(
5604 verify_routine_paused(v.to_string().as_bytes()).is_none(),
5605 "tampered {field} must fail verification"
5606 );
5607 }
5608 }
5609
5610 #[test]
5613 fn routine_paused_with_no_reason_round_trips_none() {
5614 let signer = ApprovalSigner::from_seed(52);
5615 let (payload, _sig, _pk) = routine_paused_payload(
5616 "weekly-digest",
5617 "persona-2",
5618 "conv-2",
5619 "call-2",
5620 "hash-2",
5621 2_000,
5622 None,
5623 &signer,
5624 );
5625 let verified = verify_routine_paused(&payload).expect("genuine record verifies");
5626 assert_eq!(verified.reason, None);
5627 }
5628
5629 #[test]
5630 fn malformed_routine_paused_payload_fails_closed() {
5631 assert!(verify_routine_paused(b"not json").is_none());
5632 assert!(verify_routine_paused(b"{}").is_none());
5633 }
5634}
5635
5636#[cfg(test)]
5637mod routine_resumed_tests {
5638 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
5639 use super::*;
5640
5641 #[test]
5644 fn routine_resumed_round_trips_and_is_tamper_evident() {
5645 let signer = ApprovalSigner::from_seed(53);
5646 let (payload, _sig, _pk) = routine_resumed_payload(
5647 "daily-standup-a1b2",
5648 "persona-1",
5649 "conv-1",
5650 "call-1",
5651 "hash-1",
5652 3_000,
5653 &signer,
5654 );
5655 let verified = verify_routine_resumed(&payload).expect("genuine record verifies");
5656 assert_eq!(verified.routine, "daily-standup-a1b2");
5657 assert_eq!(verified.actor_persona, "persona-1");
5658 assert_eq!(verified.conversation_id, "conv-1");
5659 assert_eq!(verified.tool_call_id, "call-1");
5660 assert_eq!(verified.args_hash, "hash-1");
5661 assert_eq!(verified.resumed_at_ms, 3_000);
5662 assert_eq!(verified.signer_public_key, signer.public_key_bytes());
5663
5664 for (field, val) in [
5665 ("routine", serde_json::json!("someone-elses-routine")),
5666 ("actor_persona", serde_json::json!("attacker")),
5667 ("conversation_id", serde_json::json!("conv-other")),
5668 ("tool_call_id", serde_json::json!("call-other")),
5669 ("args_hash", serde_json::json!("hash-other")),
5670 ] {
5671 let mut v: Value = serde_json::from_slice(&payload).unwrap();
5672 v[field] = val;
5673 assert!(
5674 verify_routine_resumed(v.to_string().as_bytes()).is_none(),
5675 "tampered {field} must fail verification"
5676 );
5677 }
5678 }
5679
5680 #[test]
5681 fn malformed_routine_resumed_payload_fails_closed() {
5682 assert!(verify_routine_resumed(b"not json").is_none());
5683 assert!(verify_routine_resumed(b"{}").is_none());
5684 }
5685}
5686
5687#[cfg(test)]
5688mod routine_deleted_tests {
5689 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
5690 use super::*;
5691
5692 #[test]
5695 fn routine_deleted_round_trips_and_is_tamper_evident() {
5696 let signer = ApprovalSigner::from_seed(54);
5697 let (payload, _sig, _pk) = routine_deleted_payload(
5698 "daily-standup-a1b2",
5699 "persona-1",
5700 "conv-1",
5701 "call-1",
5702 "hash-1",
5703 4_000,
5704 &signer,
5705 );
5706 let verified = verify_routine_deleted(&payload).expect("genuine record verifies");
5707 assert_eq!(verified.routine, "daily-standup-a1b2");
5708 assert_eq!(verified.actor_persona, "persona-1");
5709 assert_eq!(verified.conversation_id, "conv-1");
5710 assert_eq!(verified.tool_call_id, "call-1");
5711 assert_eq!(verified.args_hash, "hash-1");
5712 assert_eq!(verified.deleted_at_ms, 4_000);
5713 assert_eq!(verified.signer_public_key, signer.public_key_bytes());
5714
5715 for (field, val) in [
5716 ("routine", serde_json::json!("someone-elses-routine")),
5717 ("actor_persona", serde_json::json!("attacker")),
5718 ("conversation_id", serde_json::json!("conv-other")),
5719 ("tool_call_id", serde_json::json!("call-other")),
5720 ("args_hash", serde_json::json!("hash-other")),
5721 ] {
5722 let mut v: Value = serde_json::from_slice(&payload).unwrap();
5723 v[field] = val;
5724 assert!(
5725 verify_routine_deleted(v.to_string().as_bytes()).is_none(),
5726 "tampered {field} must fail verification"
5727 );
5728 }
5729 }
5730
5731 #[test]
5732 fn malformed_routine_deleted_payload_fails_closed() {
5733 assert!(verify_routine_deleted(b"not json").is_none());
5734 assert!(verify_routine_deleted(b"{}").is_none());
5735 }
5736}
5737
5738#[cfg(test)]
5739mod routine_scope_changed_tests {
5740 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
5741 use super::*;
5742
5743 #[test]
5747 fn routine_scope_changed_round_trips_and_is_tamper_evident() {
5748 let signer = ApprovalSigner::from_seed(55);
5749 let (payload, _sig, _pk) = routine_scope_changed_payload(
5750 "daily-standup-a1b2",
5751 "persona-1",
5752 "conv-1",
5753 "call-1",
5754 "hash-1",
5755 "public",
5756 5_000,
5757 &signer,
5758 );
5759 let verified = verify_routine_scope_changed(&payload).expect("genuine record verifies");
5760 assert_eq!(verified.routine, "daily-standup-a1b2");
5761 assert_eq!(verified.actor_persona, "persona-1");
5762 assert_eq!(verified.conversation_id, "conv-1");
5763 assert_eq!(verified.tool_call_id, "call-1");
5764 assert_eq!(verified.args_hash, "hash-1");
5765 assert_eq!(verified.scope, "public");
5766 assert_eq!(verified.changed_at_ms, 5_000);
5767 assert_eq!(verified.signer_public_key, signer.public_key_bytes());
5768
5769 for (field, val) in [
5770 ("routine", serde_json::json!("someone-elses-routine")),
5771 ("actor_persona", serde_json::json!("attacker")),
5772 ("conversation_id", serde_json::json!("conv-other")),
5773 ("tool_call_id", serde_json::json!("call-other")),
5774 ("args_hash", serde_json::json!("hash-other")),
5775 ("scope", serde_json::json!("private")),
5776 ] {
5777 let mut v: Value = serde_json::from_slice(&payload).unwrap();
5778 v[field] = val;
5779 assert!(
5780 verify_routine_scope_changed(v.to_string().as_bytes()).is_none(),
5781 "tampered {field} must fail verification"
5782 );
5783 }
5784 }
5785
5786 #[test]
5787 fn malformed_routine_scope_changed_payload_fails_closed() {
5788 assert!(verify_routine_scope_changed(b"not json").is_none());
5789 assert!(verify_routine_scope_changed(b"{}").is_none());
5790 }
5791}
5792
5793#[cfg(test)]
5794mod canonical_freeze {
5795 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
5821
5822 use super::*;
5823
5824 fn frozen(label: &str, got: &[u8], want: &str) {
5826 assert_eq!(
5827 String::from_utf8(got.to_vec()).unwrap(),
5828 want,
5829 "{label}: canonical bytes moved — every signature over the old bytes is now unverifiable"
5830 );
5831 }
5832
5833 fn caps() -> Vec<String> {
5834 vec!["arbitrary-egress".to_owned(), "mutate-external".to_owned()]
5835 }
5836
5837 fn transitions() -> Vec<CredentialKeyTransition> {
5838 vec![
5839 CredentialKeyTransition {
5840 kid: "k1".to_owned(),
5841 from: "absent".to_owned(),
5842 to: "active".to_owned(),
5843 },
5844 CredentialKeyTransition {
5845 kid: "k2".to_owned(),
5846 from: "active".to_owned(),
5847 to: "revoked".to_owned(),
5848 },
5849 ]
5850 }
5851
5852 const AT_MS: u64 = 1_750_000_000_000;
5853 const ROUTINE_CALL: &str = "call-1";
5856 const ROUTINE_ARGS_HASH: &str = "abcd1234";
5857
5858 #[test]
5859 fn approval_response_canonical_is_frozen() {
5860 let caps = caps();
5861 frozen(
5862 "response_canonical (no approver)",
5863 &response_canonical(
5864 "req-1",
5865 "paid_fetch",
5866 "{\"a\":1}",
5867 "{\"a\":2}",
5868 true,
5869 false,
5870 &caps,
5871 "persona:alice",
5872 "",
5873 "workspace-write",
5874 "looks fine",
5875 "ctx",
5876 "conv-1",
5877 "nonce-1",
5878 ),
5879 NO_APPROVER_CANONICAL,
5880 );
5881 frozen(
5885 "response_canonical (approver)",
5886 &response_canonical(
5887 "req-1",
5888 "paid_fetch",
5889 "{\"a\":1}",
5890 "{\"a\":2}",
5891 true,
5892 true,
5893 &caps,
5894 "persona:alice",
5895 "persona:admin",
5896 "workspace-write",
5897 "looks fine",
5898 "ctx",
5899 "conv-1",
5900 "nonce-1",
5901 ),
5902 APPROVER_CANONICAL,
5903 );
5904 let (full, sig, _pk) = response_payload(
5905 "req-1",
5906 "paid_fetch",
5907 "{\"a\":1}",
5908 "{\"a\":2}",
5909 true,
5910 true,
5911 &caps,
5912 "persona:alice",
5913 "persona:admin",
5914 "workspace-write",
5915 "looks fine",
5916 "ctx",
5917 "conv-1",
5918 "nonce-1",
5919 &ApprovalSigner::from_seed(99),
5920 );
5921 frozen("response_payload", &full, RESPONSE_PAYLOAD);
5922 assert_eq!(crate::hex::lower(&sig), RESPONSE_SIG);
5923 }
5924
5925 const NO_APPROVER_CANONICAL: &str = r#"{"request_id":"req-1","tool_name":"paid_fetch","args_json":"{\"a\":1}","modified_args_json":"{\"a\":2}","approved":true,"approved_for_session":false,"covered_capabilities":["arbitrary-egress","mutate-external"],"caller":"persona:alice","sandbox_mode":"workspace-write","reason":"looks fine","injected_context":"ctx","conversation_id":"conv-1","nonce":"nonce-1"}"#;
5926 const APPROVER_CANONICAL: &str = r#"{"request_id":"req-1","tool_name":"paid_fetch","args_json":"{\"a\":1}","modified_args_json":"{\"a\":2}","approved":true,"approved_for_session":true,"covered_capabilities":["arbitrary-egress","mutate-external"],"caller":"persona:alice","sandbox_mode":"workspace-write","reason":"looks fine","injected_context":"ctx","conversation_id":"conv-1","nonce":"nonce-1","approver":"persona:admin"}"#;
5927 const RESPONSE_PAYLOAD: &str = r#"{"request_id":"req-1","tool_name":"paid_fetch","args_json":"{\"a\":1}","modified_args_json":"{\"a\":2}","approved":true,"approved_for_session":true,"covered_capabilities":["arbitrary-egress","mutate-external"],"caller":"persona:alice","sandbox_mode":"workspace-write","reason":"looks fine","injected_context":"ctx","conversation_id":"conv-1","nonce":"nonce-1","signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"aea101dfae5a1cf2568540d4d737b11764d060280049ddfb47d865d108e4897f3144fa3aaeaaa8e0c20a59abf5e18834b51b389c986349bf28391e68a5bc5300","approver":"persona:admin"}"#;
5928 const RESPONSE_SIG: &str = "aea101dfae5a1cf2568540d4d737b11764d060280049ddfb47d865d108e4897f3144fa3aaeaaa8e0c20a59abf5e18834b51b389c986349bf28391e68a5bc5300";
5929
5930 #[test]
5931 fn excision_canonical_is_frozen() {
5932 let signer = ApprovalSigner::from_seed(99);
5933 frozen(
5934 "excision_canonical",
5935 &excision_canonical(
5936 "conv-1",
5937 EXCISION_SCOPE_CASCADE,
5938 &[17, 23, 40],
5939 "persona:alice",
5940 "prompt injection",
5941 ),
5942 EXCISION_CANONICAL_LIT,
5943 );
5944 let (full, sig, _) = excision_payload(
5945 "conv-1",
5946 EXCISION_SCOPE_CASCADE,
5947 &[17, 23, 40],
5948 "persona:alice",
5949 "prompt injection",
5950 &signer,
5951 );
5952 frozen("excision_payload", &full, EXCISION_PAYLOAD_LIT);
5953 assert_eq!(crate::hex::lower(&sig), EXCISION_SIG_LIT);
5954 }
5955
5956 #[test]
5957 fn grant_replay_canonical_is_frozen() {
5958 let signer = ApprovalSigner::from_seed(99);
5959 let caps = caps();
5960 frozen(
5961 "grant_replay_canonical",
5962 &grant_replay_canonical(
5963 "conv-1",
5964 "turn-8",
5965 "paid_fetch",
5966 "cafe",
5967 &caps,
5968 "sha256:abc",
5969 ),
5970 GRANT_REPLAY_CANONICAL_LIT,
5971 );
5972 let (full, sig, _) = grant_replay_payload(
5973 "conv-1",
5974 "turn-8",
5975 "paid_fetch",
5976 "cafe",
5977 &caps,
5978 "sha256:abc",
5979 &signer,
5980 );
5981 frozen("grant_replay_payload", &full, GRANT_REPLAY_PAYLOAD_LIT);
5982 assert_eq!(crate::hex::lower(&sig), GRANT_REPLAY_SIG_LIT);
5983 }
5984
5985 #[test]
5986 fn deferred_and_mutation_canonicals_are_frozen() {
5987 let signer = ApprovalSigner::from_seed(99);
5988 let (full, sig, _) = deferred_payload("req-1", "conv-1", "needs more detail", &signer);
5989 frozen("deferred_payload", &full, DEFERRED_PAYLOAD_LIT);
5990 assert_eq!(crate::hex::lower(&sig), DEFERRED_SIG_LIT);
5991
5992 let (full, sig, _) = mutation_payload(
5993 "tool_input_rewrite",
5994 "call-1",
5995 "paid_fetch",
5996 "conv-1",
5997 "before-args",
5998 "after-args",
5999 &signer,
6000 );
6001 frozen("mutation_payload", &full, MUTATION_PAYLOAD_LIT);
6002 assert_eq!(crate::hex::lower(&sig), MUTATION_SIG_LIT);
6003 }
6004
6005 #[test]
6006 fn receipt_canonicals_are_frozen() {
6007 let signer = ApprovalSigner::from_seed(99);
6008 let fields = ReceiptPayload {
6009 kind: "outbound_payment_receipt",
6010 reference: "tx-frozen-v2",
6011 amount: "10000",
6012 currency: "0xToken",
6013 recipient: "0xrecipient",
6014 method: "tempo",
6015 timestamp: "2026-06-02T00:00:00Z",
6016 tool_call_id: "call-frozen",
6017 approval_pos: "42",
6018 approved_args_hash: "abcd1234",
6019 subject: "conv-frozen",
6020 payer_kind: "",
6024 paying_account: "",
6025 };
6026 frozen(
6027 "canonical_json_v2",
6028 &canonical_bytes(&fields.canonical_json_v2()),
6029 RECEIPT_V2_CANONICAL_LIT,
6030 );
6031 frozen(
6032 "canonical_json_v1",
6033 &canonical_bytes(&fields.canonical_json_v1()),
6034 RECEIPT_V1_CANONICAL_LIT,
6035 );
6036
6037 let fields_v3 = ReceiptPayload {
6042 payer_kind: "linked_wallet",
6043 paying_account: "0xpayer-frozen",
6044 ..fields
6045 };
6046 frozen(
6047 "canonical_json_v3",
6048 &canonical_bytes(&fields_v3.canonical_json_v3()),
6049 RECEIPT_V3_CANONICAL_LIT,
6050 );
6051 let (full, sig, _) = receipt_payload(&fields_v3, &signer);
6052 frozen("receipt_payload", &full, RECEIPT_PAYLOAD_LIT);
6053 assert_eq!(crate::hex::lower(&sig), RECEIPT_SIG_LIT);
6054 }
6055
6056 #[test]
6057 fn resolve_token_canonical_is_frozen() {
6058 let signer = ApprovalSigner::from_seed(99);
6059 frozen(
6060 "resolve_token_canonical",
6061 &resolve_token_canonical("req-1", "conv-1", AT_MS),
6062 RESOLVE_TOKEN_CANONICAL_LIT,
6063 );
6064 assert_eq!(
6065 mint_resolve_token("req-1", "conv-1", AT_MS, &signer),
6066 RESOLVE_TOKEN_LIT,
6067 "a minted resolve token's bytes are frozen — a token is hex of the whole object"
6068 );
6069 }
6070
6071 #[test]
6072 fn admin_model_change_canonical_is_frozen() {
6073 let signer = ApprovalSigner::from_seed(99);
6074 frozen(
6075 "admin_model_change_canonical",
6076 &admin_model_change_canonical(
6077 "admin:root",
6078 "prov-a",
6079 "model-a",
6080 "prov-b",
6081 "model-b",
6082 AT_MS,
6083 ),
6084 ADMIN_MODEL_CANONICAL_LIT,
6085 );
6086 let (full, sig, _) = admin_model_change_payload(
6087 "admin:root",
6088 "prov-a",
6089 "model-a",
6090 "prov-b",
6091 "model-b",
6092 AT_MS,
6093 &signer,
6094 );
6095 frozen("admin_model_change_payload", &full, ADMIN_MODEL_PAYLOAD_LIT);
6096 assert_eq!(crate::hex::lower(&sig), ADMIN_MODEL_SIG_LIT);
6097 }
6098
6099 #[test]
6103 fn credential_change_canonical_is_frozen() {
6104 let signer = ApprovalSigner::from_seed(99);
6105 let transitions = transitions();
6106 frozen(
6107 "credential_change_canonical",
6108 &credential_change_canonical(
6109 "credential_enrolled",
6110 "admin:root",
6111 "edge-1",
6112 "k1",
6113 "edge, admin",
6114 &transitions,
6115 AT_MS,
6116 ),
6117 CREDENTIAL_CANONICAL_LIT,
6118 );
6119 let (full, sig, _) = credential_change_payload(
6120 "credential_enrolled",
6121 "admin:root",
6122 "edge-1",
6123 "k1",
6124 "edge, admin",
6125 &transitions,
6126 AT_MS,
6127 &signer,
6128 );
6129 frozen("credential_change_payload", &full, CREDENTIAL_PAYLOAD_LIT);
6130 assert_eq!(crate::hex::lower(&sig), CREDENTIAL_SIG_LIT);
6131 }
6132
6133 #[test]
6134 fn routine_audit_canonicals_are_frozen() {
6135 let signer = ApprovalSigner::from_seed(99);
6136
6137 frozen(
6138 "routine_created_canonical",
6139 &routine_created_canonical(
6140 "r-1",
6141 "persona:alice",
6142 "conv-1",
6143 ROUTINE_CALL,
6144 ROUTINE_ARGS_HASH,
6145 AT_MS,
6146 ),
6147 ROUTINE_CREATED_CANONICAL_LIT,
6148 );
6149 let (full, sig, _) = routine_created_payload(
6150 "r-1",
6151 "persona:alice",
6152 "conv-1",
6153 ROUTINE_CALL,
6154 ROUTINE_ARGS_HASH,
6155 AT_MS,
6156 &signer,
6157 );
6158 frozen(
6159 "routine_created_payload",
6160 &full,
6161 ROUTINE_CREATED_PAYLOAD_LIT,
6162 );
6163 assert_eq!(crate::hex::lower(&sig), ROUTINE_CREATED_SIG_LIT);
6164
6165 frozen(
6166 "routine_paused_canonical (reason)",
6167 &routine_paused_canonical(
6168 "r-1",
6169 "persona:alice",
6170 "conv-1",
6171 ROUTINE_CALL,
6172 ROUTINE_ARGS_HASH,
6173 AT_MS,
6174 Some("too noisy"),
6175 ),
6176 ROUTINE_PAUSED_SOME_LIT,
6177 );
6178 frozen(
6179 "routine_paused_canonical (no reason)",
6180 &routine_paused_canonical(
6181 "r-1",
6182 "persona:alice",
6183 "conv-1",
6184 ROUTINE_CALL,
6185 ROUTINE_ARGS_HASH,
6186 AT_MS,
6187 None,
6188 ),
6189 ROUTINE_PAUSED_NONE_LIT,
6190 );
6191 let (full, sig, _) = routine_paused_payload(
6192 "r-1",
6193 "persona:alice",
6194 "conv-1",
6195 ROUTINE_CALL,
6196 ROUTINE_ARGS_HASH,
6197 AT_MS,
6198 Some("too noisy"),
6199 &signer,
6200 );
6201 frozen("routine_paused_payload", &full, ROUTINE_PAUSED_PAYLOAD_LIT);
6202 assert_eq!(crate::hex::lower(&sig), ROUTINE_PAUSED_SIG_LIT);
6203
6204 frozen(
6205 "routine_resumed_canonical",
6206 &routine_resumed_canonical(
6207 "r-1",
6208 "persona:alice",
6209 "conv-1",
6210 ROUTINE_CALL,
6211 ROUTINE_ARGS_HASH,
6212 AT_MS,
6213 ),
6214 ROUTINE_RESUMED_CANONICAL_LIT,
6215 );
6216 let (full, sig, _) = routine_resumed_payload(
6217 "r-1",
6218 "persona:alice",
6219 "conv-1",
6220 ROUTINE_CALL,
6221 ROUTINE_ARGS_HASH,
6222 AT_MS,
6223 &signer,
6224 );
6225 frozen(
6226 "routine_resumed_payload",
6227 &full,
6228 ROUTINE_RESUMED_PAYLOAD_LIT,
6229 );
6230 assert_eq!(crate::hex::lower(&sig), ROUTINE_RESUMED_SIG_LIT);
6231
6232 frozen(
6233 "routine_deleted_canonical",
6234 &routine_deleted_canonical(
6235 "r-1",
6236 "persona:alice",
6237 "conv-1",
6238 ROUTINE_CALL,
6239 ROUTINE_ARGS_HASH,
6240 AT_MS,
6241 ),
6242 ROUTINE_DELETED_CANONICAL_LIT,
6243 );
6244 let (full, sig, _) = routine_deleted_payload(
6245 "r-1",
6246 "persona:alice",
6247 "conv-1",
6248 ROUTINE_CALL,
6249 ROUTINE_ARGS_HASH,
6250 AT_MS,
6251 &signer,
6252 );
6253 frozen(
6254 "routine_deleted_payload",
6255 &full,
6256 ROUTINE_DELETED_PAYLOAD_LIT,
6257 );
6258 assert_eq!(crate::hex::lower(&sig), ROUTINE_DELETED_SIG_LIT);
6259
6260 frozen(
6261 "routine_scope_changed_canonical",
6262 &routine_scope_changed_canonical(
6263 "r-1",
6264 "persona:alice",
6265 "conv-1",
6266 ROUTINE_CALL,
6267 ROUTINE_ARGS_HASH,
6268 "public",
6269 AT_MS,
6270 ),
6271 ROUTINE_SCOPE_CHANGED_CANONICAL_LIT,
6272 );
6273 let (full, sig, _) = routine_scope_changed_payload(
6274 "r-1",
6275 "persona:alice",
6276 "conv-1",
6277 ROUTINE_CALL,
6278 ROUTINE_ARGS_HASH,
6279 "public",
6280 AT_MS,
6281 &signer,
6282 );
6283 frozen(
6284 "routine_scope_changed_payload",
6285 &full,
6286 ROUTINE_SCOPE_CHANGED_PAYLOAD_LIT,
6287 );
6288 assert_eq!(crate::hex::lower(&sig), ROUTINE_SCOPE_CHANGED_SIG_LIT);
6289 }
6290
6291 const EXCISION_CANONICAL_LIT: &str = r#"{"conversation_id":"conv-1","scope":"cascade","positions":[17,23,40],"requested_by":"persona:alice","reason":"prompt injection"}"#;
6292 const EXCISION_PAYLOAD_LIT: &str = r#"{"conversation_id":"conv-1","scope":"cascade","positions":[17,23,40],"requested_by":"persona:alice","reason":"prompt injection","signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"6dbe6aef29cd78faf5044e23aa9f49ba281cbda46b06ffdbba8c5b60b7de67f72beafd3fc83710a11fc8bd37a71da51dbfd722eabbc5bd9f509dfb081a35700b"}"#;
6293 const EXCISION_SIG_LIT: &str = "6dbe6aef29cd78faf5044e23aa9f49ba281cbda46b06ffdbba8c5b60b7de67f72beafd3fc83710a11fc8bd37a71da51dbfd722eabbc5bd9f509dfb081a35700b";
6294 const GRANT_REPLAY_CANONICAL_LIT: &str = r#"{"conversation_id":"conv-1","turn_id":"turn-8","tool":"paid_fetch","grant_ref":"cafe","covered_capabilities":["arbitrary-egress","mutate-external"],"coverage_hash":"sha256:abc"}"#;
6295 const GRANT_REPLAY_PAYLOAD_LIT: &str = r#"{"conversation_id":"conv-1","turn_id":"turn-8","tool":"paid_fetch","grant_ref":"cafe","covered_capabilities":["arbitrary-egress","mutate-external"],"coverage_hash":"sha256:abc","signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"b98f27ea001616a9c5edf49b71df1c65eaf391462572e1526f8be6ac132bed0aeae56644cdaa77b3112bfffc0d2dc3ac849713ec32563654ceaeb741dfc8550a"}"#;
6296 const GRANT_REPLAY_SIG_LIT: &str = "b98f27ea001616a9c5edf49b71df1c65eaf391462572e1526f8be6ac132bed0aeae56644cdaa77b3112bfffc0d2dc3ac849713ec32563654ceaeb741dfc8550a";
6297 const DEFERRED_PAYLOAD_LIT: &str = r#"{"request_id":"req-1","conversation_id":"conv-1","reason":"needs more detail","signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"95d968b15da8f3b3f248dbf05b7b819c6f7d969c9c6b3470b8ec0657d63ef1dd38e9d0fdaed43aee7b94ec16828d9ac74634940afebe5a5623dd2561c5afd506"}"#;
6298 const DEFERRED_SIG_LIT: &str = "95d968b15da8f3b3f248dbf05b7b819c6f7d969c9c6b3470b8ec0657d63ef1dd38e9d0fdaed43aee7b94ec16828d9ac74634940afebe5a5623dd2561c5afd506";
6299 const MUTATION_PAYLOAD_LIT: &str = r#"{"kind":"tool_input_rewrite","tool_call_id":"call-1","tool_name":"paid_fetch","conversation_id":"conv-1","before":"before-args","after":"after-args","signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"c1386859a36e81c0dd0d256a1fb27cd02fb73e59048687d10ec74546b4a95e485aa2f045dab568a4432da72bd7cbcbc0789ee6ce4799017ecc8aaf62efd42e03"}"#;
6300 const MUTATION_SIG_LIT: &str = "c1386859a36e81c0dd0d256a1fb27cd02fb73e59048687d10ec74546b4a95e485aa2f045dab568a4432da72bd7cbcbc0789ee6ce4799017ecc8aaf62efd42e03";
6301 const RECEIPT_V2_CANONICAL_LIT: &str = r#"{"version":2,"kind":"outbound_payment_receipt","reference":"tx-frozen-v2","amount":"10000","currency":"0xToken","recipient":"0xrecipient","method":"tempo","timestamp":"2026-06-02T00:00:00Z","tool_call_id":"call-frozen","approval_pos":"42","approved_args_hash":"abcd1234","subject":"conv-frozen"}"#;
6302 const RECEIPT_V1_CANONICAL_LIT: &str = r#"{"reference":"tx-frozen-v2","amount":"10000","currency":"0xToken","recipient":"0xrecipient","method":"tempo","timestamp":"2026-06-02T00:00:00Z"}"#;
6303 const RECEIPT_V3_CANONICAL_LIT: &str = r#"{"version":3,"kind":"outbound_payment_receipt","reference":"tx-frozen-v2","amount":"10000","currency":"0xToken","recipient":"0xrecipient","method":"tempo","timestamp":"2026-06-02T00:00:00Z","tool_call_id":"call-frozen","approval_pos":"42","approved_args_hash":"abcd1234","subject":"conv-frozen","payer_kind":"linked_wallet","paying_account":"0xpayer-frozen"}"#;
6304 const RECEIPT_PAYLOAD_LIT: &str = r#"{"version":3,"kind":"outbound_payment_receipt","reference":"tx-frozen-v2","amount":"10000","currency":"0xToken","recipient":"0xrecipient","method":"tempo","timestamp":"2026-06-02T00:00:00Z","tool_call_id":"call-frozen","approval_pos":"42","approved_args_hash":"abcd1234","subject":"conv-frozen","payer_kind":"linked_wallet","paying_account":"0xpayer-frozen","signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"eec04c909fc7ae838bde2e2ab52144a523fe23939e16c355e12420d2d4b354ba528e5162558d9565ce930aac35a8172bfb0801e0c39d80f2864c948181eefe00"}"#;
6305 const RECEIPT_SIG_LIT: &str = "eec04c909fc7ae838bde2e2ab52144a523fe23939e16c355e12420d2d4b354ba528e5162558d9565ce930aac35a8172bfb0801e0c39d80f2864c948181eefe00";
6306 const RESOLVE_TOKEN_CANONICAL_LIT: &str =
6307 r#"{"request_id":"req-1","conversation_id":"conv-1","minted_at_ms":1750000000000}"#;
6308 const RESOLVE_TOKEN_LIT: &str = "7b22726571756573745f6964223a227265712d31222c22636f6e766572736174696f6e5f6964223a22636f6e762d31222c226d696e7465645f61745f6d73223a313735303030303030303030302c227369676e61747572655f686578223a223234303734613638613638396335613137643037343334353530376535653437626263623531366165616232633363376566656431363435663235383164643665313165616237396466343537333463646136623230306463663938393334333630306365366161303064653161643234633534363733356462336236653034227d";
6309 const ADMIN_MODEL_CANONICAL_LIT: &str = r#"{"principal":"admin:root","previous_provider":"prov-a","previous_model":"model-a","new_provider":"prov-b","new_model":"model-b","changed_at_ms":1750000000000}"#;
6310 const ADMIN_MODEL_PAYLOAD_LIT: &str = r#"{"principal":"admin:root","previous_provider":"prov-a","previous_model":"model-a","new_provider":"prov-b","new_model":"model-b","changed_at_ms":1750000000000,"signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"891b2c43aba8d251ad4eb08a70d0cc3791469a4d3995c803e0baae01bfd4a8dc618433f7235ff9746ba1d83d537362d2e490608fd21f3f156794f853d874f903"}"#;
6311 const ADMIN_MODEL_SIG_LIT: &str = "891b2c43aba8d251ad4eb08a70d0cc3791469a4d3995c803e0baae01bfd4a8dc618433f7235ff9746ba1d83d537362d2e490608fd21f3f156794f853d874f903";
6312 const CREDENTIAL_CANONICAL_LIT: &str = r#"{"change":"credential_enrolled","principal":"admin:root","edge_id":"edge-1","kid":"k1","grants":"edge, admin","transitions":[{"kid":"k1","from":"absent","to":"active"},{"kid":"k2","from":"active","to":"revoked"}],"changed_at_ms":1750000000000}"#;
6313 const CREDENTIAL_PAYLOAD_LIT: &str = r#"{"change":"credential_enrolled","principal":"admin:root","edge_id":"edge-1","kid":"k1","grants":"edge, admin","transitions":[{"kid":"k1","from":"absent","to":"active"},{"kid":"k2","from":"active","to":"revoked"}],"changed_at_ms":1750000000000,"signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"e5384d9ba22a1497ce9beba54c62f2c234bd0c84ba9c2f3ded6fff8e30c01a74d0485683aab1196e64a1c567b830e34fa3f715d13f7b8746a66dfe80b18a4c0b"}"#;
6314 const CREDENTIAL_SIG_LIT: &str = "e5384d9ba22a1497ce9beba54c62f2c234bd0c84ba9c2f3ded6fff8e30c01a74d0485683aab1196e64a1c567b830e34fa3f715d13f7b8746a66dfe80b18a4c0b";
6315 const ROUTINE_CREATED_CANONICAL_LIT: &str = r#"{"routine":"r-1","creator_persona":"persona:alice","conversation_id":"conv-1","tool_call_id":"call-1","args_hash":"abcd1234","created_at_ms":1750000000000}"#;
6316 const ROUTINE_CREATED_PAYLOAD_LIT: &str = r#"{"routine":"r-1","creator_persona":"persona:alice","conversation_id":"conv-1","tool_call_id":"call-1","args_hash":"abcd1234","created_at_ms":1750000000000,"signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"65ea620c0447550151ebcedaeb325595d8d10a8ec2f39a98d9215042c636d464ed62f82a88378d254fbf4803a2e7b569b26c78c45e74e498785496ed3701400e"}"#;
6317 const ROUTINE_CREATED_SIG_LIT: &str = "65ea620c0447550151ebcedaeb325595d8d10a8ec2f39a98d9215042c636d464ed62f82a88378d254fbf4803a2e7b569b26c78c45e74e498785496ed3701400e";
6318 const ROUTINE_PAUSED_SOME_LIT: &str = r#"{"routine":"r-1","actor_persona":"persona:alice","conversation_id":"conv-1","tool_call_id":"call-1","args_hash":"abcd1234","paused_at_ms":1750000000000,"reason":"too noisy"}"#;
6319 const ROUTINE_PAUSED_NONE_LIT: &str = r#"{"routine":"r-1","actor_persona":"persona:alice","conversation_id":"conv-1","tool_call_id":"call-1","args_hash":"abcd1234","paused_at_ms":1750000000000,"reason":null}"#;
6320 const ROUTINE_PAUSED_PAYLOAD_LIT: &str = r#"{"routine":"r-1","actor_persona":"persona:alice","conversation_id":"conv-1","tool_call_id":"call-1","args_hash":"abcd1234","paused_at_ms":1750000000000,"reason":"too noisy","signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"9dca477fdc2536aa2b0bd1e7fd4a099e880c5a03742c0b4741fe7d2aa62456aa1b96b137d7c9b211f2721a88b72eb98d1694e06c1744e2fcfdf5f3fed5f97e04"}"#;
6321 const ROUTINE_PAUSED_SIG_LIT: &str = "9dca477fdc2536aa2b0bd1e7fd4a099e880c5a03742c0b4741fe7d2aa62456aa1b96b137d7c9b211f2721a88b72eb98d1694e06c1744e2fcfdf5f3fed5f97e04";
6322 const ROUTINE_RESUMED_CANONICAL_LIT: &str = r#"{"routine":"r-1","actor_persona":"persona:alice","conversation_id":"conv-1","tool_call_id":"call-1","args_hash":"abcd1234","resumed_at_ms":1750000000000}"#;
6323 const ROUTINE_RESUMED_PAYLOAD_LIT: &str = r#"{"routine":"r-1","actor_persona":"persona:alice","conversation_id":"conv-1","tool_call_id":"call-1","args_hash":"abcd1234","resumed_at_ms":1750000000000,"signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"1a034d680b5a2f034adad6a2034c8f209fdc8336c89541b51edaf889de5066ad59e54af4a8f089ca14720d421c7d85b33d5b75fd3d56c408708ca2812b519b06"}"#;
6324 const ROUTINE_RESUMED_SIG_LIT: &str = "1a034d680b5a2f034adad6a2034c8f209fdc8336c89541b51edaf889de5066ad59e54af4a8f089ca14720d421c7d85b33d5b75fd3d56c408708ca2812b519b06";
6325 const ROUTINE_DELETED_CANONICAL_LIT: &str = r#"{"routine":"r-1","actor_persona":"persona:alice","conversation_id":"conv-1","tool_call_id":"call-1","args_hash":"abcd1234","deleted_at_ms":1750000000000}"#;
6326 const ROUTINE_DELETED_PAYLOAD_LIT: &str = r#"{"routine":"r-1","actor_persona":"persona:alice","conversation_id":"conv-1","tool_call_id":"call-1","args_hash":"abcd1234","deleted_at_ms":1750000000000,"signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"c944bb5fea00c41e09ded223f4587d1b13359b9c5a7e38204f3734abafe8dbc747edc7b88dc4b5cab664711c0b1a77f2be928baecdc50d78b4b282812687f50f"}"#;
6327 const ROUTINE_DELETED_SIG_LIT: &str = "c944bb5fea00c41e09ded223f4587d1b13359b9c5a7e38204f3734abafe8dbc747edc7b88dc4b5cab664711c0b1a77f2be928baecdc50d78b4b282812687f50f";
6328 const ROUTINE_SCOPE_CHANGED_CANONICAL_LIT: &str = r#"{"routine":"r-1","actor_persona":"persona:alice","conversation_id":"conv-1","tool_call_id":"call-1","args_hash":"abcd1234","scope":"public","changed_at_ms":1750000000000}"#;
6329 const ROUTINE_SCOPE_CHANGED_PAYLOAD_LIT: &str = r#"{"routine":"r-1","actor_persona":"persona:alice","conversation_id":"conv-1","tool_call_id":"call-1","args_hash":"abcd1234","scope":"public","changed_at_ms":1750000000000,"signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"e9b66d0c4c738965f873e85e7d2c3c551a4a41778f289cbb1338be7ab3624705e90c01e3f15ca5df130ee8b075c9a91b0dcb01b9bc20c7a48c5364f9a482210e"}"#;
6330 const ROUTINE_SCOPE_CHANGED_SIG_LIT: &str = "e9b66d0c4c738965f873e85e7d2c3c551a4a41778f289cbb1338be7ab3624705e90c01e3f15ca5df130ee8b075c9a91b0dcb01b9bc20c7a48c5364f9a482210e";
6331}
6332
6333#[cfg(test)]
6338mod payment_refusal_conformance_tests {
6339 #![allow(clippy::pedantic, clippy::nursery, missing_docs, clippy::unwrap_used)]
6340
6341 use serde_json::Value;
6342
6343 use super::*;
6344
6345 fn vectors() -> Value {
6346 serde_json::from_str(polyc_conformance_vectors::PAYMENT_REFUSAL).expect("valid JSON")
6347 }
6348
6349 fn signer() -> ApprovalSigner {
6350 let v = vectors();
6351 let key_hex = v["signer"]["ed25519_private_key_hex"].as_str().unwrap();
6352 ApprovalSigner::from_key_bytes(&crate::hex::decode(key_hex).unwrap()).expect("valid key")
6353 }
6354
6355 struct VectorFields {
6360 kind: String,
6361 reason: String,
6362 reason_detail: String,
6363 merchant_host: String,
6364 requested_base_units: String,
6365 permitted_base_units: String,
6366 tool_call_id: String,
6367 subject: String,
6368 timestamp: String,
6369 }
6370
6371 impl VectorFields {
6372 fn from_json(v: &Value) -> Self {
6373 let field = |name: &str| v["fields"][name].as_str().unwrap().to_owned();
6374 Self {
6375 kind: field("kind"),
6376 reason: field("reason"),
6377 reason_detail: field("reason_detail"),
6378 merchant_host: field("merchant_host"),
6379 requested_base_units: field("requested_base_units"),
6380 permitted_base_units: field("permitted_base_units"),
6381 tool_call_id: field("tool_call_id"),
6382 subject: field("subject"),
6383 timestamp: field("timestamp"),
6384 }
6385 }
6386
6387 fn as_payload(&self) -> RefusalPayload<'_> {
6388 RefusalPayload {
6389 kind: self.kind.as_str(),
6390 reason: self.reason.as_str(),
6391 reason_detail: self.reason_detail.as_str(),
6392 merchant_host: self.merchant_host.as_str(),
6393 requested_base_units: self.requested_base_units.as_str(),
6394 permitted_base_units: self.permitted_base_units.as_str(),
6395 tool_call_id: self.tool_call_id.as_str(),
6396 subject: self.subject.as_str(),
6397 timestamp: self.timestamp.as_str(),
6398 }
6399 }
6400 }
6401
6402 #[test]
6403 fn the_known_good_vector_reproduces_its_bytes_and_signature() {
6404 let v = vectors();
6405 let signer = signer();
6406 let vf = VectorFields::from_json(&v["vector"]);
6407 let fields = vf.as_payload();
6408 let expected_canonical = crate::hex::decode(
6409 v["vector"]["expected_canonical_bytes_hex"]
6410 .as_str()
6411 .unwrap(),
6412 )
6413 .unwrap();
6414 assert_eq!(canonical_bytes(&fields.canonical()), expected_canonical);
6415
6416 let (payload, sig, _pk) = refusal_payload(&fields, &signer);
6417 assert_eq!(
6418 crate::hex::lower(&sig),
6419 v["vector"]["expected_signature_hex"].as_str().unwrap()
6420 );
6421 let expected_full = v["vector"]["expected_full_payload"].as_str().unwrap();
6422 assert_eq!(String::from_utf8(payload.clone()).unwrap(), expected_full);
6423
6424 let trusted = vec![
6425 crate::hex::decode(v["signer"]["ed25519_public_key_hex"].as_str().unwrap()).unwrap(),
6426 ];
6427 let verified = verify_signed_refusal(&payload, &trusted).expect("verifies");
6428 assert_eq!(verified.reason, vf.reason);
6429 assert_eq!(verified.requested_base_units, vf.requested_base_units);
6430 assert_eq!(verified.permitted_base_units, vf.permitted_base_units);
6431 assert_eq!(verified.timestamp, vf.timestamp);
6432 }
6433
6434 #[test]
6437 fn the_unknown_reason_vector_still_verifies() {
6438 let v = vectors();
6439 let signer = signer();
6440 let vf = VectorFields::from_json(&v["unknown_reason_vector"]);
6441 let fields = vf.as_payload();
6442 let (payload, sig, _pk) = refusal_payload(&fields, &signer);
6443 assert_eq!(
6444 crate::hex::lower(&sig),
6445 v["unknown_reason_vector"]["expected_signature_hex"]
6446 .as_str()
6447 .unwrap()
6448 );
6449
6450 let trusted = vec![
6451 crate::hex::decode(v["signer"]["ed25519_public_key_hex"].as_str().unwrap()).unwrap(),
6452 ];
6453 let verified = verify_signed_refusal(&payload, &trusted).expect("verifies");
6454 assert_eq!(verified.reason, "some_future_reason_v7");
6455 }
6456
6457 #[test]
6458 fn the_tampered_reason_vector_must_not_verify() {
6459 let v = vectors();
6460 let entry = v["must_not_verify"]
6461 .as_array()
6462 .unwrap()
6463 .iter()
6464 .find(|e| e["id"] == "tampered-reason")
6465 .unwrap();
6466 let payload = entry["full_payload"].as_str().unwrap().as_bytes();
6467 let trusted = vec![
6468 crate::hex::decode(v["signer"]["ed25519_public_key_hex"].as_str().unwrap()).unwrap(),
6469 ];
6470 assert!(verify_signed_refusal(payload, &trusted).is_none());
6471 }
6472
6473 #[test]
6480 fn the_untrusted_signer_vector_must_not_verify_against_the_main_signer() {
6481 let v = vectors();
6482 let entry = v["must_not_verify"]
6483 .as_array()
6484 .unwrap()
6485 .iter()
6486 .find(|e| e["id"] == "untrusted-signer")
6487 .unwrap();
6488 let payload = entry["full_payload"].as_str().unwrap().as_bytes();
6489
6490 let own_key =
6492 crate::hex::decode(entry["signed_by_public_key_hex"].as_str().unwrap()).unwrap();
6493 assert!(
6494 verify_signed_refusal(payload, &[own_key]).is_some(),
6495 "the untrusted-signer vector's payload must be internally consistent — a \
6496 genuinely valid signature over an out-of-allowlist key, not a malformed one"
6497 );
6498
6499 let main_trusted = vec![
6501 crate::hex::decode(v["signer"]["ed25519_public_key_hex"].as_str().unwrap()).unwrap(),
6502 ];
6503 assert!(verify_signed_refusal(payload, &main_trusted).is_none());
6504 }
6505}
6506
6507#[cfg(test)]
6512mod wallet_link_lifecycle_conformance_tests {
6513 #![allow(clippy::pedantic, clippy::nursery, missing_docs, clippy::unwrap_used)]
6514
6515 use serde_json::Value;
6516
6517 use super::*;
6518
6519 fn vectors() -> Value {
6520 serde_json::from_str(polyc_conformance_vectors::WALLET_LINK_LIFECYCLE).expect("valid JSON")
6521 }
6522
6523 fn signer() -> ApprovalSigner {
6524 let v = vectors();
6525 let key_hex = v["signer"]["ed25519_private_key_hex"].as_str().unwrap();
6526 ApprovalSigner::from_key_bytes(&crate::hex::decode(key_hex).unwrap()).expect("valid key")
6527 }
6528
6529 struct VectorFields {
6533 kind: String,
6534 transition: String,
6535 subject: String,
6536 wallet_address: String,
6537 currency: String,
6538 chain_id: String,
6539 limit_base_units: String,
6540 limit_human: String,
6541 period_secs: String,
6542 expiry_unix: String,
6543 recipients: String,
6544 conversation_id: String,
6545 timestamp: String,
6546 }
6547
6548 impl VectorFields {
6549 fn from_json(v: &Value) -> Self {
6550 let field = |name: &str| v["fields"][name].as_str().unwrap().to_owned();
6551 Self {
6552 kind: field("kind"),
6553 transition: field("transition"),
6554 subject: field("subject"),
6555 wallet_address: field("wallet_address"),
6556 currency: field("currency"),
6557 chain_id: field("chain_id"),
6558 limit_base_units: field("limit_base_units"),
6559 limit_human: field("limit_human"),
6560 period_secs: field("period_secs"),
6561 expiry_unix: field("expiry_unix"),
6562 recipients: field("recipients"),
6563 conversation_id: field("conversation_id"),
6564 timestamp: field("timestamp"),
6565 }
6566 }
6567
6568 fn as_payload(&self) -> WalletLinkLifecyclePayload<'_> {
6569 WalletLinkLifecyclePayload {
6570 kind: self.kind.as_str(),
6571 transition: self.transition.as_str(),
6572 subject: self.subject.as_str(),
6573 wallet_address: self.wallet_address.as_str(),
6574 currency: self.currency.as_str(),
6575 chain_id: self.chain_id.as_str(),
6576 limit_base_units: self.limit_base_units.as_str(),
6577 limit_human: self.limit_human.as_str(),
6578 period_secs: self.period_secs.as_str(),
6579 expiry_unix: self.expiry_unix.as_str(),
6580 recipients: self.recipients.as_str(),
6581 conversation_id: self.conversation_id.as_str(),
6582 timestamp: self.timestamp.as_str(),
6583 }
6584 }
6585 }
6586
6587 fn assert_vector_reproduces(vector_key: &str) {
6588 let v = vectors();
6589 let signer = signer();
6590 let vf = VectorFields::from_json(&v[vector_key]);
6591 let fields = vf.as_payload();
6592 let expected_canonical = crate::hex::decode(
6593 v[vector_key]["expected_canonical_bytes_hex"]
6594 .as_str()
6595 .unwrap(),
6596 )
6597 .unwrap();
6598 assert_eq!(canonical_bytes(&fields.canonical()), expected_canonical);
6599
6600 let (payload, sig, _pk) = wallet_link_lifecycle_payload(&fields, &signer);
6601 assert_eq!(
6602 crate::hex::lower(&sig),
6603 v[vector_key]["expected_signature_hex"].as_str().unwrap()
6604 );
6605 let expected_full = v[vector_key]["expected_full_payload"].as_str().unwrap();
6606 assert_eq!(String::from_utf8(payload.clone()).unwrap(), expected_full);
6607
6608 let trusted = vec![
6609 crate::hex::decode(v["signer"]["ed25519_public_key_hex"].as_str().unwrap()).unwrap(),
6610 ];
6611 let verified = verify_signed_wallet_link_lifecycle(&payload, &trusted).expect("verifies");
6612 assert_eq!(verified.transition, vf.transition);
6613 assert_eq!(verified.limit_base_units, vf.limit_base_units);
6614 assert_eq!(verified.limit_human, vf.limit_human);
6615 assert_eq!(verified.period_secs, vf.period_secs);
6616 assert_eq!(verified.expiry_unix, vf.expiry_unix);
6617 assert_eq!(verified.timestamp, vf.timestamp);
6618 }
6619
6620 #[test]
6621 fn the_known_good_linked_vector_reproduces_its_bytes_and_signature() {
6622 assert_vector_reproduces("linked_vector");
6623 }
6624
6625 #[test]
6629 fn the_known_good_renewed_vector_reproduces_its_bytes_and_signature() {
6630 assert_vector_reproduces("renewed_vector");
6631 }
6632
6633 #[test]
6636 fn the_known_good_revoked_vector_reproduces_its_bytes_and_signature() {
6637 assert_vector_reproduces("revoked_vector");
6638 }
6639
6640 #[test]
6643 fn the_unknown_transition_vector_still_verifies() {
6644 let v = vectors();
6645 let signer = signer();
6646 let vf = VectorFields::from_json(&v["unknown_transition_vector"]);
6647 let fields = vf.as_payload();
6648 let (payload, sig, _pk) = wallet_link_lifecycle_payload(&fields, &signer);
6649 assert_eq!(
6650 crate::hex::lower(&sig),
6651 v["unknown_transition_vector"]["expected_signature_hex"]
6652 .as_str()
6653 .unwrap()
6654 );
6655
6656 let trusted = vec![
6657 crate::hex::decode(v["signer"]["ed25519_public_key_hex"].as_str().unwrap()).unwrap(),
6658 ];
6659 let verified = verify_signed_wallet_link_lifecycle(&payload, &trusted).expect("verifies");
6660 assert_eq!(verified.transition, "some_future_transition_v7");
6661 }
6662
6663 #[test]
6664 fn the_tampered_transition_vector_must_not_verify() {
6665 let v = vectors();
6666 let entry = v["must_not_verify"]
6667 .as_array()
6668 .unwrap()
6669 .iter()
6670 .find(|e| e["id"] == "tampered-transition")
6671 .unwrap();
6672 let payload = entry["full_payload"].as_str().unwrap().as_bytes();
6673 let trusted = vec![
6674 crate::hex::decode(v["signer"]["ed25519_public_key_hex"].as_str().unwrap()).unwrap(),
6675 ];
6676 assert!(verify_signed_wallet_link_lifecycle(payload, &trusted).is_none());
6677 }
6678
6679 #[test]
6686 fn the_untrusted_signer_vector_must_not_verify_against_the_main_signer() {
6687 let v = vectors();
6688 let entry = v["must_not_verify"]
6689 .as_array()
6690 .unwrap()
6691 .iter()
6692 .find(|e| e["id"] == "untrusted-signer")
6693 .unwrap();
6694 let payload = entry["full_payload"].as_str().unwrap().as_bytes();
6695
6696 let own_key =
6698 crate::hex::decode(entry["signed_by_public_key_hex"].as_str().unwrap()).unwrap();
6699 assert!(
6700 verify_signed_wallet_link_lifecycle(payload, &[own_key]).is_some(),
6701 "the untrusted-signer vector's payload must be internally consistent — a \
6702 genuinely valid signature over an out-of-allowlist key, not a malformed one"
6703 );
6704
6705 let main_trusted = vec![
6707 crate::hex::decode(v["signer"]["ed25519_public_key_hex"].as_str().unwrap()).unwrap(),
6708 ];
6709 assert!(verify_signed_wallet_link_lifecycle(payload, &main_trusted).is_none());
6710 }
6711}
6712
6713#[cfg(test)]
6719mod payment_receipt_conformance_tests {
6720 #![allow(clippy::pedantic, clippy::nursery, missing_docs, clippy::unwrap_used)]
6721
6722 use serde_json::Value;
6723
6724 use super::*;
6725
6726 fn vectors() -> Value {
6727 serde_json::from_str(polyc_conformance_vectors::PAYMENT_RECEIPT).expect("valid JSON")
6728 }
6729
6730 fn signer() -> ApprovalSigner {
6731 let v = vectors();
6732 let key_hex = v["signer"]["ed25519_private_key_hex"].as_str().unwrap();
6733 ApprovalSigner::from_key_bytes(&crate::hex::decode(key_hex).unwrap()).expect("valid key")
6734 }
6735
6736 struct VectorFields {
6740 kind: String,
6741 reference: String,
6742 amount: String,
6743 currency: String,
6744 recipient: String,
6745 method: String,
6746 timestamp: String,
6747 tool_call_id: String,
6748 approval_pos: String,
6749 approved_args_hash: String,
6750 subject: String,
6751 payer_kind: String,
6752 paying_account: String,
6753 }
6754
6755 impl VectorFields {
6756 fn from_json(v: &Value) -> Self {
6760 let field = |name: &str| {
6761 v["fields"]
6762 .get(name)
6763 .and_then(Value::as_str)
6764 .unwrap_or("")
6765 .to_owned()
6766 };
6767 Self {
6768 kind: field("kind"),
6769 reference: field("reference"),
6770 amount: field("amount"),
6771 currency: field("currency"),
6772 recipient: field("recipient"),
6773 method: field("method"),
6774 timestamp: field("timestamp"),
6775 tool_call_id: field("tool_call_id"),
6776 approval_pos: field("approval_pos"),
6777 approved_args_hash: field("approved_args_hash"),
6778 subject: field("subject"),
6779 payer_kind: field("payer_kind"),
6780 paying_account: field("paying_account"),
6781 }
6782 }
6783
6784 fn as_payload(&self) -> ReceiptPayload<'_> {
6785 ReceiptPayload {
6786 kind: self.kind.as_str(),
6787 reference: self.reference.as_str(),
6788 amount: self.amount.as_str(),
6789 currency: self.currency.as_str(),
6790 recipient: self.recipient.as_str(),
6791 method: self.method.as_str(),
6792 timestamp: self.timestamp.as_str(),
6793 tool_call_id: self.tool_call_id.as_str(),
6794 approval_pos: self.approval_pos.as_str(),
6795 approved_args_hash: self.approved_args_hash.as_str(),
6796 subject: self.subject.as_str(),
6797 payer_kind: self.payer_kind.as_str(),
6798 paying_account: self.paying_account.as_str(),
6799 }
6800 }
6801 }
6802
6803 #[test]
6804 fn the_known_good_v3_vector_reproduces_its_bytes_and_signature() {
6805 let v = vectors();
6806 let signer = signer();
6807 let vf = VectorFields::from_json(&v["vector"]);
6808 let fields = vf.as_payload();
6809 let expected_canonical = crate::hex::decode(
6810 v["vector"]["expected_canonical_bytes_hex"]
6811 .as_str()
6812 .unwrap(),
6813 )
6814 .unwrap();
6815 assert_eq!(
6816 canonical_bytes(&fields.canonical_json_v3()),
6817 expected_canonical
6818 );
6819
6820 let (payload, sig, _pk) = receipt_payload(&fields, &signer);
6821 assert_eq!(
6822 crate::hex::lower(&sig),
6823 v["vector"]["expected_signature_hex"].as_str().unwrap()
6824 );
6825 let expected_full = v["vector"]["expected_full_payload"].as_str().unwrap();
6826 assert_eq!(String::from_utf8(payload.clone()).unwrap(), expected_full);
6827
6828 let trusted = vec![
6829 crate::hex::decode(v["signer"]["ed25519_public_key_hex"].as_str().unwrap()).unwrap(),
6830 ];
6831 let verified = verify_signed_receipt(&payload, &trusted).expect("verifies");
6832 assert_eq!(verified.version, 3);
6833 assert_eq!(verified.reference, vf.reference);
6834 assert_eq!(verified.payer_kind, vf.payer_kind);
6835 assert_eq!(verified.paying_account, vf.paying_account);
6836 }
6837
6838 #[test]
6842 fn the_frozen_v2_vector_still_verifies_with_payer_unknown() {
6843 let v = vectors();
6844 let entry = &v["frozen_v2_vector"];
6845 let full = entry["expected_full_payload"].as_str().unwrap();
6846 let trusted = vec![
6847 crate::hex::decode(v["signer"]["ed25519_public_key_hex"].as_str().unwrap()).unwrap(),
6848 ];
6849 let verified = verify_signed_receipt(full.as_bytes(), &trusted)
6850 .expect("the frozen v2 vector verifies");
6851 assert_eq!(verified.version, 2);
6852 assert_eq!(verified.reference, "tx-conformance-v2");
6853 assert!(verified.payer_kind.is_empty());
6854 assert!(verified.paying_account.is_empty());
6855 }
6856
6857 #[test]
6858 fn the_tampered_payer_vector_must_not_verify() {
6859 let v = vectors();
6860 let entry = v["must_not_verify"]
6861 .as_array()
6862 .unwrap()
6863 .iter()
6864 .find(|e| e["id"] == "tampered-payer")
6865 .unwrap();
6866 let payload = entry["full_payload"].as_str().unwrap().as_bytes();
6867 let trusted = vec![
6868 crate::hex::decode(v["signer"]["ed25519_public_key_hex"].as_str().unwrap()).unwrap(),
6869 ];
6870 assert!(verify_signed_receipt(payload, &trusted).is_none());
6871 }
6872
6873 #[test]
6879 fn the_untrusted_signer_vector_must_not_verify_against_the_main_signer() {
6880 let v = vectors();
6881 let entry = v["must_not_verify"]
6882 .as_array()
6883 .unwrap()
6884 .iter()
6885 .find(|e| e["id"] == "untrusted-signer")
6886 .unwrap();
6887 let payload = entry["full_payload"].as_str().unwrap().as_bytes();
6888
6889 let own_key =
6890 crate::hex::decode(entry["signed_by_public_key_hex"].as_str().unwrap()).unwrap();
6891 assert!(
6892 verify_signed_receipt(payload, &[own_key]).is_some(),
6893 "the untrusted-signer vector's payload must be internally consistent — a \
6894 genuinely valid signature over an out-of-allowlist key, not a malformed one"
6895 );
6896
6897 let main_trusted = vec![
6898 crate::hex::decode(v["signer"]["ed25519_public_key_hex"].as_str().unwrap()).unwrap(),
6899 ];
6900 assert!(verify_signed_receipt(payload, &main_trusted).is_none());
6901 }
6902}