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]
74#[allow(
75 clippy::too_many_arguments,
76 reason = "each argument is a separately durable approval-request field"
77)]
78pub fn request_payload(
79 request_id: &str,
80 tool_name: &str,
81 args_json: &str,
82 sandbox_mode: &str,
83 reason: &str,
84 missing_capabilities: &[String],
85 preview_json: &str,
86 title: &str,
87) -> Vec<u8> {
88 let preview: Value = if preview_json.is_empty() {
94 Value::Null
95 } else {
96 serde_json::from_str(preview_json).unwrap_or(Value::Null)
97 };
98 serde_json::json!({
104 "tool_name": tool_name,
105 "args_json": args_json,
106 "request_id": request_id,
107 "sandbox_mode": sandbox_mode,
108 "reason": reason,
109 "missing_capabilities": missing_capabilities,
110 "preview": preview,
111 "title": title,
115 })
116 .to_string()
117 .into_bytes()
118}
119
120#[allow(clippy::too_many_arguments)] fn response_canonical(
168 request_id: &str,
169 tool_name: &str,
170 args_json: &str,
171 modified_args_json: &str,
172 approved: bool,
173 approved_for_session: bool,
174 covered_capabilities: &[String],
175 caller: &str,
176 approver_id: &str,
177 sandbox_mode: &str,
178 reason: &str,
179 injected_context: &str,
180 conversation_id: &str,
181 nonce: &str,
182 turn_id: &str,
183) -> Vec<u8> {
184 canonical_bytes(&ResponseCanonical {
185 body: ResponseBody {
186 turn_id,
187 request_id,
188 tool_name,
189 args_json,
190 modified_args_json,
191 approved,
192 approved_for_session,
193 covered_capabilities,
194 caller,
195 sandbox_mode,
196 reason,
197 injected_context,
198 conversation_id,
199 nonce,
200 },
201 approver: approver_id,
202 })
203}
204
205#[derive(Serialize)]
214struct ResponseBody<'a> {
215 #[serde(skip_serializing_if = "str::is_empty")]
217 turn_id: &'a str,
218 request_id: &'a str,
219 tool_name: &'a str,
220 args_json: &'a str,
221 modified_args_json: &'a str,
222 approved: bool,
223 approved_for_session: bool,
224 covered_capabilities: &'a [String],
225 caller: &'a str,
226 sandbox_mode: &'a str,
227 reason: &'a str,
228 injected_context: &'a str,
229 conversation_id: &'a str,
230 nonce: &'a str,
231}
232
233#[derive(Serialize)]
240struct ResponseCanonical<'a> {
241 #[serde(flatten)]
242 body: ResponseBody<'a>,
243 #[serde(skip_serializing_if = "str::is_empty")]
244 approver: &'a str,
245}
246
247#[derive(Serialize)]
254struct ResponseFull<'a> {
255 #[serde(flatten)]
256 body: ResponseBody<'a>,
257 signed_by: String,
258 signature_hex: String,
259 #[serde(skip_serializing_if = "str::is_empty")]
260 approver: &'a str,
261}
262
263#[must_use]
281#[allow(clippy::too_many_arguments)] pub fn response_payload(
283 request_id: &str,
284 tool_name: &str,
285 args_json: &str,
286 modified_args_json: &str,
287 approved: bool,
288 approved_for_session: bool,
289 covered_capabilities: &[String],
290 caller: &str,
291 approver_id: &str,
292 sandbox_mode: &str,
293 reason: &str,
294 injected_context: &str,
295 conversation_id: &str,
296 nonce: &str,
297 turn_id: &str,
298 signer: &ApprovalSigner,
299) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
300 let canonical = response_canonical(
301 request_id,
302 tool_name,
303 args_json,
304 modified_args_json,
305 approved,
306 approved_for_session,
307 covered_capabilities,
308 caller,
309 approver_id,
310 sandbox_mode,
311 reason,
312 injected_context,
313 conversation_id,
314 nonce,
315 turn_id,
316 );
317 let signature = signer.sign(&canonical);
318 let pk = signer.public_key_bytes();
319 let full = ResponseFull {
322 body: ResponseBody {
323 turn_id,
324 request_id,
325 tool_name,
326 args_json,
327 modified_args_json,
328 approved,
329 approved_for_session,
330 covered_capabilities,
331 caller,
332 sandbox_mode,
333 reason,
334 injected_context,
335 conversation_id,
336 nonce,
337 },
338 signed_by: crate::hex::lower(&pk),
339 signature_hex: crate::hex::lower(&signature),
340 approver: approver_id,
341 };
342 (canonical_bytes(&full), signature, pk)
343}
344
345pub const EXCISION_SCOPE_CASCADE: &str = "cascade";
355pub const EXCISION_SCOPE_SOURCE_ONLY: &str = "source-only";
357
358fn excision_canonical(
366 conversation_id: &str,
367 scope: &str,
368 positions: &[u64],
369 requested_by: &str,
370 reason: &str,
371) -> Vec<u8> {
372 canonical_bytes(&ExcisionCanonical {
373 conversation_id,
374 scope,
375 positions,
376 requested_by,
377 reason,
378 })
379}
380
381#[derive(Serialize)]
383struct ExcisionCanonical<'a> {
384 conversation_id: &'a str,
385 scope: &'a str,
386 positions: &'a [u64],
387 requested_by: &'a str,
388 reason: &'a str,
389}
390
391#[must_use]
395pub fn excision_payload(
396 conversation_id: &str,
397 scope: &str,
398 positions: &[u64],
399 requested_by: &str,
400 reason: &str,
401 signer: &ApprovalSigner,
402) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
403 Envelope::seal(
404 ExcisionCanonical {
405 conversation_id,
406 scope,
407 positions,
408 requested_by,
409 reason,
410 },
411 signer.as_signer(),
412 )
413}
414
415fn grant_replay_canonical(
427 conversation_id: &str,
428 turn_id: &str,
429 tool: &str,
430 grant_ref: &str,
431 covered_capabilities: &[String],
432 coverage_hash: &str,
433) -> Vec<u8> {
434 canonical_bytes(&GrantReplayCanonical {
435 conversation_id,
436 turn_id,
437 tool,
438 grant_ref,
439 covered_capabilities,
440 coverage_hash,
441 })
442}
443
444#[derive(Serialize)]
446struct GrantReplayCanonical<'a> {
447 conversation_id: &'a str,
448 turn_id: &'a str,
449 tool: &'a str,
450 grant_ref: &'a str,
451 covered_capabilities: &'a [String],
452 coverage_hash: &'a str,
453}
454
455#[must_use]
463pub fn verify_grant_replay(payload: &[u8]) -> bool {
464 let Ok(v) = serde_json::from_slice::<serde_json::Value>(payload) else {
465 return false;
466 };
467 let (
468 Some(conversation_id),
469 Some(turn_id),
470 Some(tool),
471 Some(grant_ref),
472 Some(covered),
473 Some(coverage_hash),
474 Some(signed_by),
475 Some(signature_hex),
476 ) = (
477 v.get("conversation_id").and_then(Value::as_str),
478 v.get("turn_id").and_then(Value::as_str),
479 v.get("tool").and_then(Value::as_str),
480 v.get("grant_ref").and_then(Value::as_str),
481 v.get("covered_capabilities").and_then(Value::as_array),
482 v.get("coverage_hash").and_then(Value::as_str),
483 v.get("signed_by").and_then(Value::as_str),
484 v.get("signature_hex").and_then(Value::as_str),
485 )
486 else {
487 return false;
488 };
489 let Some(covered_capabilities) = covered
490 .iter()
491 .map(|c| c.as_str().map(str::to_owned))
492 .collect::<Option<Vec<_>>>()
493 else {
494 return false;
495 };
496 let (Some(pk), Some(sig)) = (
497 crate::hex::decode(signed_by),
498 crate::hex::decode(signature_hex),
499 ) else {
500 return false;
501 };
502 let canonical = grant_replay_canonical(
503 conversation_id,
504 turn_id,
505 tool,
506 grant_ref,
507 &covered_capabilities,
508 coverage_hash,
509 );
510 crate::verify(&pk, &canonical, &sig)
511}
512
513fn signer_is_trusted(signer_pk: &[u8], trusted_signers: &[Vec<u8>]) -> bool {
525 trusted_signers.iter().any(|k| k.as_slice() == signer_pk)
526}
527
528#[must_use]
541pub fn verify_grant_replay_pinned(payload: &[u8], trusted_signers: &[Vec<u8>]) -> bool {
542 let Some(pk) = serde_json::from_slice::<Value>(payload).ok().and_then(|v| {
546 v.get("signed_by")
547 .and_then(Value::as_str)
548 .and_then(crate::hex::decode)
549 }) else {
550 return false;
551 };
552 if !signer_is_trusted(&pk, trusted_signers) {
553 return false;
554 }
555 verify_grant_replay(payload)
556}
557
558#[derive(Debug, Clone, PartialEq, Eq)]
560pub struct VerifiedExcision {
561 pub conversation_id: String,
563 pub scope: String,
565 pub positions: Vec<u64>,
567 pub requested_by: String,
569 pub reason: String,
571 pub signer_public_key: Vec<u8>,
573}
574
575impl VerifiedExcision {
576 #[must_use]
578 pub fn is_cascade(&self) -> bool {
579 self.scope == EXCISION_SCOPE_CASCADE
580 }
581}
582
583#[must_use]
589pub fn verify_signed_excision(payload: &[u8]) -> Option<VerifiedExcision> {
590 let v: Value = serde_json::from_slice(payload).ok()?;
591 let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
592 let scope = v.get("scope")?.as_str()?.to_owned();
593 if scope != EXCISION_SCOPE_CASCADE && scope != EXCISION_SCOPE_SOURCE_ONLY {
594 return None;
595 }
596 let positions: Vec<u64> = v
597 .get("positions")?
598 .as_array()?
599 .iter()
600 .map(serde_json::Value::as_u64)
601 .collect::<Option<Vec<_>>>()?;
602 let requested_by = v.get("requested_by")?.as_str()?.to_owned();
603 let reason = v.get("reason")?.as_str()?.to_owned();
604 let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
605 let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
606 let canonical =
607 excision_canonical(&conversation_id, &scope, &positions, &requested_by, &reason);
608 if verify(&pk, &canonical, &sig) {
609 Some(VerifiedExcision {
610 conversation_id,
611 scope,
612 positions,
613 requested_by,
614 reason,
615 signer_public_key: pk,
616 })
617 } else {
618 None
619 }
620}
621
622#[must_use]
633pub fn deferred_payload(
634 request_id: &str,
635 conversation_id: &str,
636 reason: &str,
637 signer: &ApprovalSigner,
638) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
639 Envelope::seal(
640 DeferredCanonical {
641 request_id,
642 conversation_id,
643 reason,
644 },
645 signer.as_signer(),
646 )
647}
648
649#[derive(Serialize)]
651struct DeferredCanonical<'a> {
652 request_id: &'a str,
653 conversation_id: &'a str,
654 reason: &'a str,
655}
656
657#[must_use]
662pub fn verify_deferred(payload: &[u8]) -> Option<(String, String, String)> {
663 let v: Value = serde_json::from_slice(payload).ok()?;
664 let request_id = v.get("request_id")?.as_str()?.to_owned();
665 let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
666 let reason = v.get("reason")?.as_str()?.to_owned();
667 let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
668 let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
669 let canonical = canonical_bytes(&DeferredCanonical {
670 request_id: &request_id,
671 conversation_id: &conversation_id,
672 reason: &reason,
673 });
674 verify(&pk, &canonical, &sig).then_some((request_id, conversation_id, reason))
675}
676
677#[must_use]
690#[allow(clippy::too_many_arguments)] pub fn mutation_payload(
692 kind: &str,
693 tool_call_id: &str,
694 tool_name: &str,
695 conversation_id: &str,
696 before: &str,
697 after: &str,
698 signer: &ApprovalSigner,
699) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
700 Envelope::seal(
701 MutationCanonical {
702 kind,
703 tool_call_id,
704 tool_name,
705 conversation_id,
706 before,
707 after,
708 },
709 signer.as_signer(),
710 )
711}
712
713#[derive(Serialize)]
715struct MutationCanonical<'a> {
716 kind: &'a str,
717 tool_call_id: &'a str,
718 tool_name: &'a str,
719 conversation_id: &'a str,
720 before: &'a str,
721 after: &'a str,
722}
723
724#[must_use]
729pub fn verify_mutation(payload: &[u8]) -> Option<(String, String, String, String, String, String)> {
730 let v: Value = serde_json::from_slice(payload).ok()?;
731 let kind = v.get("kind")?.as_str()?.to_owned();
732 let tool_call_id = v.get("tool_call_id")?.as_str()?.to_owned();
733 let tool_name = v.get("tool_name")?.as_str()?.to_owned();
734 let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
735 let before = v.get("before")?.as_str()?.to_owned();
736 let after = v.get("after")?.as_str()?.to_owned();
737 let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
738 let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
739 let canonical = canonical_bytes(&MutationCanonical {
740 kind: &kind,
741 tool_call_id: &tool_call_id,
742 tool_name: &tool_name,
743 conversation_id: &conversation_id,
744 before: &before,
745 after: &after,
746 });
747 verify(&pk, &canonical, &sig).then_some((
748 kind,
749 tool_call_id,
750 tool_name,
751 conversation_id,
752 before,
753 after,
754 ))
755}
756
757pub const AUTO_REVIEW_REASON_PREFIX: &str = "auto-review:";
773
774#[must_use]
782pub fn auto_review_reason(tier: &str) -> String {
783 format!("{AUTO_REVIEW_REASON_PREFIX}{tier}")
784}
785
786#[must_use]
792pub fn is_auto_review_reason(reason: &str) -> bool {
793 reason.starts_with(AUTO_REVIEW_REASON_PREFIX)
794}
795
796pub const APPROVE_ALL_DANGEROUS_REASON: &str = "approve-all-dangerous: blanket machine approval";
806
807#[derive(Debug, Clone)]
809pub struct VerifiedResponse {
810 pub turn_id: String,
813 pub request_id: String,
815 pub tool_name: String,
817 pub args_json: String,
822 pub modified_args_json: String,
828 pub approved: bool,
830 pub approved_for_session: bool,
833 pub covered_capabilities: Vec<String>,
840 pub caller: String,
845 pub approver: String,
853 pub sandbox_mode: String,
856 pub reason: String,
858 pub injected_context: String,
862 pub conversation_id: String,
866 pub nonce: String,
869 pub signer_public_key: Vec<u8>,
871}
872
873impl VerifiedResponse {
874 #[must_use]
882 pub fn authorizes_call(&self, request_id: &str, tool_name: &str, args_json: &str) -> bool {
883 self.approved
884 && self.request_id == request_id
885 && self.tool_name == tool_name
886 && self.args_json == args_json
887 }
888}
889
890#[must_use]
897pub fn verify_signed_response(payload: &[u8]) -> Option<VerifiedResponse> {
898 let v: Value = serde_json::from_slice(payload).ok()?;
899 let turn_id = v
902 .get("turn_id")
903 .and_then(Value::as_str)
904 .unwrap_or_default()
905 .to_owned();
906 let request_id = v.get("request_id")?.as_str()?.to_owned();
907 let tool_name = v.get("tool_name")?.as_str()?.to_owned();
908 let args_json = v.get("args_json")?.as_str()?.to_owned();
909 let modified_args_json = v.get("modified_args_json")?.as_str()?.to_owned();
910 let approved = v.get("approved")?.as_bool()?;
911 let approved_for_session = v.get("approved_for_session")?.as_bool()?;
912 let covered_capabilities: Vec<String> = v
913 .get("covered_capabilities")?
914 .as_array()?
915 .iter()
916 .map(|c| c.as_str().map(str::to_owned))
917 .collect::<Option<Vec<_>>>()?;
918 let caller = v.get("caller")?.as_str()?.to_owned();
919 let approver_id = v
923 .get("approver")
924 .and_then(|x| x.as_str())
925 .unwrap_or_default()
926 .to_owned();
927 let sandbox_mode = v.get("sandbox_mode")?.as_str()?.to_owned();
928 let reason = v.get("reason")?.as_str()?.to_owned();
929 let injected_context = v.get("injected_context")?.as_str()?.to_owned();
930 let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
931 let nonce = v.get("nonce")?.as_str()?.to_owned();
932 let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
933 let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
934
935 let canonical = response_canonical(
936 &request_id,
937 &tool_name,
938 &args_json,
939 &modified_args_json,
940 approved,
941 approved_for_session,
942 &covered_capabilities,
943 &caller,
944 &approver_id,
945 &sandbox_mode,
946 &reason,
947 &injected_context,
948 &conversation_id,
949 &nonce,
950 &turn_id,
951 );
952 if verify(&pk, &canonical, &sig) {
953 Some(VerifiedResponse {
954 turn_id,
955 request_id,
956 tool_name,
957 args_json,
958 modified_args_json,
959 approved,
960 approved_for_session,
961 covered_capabilities,
962 caller,
963 approver: approver_id,
964 sandbox_mode,
965 reason,
966 injected_context,
967 conversation_id,
968 nonce,
969 signer_public_key: pk,
970 })
971 } else {
972 None
973 }
974}
975
976#[must_use]
989pub fn verify_signed_response_pinned(
990 payload: &[u8],
991 trusted_signers: &[Vec<u8>],
992) -> Option<VerifiedResponse> {
993 let verified = verify_signed_response(payload)?;
994 if !signer_is_trusted(&verified.signer_public_key, trusted_signers) {
997 return None;
998 }
999 Some(verified)
1000}
1001
1002#[must_use]
1023pub fn verify_capability<S: std::hash::BuildHasher>(
1024 payload: &[u8],
1025 conversation_id: &str,
1026 turn_id: &str,
1027 consumed: &HashSet<String, S>,
1028 trusted_signers: &[Vec<u8>],
1029) -> Option<VerifiedResponse> {
1030 let verified = verify_signed_response_pinned(payload, trusted_signers)?;
1031 if verified.conversation_id != conversation_id {
1032 return None;
1033 }
1034 if !verified.turn_id.is_empty() && verified.turn_id != turn_id {
1038 return None;
1039 }
1040 if verified.nonce.is_empty() || consumed.contains(&verified.nonce) {
1041 return None;
1042 }
1043 Some(verified)
1044}
1045
1046#[must_use]
1060#[allow(clippy::too_many_arguments)] pub fn verify_wire_response(
1062 request_id: &str,
1063 tool_name: &str,
1064 args_json: &str,
1065 modified_args_json: &str,
1066 approved: bool,
1067 approved_for_session: bool,
1068 covered_capabilities: &[String],
1069 caller: &str,
1070 approver_id: &str,
1071 sandbox_mode: &str,
1072 reason: &str,
1073 injected_context: &str,
1074 conversation_id: &str,
1075 nonce: &str,
1076 turn_id: &str,
1077 signer_pk_hex: &str,
1078 signature_hex: &str,
1079) -> bool {
1080 let Some(pk) = crate::hex::decode(signer_pk_hex) else {
1081 return false;
1082 };
1083 let Some(sig) = crate::hex::decode(signature_hex) else {
1084 return false;
1085 };
1086 let canonical = response_canonical(
1087 request_id,
1088 tool_name,
1089 args_json,
1090 modified_args_json,
1091 approved,
1092 approved_for_session,
1093 covered_capabilities,
1094 caller,
1095 approver_id,
1096 sandbox_mode,
1097 reason,
1098 injected_context,
1099 conversation_id,
1100 nonce,
1101 turn_id,
1102 );
1103 verify(&pk, &canonical, &sig)
1104}
1105
1106#[must_use]
1119pub fn is_session_grant_for(
1120 approved: bool,
1121 approved_for_session: bool,
1122 caller: &str,
1123 current_caller: &str,
1124) -> bool {
1125 approved && approved_for_session && !caller.is_empty() && caller == current_caller
1126}
1127
1128#[must_use]
1134pub fn decode_response_minimal(payload: &[u8]) -> Option<(String, bool)> {
1135 let v: Value = serde_json::from_slice(payload).ok()?;
1136 let request_id = v.get("request_id")?.as_str()?.to_owned();
1137 let approved = v.get("approved")?.as_bool()?;
1138 Some((request_id, approved))
1139}
1140
1141#[derive(Debug, Clone)]
1143pub struct DecodedResponse {
1144 pub turn_id: String,
1146 pub request_id: String,
1148 pub tool_name: String,
1150 pub args_json: String,
1152 pub modified_args_json: String,
1154 pub approved: bool,
1156 pub approved_for_session: bool,
1158 pub covered_capabilities: Vec<String>,
1160 pub caller: String,
1163 pub approver: String,
1167 pub sandbox_mode: String,
1169 pub reason: String,
1171 pub injected_context: String,
1173 pub conversation_id: String,
1175 pub nonce: String,
1177 pub signer_pk_hex: String,
1179 pub signature_hex: String,
1181}
1182
1183#[must_use]
1188pub fn decode_response_full(payload: &[u8]) -> Option<DecodedResponse> {
1189 let v: Value = serde_json::from_slice(payload).ok()?;
1190 Some(DecodedResponse {
1191 turn_id: v
1192 .get("turn_id")
1193 .and_then(Value::as_str)
1194 .unwrap_or_default()
1195 .to_owned(),
1196 request_id: v.get("request_id")?.as_str()?.to_owned(),
1197 tool_name: v.get("tool_name")?.as_str()?.to_owned(),
1198 args_json: v.get("args_json")?.as_str()?.to_owned(),
1199 modified_args_json: v.get("modified_args_json")?.as_str()?.to_owned(),
1200 approved: v.get("approved")?.as_bool()?,
1201 approved_for_session: v.get("approved_for_session")?.as_bool()?,
1202 covered_capabilities: v
1203 .get("covered_capabilities")
1204 .and_then(Value::as_array)
1205 .map(|a| {
1206 a.iter()
1207 .filter_map(|c| c.as_str().map(str::to_owned))
1208 .collect()
1209 })
1210 .unwrap_or_default(),
1211 caller: v.get("caller")?.as_str()?.to_owned(),
1212 approver: v
1215 .get("approver")
1216 .and_then(|x| x.as_str())
1217 .unwrap_or_default()
1218 .to_owned(),
1219 sandbox_mode: v.get("sandbox_mode")?.as_str()?.to_owned(),
1220 reason: v.get("reason")?.as_str()?.to_owned(),
1221 injected_context: v.get("injected_context")?.as_str()?.to_owned(),
1222 conversation_id: v.get("conversation_id")?.as_str()?.to_owned(),
1223 nonce: v.get("nonce")?.as_str()?.to_owned(),
1224 signer_pk_hex: v.get("signed_by")?.as_str()?.to_owned(),
1225 signature_hex: v.get("signature_hex")?.as_str()?.to_owned(),
1226 })
1227}
1228
1229pub const RECEIPT_VERSION: u64 = 3;
1246
1247const _: () = assert!(
1256 RECEIPT_VERSION == 3,
1257 "RECEIPT_VERSION changed. A bump is five edits, not one: (1) add \
1258 ReceiptPayload::canonical_json_v<N> beside the frozen builders; (2) add \
1259 ReceiptSchema::V<N> and answer number(), covers_binding() — say which \
1260 fields the new canonical actually covers — and canonical(); (3) give \
1261 ReceiptSchema::resolve an arm mapping the claimed number to it; (4) \
1262 repoint receipt_payload at the new builder; (5) in the tests, add a \
1263 golden v<N> fixture beside GOLDEN_V2_RECEIPT and re-freeze the pinned \
1264 writer shape, leaving every already-frozen v2 literal untouched"
1265);
1266
1267#[derive(Debug, Clone, Copy)]
1285pub struct ReceiptPayload<'a> {
1286 pub kind: &'a str,
1291 pub reference: &'a str,
1293 pub amount: &'a str,
1304 pub currency: &'a str,
1308 pub recipient: &'a str,
1310 pub method: &'a str,
1312 pub timestamp: &'a str,
1314 pub tool_call_id: &'a str,
1317 pub approval_pos: &'a str,
1320 pub approved_args_hash: &'a str,
1324 pub subject: &'a str,
1328 pub payer_kind: &'a str,
1333 pub paying_account: &'a str,
1338}
1339
1340impl ReceiptPayload<'_> {
1341 #[must_use]
1359 const fn canonical_json_v3(&self) -> ReceiptCanonicalV3<'_> {
1360 ReceiptCanonicalV3 {
1361 version: 3,
1362 kind: self.kind,
1363 reference: self.reference,
1364 amount: self.amount,
1365 currency: self.currency,
1366 recipient: self.recipient,
1367 method: self.method,
1368 timestamp: self.timestamp,
1369 tool_call_id: self.tool_call_id,
1370 approval_pos: self.approval_pos,
1371 approved_args_hash: self.approved_args_hash,
1372 subject: self.subject,
1373 payer_kind: self.payer_kind,
1374 paying_account: self.paying_account,
1375 }
1376 }
1377
1378 #[must_use]
1396 const fn canonical_json_v2(&self) -> ReceiptCanonicalV2<'_> {
1397 ReceiptCanonicalV2 {
1398 version: 2,
1399 kind: self.kind,
1400 reference: self.reference,
1401 amount: self.amount,
1402 currency: self.currency,
1403 recipient: self.recipient,
1404 method: self.method,
1405 timestamp: self.timestamp,
1406 tool_call_id: self.tool_call_id,
1407 approval_pos: self.approval_pos,
1408 approved_args_hash: self.approved_args_hash,
1409 subject: self.subject,
1410 }
1411 }
1412
1413 #[must_use]
1417 const fn canonical_json_v1(&self) -> ReceiptCanonicalV1<'_> {
1418 ReceiptCanonicalV1 {
1419 reference: self.reference,
1420 amount: self.amount,
1421 currency: self.currency,
1422 recipient: self.recipient,
1423 method: self.method,
1424 timestamp: self.timestamp,
1425 }
1426 }
1427}
1428
1429#[derive(Serialize)]
1436struct ReceiptCanonicalV3<'a> {
1437 version: u8,
1438 kind: &'a str,
1439 reference: &'a str,
1440 amount: &'a str,
1441 currency: &'a str,
1442 recipient: &'a str,
1443 method: &'a str,
1444 timestamp: &'a str,
1445 tool_call_id: &'a str,
1446 approval_pos: &'a str,
1447 approved_args_hash: &'a str,
1448 subject: &'a str,
1449 payer_kind: &'a str,
1450 paying_account: &'a str,
1451}
1452
1453#[derive(Serialize)]
1460struct ReceiptCanonicalV2<'a> {
1461 version: u8,
1462 kind: &'a str,
1463 reference: &'a str,
1464 amount: &'a str,
1465 currency: &'a str,
1466 recipient: &'a str,
1467 method: &'a str,
1468 timestamp: &'a str,
1469 tool_call_id: &'a str,
1470 approval_pos: &'a str,
1471 approved_args_hash: &'a str,
1472 subject: &'a str,
1473}
1474
1475#[derive(Serialize)]
1478struct ReceiptCanonicalV1<'a> {
1479 reference: &'a str,
1480 amount: &'a str,
1481 currency: &'a str,
1482 recipient: &'a str,
1483 method: &'a str,
1484 timestamp: &'a str,
1485}
1486
1487#[must_use]
1503pub fn receipt_payload(
1504 fields: &ReceiptPayload<'_>,
1505 signer: &ApprovalSigner,
1506) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
1507 Envelope::seal(fields.canonical_json_v3(), signer.as_signer())
1516}
1517
1518#[derive(Debug, Clone)]
1520pub struct VerifiedReceipt {
1521 pub reference: String,
1523 pub amount: String,
1533 pub currency: String,
1536 pub recipient: String,
1538 pub method: String,
1540 pub timestamp: String,
1542 pub version: u64,
1545 pub kind: String,
1549 pub tool_call_id: String,
1551 pub approval_pos: String,
1553 pub approved_args_hash: String,
1555 pub subject: String,
1557 pub payer_kind: String,
1561 pub paying_account: String,
1564 pub signer_public_key: Vec<u8>,
1566}
1567
1568#[derive(Debug, Clone, Copy)]
1587enum ReceiptSchema {
1588 V1,
1590 V2,
1593 V3,
1595}
1596
1597impl ReceiptSchema {
1598 #[must_use]
1613 fn resolve(claimed: Option<&Value>) -> Option<Self> {
1614 let Some(claimed) = claimed else {
1615 return Some(Self::V1);
1616 };
1617 match claimed.as_u64() {
1621 Some(2) => Some(Self::V2),
1622 Some(3) => Some(Self::V3),
1623 _ => None,
1624 }
1625 }
1626
1627 #[must_use]
1629 const fn number(self) -> u64 {
1630 match self {
1631 Self::V1 => 1,
1632 Self::V2 => 2,
1633 Self::V3 => 3,
1634 }
1635 }
1636
1637 #[must_use]
1647 const fn covers_binding(self) -> bool {
1648 match self {
1649 Self::V1 => false,
1650 Self::V2 | Self::V3 => true,
1651 }
1652 }
1653
1654 #[must_use]
1663 const fn covers_payer(self) -> bool {
1664 match self {
1665 Self::V1 | Self::V2 => false,
1666 Self::V3 => true,
1667 }
1668 }
1669
1670 #[must_use]
1673 fn canonical(self, fields: &ReceiptPayload<'_>) -> Vec<u8> {
1674 match self {
1675 Self::V1 => canonical_bytes(&fields.canonical_json_v1()),
1676 Self::V2 => canonical_bytes(&fields.canonical_json_v2()),
1677 Self::V3 => canonical_bytes(&fields.canonical_json_v3()),
1678 }
1679 }
1680}
1681
1682#[must_use]
1706pub fn verify_signed_receipt(
1707 payload: &[u8],
1708 trusted_signers: &[Vec<u8>],
1709) -> Option<VerifiedReceipt> {
1710 let v: Value = serde_json::from_slice(payload).ok()?;
1711 let reference = v.get("reference")?.as_str()?.to_owned();
1712 let amount = v.get("amount")?.as_str()?.to_owned();
1713 let currency = v.get("currency")?.as_str()?.to_owned();
1714 let recipient = v.get("recipient")?.as_str()?.to_owned();
1715 let method = v.get("method")?.as_str()?.to_owned();
1716 let timestamp = v.get("timestamp")?.as_str()?.to_owned();
1717 let signed_by_hex = v.get("signed_by")?.as_str()?;
1718 let signature_hex = v.get("signature_hex")?.as_str()?;
1719 let pk = crate::hex::decode(signed_by_hex)?;
1720 let sig = crate::hex::decode(signature_hex)?;
1721 if !signer_is_trusted(&pk, trusted_signers) {
1726 return None;
1727 }
1728 let schema = ReceiptSchema::resolve(v.get("version"))?;
1734 let (kind, tool_call_id, approval_pos, approved_args_hash, subject) = if schema.covers_binding()
1735 {
1736 (
1737 v.get("kind")?.as_str()?.to_owned(),
1738 v.get("tool_call_id")?.as_str()?.to_owned(),
1739 v.get("approval_pos")?.as_str()?.to_owned(),
1740 v.get("approved_args_hash")?.as_str()?.to_owned(),
1741 v.get("subject")?.as_str()?.to_owned(),
1742 )
1743 } else {
1744 (
1748 String::new(),
1749 String::new(),
1750 String::new(),
1751 String::new(),
1752 String::new(),
1753 )
1754 };
1755 let (payer_kind, paying_account) = if schema.covers_payer() {
1756 (
1757 v.get("payer_kind")?.as_str()?.to_owned(),
1758 v.get("paying_account")?.as_str()?.to_owned(),
1759 )
1760 } else {
1761 (String::new(), String::new())
1765 };
1766 let fields = ReceiptPayload {
1769 kind: &kind,
1770 reference: &reference,
1771 amount: &amount,
1772 currency: ¤cy,
1773 recipient: &recipient,
1774 method: &method,
1775 timestamp: ×tamp,
1776 tool_call_id: &tool_call_id,
1777 approval_pos: &approval_pos,
1778 approved_args_hash: &approved_args_hash,
1779 subject: &subject,
1780 payer_kind: &payer_kind,
1781 paying_account: &paying_account,
1782 };
1783 let canonical = schema.canonical(&fields);
1784 if verify(&pk, &canonical, &sig) {
1785 Some(VerifiedReceipt {
1786 reference,
1787 amount,
1788 currency,
1789 recipient,
1790 method,
1791 timestamp,
1792 version: schema.number(),
1793 kind,
1794 tool_call_id,
1795 approval_pos,
1796 approved_args_hash,
1797 subject,
1798 payer_kind,
1799 paying_account,
1800 signer_public_key: pk,
1801 })
1802 } else {
1803 None
1804 }
1805}
1806
1807#[derive(Debug, Clone, Copy)]
1817pub struct RefusalPayload<'a> {
1818 pub kind: &'a str,
1822 pub reason: &'a str,
1827 pub reason_detail: &'a str,
1831 pub merchant_host: &'a str,
1834 pub requested_base_units: &'a str,
1838 pub permitted_base_units: &'a str,
1841 pub tool_call_id: &'a str,
1843 pub subject: &'a str,
1846 pub timestamp: &'a str,
1855}
1856
1857impl RefusalPayload<'_> {
1858 #[must_use]
1863 const fn canonical(&self) -> RefusalCanonical<'_> {
1864 RefusalCanonical {
1865 kind: self.kind,
1866 reason: self.reason,
1867 reason_detail: self.reason_detail,
1868 merchant_host: self.merchant_host,
1869 requested_base_units: self.requested_base_units,
1870 permitted_base_units: self.permitted_base_units,
1871 tool_call_id: self.tool_call_id,
1872 subject: self.subject,
1873 timestamp: self.timestamp,
1874 }
1875 }
1876}
1877
1878#[derive(Serialize)]
1881struct RefusalCanonical<'a> {
1882 kind: &'a str,
1883 reason: &'a str,
1884 reason_detail: &'a str,
1885 merchant_host: &'a str,
1886 requested_base_units: &'a str,
1887 permitted_base_units: &'a str,
1888 tool_call_id: &'a str,
1889 subject: &'a str,
1890 timestamp: &'a str,
1891}
1892
1893#[must_use]
1902pub fn refusal_payload(
1903 fields: &RefusalPayload<'_>,
1904 signer: &ApprovalSigner,
1905) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
1906 Envelope::seal(fields.canonical(), signer.as_signer())
1907}
1908
1909#[derive(Debug, Clone)]
1911pub struct VerifiedRefusal {
1912 pub kind: String,
1916 pub reason: String,
1918 pub reason_detail: String,
1920 pub merchant_host: String,
1922 pub requested_base_units: String,
1924 pub permitted_base_units: String,
1926 pub tool_call_id: String,
1928 pub subject: String,
1930 pub timestamp: String,
1933 pub signer_public_key: Vec<u8>,
1935}
1936
1937#[must_use]
1949pub fn verify_signed_refusal(
1950 payload: &[u8],
1951 trusted_signers: &[Vec<u8>],
1952) -> Option<VerifiedRefusal> {
1953 let v: Value = serde_json::from_slice(payload).ok()?;
1954 let kind = v.get("kind")?.as_str()?.to_owned();
1955 let reason = v.get("reason")?.as_str()?.to_owned();
1956 let reason_detail = v.get("reason_detail")?.as_str()?.to_owned();
1957 let merchant_host = v.get("merchant_host")?.as_str()?.to_owned();
1958 let requested_base_units = v.get("requested_base_units")?.as_str()?.to_owned();
1959 let permitted_base_units = v.get("permitted_base_units")?.as_str()?.to_owned();
1960 let tool_call_id = v.get("tool_call_id")?.as_str()?.to_owned();
1961 let subject = v.get("subject")?.as_str()?.to_owned();
1962 let timestamp = v.get("timestamp")?.as_str()?.to_owned();
1963 let signed_by_hex = v.get("signed_by")?.as_str()?;
1964 let signature_hex = v.get("signature_hex")?.as_str()?;
1965 let pk = crate::hex::decode(signed_by_hex)?;
1966 let sig = crate::hex::decode(signature_hex)?;
1967 if !signer_is_trusted(&pk, trusted_signers) {
1968 return None;
1969 }
1970 let fields = RefusalPayload {
1971 kind: &kind,
1972 reason: &reason,
1973 reason_detail: &reason_detail,
1974 merchant_host: &merchant_host,
1975 requested_base_units: &requested_base_units,
1976 permitted_base_units: &permitted_base_units,
1977 tool_call_id: &tool_call_id,
1978 subject: &subject,
1979 timestamp: ×tamp,
1980 };
1981 let canonical = canonical_bytes(&fields.canonical());
1982 if verify(&pk, &canonical, &sig) {
1983 Some(VerifiedRefusal {
1984 kind,
1985 reason,
1986 reason_detail,
1987 merchant_host,
1988 requested_base_units,
1989 permitted_base_units,
1990 tool_call_id,
1991 subject,
1992 timestamp,
1993 signer_public_key: pk,
1994 })
1995 } else {
1996 None
1997 }
1998}
1999
2000#[derive(Debug, Clone, Copy)]
2008pub struct WalletLinkLifecyclePayload<'a> {
2009 pub kind: &'a str,
2012 pub transition: &'a str,
2018 pub subject: &'a str,
2021 pub wallet_address: &'a str,
2023 pub currency: &'a str,
2025 pub chain_id: &'a str,
2027 pub limit_base_units: &'a str,
2030 pub limit_human: &'a str,
2032 pub period_secs: &'a str,
2037 pub expiry_unix: &'a str,
2042 pub recipients: &'a str,
2045 pub conversation_id: &'a str,
2049 pub timestamp: &'a str,
2054}
2055
2056impl WalletLinkLifecyclePayload<'_> {
2057 #[must_use]
2063 const fn canonical(&self) -> WalletLinkLifecycleCanonical<'_> {
2064 WalletLinkLifecycleCanonical {
2065 kind: self.kind,
2066 transition: self.transition,
2067 subject: self.subject,
2068 wallet_address: self.wallet_address,
2069 currency: self.currency,
2070 chain_id: self.chain_id,
2071 limit_base_units: self.limit_base_units,
2072 limit_human: self.limit_human,
2073 period_secs: self.period_secs,
2074 expiry_unix: self.expiry_unix,
2075 recipients: self.recipients,
2076 conversation_id: self.conversation_id,
2077 timestamp: self.timestamp,
2078 }
2079 }
2080}
2081
2082#[derive(Serialize)]
2085struct WalletLinkLifecycleCanonical<'a> {
2086 kind: &'a str,
2087 transition: &'a str,
2088 subject: &'a str,
2089 wallet_address: &'a str,
2090 currency: &'a str,
2091 chain_id: &'a str,
2092 limit_base_units: &'a str,
2093 limit_human: &'a str,
2094 period_secs: &'a str,
2095 expiry_unix: &'a str,
2096 recipients: &'a str,
2097 conversation_id: &'a str,
2098 timestamp: &'a str,
2099}
2100
2101#[must_use]
2111pub fn wallet_link_lifecycle_payload(
2112 fields: &WalletLinkLifecyclePayload<'_>,
2113 signer: &ApprovalSigner,
2114) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
2115 Envelope::seal(fields.canonical(), signer.as_signer())
2116}
2117
2118#[derive(Debug, Clone)]
2120pub struct VerifiedWalletLinkLifecycle {
2121 pub kind: String,
2123 pub transition: String,
2128 pub subject: String,
2130 pub wallet_address: String,
2132 pub currency: String,
2134 pub chain_id: String,
2136 pub limit_base_units: String,
2138 pub limit_human: String,
2140 pub period_secs: String,
2142 pub expiry_unix: String,
2144 pub recipients: String,
2146 pub conversation_id: String,
2148 pub timestamp: String,
2151 pub signer_public_key: Vec<u8>,
2153}
2154
2155#[must_use]
2166pub fn verify_signed_wallet_link_lifecycle(
2167 payload: &[u8],
2168 trusted_signers: &[Vec<u8>],
2169) -> Option<VerifiedWalletLinkLifecycle> {
2170 let v: Value = serde_json::from_slice(payload).ok()?;
2171 let kind = v.get("kind")?.as_str()?.to_owned();
2172 let transition = v.get("transition")?.as_str()?.to_owned();
2173 let subject = v.get("subject")?.as_str()?.to_owned();
2174 let wallet_address = v.get("wallet_address")?.as_str()?.to_owned();
2175 let currency = v.get("currency")?.as_str()?.to_owned();
2176 let chain_id = v.get("chain_id")?.as_str()?.to_owned();
2177 let limit_base_units = v.get("limit_base_units")?.as_str()?.to_owned();
2178 let limit_human = v.get("limit_human")?.as_str()?.to_owned();
2179 let period_secs = v.get("period_secs")?.as_str()?.to_owned();
2180 let expiry_unix = v.get("expiry_unix")?.as_str()?.to_owned();
2181 let recipients = v.get("recipients")?.as_str()?.to_owned();
2182 let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
2183 let timestamp = v.get("timestamp")?.as_str()?.to_owned();
2184 let signed_by_hex = v.get("signed_by")?.as_str()?;
2185 let signature_hex = v.get("signature_hex")?.as_str()?;
2186 let pk = crate::hex::decode(signed_by_hex)?;
2187 let sig = crate::hex::decode(signature_hex)?;
2188 if !signer_is_trusted(&pk, trusted_signers) {
2189 return None;
2190 }
2191 let fields = WalletLinkLifecyclePayload {
2192 kind: &kind,
2193 transition: &transition,
2194 subject: &subject,
2195 wallet_address: &wallet_address,
2196 currency: ¤cy,
2197 chain_id: &chain_id,
2198 limit_base_units: &limit_base_units,
2199 limit_human: &limit_human,
2200 period_secs: &period_secs,
2201 expiry_unix: &expiry_unix,
2202 recipients: &recipients,
2203 conversation_id: &conversation_id,
2204 timestamp: ×tamp,
2205 };
2206 let canonical = canonical_bytes(&fields.canonical());
2207 if verify(&pk, &canonical, &sig) {
2208 Some(VerifiedWalletLinkLifecycle {
2209 kind,
2210 transition,
2211 subject,
2212 wallet_address,
2213 currency,
2214 chain_id,
2215 limit_base_units,
2216 limit_human,
2217 period_secs,
2218 expiry_unix,
2219 recipients,
2220 conversation_id,
2221 timestamp,
2222 signer_public_key: pk,
2223 })
2224 } else {
2225 None
2226 }
2227}
2228
2229pub const RESOLVE_TOKEN_TTL_MS: u64 = 24 * 60 * 60 * 1000;
2238
2239fn resolve_token_canonical(
2246 turn_id: &str,
2247 request_id: &str,
2248 conversation_id: &str,
2249 minted_at_ms: u64,
2250) -> Vec<u8> {
2251 canonical_bytes(&ResolveTokenCanonical {
2252 turn_id,
2253 request_id,
2254 conversation_id,
2255 minted_at_ms,
2256 })
2257}
2258
2259#[derive(Serialize)]
2261struct ResolveTokenCanonical<'a> {
2262 turn_id: &'a str,
2263 request_id: &'a str,
2264 conversation_id: &'a str,
2265 minted_at_ms: u64,
2266}
2267
2268#[derive(Serialize)]
2274struct ResolveToken<'a> {
2275 #[serde(flatten)]
2276 body: ResolveTokenCanonical<'a>,
2277 signature_hex: String,
2278}
2279
2280#[must_use]
2297pub fn mint_resolve_token(
2298 turn_id: &str,
2299 request_id: &str,
2300 conversation_id: &str,
2301 minted_at_ms: u64,
2302 signer: &ApprovalSigner,
2303) -> String {
2304 let canonical = resolve_token_canonical(turn_id, request_id, conversation_id, minted_at_ms);
2305 let signature = signer.sign(&canonical);
2306 let full = ResolveToken {
2307 body: ResolveTokenCanonical {
2308 turn_id,
2309 request_id,
2310 conversation_id,
2311 minted_at_ms,
2312 },
2313 signature_hex: crate::hex::lower(&signature),
2314 };
2315 crate::hex::lower(&canonical_bytes(&full))
2316}
2317
2318#[must_use]
2328pub fn verify_resolve_token(
2329 token: &str,
2330 turn_id: &str,
2331 request_id: &str,
2332 conversation_id: &str,
2333 now_ms: u64,
2334 signer: &ApprovalSigner,
2335) -> bool {
2336 let Some(bytes) = crate::hex::decode(token) else {
2337 return false;
2338 };
2339 let Ok(v) = serde_json::from_slice::<Value>(&bytes) else {
2340 return false;
2341 };
2342 let (
2343 Some(bound_turn_id),
2344 Some(bound_request_id),
2345 Some(bound_conversation_id),
2346 Some(minted_at_ms),
2347 Some(signature_hex),
2348 ) = (
2349 v.get("turn_id").and_then(Value::as_str),
2350 v.get("request_id").and_then(Value::as_str),
2351 v.get("conversation_id").and_then(Value::as_str),
2352 v.get("minted_at_ms").and_then(Value::as_u64),
2353 v.get("signature_hex").and_then(Value::as_str),
2354 )
2355 else {
2356 return false;
2357 };
2358 if bound_turn_id != turn_id
2359 || bound_request_id != request_id
2360 || bound_conversation_id != conversation_id
2361 {
2362 return false;
2363 }
2364 let elapsed = now_ms.abs_diff(minted_at_ms);
2365 if elapsed > RESOLVE_TOKEN_TTL_MS {
2366 return false;
2367 }
2368 let Some(sig) = crate::hex::decode(signature_hex) else {
2369 return false;
2370 };
2371 let canonical = resolve_token_canonical(
2372 bound_turn_id,
2373 bound_request_id,
2374 bound_conversation_id,
2375 minted_at_ms,
2376 );
2377 verify(&signer.public_key_bytes(), &canonical, &sig)
2378}
2379
2380fn admin_model_change_canonical(
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) -> Vec<u8> {
2396 canonical_bytes(&AdminModelChangeCanonical {
2397 principal,
2398 previous_provider,
2399 previous_model,
2400 new_provider,
2401 new_model,
2402 changed_at_ms,
2403 })
2404}
2405
2406#[derive(Serialize)]
2408struct AdminModelChangeCanonical<'a> {
2409 principal: &'a str,
2410 previous_provider: &'a str,
2411 previous_model: &'a str,
2412 new_provider: &'a str,
2413 new_model: &'a str,
2414 changed_at_ms: u64,
2415}
2416
2417#[must_use]
2424#[allow(clippy::too_many_arguments)] pub fn admin_model_change_payload(
2426 principal: &str,
2427 previous_provider: &str,
2428 previous_model: &str,
2429 new_provider: &str,
2430 new_model: &str,
2431 changed_at_ms: u64,
2432 signer: &ApprovalSigner,
2433) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
2434 Envelope::seal(
2435 AdminModelChangeCanonical {
2436 principal,
2437 previous_provider,
2438 previous_model,
2439 new_provider,
2440 new_model,
2441 changed_at_ms,
2442 },
2443 signer.as_signer(),
2444 )
2445}
2446
2447#[derive(Debug, Clone, PartialEq, Eq)]
2449pub struct VerifiedAdminModelChange {
2450 pub principal: String,
2452 pub previous_provider: String,
2454 pub previous_model: String,
2456 pub new_provider: String,
2458 pub new_model: String,
2460 pub changed_at_ms: u64,
2462 pub signer_public_key: Vec<u8>,
2464}
2465
2466#[must_use]
2471pub fn verify_admin_model_change(payload: &[u8]) -> Option<VerifiedAdminModelChange> {
2472 let v: Value = serde_json::from_slice(payload).ok()?;
2473 let principal = v.get("principal")?.as_str()?.to_owned();
2474 let previous_provider = v.get("previous_provider")?.as_str()?.to_owned();
2475 let previous_model = v.get("previous_model")?.as_str()?.to_owned();
2476 let new_provider = v.get("new_provider")?.as_str()?.to_owned();
2477 let new_model = v.get("new_model")?.as_str()?.to_owned();
2478 let changed_at_ms = v.get("changed_at_ms")?.as_u64()?;
2479 let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
2480 let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
2481 let canonical = admin_model_change_canonical(
2482 &principal,
2483 &previous_provider,
2484 &previous_model,
2485 &new_provider,
2486 &new_model,
2487 changed_at_ms,
2488 );
2489 if verify(&pk, &canonical, &sig) {
2490 Some(VerifiedAdminModelChange {
2491 principal,
2492 previous_provider,
2493 previous_model,
2494 new_provider,
2495 new_model,
2496 changed_at_ms,
2497 signer_public_key: pk,
2498 })
2499 } else {
2500 None
2501 }
2502}
2503
2504#[derive(Debug, Clone, PartialEq, Eq)]
2510pub struct CredentialKeyTransition {
2511 pub kid: String,
2513 pub from: String,
2515 pub to: String,
2517}
2518
2519fn credential_change_canonical(
2528 change: &str,
2529 principal: &str,
2530 edge_id: &str,
2531 kid: &str,
2532 grants: &str,
2533 transitions: &[CredentialKeyTransition],
2534 changed_at_ms: u64,
2535) -> Vec<u8> {
2536 canonical_bytes(&credential_change_fields(
2537 change,
2538 principal,
2539 edge_id,
2540 kid,
2541 grants,
2542 transitions,
2543 changed_at_ms,
2544 ))
2545}
2546
2547#[derive(Serialize)]
2554struct CredentialChangeCanonical<'a> {
2555 change: &'a str,
2556 principal: &'a str,
2557 edge_id: &'a str,
2558 kid: &'a str,
2559 grants: &'a str,
2560 transitions: Vec<CredentialTransition<'a>>,
2561 changed_at_ms: u64,
2562}
2563
2564#[derive(Serialize)]
2567struct CredentialTransition<'a> {
2568 kid: &'a str,
2569 from: &'a str,
2570 to: &'a str,
2571}
2572
2573#[allow(clippy::too_many_arguments)] fn credential_change_fields<'a>(
2580 change: &'a str,
2581 principal: &'a str,
2582 edge_id: &'a str,
2583 kid: &'a str,
2584 grants: &'a str,
2585 transitions: &'a [CredentialKeyTransition],
2586 changed_at_ms: u64,
2587) -> CredentialChangeCanonical<'a> {
2588 CredentialChangeCanonical {
2589 change,
2590 principal,
2591 edge_id,
2592 kid,
2593 grants,
2594 transitions: transitions
2595 .iter()
2596 .map(|t| CredentialTransition {
2597 kid: &t.kid,
2598 from: &t.from,
2599 to: &t.to,
2600 })
2601 .collect(),
2602 changed_at_ms,
2603 }
2604}
2605
2606#[must_use]
2624#[allow(clippy::too_many_arguments)] pub fn credential_change_payload(
2626 change: &str,
2627 principal: &str,
2628 edge_id: &str,
2629 kid: &str,
2630 grants: &str,
2631 transitions: &[CredentialKeyTransition],
2632 changed_at_ms: u64,
2633 signer: &ApprovalSigner,
2634) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
2635 Envelope::seal(
2636 credential_change_fields(
2637 change,
2638 principal,
2639 edge_id,
2640 kid,
2641 grants,
2642 transitions,
2643 changed_at_ms,
2644 ),
2645 signer.as_signer(),
2646 )
2647}
2648
2649#[derive(Debug, Clone, PartialEq, Eq)]
2651pub struct VerifiedCredentialChange {
2652 pub change: String,
2654 pub principal: String,
2656 pub edge_id: String,
2658 pub kid: String,
2660 pub grants: String,
2668 pub transitions: Vec<CredentialKeyTransition>,
2670 pub changed_at_ms: u64,
2672 pub signer_public_key: Vec<u8>,
2674}
2675
2676#[must_use]
2692pub fn verify_credential_change(payload: &[u8]) -> Option<VerifiedCredentialChange> {
2693 let v: Value = serde_json::from_slice(payload).ok()?;
2694 let change = v.get("change")?.as_str()?.to_owned();
2695 let principal = v.get("principal")?.as_str()?.to_owned();
2696 let edge_id = v.get("edge_id")?.as_str()?.to_owned();
2697 let kid = v.get("kid")?.as_str()?.to_owned();
2698 let grants = v.get("grants")?.as_str()?.to_owned();
2699 let mut transitions = Vec::new();
2700 for item in v.get("transitions")?.as_array()? {
2701 transitions.push(CredentialKeyTransition {
2702 kid: item.get("kid")?.as_str()?.to_owned(),
2703 from: item.get("from")?.as_str()?.to_owned(),
2704 to: item.get("to")?.as_str()?.to_owned(),
2705 });
2706 }
2707 let changed_at_ms = v.get("changed_at_ms")?.as_u64()?;
2708 let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
2709 let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
2710 let canonical = credential_change_canonical(
2711 &change,
2712 &principal,
2713 &edge_id,
2714 &kid,
2715 &grants,
2716 &transitions,
2717 changed_at_ms,
2718 );
2719 if verify(&pk, &canonical, &sig) {
2720 Some(VerifiedCredentialChange {
2721 change,
2722 principal,
2723 edge_id,
2724 kid,
2725 grants,
2726 transitions,
2727 changed_at_ms,
2728 signer_public_key: pk,
2729 })
2730 } else {
2731 None
2732 }
2733}
2734
2735fn routine_created_canonical(
2737 routine: &str,
2738 creator_persona: &str,
2739 conversation_id: &str,
2740 tool_call_id: &str,
2741 args_hash: &str,
2742 created_at_ms: u64,
2743) -> Vec<u8> {
2744 canonical_bytes(&RoutineCreatedCanonical {
2745 routine,
2746 creator_persona,
2747 conversation_id,
2748 tool_call_id,
2749 args_hash,
2750 created_at_ms,
2751 })
2752}
2753
2754#[derive(Serialize)]
2756struct RoutineCreatedCanonical<'a> {
2757 routine: &'a str,
2758 creator_persona: &'a str,
2759 conversation_id: &'a str,
2760 tool_call_id: &'a str,
2761 args_hash: &'a str,
2762 created_at_ms: u64,
2763}
2764
2765#[must_use]
2784pub fn routine_created_payload(
2785 routine: &str,
2786 creator_persona: &str,
2787 conversation_id: &str,
2788 tool_call_id: &str,
2789 args_hash: &str,
2790 created_at_ms: u64,
2791 signer: &ApprovalSigner,
2792) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
2793 Envelope::seal(
2794 RoutineCreatedCanonical {
2795 routine,
2796 creator_persona,
2797 conversation_id,
2798 tool_call_id,
2799 args_hash,
2800 created_at_ms,
2801 },
2802 signer.as_signer(),
2803 )
2804}
2805
2806#[derive(Debug, Clone, PartialEq, Eq)]
2808pub struct VerifiedRoutineCreated {
2809 pub routine: String,
2811 pub creator_persona: String,
2813 pub conversation_id: String,
2815 pub tool_call_id: String,
2817 pub args_hash: String,
2819 pub created_at_ms: u64,
2821 pub signer_public_key: Vec<u8>,
2823}
2824
2825#[must_use]
2830pub fn verify_routine_created(payload: &[u8]) -> Option<VerifiedRoutineCreated> {
2831 let v: Value = serde_json::from_slice(payload).ok()?;
2832 let routine = v.get("routine")?.as_str()?.to_owned();
2833 let creator_persona = v.get("creator_persona")?.as_str()?.to_owned();
2834 let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
2835 let tool_call_id = v.get("tool_call_id")?.as_str()?.to_owned();
2836 let args_hash = v.get("args_hash")?.as_str()?.to_owned();
2837 let created_at_ms = v.get("created_at_ms")?.as_u64()?;
2838 let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
2839 let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
2840 let canonical = routine_created_canonical(
2841 &routine,
2842 &creator_persona,
2843 &conversation_id,
2844 &tool_call_id,
2845 &args_hash,
2846 created_at_ms,
2847 );
2848 if verify(&pk, &canonical, &sig) {
2849 Some(VerifiedRoutineCreated {
2850 routine,
2851 creator_persona,
2852 conversation_id,
2853 tool_call_id,
2854 args_hash,
2855 created_at_ms,
2856 signer_public_key: pk,
2857 })
2858 } else {
2859 None
2860 }
2861}
2862
2863fn routine_paused_canonical(
2865 routine: &str,
2866 actor_persona: &str,
2867 conversation_id: &str,
2868 tool_call_id: &str,
2869 args_hash: &str,
2870 paused_at_ms: u64,
2871 reason: Option<&str>,
2872) -> Vec<u8> {
2873 canonical_bytes(&RoutinePausedCanonical {
2874 routine,
2875 actor_persona,
2876 conversation_id,
2877 tool_call_id,
2878 args_hash,
2879 paused_at_ms,
2880 reason,
2881 })
2882}
2883
2884#[derive(Serialize)]
2890struct RoutinePausedCanonical<'a> {
2891 routine: &'a str,
2892 actor_persona: &'a str,
2893 conversation_id: &'a str,
2894 tool_call_id: &'a str,
2895 args_hash: &'a str,
2896 paused_at_ms: u64,
2897 reason: Option<&'a str>,
2898}
2899
2900#[must_use]
2912#[allow(clippy::too_many_arguments)] pub fn routine_paused_payload(
2914 routine: &str,
2915 actor_persona: &str,
2916 conversation_id: &str,
2917 tool_call_id: &str,
2918 args_hash: &str,
2919 paused_at_ms: u64,
2920 reason: Option<&str>,
2921 signer: &ApprovalSigner,
2922) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
2923 Envelope::seal(
2924 RoutinePausedCanonical {
2925 routine,
2926 actor_persona,
2927 conversation_id,
2928 tool_call_id,
2929 args_hash,
2930 paused_at_ms,
2931 reason,
2932 },
2933 signer.as_signer(),
2934 )
2935}
2936
2937#[derive(Debug, Clone, PartialEq, Eq)]
2939pub struct VerifiedRoutinePaused {
2940 pub routine: String,
2942 pub actor_persona: String,
2944 pub conversation_id: String,
2946 pub tool_call_id: String,
2948 pub args_hash: String,
2950 pub paused_at_ms: u64,
2952 pub reason: Option<String>,
2954 pub signer_public_key: Vec<u8>,
2956}
2957
2958#[must_use]
2963pub fn verify_routine_paused(payload: &[u8]) -> Option<VerifiedRoutinePaused> {
2964 let v: Value = serde_json::from_slice(payload).ok()?;
2965 let routine = v.get("routine")?.as_str()?.to_owned();
2966 let actor_persona = v.get("actor_persona")?.as_str()?.to_owned();
2967 let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
2968 let tool_call_id = v.get("tool_call_id")?.as_str()?.to_owned();
2969 let args_hash = v.get("args_hash")?.as_str()?.to_owned();
2970 let paused_at_ms = v.get("paused_at_ms")?.as_u64()?;
2971 let reason = v.get("reason").and_then(|r| r.as_str()).map(str::to_owned);
2972 let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
2973 let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
2974 let canonical = routine_paused_canonical(
2975 &routine,
2976 &actor_persona,
2977 &conversation_id,
2978 &tool_call_id,
2979 &args_hash,
2980 paused_at_ms,
2981 reason.as_deref(),
2982 );
2983 if verify(&pk, &canonical, &sig) {
2984 Some(VerifiedRoutinePaused {
2985 routine,
2986 actor_persona,
2987 conversation_id,
2988 tool_call_id,
2989 args_hash,
2990 paused_at_ms,
2991 reason,
2992 signer_public_key: pk,
2993 })
2994 } else {
2995 None
2996 }
2997}
2998
2999fn routine_resumed_canonical(
3001 routine: &str,
3002 actor_persona: &str,
3003 conversation_id: &str,
3004 tool_call_id: &str,
3005 args_hash: &str,
3006 resumed_at_ms: u64,
3007) -> Vec<u8> {
3008 canonical_bytes(&RoutineResumedCanonical {
3009 routine,
3010 actor_persona,
3011 conversation_id,
3012 tool_call_id,
3013 args_hash,
3014 resumed_at_ms,
3015 })
3016}
3017
3018#[derive(Serialize)]
3020struct RoutineResumedCanonical<'a> {
3021 routine: &'a str,
3022 actor_persona: &'a str,
3023 conversation_id: &'a str,
3024 tool_call_id: &'a str,
3025 args_hash: &'a str,
3026 resumed_at_ms: u64,
3027}
3028
3029#[must_use]
3037pub fn routine_resumed_payload(
3038 routine: &str,
3039 actor_persona: &str,
3040 conversation_id: &str,
3041 tool_call_id: &str,
3042 args_hash: &str,
3043 resumed_at_ms: u64,
3044 signer: &ApprovalSigner,
3045) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
3046 Envelope::seal(
3047 RoutineResumedCanonical {
3048 routine,
3049 actor_persona,
3050 conversation_id,
3051 tool_call_id,
3052 args_hash,
3053 resumed_at_ms,
3054 },
3055 signer.as_signer(),
3056 )
3057}
3058
3059#[derive(Debug, Clone, PartialEq, Eq)]
3061pub struct VerifiedRoutineResumed {
3062 pub routine: String,
3064 pub actor_persona: String,
3066 pub conversation_id: String,
3068 pub tool_call_id: String,
3070 pub args_hash: String,
3072 pub resumed_at_ms: u64,
3074 pub signer_public_key: Vec<u8>,
3076}
3077
3078#[must_use]
3083pub fn verify_routine_resumed(payload: &[u8]) -> Option<VerifiedRoutineResumed> {
3084 let v: Value = serde_json::from_slice(payload).ok()?;
3085 let routine = v.get("routine")?.as_str()?.to_owned();
3086 let actor_persona = v.get("actor_persona")?.as_str()?.to_owned();
3087 let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
3088 let tool_call_id = v.get("tool_call_id")?.as_str()?.to_owned();
3089 let args_hash = v.get("args_hash")?.as_str()?.to_owned();
3090 let resumed_at_ms = v.get("resumed_at_ms")?.as_u64()?;
3091 let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
3092 let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
3093 let canonical = routine_resumed_canonical(
3094 &routine,
3095 &actor_persona,
3096 &conversation_id,
3097 &tool_call_id,
3098 &args_hash,
3099 resumed_at_ms,
3100 );
3101 if verify(&pk, &canonical, &sig) {
3102 Some(VerifiedRoutineResumed {
3103 routine,
3104 actor_persona,
3105 conversation_id,
3106 tool_call_id,
3107 args_hash,
3108 resumed_at_ms,
3109 signer_public_key: pk,
3110 })
3111 } else {
3112 None
3113 }
3114}
3115
3116fn routine_deleted_canonical(
3118 routine: &str,
3119 actor_persona: &str,
3120 conversation_id: &str,
3121 tool_call_id: &str,
3122 args_hash: &str,
3123 deleted_at_ms: u64,
3124) -> Vec<u8> {
3125 canonical_bytes(&RoutineDeletedCanonical {
3126 routine,
3127 actor_persona,
3128 conversation_id,
3129 tool_call_id,
3130 args_hash,
3131 deleted_at_ms,
3132 })
3133}
3134
3135#[derive(Serialize)]
3137struct RoutineDeletedCanonical<'a> {
3138 routine: &'a str,
3139 actor_persona: &'a str,
3140 conversation_id: &'a str,
3141 tool_call_id: &'a str,
3142 args_hash: &'a str,
3143 deleted_at_ms: u64,
3144}
3145
3146#[must_use]
3155pub fn routine_deleted_payload(
3156 routine: &str,
3157 actor_persona: &str,
3158 conversation_id: &str,
3159 tool_call_id: &str,
3160 args_hash: &str,
3161 deleted_at_ms: u64,
3162 signer: &ApprovalSigner,
3163) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
3164 Envelope::seal(
3165 RoutineDeletedCanonical {
3166 routine,
3167 actor_persona,
3168 conversation_id,
3169 tool_call_id,
3170 args_hash,
3171 deleted_at_ms,
3172 },
3173 signer.as_signer(),
3174 )
3175}
3176
3177#[derive(Debug, Clone, PartialEq, Eq)]
3179pub struct VerifiedRoutineDeleted {
3180 pub routine: String,
3182 pub actor_persona: String,
3184 pub conversation_id: String,
3186 pub tool_call_id: String,
3188 pub args_hash: String,
3190 pub deleted_at_ms: u64,
3192 pub signer_public_key: Vec<u8>,
3194}
3195
3196#[must_use]
3201pub fn verify_routine_deleted(payload: &[u8]) -> Option<VerifiedRoutineDeleted> {
3202 let v: Value = serde_json::from_slice(payload).ok()?;
3203 let routine = v.get("routine")?.as_str()?.to_owned();
3204 let actor_persona = v.get("actor_persona")?.as_str()?.to_owned();
3205 let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
3206 let tool_call_id = v.get("tool_call_id")?.as_str()?.to_owned();
3207 let args_hash = v.get("args_hash")?.as_str()?.to_owned();
3208 let deleted_at_ms = v.get("deleted_at_ms")?.as_u64()?;
3209 let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
3210 let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
3211 let canonical = routine_deleted_canonical(
3212 &routine,
3213 &actor_persona,
3214 &conversation_id,
3215 &tool_call_id,
3216 &args_hash,
3217 deleted_at_ms,
3218 );
3219 if verify(&pk, &canonical, &sig) {
3220 Some(VerifiedRoutineDeleted {
3221 routine,
3222 actor_persona,
3223 conversation_id,
3224 tool_call_id,
3225 args_hash,
3226 deleted_at_ms,
3227 signer_public_key: pk,
3228 })
3229 } else {
3230 None
3231 }
3232}
3233
3234fn routine_scope_changed_canonical(
3236 routine: &str,
3237 actor_persona: &str,
3238 conversation_id: &str,
3239 tool_call_id: &str,
3240 args_hash: &str,
3241 scope: &str,
3242 changed_at_ms: u64,
3243) -> Vec<u8> {
3244 canonical_bytes(&RoutineScopeChangedCanonical {
3245 routine,
3246 actor_persona,
3247 conversation_id,
3248 tool_call_id,
3249 args_hash,
3250 scope,
3251 changed_at_ms,
3252 })
3253}
3254
3255#[derive(Serialize)]
3257struct RoutineScopeChangedCanonical<'a> {
3258 routine: &'a str,
3259 actor_persona: &'a str,
3260 conversation_id: &'a str,
3261 tool_call_id: &'a str,
3262 args_hash: &'a str,
3263 scope: &'a str,
3264 changed_at_ms: u64,
3265}
3266
3267#[must_use]
3281#[allow(clippy::too_many_arguments)] pub fn routine_scope_changed_payload(
3283 routine: &str,
3284 actor_persona: &str,
3285 conversation_id: &str,
3286 tool_call_id: &str,
3287 args_hash: &str,
3288 scope: &str,
3289 changed_at_ms: u64,
3290 signer: &ApprovalSigner,
3291) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
3292 Envelope::seal(
3293 RoutineScopeChangedCanonical {
3294 routine,
3295 actor_persona,
3296 conversation_id,
3297 tool_call_id,
3298 args_hash,
3299 scope,
3300 changed_at_ms,
3301 },
3302 signer.as_signer(),
3303 )
3304}
3305
3306#[derive(Debug, Clone, PartialEq, Eq)]
3308pub struct VerifiedRoutineScopeChanged {
3309 pub routine: String,
3311 pub actor_persona: String,
3313 pub conversation_id: String,
3315 pub tool_call_id: String,
3317 pub args_hash: String,
3319 pub scope: String,
3321 pub changed_at_ms: u64,
3323 pub signer_public_key: Vec<u8>,
3325}
3326
3327#[must_use]
3332pub fn verify_routine_scope_changed(payload: &[u8]) -> Option<VerifiedRoutineScopeChanged> {
3333 let v: Value = serde_json::from_slice(payload).ok()?;
3334 let routine = v.get("routine")?.as_str()?.to_owned();
3335 let actor_persona = v.get("actor_persona")?.as_str()?.to_owned();
3336 let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
3337 let tool_call_id = v.get("tool_call_id")?.as_str()?.to_owned();
3338 let args_hash = v.get("args_hash")?.as_str()?.to_owned();
3339 let scope = v.get("scope")?.as_str()?.to_owned();
3340 let changed_at_ms = v.get("changed_at_ms")?.as_u64()?;
3341 let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
3342 let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
3343 let canonical = routine_scope_changed_canonical(
3344 &routine,
3345 &actor_persona,
3346 &conversation_id,
3347 &tool_call_id,
3348 &args_hash,
3349 &scope,
3350 changed_at_ms,
3351 );
3352 if verify(&pk, &canonical, &sig) {
3353 Some(VerifiedRoutineScopeChanged {
3354 routine,
3355 actor_persona,
3356 conversation_id,
3357 tool_call_id,
3358 args_hash,
3359 scope,
3360 changed_at_ms,
3361 signer_public_key: pk,
3362 })
3363 } else {
3364 None
3365 }
3366}
3367
3368#[must_use]
3370pub fn decode_request_id(payload: &[u8]) -> Option<String> {
3371 let v: Value = serde_json::from_slice(payload).ok()?;
3372 Some(v.get("request_id")?.as_str()?.to_owned())
3373}
3374
3375#[must_use]
3379pub fn decode_request_fields(payload: &[u8]) -> Option<(String, String, String)> {
3380 let v: Value = serde_json::from_slice(payload).ok()?;
3381 Some((
3382 v.get("request_id")?.as_str()?.to_owned(),
3383 v.get("tool_name")?.as_str()?.to_owned(),
3384 v.get("args_json")?.as_str()?.to_owned(),
3385 ))
3386}
3387
3388#[must_use]
3395pub fn decode_request_sandbox_mode(payload: &[u8]) -> String {
3396 serde_json::from_slice::<Value>(payload)
3397 .ok()
3398 .and_then(|v| {
3399 v.get("sandbox_mode")
3400 .and_then(Value::as_str)
3401 .map(str::to_owned)
3402 })
3403 .unwrap_or_default()
3404}
3405
3406#[must_use]
3413pub fn decode_request_reason(payload: &[u8]) -> String {
3414 serde_json::from_slice::<Value>(payload)
3415 .ok()
3416 .and_then(|v| v.get("reason").and_then(Value::as_str).map(str::to_owned))
3417 .unwrap_or_default()
3418}
3419
3420#[must_use]
3425pub fn decode_request_title(payload: &[u8]) -> String {
3426 serde_json::from_slice::<Value>(payload)
3427 .ok()
3428 .and_then(|value| {
3429 value
3430 .get("title")
3431 .and_then(Value::as_str)
3432 .map(str::to_owned)
3433 })
3434 .unwrap_or_default()
3435}
3436
3437#[must_use]
3445pub fn decode_request_missing_capabilities(payload: &[u8]) -> Vec<String> {
3446 serde_json::from_slice::<Value>(payload)
3447 .ok()
3448 .and_then(|v| {
3449 v.get("missing_capabilities")
3450 .and_then(Value::as_array)
3451 .map(|a| {
3452 a.iter()
3453 .filter_map(|c| c.as_str().map(str::to_owned))
3454 .collect()
3455 })
3456 })
3457 .unwrap_or_default()
3458}
3459
3460#[must_use]
3468pub fn decode_request_preview_json(payload: &[u8]) -> Option<String> {
3469 let v: Value = serde_json::from_slice(payload).ok()?;
3470 let preview = v.get("preview")?;
3471 if preview.is_null() {
3472 None
3473 } else {
3474 Some(preview.to_string())
3475 }
3476}
3477
3478#[cfg(any(test, feature = "test-util"))]
3485pub mod test_util {
3486 use super::{ApprovalSigner, Envelope, GrantReplayCanonical};
3487
3488 #[must_use]
3490 #[allow(clippy::too_many_arguments)] pub fn grant_replay_payload(
3492 conversation_id: &str,
3493 turn_id: &str,
3494 tool: &str,
3495 grant_ref: &str,
3496 covered_capabilities: &[String],
3497 coverage_hash: &str,
3498 signer: &ApprovalSigner,
3499 ) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
3500 Envelope::seal(
3501 GrantReplayCanonical {
3502 conversation_id,
3503 turn_id,
3504 tool,
3505 grant_ref,
3506 covered_capabilities,
3507 coverage_hash,
3508 },
3509 signer.as_signer(),
3510 )
3511 }
3512}
3513
3514#[cfg(test)]
3515mod tests {
3516 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
3517
3518 use super::*;
3519
3520 #[allow(clippy::too_many_arguments)]
3523 fn response_payload(
3524 request_id: &str,
3525 tool_name: &str,
3526 args_json: &str,
3527 modified_args_json: &str,
3528 approved: bool,
3529 approved_for_session: bool,
3530 covered_capabilities: &[String],
3531 caller: &str,
3532 approver_id: &str,
3533 sandbox_mode: &str,
3534 reason: &str,
3535 injected_context: &str,
3536 conversation_id: &str,
3537 nonce: &str,
3538 signer: &ApprovalSigner,
3539 ) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
3540 super::response_payload(
3541 request_id,
3542 tool_name,
3543 args_json,
3544 modified_args_json,
3545 approved,
3546 approved_for_session,
3547 covered_capabilities,
3548 caller,
3549 approver_id,
3550 sandbox_mode,
3551 reason,
3552 injected_context,
3553 conversation_id,
3554 nonce,
3555 "",
3556 signer,
3557 )
3558 }
3559
3560 #[allow(clippy::too_many_arguments)]
3561 fn verify_wire_response(
3562 request_id: &str,
3563 tool_name: &str,
3564 args_json: &str,
3565 modified_args_json: &str,
3566 approved: bool,
3567 approved_for_session: bool,
3568 covered_capabilities: &[String],
3569 caller: &str,
3570 approver_id: &str,
3571 sandbox_mode: &str,
3572 reason: &str,
3573 injected_context: &str,
3574 conversation_id: &str,
3575 nonce: &str,
3576 signer_pk_hex: &str,
3577 signature_hex: &str,
3578 ) -> bool {
3579 super::verify_wire_response(
3580 request_id,
3581 tool_name,
3582 args_json,
3583 modified_args_json,
3584 approved,
3585 approved_for_session,
3586 covered_capabilities,
3587 caller,
3588 approver_id,
3589 sandbox_mode,
3590 reason,
3591 injected_context,
3592 conversation_id,
3593 nonce,
3594 "",
3595 signer_pk_hex,
3596 signature_hex,
3597 )
3598 }
3599
3600 fn verify_capability<S: std::hash::BuildHasher>(
3601 payload: &[u8],
3602 conversation_id: &str,
3603 consumed: &HashSet<String, S>,
3604 trusted_signers: &[Vec<u8>],
3605 ) -> Option<VerifiedResponse> {
3606 super::verify_capability(payload, conversation_id, "", consumed, trusted_signers)
3607 }
3608
3609 #[test]
3610 fn response_signature_binds_the_turn_occurrence() {
3611 let signer = ApprovalSigner::from_seed(19);
3612 let turn_a = "018f47f0-5f70-7cc5-98df-123456789abc";
3613 let turn_b = "018f47f0-5f70-7cc5-98df-123456789abd";
3614 let (payload, _, _) = super::response_payload(
3615 "call-0",
3616 "paid_fetch",
3617 "{}",
3618 "",
3619 true,
3620 false,
3621 &[],
3622 "persona-1",
3623 "",
3624 "default",
3625 "ok",
3626 "",
3627 "conv-1",
3628 "nonce-1",
3629 turn_a,
3630 &signer,
3631 );
3632 let d = decode_response_full(&payload).expect("current response decodes");
3633 assert_eq!(d.turn_id, turn_a);
3634 assert!(super::verify_wire_response(
3635 &d.request_id,
3636 &d.tool_name,
3637 &d.args_json,
3638 &d.modified_args_json,
3639 d.approved,
3640 d.approved_for_session,
3641 &d.covered_capabilities,
3642 &d.caller,
3643 &d.approver,
3644 &d.sandbox_mode,
3645 &d.reason,
3646 &d.injected_context,
3647 &d.conversation_id,
3648 &d.nonce,
3649 turn_a,
3650 &d.signer_pk_hex,
3651 &d.signature_hex,
3652 ));
3653 assert!(!super::verify_wire_response(
3654 &d.request_id,
3655 &d.tool_name,
3656 &d.args_json,
3657 &d.modified_args_json,
3658 d.approved,
3659 d.approved_for_session,
3660 &d.covered_capabilities,
3661 &d.caller,
3662 &d.approver,
3663 &d.sandbox_mode,
3664 &d.reason,
3665 &d.injected_context,
3666 &d.conversation_id,
3667 &d.nonce,
3668 turn_b,
3669 &d.signer_pk_hex,
3670 &d.signature_hex,
3671 ));
3672
3673 let (historical_payload, _, _) = response_payload(
3674 "call-0",
3675 "paid_fetch",
3676 "{}",
3677 "",
3678 true,
3679 false,
3680 &[],
3681 "persona-1",
3682 "",
3683 "default",
3684 "ok",
3685 "",
3686 "conv-1",
3687 "historical-nonce",
3688 &signer,
3689 );
3690 let historical = decode_response_full(&historical_payload).expect("historical response");
3691 assert!(historical.turn_id.is_empty());
3692 assert!(!super::verify_wire_response(
3693 &historical.request_id,
3694 &historical.tool_name,
3695 &historical.args_json,
3696 &historical.modified_args_json,
3697 historical.approved,
3698 historical.approved_for_session,
3699 &historical.covered_capabilities,
3700 &historical.caller,
3701 &historical.approver,
3702 &historical.sandbox_mode,
3703 &historical.reason,
3704 &historical.injected_context,
3705 &historical.conversation_id,
3706 &historical.nonce,
3707 turn_a,
3708 &historical.signer_pk_hex,
3709 &historical.signature_hex,
3710 ));
3711 }
3712
3713 #[test]
3720 fn loaded_key_signature_verifies_and_from_seed_signature_does_not() {
3721 let key_bytes = [42u8; 32];
3724 let loaded = ApprovalSigner::from_key_bytes(&key_bytes).expect("valid key material");
3725 let forged = ApprovalSigner::from_seed(1);
3726 assert_ne!(
3727 loaded.public_key_bytes(),
3728 forged.public_key_bytes(),
3729 "a loaded key must not collide with the public, deterministic seed-1 key"
3730 );
3731
3732 let (payload, _sig, _pk) = response_payload(
3733 "req-1",
3734 "web_fetch",
3735 r#"{"url":"https://a"}"#,
3736 "",
3737 true,
3738 false,
3739 &[],
3740 "slack:T1:U9",
3741 "",
3742 "workspace-write",
3743 "",
3744 "",
3745 "conv-1",
3746 "nonce-1",
3747 &loaded,
3748 );
3749 let verified = verify_signed_response(&payload).expect("verifies under the loaded key");
3750 assert_eq!(verified.signer_public_key, loaded.public_key_bytes());
3751
3752 let (forged_payload, _sig, _pk) = response_payload(
3756 "req-1",
3757 "web_fetch",
3758 r#"{"url":"https://a"}"#,
3759 "",
3760 true,
3761 false,
3762 &[],
3763 "slack:T1:U9",
3764 "",
3765 "workspace-write",
3766 "",
3767 "",
3768 "conv-1",
3769 "nonce-1",
3770 &forged,
3771 );
3772 let mut v: serde_json::Value = serde_json::from_slice(&forged_payload).unwrap();
3773 v["signed_by"] = serde_json::json!(crate::hex::lower(&loaded.public_key_bytes()));
3774 assert!(
3775 verify_signed_response(v.to_string().as_bytes()).is_none(),
3776 "a from_seed(1) signature must not verify against the loaded key"
3777 );
3778 }
3779
3780 #[test]
3783 fn grant_replay_audit_is_signed_and_tamper_evident() {
3784 let signer = ApprovalSigner::from_seed(9);
3785 let covered = vec!["arbitrary-egress".to_owned()];
3786 let (payload, _sig, _pk) = test_util::grant_replay_payload(
3787 "conv-1",
3788 "turn-7",
3789 "post_summary",
3790 "deadbeef",
3791 &covered,
3792 "sha256:template-abc",
3793 &signer,
3794 );
3795 assert!(verify_grant_replay(&payload), "the genuine record verifies");
3796 for (field, val) in [
3798 ("conversation_id", serde_json::json!("conv-EVIL")),
3799 ("turn_id", serde_json::json!("turn-8")),
3800 ("tool", serde_json::json!("exfiltrate")),
3801 ("grant_ref", serde_json::json!("cafe")),
3802 (
3803 "covered_capabilities",
3804 serde_json::json!(["arbitrary-egress", "mutate-external"]),
3805 ),
3806 ("coverage_hash", serde_json::json!("sha256:other")),
3807 ] {
3808 let mut v: Value = serde_json::from_slice(&payload).unwrap();
3809 v[field] = val;
3810 assert!(
3811 !verify_grant_replay(v.to_string().as_bytes()),
3812 "tampered {field} must fail verification"
3813 );
3814 }
3815 assert!(!verify_grant_replay(b"not json"));
3816 }
3817
3818 #[test]
3821 fn covered_capabilities_are_signed_and_tamper_evident() {
3822 let signer = ApprovalSigner::from_seed(42);
3823 let covered = vec!["arbitrary-egress".to_owned()];
3824 let (payload, _sig, _pk) = response_payload(
3825 "req-1",
3826 "web_fetch",
3827 r#"{"url":"https://a"}"#,
3828 "",
3829 true,
3830 true,
3831 &covered,
3832 "slack:T1:U9",
3833 "",
3834 "workspace-write",
3835 "",
3836 "",
3837 "conv-1",
3838 "nonce-1",
3839 &signer,
3840 );
3841 let verified = verify_signed_response(&payload).expect("verifies untampered");
3842 assert_eq!(verified.covered_capabilities, covered);
3843
3844 let mut v: serde_json::Value = serde_json::from_slice(&payload).unwrap();
3846 v["covered_capabilities"] = serde_json::json!(["arbitrary-egress", "mutate-external"]);
3847 assert!(
3848 verify_signed_response(v.to_string().as_bytes()).is_none(),
3849 "a tampered covered set must fail verification"
3850 );
3851 let mut v: serde_json::Value = serde_json::from_slice(&payload).unwrap();
3853 v["covered_capabilities"] = serde_json::json!([]);
3854 assert!(verify_signed_response(v.to_string().as_bytes()).is_none());
3855 }
3856
3857 #[test]
3860 fn request_missing_capabilities_round_trip() {
3861 let missing = vec!["arbitrary-egress".to_owned(), "mutate-external".to_owned()];
3862 let bytes = request_payload("call-1", "web_fetch", "{}", "", "", &missing, "", "");
3863 assert_eq!(decode_request_missing_capabilities(&bytes), missing);
3864 let bare = request_payload("call-2", "grep", "{}", "", "", &[], "", "");
3865 assert_eq!(
3866 decode_request_missing_capabilities(&bare),
3867 Vec::<String>::new()
3868 );
3869 assert_eq!(
3870 decode_request_missing_capabilities(b"{\"nope\":1}"),
3871 Vec::<String>::new()
3872 );
3873 }
3874
3875 #[test]
3879 fn signed_excision_round_trips_and_is_tamper_evident() {
3880 let signer = ApprovalSigner::from_seed(11);
3881 let (payload, _sig, _pk) = excision_payload(
3882 "conv-1",
3883 EXCISION_SCOPE_CASCADE,
3884 &[17, 23],
3885 "persona-9",
3886 "poisoned fetch",
3887 &signer,
3888 );
3889 let v = verify_signed_excision(&payload).expect("verifies untampered");
3890 assert_eq!(v.conversation_id, "conv-1");
3891 assert!(v.is_cascade());
3892 assert_eq!(v.positions, vec![17, 23]);
3893 assert_eq!(v.requested_by, "persona-9");
3894
3895 for (field, value) in [
3896 ("positions", serde_json::json!([17, 23, 40])),
3897 ("scope", serde_json::json!(EXCISION_SCOPE_SOURCE_ONLY)),
3898 ("conversation_id", serde_json::json!("conv-2")),
3899 ("requested_by", serde_json::json!("someone-else")),
3900 ] {
3901 let mut t: serde_json::Value = serde_json::from_slice(&payload).unwrap();
3902 t[field] = value;
3903 assert!(
3904 verify_signed_excision(t.to_string().as_bytes()).is_none(),
3905 "tampered {field} must fail verification"
3906 );
3907 }
3908 let mut t: serde_json::Value = serde_json::from_slice(&payload).unwrap();
3910 t["scope"] = serde_json::json!("everything");
3911 assert!(verify_signed_excision(t.to_string().as_bytes()).is_none());
3912 assert!(verify_signed_excision(b"not json").is_none());
3914 }
3915
3916 #[test]
3917 fn signed_response_round_trips() {
3918 let signer = ApprovalSigner::from_seed(42);
3919 let (payload, _sig, _pk) = response_payload(
3921 "req-1",
3922 "rm",
3923 r#"{"path":"/etc"}"#,
3924 "",
3925 true,
3926 false,
3927 &[],
3928 "slack:T1:U9",
3929 "",
3930 "workspace-write",
3931 "looks fine",
3932 "",
3933 "conv-1",
3934 "nonce-1",
3935 &signer,
3936 );
3937 let verified =
3938 verify_signed_response(&payload).expect("signature verifies on untampered payload");
3939 assert!(verified.approved);
3940 assert_eq!(verified.reason, "looks fine");
3941 assert_eq!(verified.request_id, "req-1");
3942 assert_eq!(verified.tool_name, "rm");
3943 assert_eq!(verified.args_json, r#"{"path":"/etc"}"#);
3944 assert_eq!(verified.caller, "slack:T1:U9");
3945 assert_eq!(verified.conversation_id, "conv-1");
3946 assert_eq!(verified.nonce, "nonce-1");
3947 assert!(verified.approved);
3948 assert!(!verified.approved_for_session);
3950 }
3951
3952 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"}"#;
3971
3972 #[test]
3973 fn pre_approver_field_payload_still_verifies_unchanged() {
3974 let signer = ApprovalSigner::from_seed(42);
3975 let (request_id, tool_name, args_json, modified_args_json) =
3976 ("req-1", "tool.name", r#"{"a":1}"#, "");
3977 let (approved, approved_for_session) = (true, true);
3978 let covered_capabilities = ["cap.a".to_owned(), "cap.b".to_owned()];
3979 let caller = "persona-caller";
3980 let sandbox_mode = "sandboxed";
3981 let reason = "looks fine";
3982 let injected_context = "";
3983 let conversation_id = "conv-1";
3984 let nonce = "nonce-1";
3985
3986 let pre_approver_payload = PRE_APPROVER_RESPONSE_PAYLOAD.as_bytes();
3995 let expected_pk = signer.public_key_bytes();
3996 let expected_sig = crate::hex::decode(
3997 serde_json::from_slice::<Value>(pre_approver_payload)
3998 .expect("the frozen payload is valid JSON")["signature_hex"]
3999 .as_str()
4000 .expect("the frozen payload carries a signature"),
4001 )
4002 .expect("the frozen signature is valid hex");
4003
4004 let verified = verify_signed_response(pre_approver_payload)
4005 .expect("a pre-#1025 payload must still verify");
4006 assert_eq!(verified.request_id, request_id);
4007 assert_eq!(verified.caller, caller);
4008 assert_eq!(
4009 verified.approver, "",
4010 "no approver field existed on this payload — decodes to empty, not an error"
4011 );
4012
4013 let (regenerated, sig, pk) = response_payload(
4017 request_id,
4018 tool_name,
4019 args_json,
4020 modified_args_json,
4021 approved,
4022 approved_for_session,
4023 &covered_capabilities,
4024 caller,
4025 "",
4026 sandbox_mode,
4027 reason,
4028 injected_context,
4029 conversation_id,
4030 nonce,
4031 &signer,
4032 );
4033 assert_eq!(
4034 regenerated, pre_approver_payload,
4035 "an empty approver must produce byte-identical canonical/payload to before #1025"
4036 );
4037 assert_eq!(
4038 sig, expected_sig,
4039 "an empty approver must sign byte-identically to before #1025"
4040 );
4041 assert_eq!(pk, expected_pk, "public key must be unchanged");
4042 }
4043
4044 #[test]
4045 fn session_response_round_trips_with_caller_binding() {
4046 let signer = ApprovalSigner::from_seed(42);
4047 let (payload, _sig, _pk) = response_payload(
4048 "req-1",
4049 "grep",
4050 r#"{"pattern":"x"}"#,
4051 "",
4052 true,
4053 true,
4054 &[],
4055 "slack:T1:U9",
4056 "",
4057 "workspace-write",
4058 "remember it",
4059 "",
4060 "conv-1",
4061 "nonce-1",
4062 &signer,
4063 );
4064 let verified = verify_signed_response(&payload).expect("session signature verifies");
4065 assert!(verified.approved);
4066 assert!(verified.approved_for_session, "carries session scope");
4067 assert_eq!(verified.caller, "slack:T1:U9");
4068 assert_eq!(verified.tool_name, "grep");
4069 assert!(verified.approved);
4070 }
4071
4072 #[test]
4073 fn tampered_session_or_caller_fails_verification() {
4074 let signer = ApprovalSigner::from_seed(42);
4075 let (payload, _sig, _pk) = response_payload(
4076 "req-1",
4077 "grep",
4078 "{}",
4079 "",
4080 true,
4081 true,
4082 &[],
4083 "slack:T1:U9",
4084 "",
4085 "workspace-write",
4086 "ok",
4087 "",
4088 "conv-1",
4089 "nonce-1",
4090 &signer,
4091 );
4092 for (field, val) in [
4096 ("caller", Value::String("slack:T1:ATTACKER".to_owned())),
4097 ("approved_for_session", Value::Bool(false)),
4098 ("tool_name", Value::String("rm".to_owned())),
4099 ("approved", Value::Bool(false)),
4100 ("args_json", Value::String("EVIL".to_owned())),
4101 (
4105 "modified_args_json",
4106 Value::String(r#"{"path":"/EVIL"}"#.to_owned()),
4107 ),
4108 ("injected_context", Value::String("do EVIL".to_owned())),
4109 (
4110 "sandbox_mode",
4111 Value::String("danger-full-access".to_owned()),
4112 ),
4113 ("conversation_id", Value::String("conv-OTHER".to_owned())),
4114 ("nonce", Value::String("nonce-OTHER".to_owned())),
4115 ] {
4116 let mut v: Value = serde_json::from_slice(&payload).unwrap();
4117 v[field] = val;
4118 assert!(
4119 verify_signed_response(&v.to_string().into_bytes()).is_none(),
4120 "tampering with {field} must fail verification"
4121 );
4122 }
4123 }
4124
4125 #[test]
4136 fn edited_response_binds_proposed_and_carries_modified() {
4137 let signer = ApprovalSigner::from_seed(7);
4138 let proposed = r#"{"path":"/etc/shadow"}"#;
4139 let edited = r#"{"path":"/etc/hostname"}"#;
4140 let (payload, _sig, _pk) = response_payload(
4141 "call-1",
4142 "read_file",
4143 proposed,
4144 edited,
4145 true,
4146 false,
4147 &[],
4148 "slack:T1:U9",
4149 "",
4150 "workspace-write",
4151 "narrowed the path",
4152 "",
4153 "conv-1",
4154 "nonce-1",
4155 &signer,
4156 );
4157 let v = verify_signed_response(&payload).expect("edited approval verifies");
4158 assert_eq!(v.args_json, proposed, "identity binds the proposed args");
4159 assert_eq!(
4160 v.modified_args_json, edited,
4161 "the edit is carried and signed"
4162 );
4163 assert!(
4166 v.authorizes_call("call-1", "read_file", proposed),
4167 "the exact proposed call is authorized"
4168 );
4169 assert!(
4170 !v.authorizes_call("call-1", "read_file", edited),
4171 "the edited args are NOT the identity — authorizes_call binds proposed"
4172 );
4173 }
4174
4175 #[test]
4179 fn unedited_response_carries_empty_edit() {
4180 let signer = ApprovalSigner::from_seed(7);
4181 let proposed = r#"{"path":"/tmp/x"}"#;
4182 let (payload, _sig, _pk) = response_payload(
4183 "call-1",
4184 "read_file",
4185 proposed,
4186 "",
4187 true,
4188 false,
4189 &[],
4190 "slack:T1:U9",
4191 "",
4192 "workspace-write",
4193 "ok",
4194 "",
4195 "conv-1",
4196 "nonce-1",
4197 &signer,
4198 );
4199 let v = verify_signed_response(&payload).expect("verifies");
4200 assert!(v.modified_args_json.is_empty(), "no edit ⇒ empty");
4201 assert!(v.injected_context.is_empty(), "no injected context ⇒ empty");
4202 assert!(v.authorizes_call("call-1", "read_file", proposed));
4203 }
4204
4205 #[test]
4208 fn injected_context_round_trips_and_is_signed() {
4209 let signer = ApprovalSigner::from_seed(7);
4210 let (payload, _sig, _pk) = response_payload(
4211 "call-1",
4212 "shell",
4213 r#"{"cmd":"ls"}"#,
4214 "",
4215 true,
4216 false,
4217 &[],
4218 "slack:T1:U9",
4219 "",
4220 "workspace-write",
4221 "ok",
4222 "only touch files under src/",
4223 "conv-1",
4224 "nonce-1",
4225 &signer,
4226 );
4227 let v = verify_signed_response(&payload).expect("verifies");
4228 assert_eq!(v.injected_context, "only touch files under src/");
4229 }
4230
4231 #[test]
4234 fn mutation_round_trips_and_tamper_fails() {
4235 let signer = ApprovalSigner::from_seed(7);
4236 let (payload, _s, _p) = mutation_payload(
4237 "tool_input_rewrite",
4238 "call-1",
4239 "shell",
4240 "conv-1",
4241 r#"{"cmd":"rm -rf /"}"#,
4242 r#"{"cmd":"rm /tmp/x"}"#,
4243 &signer,
4244 );
4245 assert_eq!(
4246 verify_mutation(&payload).map(|t| (t.0, t.4, t.5)),
4247 Some((
4248 "tool_input_rewrite".to_owned(),
4249 r#"{"cmd":"rm -rf /"}"#.to_owned(),
4250 r#"{"cmd":"rm /tmp/x"}"#.to_owned()
4251 ))
4252 );
4253 for field in [
4254 "kind",
4255 "tool_call_id",
4256 "tool_name",
4257 "conversation_id",
4258 "before",
4259 "after",
4260 ] {
4261 let mut v: Value = serde_json::from_slice(&payload).unwrap();
4262 v[field] = Value::String("EVIL".to_owned());
4263 assert!(
4264 verify_mutation(&v.to_string().into_bytes()).is_none(),
4265 "tampering with {field} must fail"
4266 );
4267 }
4268 }
4269
4270 #[test]
4273 fn deferred_round_trips_and_tamper_fails() {
4274 let signer = ApprovalSigner::from_seed(7);
4275 let (payload, _sig, _pk) = deferred_payload("call-1", "conv-1", "need more info", &signer);
4276 assert_eq!(
4277 verify_deferred(&payload),
4278 Some((
4279 "call-1".to_owned(),
4280 "conv-1".to_owned(),
4281 "need more info".to_owned()
4282 ))
4283 );
4284 for field in ["request_id", "conversation_id", "reason"] {
4285 let mut v: Value = serde_json::from_slice(&payload).unwrap();
4286 v[field] = Value::String("EVIL".to_owned());
4287 assert!(
4288 verify_deferred(&v.to_string().into_bytes()).is_none(),
4289 "tampering with {field} must fail"
4290 );
4291 }
4292 }
4293
4294 #[test]
4295 fn wire_verification_round_trips_and_binds_session_and_caller() {
4296 let signer = ApprovalSigner::from_seed(7);
4297 let (payload, _sig, _pk) = response_payload(
4298 "req-x",
4299 "grep",
4300 r#"{"p":"x"}"#,
4301 "",
4302 true,
4303 true,
4304 &[],
4305 "slack:T1:U9",
4306 "",
4307 "workspace-write",
4308 "go",
4309 "",
4310 "conv-7",
4311 "nonce-7",
4312 &signer,
4313 );
4314 let d = decode_response_full(&payload).expect("decoded payload");
4315 assert!(d.approved_for_session);
4317 assert_eq!(d.caller, "slack:T1:U9");
4318 assert_eq!(d.sandbox_mode, "workspace-write");
4319 assert_eq!(d.conversation_id, "conv-7");
4320 assert_eq!(d.nonce, "nonce-7");
4321 assert!(verify_wire_response(
4322 &d.request_id,
4323 &d.tool_name,
4324 &d.args_json,
4325 "",
4326 d.approved,
4327 d.approved_for_session,
4328 &[],
4329 &d.caller,
4330 &d.approver,
4331 &d.sandbox_mode,
4332 &d.reason,
4333 "",
4334 &d.conversation_id,
4335 &d.nonce,
4336 &d.signer_pk_hex,
4337 &d.signature_hex
4338 ));
4339 assert!(!verify_wire_response(
4342 &d.request_id,
4343 &d.tool_name,
4344 &d.args_json,
4345 "",
4346 d.approved,
4347 d.approved_for_session,
4348 &[],
4349 "slack:T1:ATTACKER",
4350 &d.approver,
4351 &d.sandbox_mode,
4352 &d.reason,
4353 "",
4354 &d.conversation_id,
4355 &d.nonce,
4356 &d.signer_pk_hex,
4357 &d.signature_hex
4358 ));
4359 assert!(!verify_wire_response(
4361 &d.request_id,
4362 &d.tool_name,
4363 r#"{"p":"EVIL"}"#,
4364 "",
4365 d.approved,
4366 d.approved_for_session,
4367 &[],
4368 &d.caller,
4369 &d.approver,
4370 &d.sandbox_mode,
4371 &d.reason,
4372 "",
4373 &d.conversation_id,
4374 &d.nonce,
4375 &d.signer_pk_hex,
4376 &d.signature_hex
4377 ));
4378 assert!(!verify_wire_response(
4381 &d.request_id,
4382 &d.tool_name,
4383 &d.args_json,
4384 "",
4385 d.approved,
4386 d.approved_for_session,
4387 &[],
4388 &d.caller,
4389 &d.approver,
4390 &d.sandbox_mode,
4391 &d.reason,
4392 "",
4393 "conv-OTHER",
4394 &d.nonce,
4395 &d.signer_pk_hex,
4396 &d.signature_hex
4397 ));
4398 }
4399
4400 #[test]
4405 fn approver_is_recoverable_and_distinct_from_caller() {
4406 let signer = ApprovalSigner::from_seed(3);
4407
4408 let (self_approved, ..) = response_payload(
4412 "req-1",
4413 "grep",
4414 r#"{"q":"x"}"#,
4415 "",
4416 true,
4417 false,
4418 &[],
4419 "slack:T1:U9",
4420 "slack:T1:U9",
4421 "workspace-write",
4422 "self-approved",
4423 "",
4424 "conv-1",
4425 "nonce-1",
4426 &signer,
4427 );
4428 let self_decoded = decode_response_full(&self_approved).expect("decodes");
4429 assert_eq!(self_decoded.caller, "slack:T1:U9");
4430 assert_eq!(self_decoded.approver, "slack:T1:U9");
4431 let self_verified = verify_signed_response(&self_approved).expect("verifies");
4432 assert_eq!(self_verified.caller, self_verified.approver);
4433
4434 let (admin_approved, ..) = response_payload(
4440 "req-2",
4441 "rm",
4442 r#"{"path":"/tmp/x"}"#,
4443 "",
4444 true,
4445 false,
4446 &[],
4447 "slack:T1:BENEFICIARY",
4448 "slack:T1:ADMIN",
4449 "workspace-write",
4450 "approved on your behalf",
4451 "",
4452 "conv-2",
4453 "nonce-2",
4454 &signer,
4455 );
4456 let admin_decoded = decode_response_full(&admin_approved).expect("decodes");
4457 assert_eq!(admin_decoded.caller, "slack:T1:BENEFICIARY");
4458 assert_eq!(admin_decoded.approver, "slack:T1:ADMIN");
4459 assert_ne!(
4460 admin_decoded.caller, admin_decoded.approver,
4461 "admin-approves-for-someone-else must decode two DISTINCT identities"
4462 );
4463 let admin_verified = verify_signed_response(&admin_approved).expect("verifies");
4464 assert_eq!(admin_verified.caller, "slack:T1:BENEFICIARY");
4465 assert_eq!(admin_verified.approver, "slack:T1:ADMIN");
4466
4467 let mut v: serde_json::Value = serde_json::from_slice(&admin_approved).unwrap();
4471 v["approver"] = serde_json::json!("slack:T1:ATTACKER");
4472 assert!(
4473 verify_signed_response(v.to_string().as_bytes()).is_none(),
4474 "a tampered approver must fail verification"
4475 );
4476 }
4477
4478 #[test]
4500 fn auto_review_signs_byte_identical_canonical_and_is_distinguishable() {
4501 let signer = ApprovalSigner::from_seed(7);
4502 let (rid, tool, args, caller, mode, conv, nonce) = (
4503 "req-9",
4504 "file_read",
4505 r#"{"path":"a.txt"}"#,
4506 "slack:T1:U9",
4507 "read-only",
4508 "conv-9",
4509 "nonce-9",
4510 );
4511
4512 let shared_reason = auto_review_reason("low");
4516 let human_like = response_payload(
4517 rid,
4518 tool,
4519 args,
4520 "",
4521 true,
4522 false,
4523 &[],
4524 caller,
4525 "",
4526 mode,
4527 &shared_reason,
4528 "",
4529 conv,
4530 nonce,
4531 &signer,
4532 );
4533 let reviewer = response_payload(
4534 rid,
4535 tool,
4536 args,
4537 "",
4538 true,
4539 false,
4540 &[],
4541 caller,
4542 "",
4543 mode,
4544 &shared_reason,
4545 "",
4546 conv,
4547 nonce,
4548 &signer,
4549 );
4550 assert_eq!(human_like.0, reviewer.0, "auto path must be byte-identical");
4551 assert_eq!(human_like.1, reviewer.1, "signature must be identical");
4552
4553 let verified = verify_signed_response(&reviewer.0).expect("auto-review verifies");
4555 assert!(verified.approved);
4556 assert!(
4557 !verified.approved_for_session,
4558 "a machine decision is never remembered per-caller"
4559 );
4560 assert!(
4561 is_auto_review_reason(&verified.reason),
4562 "the signed reason marks this as an auto-approval"
4563 );
4564
4565 let (human_payload, _s, _p) = response_payload(
4568 rid,
4569 tool,
4570 args,
4571 "",
4572 true,
4573 false,
4574 &[],
4575 caller,
4576 "",
4577 mode,
4578 "looks fine",
4579 "",
4580 conv,
4581 nonce,
4582 &signer,
4583 );
4584 let human = verify_signed_response(&human_payload).expect("human verifies");
4585 assert!(!is_auto_review_reason(&human.reason));
4586
4587 let a: Value = serde_json::from_slice(&reviewer.0).unwrap();
4591 let h: Value = serde_json::from_slice(&human_payload).unwrap();
4592 for field in [
4593 "request_id",
4594 "tool_name",
4595 "args_json",
4596 "modified_args_json",
4597 "approved",
4598 "approved_for_session",
4599 "caller",
4600 "sandbox_mode",
4601 "injected_context",
4602 "conversation_id",
4603 "nonce",
4604 ] {
4605 assert_eq!(a[field], h[field], "{field} must match the human payload");
4606 }
4607 assert_ne!(a["reason"], h["reason"], "reason is the sole distinguisher");
4608
4609 let base = &reviewer.0;
4619 let base_sig = &reviewer.1;
4620 for (label, variant) in [
4621 (
4622 "tool",
4623 response_payload(
4624 rid,
4625 "file_write",
4626 args,
4627 "",
4628 true,
4629 false,
4630 &[],
4631 caller,
4632 "",
4633 mode,
4634 &shared_reason,
4635 "",
4636 conv,
4637 nonce,
4638 &signer,
4639 ),
4640 ),
4641 (
4642 "args",
4643 response_payload(
4644 rid,
4645 tool,
4646 r#"{"path":"b.txt"}"#,
4647 "",
4648 true,
4649 false,
4650 &[],
4651 caller,
4652 "",
4653 mode,
4654 &shared_reason,
4655 "",
4656 conv,
4657 nonce,
4658 &signer,
4659 ),
4660 ),
4661 (
4662 "caller",
4663 response_payload(
4664 rid,
4665 tool,
4666 args,
4667 "",
4668 true,
4669 false,
4670 &[],
4671 "slack:T1:UEVIL",
4672 "",
4673 mode,
4674 &shared_reason,
4675 "",
4676 conv,
4677 nonce,
4678 &signer,
4679 ),
4680 ),
4681 (
4682 "request_id",
4683 response_payload(
4684 "req-OTHER",
4685 tool,
4686 args,
4687 "",
4688 true,
4689 false,
4690 &[],
4691 caller,
4692 "",
4693 mode,
4694 &shared_reason,
4695 "",
4696 conv,
4697 nonce,
4698 &signer,
4699 ),
4700 ),
4701 (
4702 "conversation_id",
4703 response_payload(
4704 rid,
4705 tool,
4706 args,
4707 "",
4708 true,
4709 false,
4710 &[],
4711 caller,
4712 "",
4713 mode,
4714 &shared_reason,
4715 "",
4716 "conv-OTHER",
4717 nonce,
4718 &signer,
4719 ),
4720 ),
4721 (
4722 "nonce",
4723 response_payload(
4724 rid,
4725 tool,
4726 args,
4727 "",
4728 true,
4729 false,
4730 &[],
4731 caller,
4732 "",
4733 mode,
4734 &shared_reason,
4735 "",
4736 conv,
4737 "nonce-OTHER",
4738 &signer,
4739 ),
4740 ),
4741 ] {
4742 assert_ne!(
4743 &variant.0, base,
4744 "{label}: a different {label} must change the canonical bytes"
4745 );
4746 assert_ne!(
4747 &variant.1, base_sig,
4748 "{label}: a different {label} must change the signature"
4749 );
4750 }
4751 }
4752
4753 #[test]
4761 fn capability_rejected_across_conversations() {
4762 let signer = ApprovalSigner::from_seed(11);
4763 let (payload, _sig, _pk) = response_payload(
4764 "call-0",
4765 "delete_file",
4766 r#"{"path":"/etc/hosts"}"#,
4767 "",
4768 true,
4769 false,
4770 &[],
4771 "slack:T1:U9",
4772 "",
4773 "workspace-write",
4774 "ok",
4775 "",
4776 "conv-A",
4777 "nonce-A",
4778 &signer,
4779 );
4780 let consumed = HashSet::new();
4781 assert!(
4783 verify_capability(&payload, "conv-A", &consumed, &[signer.public_key_bytes()])
4784 .is_some(),
4785 "a token must verify in the conversation it was signed for"
4786 );
4787 assert!(
4790 verify_capability(&payload, "conv-B", &consumed, &[signer.public_key_bytes()])
4791 .is_none(),
4792 "a token signed for conv-A must be rejected when consumed in conv-B"
4793 );
4794 }
4795
4796 #[test]
4800 fn capability_is_single_use() {
4801 let signer = ApprovalSigner::from_seed(11);
4802 let (payload, _sig, _pk) = response_payload(
4803 "call-0",
4804 "delete_file",
4805 r#"{"path":"/etc/hosts"}"#,
4806 "",
4807 true,
4808 false,
4809 &[],
4810 "slack:T1:U9",
4811 "",
4812 "workspace-write",
4813 "ok",
4814 "",
4815 "conv-A",
4816 "nonce-A",
4817 &signer,
4818 );
4819 let mut consumed = HashSet::new();
4820 let v = verify_capability(&payload, "conv-A", &consumed, &[signer.public_key_bytes()])
4822 .expect("first use honored");
4823 assert_eq!(v.nonce, "nonce-A");
4824 consumed.insert(v.nonce.clone());
4825 assert!(
4827 verify_capability(&payload, "conv-A", &consumed, &[signer.public_key_bytes()])
4828 .is_none(),
4829 "a spent token must be rejected on re-presentation"
4830 );
4831 }
4832
4833 #[test]
4838 fn capability_authorizes_only_matching_args() {
4839 let signer = ApprovalSigner::from_seed(11);
4840 let (payload, _sig, _pk) = response_payload(
4841 "call-0",
4842 "delete_file",
4843 r#"{"path":"/tmp/scratch"}"#,
4844 "",
4845 true,
4846 false,
4847 &[],
4848 "slack:T1:U9",
4849 "",
4850 "workspace-write",
4851 "ok",
4852 "",
4853 "conv-A",
4854 "nonce-A",
4855 &signer,
4856 );
4857 let consumed = HashSet::new();
4858 let v = verify_capability(&payload, "conv-A", &consumed, &[signer.public_key_bytes()])
4859 .expect("verifies in conv-A");
4860 assert!(
4862 !v.authorizes_call("call-0", "delete_file", r#"{"path":"/etc/hosts"}"#),
4863 "a token must not authorize a call with different args"
4864 );
4865 assert!(
4867 !v.authorizes_call("call-0", "shell", r#"{"path":"/tmp/scratch"}"#),
4868 "a token must not authorize a different tool"
4869 );
4870 assert!(
4872 v.authorizes_call("call-0", "delete_file", r#"{"path":"/tmp/scratch"}"#),
4873 "the exact signed call must be authorized"
4874 );
4875 }
4876
4877 #[test]
4879 fn denied_capability_authorizes_nothing() {
4880 let signer = ApprovalSigner::from_seed(11);
4881 let (payload, _sig, _pk) = response_payload(
4882 "call-0",
4883 "delete_file",
4884 "{}",
4885 "",
4886 false,
4887 false,
4888 &[],
4889 "slack:T1:U9",
4890 "",
4891 "workspace-write",
4892 "deny",
4893 "",
4894 "conv-A",
4895 "nonce-A",
4896 &signer,
4897 );
4898 let consumed = HashSet::new();
4899 let v = verify_capability(&payload, "conv-A", &consumed, &[signer.public_key_bytes()])
4900 .expect("verifies");
4901 assert!(!v.approved);
4902 assert!(
4903 !v.authorizes_call("call-0", "delete_file", "{}"),
4904 "a denied token authorizes nothing even on an exact identity match"
4905 );
4906 }
4907
4908 #[test]
4909 fn request_payload_round_trips_id() {
4910 let bytes = request_payload(
4911 "call-7",
4912 "rm",
4913 r#"{"path":"/etc"}"#,
4914 "workspace-write",
4915 "",
4916 &[],
4917 "",
4918 "Remove files",
4919 );
4920 assert_eq!(decode_request_id(&bytes).as_deref(), Some("call-7"));
4921 assert_eq!(decode_request_reason(&bytes), "");
4923 assert_eq!(decode_request_title(&bytes), "Remove files");
4924 }
4925
4926 #[test]
4927 fn request_payload_carries_override_reason() {
4928 let reason = "lethal-trifecta / Rule-of-Two: untrusted content is in context";
4931 let bytes = request_payload(
4932 "call-9",
4933 "web_fetch",
4934 r#"{"url":"https://x"}"#,
4935 "",
4936 reason,
4937 &[],
4938 "",
4939 "Fetch page",
4940 );
4941 assert_eq!(decode_request_reason(&bytes), reason);
4942 assert_eq!(decode_request_title(&bytes), "Fetch page");
4943 assert_eq!(decode_request_id(&bytes).as_deref(), Some("call-9"));
4945 assert_eq!(
4946 decode_request_fields(&bytes),
4947 Some((
4948 "call-9".to_owned(),
4949 "web_fetch".to_owned(),
4950 r#"{"url":"https://x"}"#.to_owned()
4951 ))
4952 );
4953 }
4954
4955 #[test]
4959 fn request_preview_json_round_trips() {
4960 let preview =
4961 r#"{"prompt_text":"hi","next_fires":[],"zone_name":"UTC","zone_is_fallback":true}"#;
4962 let bytes = request_payload(
4963 "call-10",
4964 "routine_create",
4965 "{}",
4966 "",
4967 "",
4968 &[],
4969 preview,
4970 "Create routine",
4971 );
4972 let decoded = decode_request_preview_json(&bytes).expect("preview present");
4973 let expected: Value = serde_json::from_str(preview).unwrap();
4976 let actual: Value = serde_json::from_str(&decoded).unwrap();
4977 assert_eq!(actual, expected);
4978 }
4979
4980 #[test]
4981 fn request_preview_json_is_absent_when_empty_or_missing() {
4982 let bytes = request_payload("call-11", "grep", "{}", "", "", &[], "", "");
4983 assert_eq!(decode_request_preview_json(&bytes), None);
4984 assert_eq!(decode_request_preview_json(b"{\"nope\":1}"), None);
4985 assert_eq!(decode_request_title(b"{\"nope\":1}"), "");
4986 }
4987
4988 #[test]
5007 fn receipt_payload_pins_v3_canonical_shape() {
5008 let signer = ApprovalSigner::from_seed(99);
5009 let (reference, amount, currency, recipient, method, timestamp) = (
5010 "tx-abc",
5011 "0.01",
5012 "USDC",
5013 "0xrecipient",
5014 "tempo",
5015 "2026-06-02T00:00:00Z",
5016 );
5017 let (kind, tool_call_id, approval_pos, approved_args_hash, subject) = (
5018 "outbound_payment_receipt",
5019 "call-1",
5020 "42",
5021 "abcd1234",
5022 "conv-xyz",
5023 );
5024 let (payer_kind, paying_account) = ("linked_wallet", "0xpayer");
5025
5026 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"}"#;
5035 let expected_sig = signer.sign(expected_canonical.as_bytes());
5036 let expected_pk = signer.public_key_bytes();
5037 let expected_full = format!(
5038 r#"{{{expected_body},"signed_by":"{pk}","signature_hex":"{sig}"}}"#,
5039 expected_body = expected_canonical
5040 .trim_start_matches('{')
5041 .trim_end_matches('}'),
5042 pk = crate::hex::lower(&expected_pk),
5043 sig = crate::hex::lower(&expected_sig),
5044 );
5045
5046 let (payload, sig, pk) = receipt_payload(
5047 &ReceiptPayload {
5048 kind,
5049 reference,
5050 amount,
5051 currency,
5052 recipient,
5053 method,
5054 timestamp,
5055 tool_call_id,
5056 approval_pos,
5057 approved_args_hash,
5058 subject,
5059 payer_kind,
5060 paying_account,
5061 },
5062 &signer,
5063 );
5064
5065 assert_eq!(
5066 String::from_utf8(payload).unwrap(),
5067 expected_full,
5068 "v3 receipt payload must be byte-identical to the pinned v3 shape"
5069 );
5070 assert_eq!(
5071 sig, expected_sig,
5072 "signature must match the pinned v3 shape"
5073 );
5074 assert_eq!(pk, expected_pk, "public key must be unchanged");
5075 }
5076
5077 #[test]
5089 fn receipt_sign_and_verify_share_one_canonical_source() {
5090 let signer = ApprovalSigner::from_seed(99);
5091 let trusted = vec![signer.public_key_bytes()];
5092 let fields = ReceiptPayload {
5093 kind: "outbound_payment_receipt",
5094 reference: "tx-abc",
5095 amount: "0.01",
5096 currency: "USDC",
5097 recipient: "0xrecipient",
5098 method: "tempo",
5099 timestamp: "2026-06-02T00:00:00Z",
5100 tool_call_id: "call-1",
5101 approval_pos: "42",
5102 approved_args_hash: "abcd1234",
5103 subject: "conv-xyz",
5104 payer_kind: "linked_wallet",
5105 paying_account: "0xpayer",
5106 };
5107
5108 let signed_bytes = canonical_bytes(&fields.canonical_json_v3());
5110 let expected_sig = signer.sign(&signed_bytes);
5111 let (_payload, sig, _pk) = receipt_payload(&fields, &signer);
5112 assert_eq!(
5113 sig, expected_sig,
5114 "receipt_payload must sign exactly ReceiptPayload::canonical_json_v3"
5115 );
5116
5117 let (payload, _sig, _pk) = receipt_payload(&fields, &signer);
5120 let verified = verify_signed_receipt(&payload, &trusted).expect("verifies");
5121 let verified_fields = ReceiptPayload {
5122 kind: &verified.kind,
5123 reference: &verified.reference,
5124 amount: &verified.amount,
5125 currency: &verified.currency,
5126 recipient: &verified.recipient,
5127 method: &verified.method,
5128 timestamp: &verified.timestamp,
5129 tool_call_id: &verified.tool_call_id,
5130 approval_pos: &verified.approval_pos,
5131 approved_args_hash: &verified.approved_args_hash,
5132 subject: &verified.subject,
5133 payer_kind: &verified.payer_kind,
5134 paying_account: &verified.paying_account,
5135 };
5136 let verified_canonical = canonical_bytes(&verified_fields.canonical_json_v3());
5137 assert_eq!(
5138 verified_canonical, signed_bytes,
5139 "verify path must derive canonical JSON from the same single source"
5140 );
5141 }
5142
5143 #[test]
5144 fn crypto_receipt_payload_signs_and_verifies() {
5145 let signer = ApprovalSigner::from_seed(99);
5146 let trusted = vec![signer.public_key_bytes()];
5147 let (payload, _sig, _pk) = receipt_payload(
5148 &ReceiptPayload {
5149 kind: "outbound_payment_receipt",
5150 reference: "tx-abc",
5151 amount: "0.01",
5152 currency: "USDC",
5153 recipient: "0xrecipient",
5154 method: "tempo",
5155 timestamp: "2026-06-02T00:00:00Z",
5156 tool_call_id: "call-1",
5157 approval_pos: "42",
5158 approved_args_hash: "abcd1234",
5159 subject: "conv-xyz",
5160 payer_kind: "linked_wallet",
5161 paying_account: "0xpayer",
5162 },
5163 &signer,
5164 );
5165 let verified = verify_signed_receipt(&payload, &trusted)
5166 .expect("signature verifies on untampered receipt");
5167 assert_eq!(verified.reference, "tx-abc");
5168 assert_eq!(verified.amount, "0.01");
5169 assert_eq!(verified.currency, "USDC");
5170 assert_eq!(verified.recipient, "0xrecipient");
5171 assert_eq!(verified.method, "tempo");
5172 assert_eq!(verified.timestamp, "2026-06-02T00:00:00Z");
5173 assert_eq!(verified.version, RECEIPT_VERSION);
5176 assert_eq!(verified.kind, "outbound_payment_receipt");
5177 assert_eq!(verified.tool_call_id, "call-1");
5178 assert_eq!(verified.approval_pos, "42");
5179 assert_eq!(verified.approved_args_hash, "abcd1234");
5180 assert_eq!(verified.subject, "conv-xyz");
5181 assert_eq!(verified.payer_kind, "linked_wallet");
5183 assert_eq!(verified.paying_account, "0xpayer");
5184 }
5185
5186 #[test]
5187 fn tampered_receipt_fails_verification() {
5188 let signer = ApprovalSigner::from_seed(99);
5189 let trusted = vec![signer.public_key_bytes()];
5190 let (payload, _sig, _pk) = receipt_payload(
5191 &ReceiptPayload {
5192 kind: "outbound_payment_receipt",
5193 reference: "tx-abc",
5194 amount: "0.01",
5195 currency: "USDC",
5196 recipient: "0xrecipient",
5197 method: "tempo",
5198 timestamp: "2026-06-02T00:00:00Z",
5199 tool_call_id: "call-1",
5200 approval_pos: "42",
5201 approved_args_hash: "abcd1234",
5202 subject: "conv-xyz",
5203 payer_kind: "linked_wallet",
5204 paying_account: "0xpayer",
5205 },
5206 &signer,
5207 );
5208 let mut v: Value = serde_json::from_slice(&payload).unwrap();
5210 v["amount"] = Value::String("9999.00".to_owned());
5211 let tampered = v.to_string().into_bytes();
5212 assert!(verify_signed_receipt(&tampered, &trusted).is_none());
5213 }
5214
5215 #[test]
5216 fn tampered_receipt_binding_field_fails_verification() {
5217 let signer = ApprovalSigner::from_seed(99);
5218 let trusted = vec![signer.public_key_bytes()];
5219 let (payload, _sig, _pk) = receipt_payload(
5220 &ReceiptPayload {
5221 kind: "outbound_payment_receipt",
5222 reference: "tx-abc",
5223 amount: "0.01",
5224 currency: "USDC",
5225 recipient: "0xrecipient",
5226 method: "tempo",
5227 timestamp: "2026-06-02T00:00:00Z",
5228 tool_call_id: "call-1",
5229 approval_pos: "42",
5230 approved_args_hash: "abcd1234",
5231 subject: "conv-xyz",
5232 payer_kind: "linked_wallet",
5233 paying_account: "0xpayer",
5234 },
5235 &signer,
5236 );
5237 let mut v: Value = serde_json::from_slice(&payload).unwrap();
5240 v["approval_pos"] = Value::String("7".to_owned());
5241 let tampered = v.to_string().into_bytes();
5242 assert!(verify_signed_receipt(&tampered, &trusted).is_none());
5243
5244 let mut v: Value = serde_json::from_slice(&payload).unwrap();
5248 v["kind"] = Value::String("payment_receipt".to_owned());
5249 let refiled = v.to_string().into_bytes();
5250 assert!(verify_signed_receipt(&refiled, &trusted).is_none());
5251
5252 let mut v: Value = serde_json::from_slice(&payload).unwrap();
5257 v["payer_kind"] = Value::String("deployment".to_owned());
5258 let repayered = v.to_string().into_bytes();
5259 assert!(verify_signed_receipt(&repayered, &trusted).is_none());
5260 }
5261
5262 #[test]
5268 fn receipt_from_non_allowlisted_signer_is_rejected() {
5269 let trusted_signer = ApprovalSigner::from_seed(99);
5270 let attacker_signer = ApprovalSigner::from_seed(31337);
5271 let fields = ReceiptPayload {
5272 kind: "outbound_payment_receipt",
5273 reference: "tx-forged",
5274 amount: "100.00",
5275 currency: "USDC",
5276 recipient: "0xattacker",
5277 method: "tempo",
5278 timestamp: "2026-06-02T00:00:00Z",
5279 tool_call_id: "call-1",
5280 approval_pos: "42",
5281 approved_args_hash: "abcd1234",
5282 subject: "conv-xyz",
5283 payer_kind: "linked_wallet",
5284 paying_account: "0xpayer",
5285 };
5286 let (payload, _sig, _pk) = receipt_payload(&fields, &attacker_signer);
5289
5290 let trusted = vec![trusted_signer.public_key_bytes()];
5293 assert!(
5294 verify_signed_receipt(&payload, &trusted).is_none(),
5295 "a receipt signed by a non-allow-listed key must not verify"
5296 );
5297
5298 let trusted_plus_attacker = vec![
5302 trusted_signer.public_key_bytes(),
5303 attacker_signer.public_key_bytes(),
5304 ];
5305 assert!(
5306 verify_signed_receipt(&payload, &trusted_plus_attacker).is_some(),
5307 "the same receipt must verify once its signer is allow-listed"
5308 );
5309
5310 assert!(verify_signed_receipt(&payload, &[]).is_none());
5313 }
5314
5315 #[test]
5322 fn grant_replay_from_non_allowlisted_signer_is_rejected() {
5323 let trusted_signer = ApprovalSigner::from_seed(99);
5324 let attacker_signer = ApprovalSigner::from_seed(31337);
5325 let covered = vec!["arbitrary-egress".to_owned()];
5326 let (payload, _sig, _pk) = test_util::grant_replay_payload(
5328 "conv-1",
5329 "turn-7",
5330 "post_summary",
5331 "deadbeef",
5332 &covered,
5333 "sha256:template-abc",
5334 &attacker_signer,
5335 );
5336
5337 assert!(
5340 verify_grant_replay(&payload),
5341 "the unpinned verifier trusts any self-consistent signature"
5342 );
5343
5344 let trusted = vec![trusted_signer.public_key_bytes()];
5346 assert!(
5347 !verify_grant_replay_pinned(&payload, &trusted),
5348 "a grant_replay signed by a non-allow-listed key must not verify"
5349 );
5350
5351 let trusted_plus_attacker = vec![
5354 trusted_signer.public_key_bytes(),
5355 attacker_signer.public_key_bytes(),
5356 ];
5357 assert!(
5358 verify_grant_replay_pinned(&payload, &trusted_plus_attacker),
5359 "the same record must verify once its signer is allow-listed"
5360 );
5361
5362 assert!(!verify_grant_replay_pinned(&payload, &[]));
5364 }
5365
5366 #[test]
5373 fn signed_response_from_non_allowlisted_signer_is_rejected() {
5374 let trusted_signer = ApprovalSigner::from_seed(99);
5375 let attacker_signer = ApprovalSigner::from_seed(31337);
5376 let (payload, _sig, _pk) = response_payload(
5378 "call-0",
5379 "delete_file",
5380 r#"{"path":"/etc/hosts"}"#,
5381 "",
5382 true,
5383 false,
5384 &[],
5385 "slack:T1:U9",
5386 "slack:T1:U9",
5387 "workspace-write",
5388 "ok",
5389 "",
5390 "conv-A",
5391 "nonce-A",
5392 &attacker_signer,
5393 );
5394
5395 assert!(
5397 verify_signed_response(&payload).is_some(),
5398 "the unpinned verifier trusts any self-consistent signature"
5399 );
5400
5401 let trusted = vec![trusted_signer.public_key_bytes()];
5404 let consumed = HashSet::new();
5405 assert!(
5406 verify_signed_response_pinned(&payload, &trusted).is_none(),
5407 "an approval_response signed by a non-allow-listed key must not verify"
5408 );
5409 assert!(
5410 verify_capability(&payload, "conv-A", &consumed, &trusted).is_none(),
5411 "the capability gate must reject a non-allow-listed signer"
5412 );
5413
5414 let trusted_plus_attacker = vec![
5416 trusted_signer.public_key_bytes(),
5417 attacker_signer.public_key_bytes(),
5418 ];
5419 assert!(
5420 verify_signed_response_pinned(&payload, &trusted_plus_attacker).is_some(),
5421 "the same response must verify once its signer is allow-listed"
5422 );
5423 assert!(
5424 verify_capability(&payload, "conv-A", &consumed, &trusted_plus_attacker).is_some(),
5425 "the capability gate honors an allow-listed signer"
5426 );
5427
5428 assert!(verify_signed_response_pinned(&payload, &[]).is_none());
5430 assert!(verify_capability(&payload, "conv-A", &consumed, &[]).is_none());
5431 }
5432
5433 #[test]
5437 fn legacy_v1_receipt_still_verifies() {
5438 let signer = ApprovalSigner::from_seed(99);
5439 let trusted = vec![signer.public_key_bytes()];
5440 let verified = verify_signed_receipt(GOLDEN_V1_RECEIPT.as_bytes(), &trusted)
5441 .expect("a valid v1 receipt still verifies");
5442 assert_eq!(verified.version, 1);
5443 assert_eq!(verified.reference, "tx-old");
5444 assert!(verified.kind.is_empty());
5445 assert!(verified.tool_call_id.is_empty());
5446 assert!(verified.approval_pos.is_empty());
5447 assert!(verified.subject.is_empty());
5448 assert!(verified.payer_kind.is_empty());
5451 assert!(verified.paying_account.is_empty());
5452 }
5453
5454 #[test]
5462 fn injected_version_on_v1_signed_receipt_fails() {
5463 let signer = ApprovalSigner::from_seed(99);
5464 let trusted = vec![signer.public_key_bytes()];
5465 let full: Value =
5466 serde_json::from_str(GOLDEN_V1_RECEIPT).expect("the frozen v1 receipt is valid JSON");
5467
5468 for injected in [
5475 Value::from(7_u64),
5476 Value::from(0_u64),
5477 Value::from(1_u64),
5478 Value::from(2.0_f64),
5479 Value::String("2".to_owned()),
5480 Value::Null,
5481 Value::from(-1_i64),
5482 ] {
5483 let mut tampered = full.clone();
5484 tampered["version"] = injected;
5485 assert!(
5486 verify_signed_receipt(&tampered.to_string().into_bytes(), &trusted).is_none(),
5487 "a writer-chosen version key must never verify"
5488 );
5489 }
5490 assert!(verify_signed_receipt(GOLDEN_V1_RECEIPT.as_bytes(), &trusted).is_some());
5492 }
5493
5494 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"}"#;
5500
5501 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"}"#;
5523
5524 const fn golden_v2_receipt_fields() -> ReceiptPayload<'static> {
5529 ReceiptPayload {
5530 kind: "outbound_payment_receipt",
5531 reference: "tx-frozen-v2",
5532 amount: "10000",
5533 currency: "0xToken",
5534 recipient: "0xrecipient",
5535 method: "tempo",
5536 timestamp: "2026-06-02T00:00:00Z",
5537 tool_call_id: "call-frozen",
5538 approval_pos: "42",
5539 approved_args_hash: "abcd1234",
5540 subject: "conv-frozen",
5541 payer_kind: "",
5542 paying_account: "",
5543 }
5544 }
5545
5546 #[test]
5559 fn frozen_v2_receipts_survive_a_later_version_bump() {
5560 let signer = ApprovalSigner::from_seed(99);
5561 let trusted = vec![signer.public_key_bytes()];
5562
5563 let verified = verify_signed_receipt(GOLDEN_V2_RECEIPT.as_bytes(), &trusted)
5564 .expect("the frozen v2 canonical must keep verifying receipts already signed under it");
5565
5566 assert_eq!(verified.version, 2);
5569 assert_eq!(verified.reference, "tx-frozen-v2");
5570 assert_eq!(verified.amount, "10000");
5571 assert_eq!(verified.currency, "0xToken");
5572 assert_eq!(verified.recipient, "0xrecipient");
5573 assert_eq!(verified.method, "tempo");
5574 assert_eq!(verified.timestamp, "2026-06-02T00:00:00Z");
5575 assert_eq!(verified.kind, "outbound_payment_receipt");
5578 assert_eq!(verified.tool_call_id, "call-frozen");
5579 assert_eq!(verified.approval_pos, "42");
5580 assert_eq!(verified.approved_args_hash, "abcd1234");
5581 assert_eq!(verified.subject, "conv-frozen");
5582 assert!(verified.payer_kind.is_empty());
5585 assert!(verified.paying_account.is_empty());
5586 assert_eq!(verified.signer_public_key, signer.public_key_bytes());
5587
5588 let mut relabelled: Value = serde_json::from_str(GOLDEN_V2_RECEIPT).unwrap();
5594 for unknown in [Value::from(4_u64), Value::from(5_u64)] {
5595 relabelled["version"] = unknown;
5596 assert!(
5597 verify_signed_receipt(&relabelled.to_string().into_bytes(), &trusted).is_none(),
5598 "a version with no frozen canonical must never be guessed at"
5599 );
5600 }
5601 }
5602
5603 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"}"#;
5607
5608 const fn golden_v3_receipt_fields() -> ReceiptPayload<'static> {
5610 ReceiptPayload {
5611 kind: "outbound_payment_receipt",
5612 reference: "tx-frozen-v3",
5613 amount: "10000",
5614 currency: "0xToken",
5615 recipient: "0xrecipient",
5616 method: "tempo",
5617 timestamp: "2026-06-02T00:00:00Z",
5618 tool_call_id: "call-frozen",
5619 approval_pos: "42",
5620 approved_args_hash: "abcd1234",
5621 subject: "conv-frozen",
5622 payer_kind: "linked_wallet",
5623 paying_account: "0xpayer-frozen",
5624 }
5625 }
5626
5627 #[test]
5630 fn frozen_v3_receipts_survive_a_later_version_bump() {
5631 let signer = ApprovalSigner::from_seed(99);
5632 let trusted = vec![signer.public_key_bytes()];
5633
5634 let verified = verify_signed_receipt(GOLDEN_V3_RECEIPT.as_bytes(), &trusted)
5635 .expect("the frozen v3 canonical must keep verifying receipts already signed under it");
5636
5637 assert_eq!(verified.version, 3);
5638 assert_eq!(verified.reference, "tx-frozen-v3");
5639 assert_eq!(verified.kind, "outbound_payment_receipt");
5640 assert_eq!(verified.tool_call_id, "call-frozen");
5641 assert_eq!(verified.approval_pos, "42");
5642 assert_eq!(verified.approved_args_hash, "abcd1234");
5643 assert_eq!(verified.subject, "conv-frozen");
5644 assert_eq!(verified.payer_kind, "linked_wallet");
5645 assert_eq!(verified.paying_account, "0xpayer-frozen");
5646 assert_eq!(verified.signer_public_key, signer.public_key_bytes());
5647 }
5648
5649 #[test]
5656 fn the_frozen_v2_fixture_reproduces_via_its_own_canonical() {
5657 let signer = ApprovalSigner::from_seed(99);
5658 let fields = golden_v2_receipt_fields();
5659 let canonical = ReceiptSchema::V2.canonical(&fields);
5660 let sig = signer.sign(&canonical);
5661 let full = format!(
5662 r#"{{{body},"signed_by":"{pk}","signature_hex":"{sig}"}}"#,
5663 body = String::from_utf8(canonical)
5664 .unwrap()
5665 .trim_start_matches('{')
5666 .trim_end_matches('}'),
5667 pk = crate::hex::lower(&signer.public_key_bytes()),
5668 sig = crate::hex::lower(&sig),
5669 );
5670 assert_eq!(
5671 full, GOLDEN_V2_RECEIPT,
5672 "the checked-in v2 fixture must be exactly what the frozen v2 canonical produces"
5673 );
5674 }
5675
5676 #[test]
5687 fn the_frozen_v3_fixture_is_what_todays_writer_signs() {
5688 let signer = ApprovalSigner::from_seed(99);
5689 let (payload, _sig, _pk) = receipt_payload(&golden_v3_receipt_fields(), &signer);
5690 assert_eq!(
5691 String::from_utf8(payload).unwrap(),
5692 GOLDEN_V3_RECEIPT,
5693 "the checked-in v3 fixture must be exactly what receipt_payload emits today"
5694 );
5695 }
5696}
5697
5698#[cfg(test)]
5699mod resolve_token_tests {
5700 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
5701 use super::*;
5702 const TURN: &str = "018f47f0-5f70-7cc5-98df-123456789abc";
5703
5704 #[test]
5705 fn resolve_token_verifies_for_its_own_request_and_conversation() {
5706 let signer = ApprovalSigner::from_seed(11);
5707 let token = mint_resolve_token(TURN, "call-1", "conv-a", 1_000, &signer);
5708 assert!(verify_resolve_token(
5709 &token, TURN, "call-1", "conv-a", 1_000, &signer
5710 ));
5711 }
5712
5713 #[test]
5714 fn resolve_token_rejects_a_different_request_id() {
5715 let signer = ApprovalSigner::from_seed(11);
5716 let token = mint_resolve_token(TURN, "call-1", "conv-a", 1_000, &signer);
5717 assert!(!verify_resolve_token(
5718 &token, TURN, "call-2", "conv-a", 1_000, &signer
5719 ));
5720 }
5721
5722 #[test]
5723 fn pre_deploy_token_without_turn_id_fails_closed() {
5724 let signer = ApprovalSigner::from_seed(11);
5725 let old_body = serde_json::json!({
5726 "request_id": "call-0",
5727 "conversation_id": "conv-a",
5728 "minted_at_ms": 1_000,
5729 });
5730 let old_signature = signer.sign(&canonical_bytes(&old_body));
5731 let old_token = crate::hex::lower(
5732 &serde_json::to_vec(&serde_json::json!({
5733 "request_id": "call-0",
5734 "conversation_id": "conv-a",
5735 "minted_at_ms": 1_000,
5736 "signature_hex": crate::hex::lower(&old_signature),
5737 }))
5738 .expect("old token serializes"),
5739 );
5740 assert!(!verify_resolve_token(
5741 &old_token, "turn-new", "call-0", "conv-a", 1_000, &signer,
5742 ));
5743 }
5744
5745 #[test]
5746 fn resolve_token_rejects_a_different_conversation() {
5747 let signer = ApprovalSigner::from_seed(11);
5748 let token = mint_resolve_token(TURN, "call-1", "conv-a", 1_000, &signer);
5749 assert!(!verify_resolve_token(
5750 &token, TURN, "call-1", "conv-b", 1_000, &signer
5751 ));
5752 }
5753
5754 #[test]
5755 fn resolve_token_rejects_wrong_signer() {
5756 let signer = ApprovalSigner::from_seed(11);
5757 let other = ApprovalSigner::from_seed(12);
5758 let token = mint_resolve_token(TURN, "call-1", "conv-a", 1_000, &signer);
5759 assert!(!verify_resolve_token(
5760 &token, TURN, "call-1", "conv-a", 1_000, &other
5761 ));
5762 }
5763
5764 #[test]
5765 fn resolve_token_rejects_after_ttl_elapses() {
5766 let signer = ApprovalSigner::from_seed(11);
5767 let token = mint_resolve_token(TURN, "call-1", "conv-a", 0, &signer);
5768 assert!(verify_resolve_token(
5769 &token,
5770 TURN,
5771 "call-1",
5772 "conv-a",
5773 RESOLVE_TOKEN_TTL_MS,
5774 &signer
5775 ));
5776 assert!(!verify_resolve_token(
5777 &token,
5778 TURN,
5779 "call-1",
5780 "conv-a",
5781 RESOLVE_TOKEN_TTL_MS + 1,
5782 &signer
5783 ));
5784 }
5785
5786 #[test]
5787 fn resolve_token_rejects_garbage() {
5788 let signer = ApprovalSigner::from_seed(11);
5789 assert!(!verify_resolve_token(
5790 "not-hex", TURN, "call-1", "conv-a", 0, &signer
5791 ));
5792 assert!(!verify_resolve_token(
5793 "", TURN, "call-1", "conv-a", 0, &signer
5794 ));
5795 }
5796}
5797
5798#[cfg(test)]
5799mod admin_model_change_tests {
5800 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
5801 use super::*;
5802
5803 #[test]
5804 fn admin_model_change_round_trips_and_is_tamper_evident() {
5805 let signer = ApprovalSigner::from_seed(31);
5806 let (payload, _sig, _pk) = admin_model_change_payload(
5807 "team-a",
5808 "vertex",
5809 "old-model",
5810 "vertex",
5811 "new-model",
5812 1_000,
5813 &signer,
5814 );
5815 let verified = verify_admin_model_change(&payload).expect("genuine record verifies");
5816 assert_eq!(verified.principal, "team-a");
5817 assert_eq!(verified.new_model, "new-model");
5818 assert_eq!(verified.signer_public_key, signer.public_key_bytes());
5819
5820 for (field, val) in [
5821 ("principal", serde_json::json!("attacker")),
5822 ("new_model", serde_json::json!("evil-model")),
5823 ("new_provider", serde_json::json!("evil-provider")),
5824 ] {
5825 let mut v: Value = serde_json::from_slice(&payload).unwrap();
5826 v[field] = val;
5827 assert!(
5828 verify_admin_model_change(v.to_string().as_bytes()).is_none(),
5829 "tampered {field} must fail verification"
5830 );
5831 }
5832 }
5833}
5834
5835#[cfg(test)]
5836mod routine_created_tests {
5837 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
5838 use super::*;
5839
5840 #[test]
5843 fn routine_created_round_trips_and_is_tamper_evident() {
5844 let signer = ApprovalSigner::from_seed(41);
5845 let (payload, _sig, _pk) = routine_created_payload(
5846 "daily-standup-a1b2",
5847 "persona-1",
5848 "conv-1",
5849 "call-1",
5850 "hash-1",
5851 1_000,
5852 &signer,
5853 );
5854 let verified = verify_routine_created(&payload).expect("genuine record verifies");
5855 assert_eq!(verified.routine, "daily-standup-a1b2");
5856 assert_eq!(verified.creator_persona, "persona-1");
5857 assert_eq!(verified.conversation_id, "conv-1");
5858 assert_eq!(verified.tool_call_id, "call-1");
5859 assert_eq!(verified.args_hash, "hash-1");
5860 assert_eq!(verified.created_at_ms, 1_000);
5861 assert_eq!(verified.signer_public_key, signer.public_key_bytes());
5862
5863 for (field, val) in [
5864 ("routine", serde_json::json!("someone-elses-routine")),
5865 ("creator_persona", serde_json::json!("attacker")),
5866 ("conversation_id", serde_json::json!("conv-other")),
5867 ("tool_call_id", serde_json::json!("call-other")),
5868 ("args_hash", serde_json::json!("hash-other")),
5869 ] {
5870 let mut v: Value = serde_json::from_slice(&payload).unwrap();
5871 v[field] = val;
5872 assert!(
5873 verify_routine_created(v.to_string().as_bytes()).is_none(),
5874 "tampered {field} must fail verification"
5875 );
5876 }
5877 }
5878
5879 #[test]
5880 fn malformed_routine_created_payload_fails_closed() {
5881 assert!(verify_routine_created(b"not json").is_none());
5882 assert!(verify_routine_created(b"{}").is_none());
5883 }
5884}
5885
5886#[cfg(test)]
5887mod routine_paused_tests {
5888 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
5889 use super::*;
5890
5891 #[test]
5894 fn routine_paused_round_trips_and_is_tamper_evident() {
5895 let signer = ApprovalSigner::from_seed(51);
5896 let (payload, _sig, _pk) = routine_paused_payload(
5897 "daily-standup-a1b2",
5898 "persona-1",
5899 "conv-1",
5900 "call-1",
5901 "hash-1",
5902 1_000,
5903 Some("rotating content"),
5904 &signer,
5905 );
5906 let verified = verify_routine_paused(&payload).expect("genuine record verifies");
5907 assert_eq!(verified.routine, "daily-standup-a1b2");
5908 assert_eq!(verified.actor_persona, "persona-1");
5909 assert_eq!(verified.conversation_id, "conv-1");
5910 assert_eq!(verified.tool_call_id, "call-1");
5911 assert_eq!(verified.args_hash, "hash-1");
5912 assert_eq!(verified.paused_at_ms, 1_000);
5913 assert_eq!(verified.reason.as_deref(), Some("rotating content"));
5914 assert_eq!(verified.signer_public_key, signer.public_key_bytes());
5915
5916 for (field, val) in [
5917 ("routine", serde_json::json!("someone-elses-routine")),
5918 ("actor_persona", serde_json::json!("attacker")),
5919 ("conversation_id", serde_json::json!("conv-other")),
5920 ("tool_call_id", serde_json::json!("call-other")),
5921 ("args_hash", serde_json::json!("hash-other")),
5922 ("reason", serde_json::json!("a different reason")),
5923 ] {
5924 let mut v: Value = serde_json::from_slice(&payload).unwrap();
5925 v[field] = val;
5926 assert!(
5927 verify_routine_paused(v.to_string().as_bytes()).is_none(),
5928 "tampered {field} must fail verification"
5929 );
5930 }
5931 }
5932
5933 #[test]
5936 fn routine_paused_with_no_reason_round_trips_none() {
5937 let signer = ApprovalSigner::from_seed(52);
5938 let (payload, _sig, _pk) = routine_paused_payload(
5939 "weekly-digest",
5940 "persona-2",
5941 "conv-2",
5942 "call-2",
5943 "hash-2",
5944 2_000,
5945 None,
5946 &signer,
5947 );
5948 let verified = verify_routine_paused(&payload).expect("genuine record verifies");
5949 assert_eq!(verified.reason, None);
5950 }
5951
5952 #[test]
5953 fn malformed_routine_paused_payload_fails_closed() {
5954 assert!(verify_routine_paused(b"not json").is_none());
5955 assert!(verify_routine_paused(b"{}").is_none());
5956 }
5957}
5958
5959#[cfg(test)]
5960mod routine_resumed_tests {
5961 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
5962 use super::*;
5963
5964 #[test]
5967 fn routine_resumed_round_trips_and_is_tamper_evident() {
5968 let signer = ApprovalSigner::from_seed(53);
5969 let (payload, _sig, _pk) = routine_resumed_payload(
5970 "daily-standup-a1b2",
5971 "persona-1",
5972 "conv-1",
5973 "call-1",
5974 "hash-1",
5975 3_000,
5976 &signer,
5977 );
5978 let verified = verify_routine_resumed(&payload).expect("genuine record verifies");
5979 assert_eq!(verified.routine, "daily-standup-a1b2");
5980 assert_eq!(verified.actor_persona, "persona-1");
5981 assert_eq!(verified.conversation_id, "conv-1");
5982 assert_eq!(verified.tool_call_id, "call-1");
5983 assert_eq!(verified.args_hash, "hash-1");
5984 assert_eq!(verified.resumed_at_ms, 3_000);
5985 assert_eq!(verified.signer_public_key, signer.public_key_bytes());
5986
5987 for (field, val) in [
5988 ("routine", serde_json::json!("someone-elses-routine")),
5989 ("actor_persona", serde_json::json!("attacker")),
5990 ("conversation_id", serde_json::json!("conv-other")),
5991 ("tool_call_id", serde_json::json!("call-other")),
5992 ("args_hash", serde_json::json!("hash-other")),
5993 ] {
5994 let mut v: Value = serde_json::from_slice(&payload).unwrap();
5995 v[field] = val;
5996 assert!(
5997 verify_routine_resumed(v.to_string().as_bytes()).is_none(),
5998 "tampered {field} must fail verification"
5999 );
6000 }
6001 }
6002
6003 #[test]
6004 fn malformed_routine_resumed_payload_fails_closed() {
6005 assert!(verify_routine_resumed(b"not json").is_none());
6006 assert!(verify_routine_resumed(b"{}").is_none());
6007 }
6008}
6009
6010#[cfg(test)]
6011mod routine_deleted_tests {
6012 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
6013 use super::*;
6014
6015 #[test]
6018 fn routine_deleted_round_trips_and_is_tamper_evident() {
6019 let signer = ApprovalSigner::from_seed(54);
6020 let (payload, _sig, _pk) = routine_deleted_payload(
6021 "daily-standup-a1b2",
6022 "persona-1",
6023 "conv-1",
6024 "call-1",
6025 "hash-1",
6026 4_000,
6027 &signer,
6028 );
6029 let verified = verify_routine_deleted(&payload).expect("genuine record verifies");
6030 assert_eq!(verified.routine, "daily-standup-a1b2");
6031 assert_eq!(verified.actor_persona, "persona-1");
6032 assert_eq!(verified.conversation_id, "conv-1");
6033 assert_eq!(verified.tool_call_id, "call-1");
6034 assert_eq!(verified.args_hash, "hash-1");
6035 assert_eq!(verified.deleted_at_ms, 4_000);
6036 assert_eq!(verified.signer_public_key, signer.public_key_bytes());
6037
6038 for (field, val) in [
6039 ("routine", serde_json::json!("someone-elses-routine")),
6040 ("actor_persona", serde_json::json!("attacker")),
6041 ("conversation_id", serde_json::json!("conv-other")),
6042 ("tool_call_id", serde_json::json!("call-other")),
6043 ("args_hash", serde_json::json!("hash-other")),
6044 ] {
6045 let mut v: Value = serde_json::from_slice(&payload).unwrap();
6046 v[field] = val;
6047 assert!(
6048 verify_routine_deleted(v.to_string().as_bytes()).is_none(),
6049 "tampered {field} must fail verification"
6050 );
6051 }
6052 }
6053
6054 #[test]
6055 fn malformed_routine_deleted_payload_fails_closed() {
6056 assert!(verify_routine_deleted(b"not json").is_none());
6057 assert!(verify_routine_deleted(b"{}").is_none());
6058 }
6059}
6060
6061#[cfg(test)]
6062mod routine_scope_changed_tests {
6063 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
6064 use super::*;
6065
6066 #[test]
6070 fn routine_scope_changed_round_trips_and_is_tamper_evident() {
6071 let signer = ApprovalSigner::from_seed(55);
6072 let (payload, _sig, _pk) = routine_scope_changed_payload(
6073 "daily-standup-a1b2",
6074 "persona-1",
6075 "conv-1",
6076 "call-1",
6077 "hash-1",
6078 "public",
6079 5_000,
6080 &signer,
6081 );
6082 let verified = verify_routine_scope_changed(&payload).expect("genuine record verifies");
6083 assert_eq!(verified.routine, "daily-standup-a1b2");
6084 assert_eq!(verified.actor_persona, "persona-1");
6085 assert_eq!(verified.conversation_id, "conv-1");
6086 assert_eq!(verified.tool_call_id, "call-1");
6087 assert_eq!(verified.args_hash, "hash-1");
6088 assert_eq!(verified.scope, "public");
6089 assert_eq!(verified.changed_at_ms, 5_000);
6090 assert_eq!(verified.signer_public_key, signer.public_key_bytes());
6091
6092 for (field, val) in [
6093 ("routine", serde_json::json!("someone-elses-routine")),
6094 ("actor_persona", serde_json::json!("attacker")),
6095 ("conversation_id", serde_json::json!("conv-other")),
6096 ("tool_call_id", serde_json::json!("call-other")),
6097 ("args_hash", serde_json::json!("hash-other")),
6098 ("scope", serde_json::json!("private")),
6099 ] {
6100 let mut v: Value = serde_json::from_slice(&payload).unwrap();
6101 v[field] = val;
6102 assert!(
6103 verify_routine_scope_changed(v.to_string().as_bytes()).is_none(),
6104 "tampered {field} must fail verification"
6105 );
6106 }
6107 }
6108
6109 #[test]
6110 fn malformed_routine_scope_changed_payload_fails_closed() {
6111 assert!(verify_routine_scope_changed(b"not json").is_none());
6112 assert!(verify_routine_scope_changed(b"{}").is_none());
6113 }
6114}
6115
6116#[cfg(test)]
6117mod canonical_freeze {
6118 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
6144
6145 use super::*;
6146
6147 fn frozen(label: &str, got: &[u8], want: &str) {
6149 assert_eq!(
6150 String::from_utf8(got.to_vec()).unwrap(),
6151 want,
6152 "{label}: canonical bytes moved — every signature over the old bytes is now unverifiable"
6153 );
6154 }
6155
6156 fn caps() -> Vec<String> {
6157 vec!["arbitrary-egress".to_owned(), "mutate-external".to_owned()]
6158 }
6159
6160 fn transitions() -> Vec<CredentialKeyTransition> {
6161 vec![
6162 CredentialKeyTransition {
6163 kid: "k1".to_owned(),
6164 from: "absent".to_owned(),
6165 to: "active".to_owned(),
6166 },
6167 CredentialKeyTransition {
6168 kid: "k2".to_owned(),
6169 from: "active".to_owned(),
6170 to: "revoked".to_owned(),
6171 },
6172 ]
6173 }
6174
6175 const AT_MS: u64 = 1_750_000_000_000;
6176 const ROUTINE_CALL: &str = "call-1";
6179 const ROUTINE_ARGS_HASH: &str = "abcd1234";
6180
6181 #[test]
6182 fn approval_response_canonical_is_frozen() {
6183 let caps = caps();
6184 frozen(
6185 "response_canonical (no approver)",
6186 &response_canonical(
6187 "req-1",
6188 "paid_fetch",
6189 "{\"a\":1}",
6190 "{\"a\":2}",
6191 true,
6192 false,
6193 &caps,
6194 "persona:alice",
6195 "",
6196 "workspace-write",
6197 "looks fine",
6198 "ctx",
6199 "conv-1",
6200 "nonce-1",
6201 "",
6202 ),
6203 NO_APPROVER_CANONICAL,
6204 );
6205 frozen(
6209 "response_canonical (approver)",
6210 &response_canonical(
6211 "req-1",
6212 "paid_fetch",
6213 "{\"a\":1}",
6214 "{\"a\":2}",
6215 true,
6216 true,
6217 &caps,
6218 "persona:alice",
6219 "persona:admin",
6220 "workspace-write",
6221 "looks fine",
6222 "ctx",
6223 "conv-1",
6224 "nonce-1",
6225 "",
6226 ),
6227 APPROVER_CANONICAL,
6228 );
6229 let (full, sig, _pk) = response_payload(
6230 "req-1",
6231 "paid_fetch",
6232 "{\"a\":1}",
6233 "{\"a\":2}",
6234 true,
6235 true,
6236 &caps,
6237 "persona:alice",
6238 "persona:admin",
6239 "workspace-write",
6240 "looks fine",
6241 "ctx",
6242 "conv-1",
6243 "nonce-1",
6244 "",
6245 &ApprovalSigner::from_seed(99),
6246 );
6247 frozen("response_payload", &full, RESPONSE_PAYLOAD);
6248 assert_eq!(crate::hex::lower(&sig), RESPONSE_SIG);
6249 }
6250
6251 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"}"#;
6252 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"}"#;
6253 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"}"#;
6254 const RESPONSE_SIG: &str = "aea101dfae5a1cf2568540d4d737b11764d060280049ddfb47d865d108e4897f3144fa3aaeaaa8e0c20a59abf5e18834b51b389c986349bf28391e68a5bc5300";
6255
6256 #[test]
6257 fn excision_canonical_is_frozen() {
6258 let signer = ApprovalSigner::from_seed(99);
6259 frozen(
6260 "excision_canonical",
6261 &excision_canonical(
6262 "conv-1",
6263 EXCISION_SCOPE_CASCADE,
6264 &[17, 23, 40],
6265 "persona:alice",
6266 "prompt injection",
6267 ),
6268 EXCISION_CANONICAL_LIT,
6269 );
6270 let (full, sig, _) = excision_payload(
6271 "conv-1",
6272 EXCISION_SCOPE_CASCADE,
6273 &[17, 23, 40],
6274 "persona:alice",
6275 "prompt injection",
6276 &signer,
6277 );
6278 frozen("excision_payload", &full, EXCISION_PAYLOAD_LIT);
6279 assert_eq!(crate::hex::lower(&sig), EXCISION_SIG_LIT);
6280 }
6281
6282 #[test]
6283 fn grant_replay_canonical_is_frozen() {
6284 let signer = ApprovalSigner::from_seed(99);
6285 let caps = caps();
6286 frozen(
6287 "grant_replay_canonical",
6288 &grant_replay_canonical(
6289 "conv-1",
6290 "turn-8",
6291 "paid_fetch",
6292 "cafe",
6293 &caps,
6294 "sha256:abc",
6295 ),
6296 GRANT_REPLAY_CANONICAL_LIT,
6297 );
6298 let (full, sig, _) = test_util::grant_replay_payload(
6299 "conv-1",
6300 "turn-8",
6301 "paid_fetch",
6302 "cafe",
6303 &caps,
6304 "sha256:abc",
6305 &signer,
6306 );
6307 frozen("grant_replay_payload", &full, GRANT_REPLAY_PAYLOAD_LIT);
6308 assert_eq!(crate::hex::lower(&sig), GRANT_REPLAY_SIG_LIT);
6309 }
6310
6311 #[test]
6312 fn deferred_and_mutation_canonicals_are_frozen() {
6313 let signer = ApprovalSigner::from_seed(99);
6314 let (full, sig, _) = deferred_payload("req-1", "conv-1", "needs more detail", &signer);
6315 frozen("deferred_payload", &full, DEFERRED_PAYLOAD_LIT);
6316 assert_eq!(crate::hex::lower(&sig), DEFERRED_SIG_LIT);
6317
6318 let (full, sig, _) = mutation_payload(
6319 "tool_input_rewrite",
6320 "call-1",
6321 "paid_fetch",
6322 "conv-1",
6323 "before-args",
6324 "after-args",
6325 &signer,
6326 );
6327 frozen("mutation_payload", &full, MUTATION_PAYLOAD_LIT);
6328 assert_eq!(crate::hex::lower(&sig), MUTATION_SIG_LIT);
6329 }
6330
6331 #[test]
6332 fn receipt_canonicals_are_frozen() {
6333 let signer = ApprovalSigner::from_seed(99);
6334 let fields = ReceiptPayload {
6335 kind: "outbound_payment_receipt",
6336 reference: "tx-frozen-v2",
6337 amount: "10000",
6338 currency: "0xToken",
6339 recipient: "0xrecipient",
6340 method: "tempo",
6341 timestamp: "2026-06-02T00:00:00Z",
6342 tool_call_id: "call-frozen",
6343 approval_pos: "42",
6344 approved_args_hash: "abcd1234",
6345 subject: "conv-frozen",
6346 payer_kind: "",
6350 paying_account: "",
6351 };
6352 frozen(
6353 "canonical_json_v2",
6354 &canonical_bytes(&fields.canonical_json_v2()),
6355 RECEIPT_V2_CANONICAL_LIT,
6356 );
6357 frozen(
6358 "canonical_json_v1",
6359 &canonical_bytes(&fields.canonical_json_v1()),
6360 RECEIPT_V1_CANONICAL_LIT,
6361 );
6362
6363 let fields_v3 = ReceiptPayload {
6368 payer_kind: "linked_wallet",
6369 paying_account: "0xpayer-frozen",
6370 ..fields
6371 };
6372 frozen(
6373 "canonical_json_v3",
6374 &canonical_bytes(&fields_v3.canonical_json_v3()),
6375 RECEIPT_V3_CANONICAL_LIT,
6376 );
6377 let (full, sig, _) = receipt_payload(&fields_v3, &signer);
6378 frozen("receipt_payload", &full, RECEIPT_PAYLOAD_LIT);
6379 assert_eq!(crate::hex::lower(&sig), RECEIPT_SIG_LIT);
6380 }
6381
6382 #[test]
6383 fn resolve_token_canonical_is_frozen() {
6384 let signer = ApprovalSigner::from_seed(99);
6385 frozen(
6386 "resolve_token_canonical",
6387 &resolve_token_canonical(
6388 "018f47f0-5f70-7cc5-98df-123456789abc",
6389 "req-1",
6390 "conv-1",
6391 AT_MS,
6392 ),
6393 RESOLVE_TOKEN_CANONICAL_LIT,
6394 );
6395 assert_eq!(
6396 mint_resolve_token(
6397 "018f47f0-5f70-7cc5-98df-123456789abc",
6398 "req-1",
6399 "conv-1",
6400 AT_MS,
6401 &signer,
6402 ),
6403 RESOLVE_TOKEN_LIT,
6404 "a minted resolve token's bytes are frozen — a token is hex of the whole object"
6405 );
6406 }
6407
6408 #[test]
6409 fn admin_model_change_canonical_is_frozen() {
6410 let signer = ApprovalSigner::from_seed(99);
6411 frozen(
6412 "admin_model_change_canonical",
6413 &admin_model_change_canonical(
6414 "admin:root",
6415 "prov-a",
6416 "model-a",
6417 "prov-b",
6418 "model-b",
6419 AT_MS,
6420 ),
6421 ADMIN_MODEL_CANONICAL_LIT,
6422 );
6423 let (full, sig, _) = admin_model_change_payload(
6424 "admin:root",
6425 "prov-a",
6426 "model-a",
6427 "prov-b",
6428 "model-b",
6429 AT_MS,
6430 &signer,
6431 );
6432 frozen("admin_model_change_payload", &full, ADMIN_MODEL_PAYLOAD_LIT);
6433 assert_eq!(crate::hex::lower(&sig), ADMIN_MODEL_SIG_LIT);
6434 }
6435
6436 #[test]
6440 fn credential_change_canonical_is_frozen() {
6441 let signer = ApprovalSigner::from_seed(99);
6442 let transitions = transitions();
6443 frozen(
6444 "credential_change_canonical",
6445 &credential_change_canonical(
6446 "credential_enrolled",
6447 "admin:root",
6448 "edge-1",
6449 "k1",
6450 "edge, admin",
6451 &transitions,
6452 AT_MS,
6453 ),
6454 CREDENTIAL_CANONICAL_LIT,
6455 );
6456 let (full, sig, _) = credential_change_payload(
6457 "credential_enrolled",
6458 "admin:root",
6459 "edge-1",
6460 "k1",
6461 "edge, admin",
6462 &transitions,
6463 AT_MS,
6464 &signer,
6465 );
6466 frozen("credential_change_payload", &full, CREDENTIAL_PAYLOAD_LIT);
6467 assert_eq!(crate::hex::lower(&sig), CREDENTIAL_SIG_LIT);
6468 }
6469
6470 #[test]
6471 fn routine_audit_canonicals_are_frozen() {
6472 let signer = ApprovalSigner::from_seed(99);
6473
6474 frozen(
6475 "routine_created_canonical",
6476 &routine_created_canonical(
6477 "r-1",
6478 "persona:alice",
6479 "conv-1",
6480 ROUTINE_CALL,
6481 ROUTINE_ARGS_HASH,
6482 AT_MS,
6483 ),
6484 ROUTINE_CREATED_CANONICAL_LIT,
6485 );
6486 let (full, sig, _) = routine_created_payload(
6487 "r-1",
6488 "persona:alice",
6489 "conv-1",
6490 ROUTINE_CALL,
6491 ROUTINE_ARGS_HASH,
6492 AT_MS,
6493 &signer,
6494 );
6495 frozen(
6496 "routine_created_payload",
6497 &full,
6498 ROUTINE_CREATED_PAYLOAD_LIT,
6499 );
6500 assert_eq!(crate::hex::lower(&sig), ROUTINE_CREATED_SIG_LIT);
6501
6502 frozen(
6503 "routine_paused_canonical (reason)",
6504 &routine_paused_canonical(
6505 "r-1",
6506 "persona:alice",
6507 "conv-1",
6508 ROUTINE_CALL,
6509 ROUTINE_ARGS_HASH,
6510 AT_MS,
6511 Some("too noisy"),
6512 ),
6513 ROUTINE_PAUSED_SOME_LIT,
6514 );
6515 frozen(
6516 "routine_paused_canonical (no reason)",
6517 &routine_paused_canonical(
6518 "r-1",
6519 "persona:alice",
6520 "conv-1",
6521 ROUTINE_CALL,
6522 ROUTINE_ARGS_HASH,
6523 AT_MS,
6524 None,
6525 ),
6526 ROUTINE_PAUSED_NONE_LIT,
6527 );
6528 let (full, sig, _) = routine_paused_payload(
6529 "r-1",
6530 "persona:alice",
6531 "conv-1",
6532 ROUTINE_CALL,
6533 ROUTINE_ARGS_HASH,
6534 AT_MS,
6535 Some("too noisy"),
6536 &signer,
6537 );
6538 frozen("routine_paused_payload", &full, ROUTINE_PAUSED_PAYLOAD_LIT);
6539 assert_eq!(crate::hex::lower(&sig), ROUTINE_PAUSED_SIG_LIT);
6540
6541 frozen(
6542 "routine_resumed_canonical",
6543 &routine_resumed_canonical(
6544 "r-1",
6545 "persona:alice",
6546 "conv-1",
6547 ROUTINE_CALL,
6548 ROUTINE_ARGS_HASH,
6549 AT_MS,
6550 ),
6551 ROUTINE_RESUMED_CANONICAL_LIT,
6552 );
6553 let (full, sig, _) = routine_resumed_payload(
6554 "r-1",
6555 "persona:alice",
6556 "conv-1",
6557 ROUTINE_CALL,
6558 ROUTINE_ARGS_HASH,
6559 AT_MS,
6560 &signer,
6561 );
6562 frozen(
6563 "routine_resumed_payload",
6564 &full,
6565 ROUTINE_RESUMED_PAYLOAD_LIT,
6566 );
6567 assert_eq!(crate::hex::lower(&sig), ROUTINE_RESUMED_SIG_LIT);
6568
6569 frozen(
6570 "routine_deleted_canonical",
6571 &routine_deleted_canonical(
6572 "r-1",
6573 "persona:alice",
6574 "conv-1",
6575 ROUTINE_CALL,
6576 ROUTINE_ARGS_HASH,
6577 AT_MS,
6578 ),
6579 ROUTINE_DELETED_CANONICAL_LIT,
6580 );
6581 let (full, sig, _) = routine_deleted_payload(
6582 "r-1",
6583 "persona:alice",
6584 "conv-1",
6585 ROUTINE_CALL,
6586 ROUTINE_ARGS_HASH,
6587 AT_MS,
6588 &signer,
6589 );
6590 frozen(
6591 "routine_deleted_payload",
6592 &full,
6593 ROUTINE_DELETED_PAYLOAD_LIT,
6594 );
6595 assert_eq!(crate::hex::lower(&sig), ROUTINE_DELETED_SIG_LIT);
6596
6597 frozen(
6598 "routine_scope_changed_canonical",
6599 &routine_scope_changed_canonical(
6600 "r-1",
6601 "persona:alice",
6602 "conv-1",
6603 ROUTINE_CALL,
6604 ROUTINE_ARGS_HASH,
6605 "public",
6606 AT_MS,
6607 ),
6608 ROUTINE_SCOPE_CHANGED_CANONICAL_LIT,
6609 );
6610 let (full, sig, _) = routine_scope_changed_payload(
6611 "r-1",
6612 "persona:alice",
6613 "conv-1",
6614 ROUTINE_CALL,
6615 ROUTINE_ARGS_HASH,
6616 "public",
6617 AT_MS,
6618 &signer,
6619 );
6620 frozen(
6621 "routine_scope_changed_payload",
6622 &full,
6623 ROUTINE_SCOPE_CHANGED_PAYLOAD_LIT,
6624 );
6625 assert_eq!(crate::hex::lower(&sig), ROUTINE_SCOPE_CHANGED_SIG_LIT);
6626 }
6627
6628 const EXCISION_CANONICAL_LIT: &str = r#"{"conversation_id":"conv-1","scope":"cascade","positions":[17,23,40],"requested_by":"persona:alice","reason":"prompt injection"}"#;
6629 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"}"#;
6630 const EXCISION_SIG_LIT: &str = "6dbe6aef29cd78faf5044e23aa9f49ba281cbda46b06ffdbba8c5b60b7de67f72beafd3fc83710a11fc8bd37a71da51dbfd722eabbc5bd9f509dfb081a35700b";
6631 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"}"#;
6632 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"}"#;
6633 const GRANT_REPLAY_SIG_LIT: &str = "b98f27ea001616a9c5edf49b71df1c65eaf391462572e1526f8be6ac132bed0aeae56644cdaa77b3112bfffc0d2dc3ac849713ec32563654ceaeb741dfc8550a";
6634 const DEFERRED_PAYLOAD_LIT: &str = r#"{"request_id":"req-1","conversation_id":"conv-1","reason":"needs more detail","signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"95d968b15da8f3b3f248dbf05b7b819c6f7d969c9c6b3470b8ec0657d63ef1dd38e9d0fdaed43aee7b94ec16828d9ac74634940afebe5a5623dd2561c5afd506"}"#;
6635 const DEFERRED_SIG_LIT: &str = "95d968b15da8f3b3f248dbf05b7b819c6f7d969c9c6b3470b8ec0657d63ef1dd38e9d0fdaed43aee7b94ec16828d9ac74634940afebe5a5623dd2561c5afd506";
6636 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"}"#;
6637 const MUTATION_SIG_LIT: &str = "c1386859a36e81c0dd0d256a1fb27cd02fb73e59048687d10ec74546b4a95e485aa2f045dab568a4432da72bd7cbcbc0789ee6ce4799017ecc8aaf62efd42e03";
6638 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"}"#;
6639 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"}"#;
6640 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"}"#;
6641 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"}"#;
6642 const RECEIPT_SIG_LIT: &str = "eec04c909fc7ae838bde2e2ab52144a523fe23939e16c355e12420d2d4b354ba528e5162558d9565ce930aac35a8172bfb0801e0c39d80f2864c948181eefe00";
6643 const RESOLVE_TOKEN_CANONICAL_LIT: &str = r#"{"turn_id":"018f47f0-5f70-7cc5-98df-123456789abc","request_id":"req-1","conversation_id":"conv-1","minted_at_ms":1750000000000}"#;
6644 const RESOLVE_TOKEN_LIT: &str = "7b227475726e5f6964223a2230313866343766302d356637302d376363352d393864662d313233343536373839616263222c22726571756573745f6964223a227265712d31222c22636f6e766572736174696f6e5f6964223a22636f6e762d31222c226d696e7465645f61745f6d73223a313735303030303030303030302c227369676e61747572655f686578223a223730333562346432356233313966323033646439396138313966363736613166636537313232313235633964633133653038613432343937346235386637343539346565323135646630616366383331323135363631353535396164363533386262373438666138363165616336333631633464623937663637303733373037227d";
6645 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}"#;
6646 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"}"#;
6647 const ADMIN_MODEL_SIG_LIT: &str = "891b2c43aba8d251ad4eb08a70d0cc3791469a4d3995c803e0baae01bfd4a8dc618433f7235ff9746ba1d83d537362d2e490608fd21f3f156794f853d874f903";
6648 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}"#;
6649 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"}"#;
6650 const CREDENTIAL_SIG_LIT: &str = "e5384d9ba22a1497ce9beba54c62f2c234bd0c84ba9c2f3ded6fff8e30c01a74d0485683aab1196e64a1c567b830e34fa3f715d13f7b8746a66dfe80b18a4c0b";
6651 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}"#;
6652 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"}"#;
6653 const ROUTINE_CREATED_SIG_LIT: &str = "65ea620c0447550151ebcedaeb325595d8d10a8ec2f39a98d9215042c636d464ed62f82a88378d254fbf4803a2e7b569b26c78c45e74e498785496ed3701400e";
6654 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"}"#;
6655 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}"#;
6656 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"}"#;
6657 const ROUTINE_PAUSED_SIG_LIT: &str = "9dca477fdc2536aa2b0bd1e7fd4a099e880c5a03742c0b4741fe7d2aa62456aa1b96b137d7c9b211f2721a88b72eb98d1694e06c1744e2fcfdf5f3fed5f97e04";
6658 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}"#;
6659 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"}"#;
6660 const ROUTINE_RESUMED_SIG_LIT: &str = "1a034d680b5a2f034adad6a2034c8f209fdc8336c89541b51edaf889de5066ad59e54af4a8f089ca14720d421c7d85b33d5b75fd3d56c408708ca2812b519b06";
6661 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}"#;
6662 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"}"#;
6663 const ROUTINE_DELETED_SIG_LIT: &str = "c944bb5fea00c41e09ded223f4587d1b13359b9c5a7e38204f3734abafe8dbc747edc7b88dc4b5cab664711c0b1a77f2be928baecdc50d78b4b282812687f50f";
6664 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}"#;
6665 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"}"#;
6666 const ROUTINE_SCOPE_CHANGED_SIG_LIT: &str = "e9b66d0c4c738965f873e85e7d2c3c551a4a41778f289cbb1338be7ab3624705e90c01e3f15ca5df130ee8b075c9a91b0dcb01b9bc20c7a48c5364f9a482210e";
6667}
6668
6669#[cfg(test)]
6674mod payment_refusal_conformance_tests {
6675 #![allow(clippy::pedantic, clippy::nursery, missing_docs, clippy::unwrap_used)]
6676
6677 use serde_json::Value;
6678
6679 use super::*;
6680
6681 fn vectors() -> Value {
6682 serde_json::from_str(polyc_conformance_vectors::PAYMENT_REFUSAL).expect("valid JSON")
6683 }
6684
6685 fn signer() -> ApprovalSigner {
6686 let v = vectors();
6687 let key_hex = v["signer"]["ed25519_private_key_hex"].as_str().unwrap();
6688 ApprovalSigner::from_key_bytes(&crate::hex::decode(key_hex).unwrap()).expect("valid key")
6689 }
6690
6691 struct VectorFields {
6696 kind: String,
6697 reason: String,
6698 reason_detail: String,
6699 merchant_host: String,
6700 requested_base_units: String,
6701 permitted_base_units: String,
6702 tool_call_id: String,
6703 subject: String,
6704 timestamp: String,
6705 }
6706
6707 impl VectorFields {
6708 fn from_json(v: &Value) -> Self {
6709 let field = |name: &str| v["fields"][name].as_str().unwrap().to_owned();
6710 Self {
6711 kind: field("kind"),
6712 reason: field("reason"),
6713 reason_detail: field("reason_detail"),
6714 merchant_host: field("merchant_host"),
6715 requested_base_units: field("requested_base_units"),
6716 permitted_base_units: field("permitted_base_units"),
6717 tool_call_id: field("tool_call_id"),
6718 subject: field("subject"),
6719 timestamp: field("timestamp"),
6720 }
6721 }
6722
6723 fn as_payload(&self) -> RefusalPayload<'_> {
6724 RefusalPayload {
6725 kind: self.kind.as_str(),
6726 reason: self.reason.as_str(),
6727 reason_detail: self.reason_detail.as_str(),
6728 merchant_host: self.merchant_host.as_str(),
6729 requested_base_units: self.requested_base_units.as_str(),
6730 permitted_base_units: self.permitted_base_units.as_str(),
6731 tool_call_id: self.tool_call_id.as_str(),
6732 subject: self.subject.as_str(),
6733 timestamp: self.timestamp.as_str(),
6734 }
6735 }
6736 }
6737
6738 #[test]
6739 fn the_known_good_vector_reproduces_its_bytes_and_signature() {
6740 let v = vectors();
6741 let signer = signer();
6742 let vf = VectorFields::from_json(&v["vector"]);
6743 let fields = vf.as_payload();
6744 let expected_canonical = crate::hex::decode(
6745 v["vector"]["expected_canonical_bytes_hex"]
6746 .as_str()
6747 .unwrap(),
6748 )
6749 .unwrap();
6750 assert_eq!(canonical_bytes(&fields.canonical()), expected_canonical);
6751
6752 let (payload, sig, _pk) = refusal_payload(&fields, &signer);
6753 assert_eq!(
6754 crate::hex::lower(&sig),
6755 v["vector"]["expected_signature_hex"].as_str().unwrap()
6756 );
6757 let expected_full = v["vector"]["expected_full_payload"].as_str().unwrap();
6758 assert_eq!(String::from_utf8(payload.clone()).unwrap(), expected_full);
6759
6760 let trusted = vec![
6761 crate::hex::decode(v["signer"]["ed25519_public_key_hex"].as_str().unwrap()).unwrap(),
6762 ];
6763 let verified = verify_signed_refusal(&payload, &trusted).expect("verifies");
6764 assert_eq!(verified.reason, vf.reason);
6765 assert_eq!(verified.requested_base_units, vf.requested_base_units);
6766 assert_eq!(verified.permitted_base_units, vf.permitted_base_units);
6767 assert_eq!(verified.timestamp, vf.timestamp);
6768 }
6769
6770 #[test]
6773 fn the_unknown_reason_vector_still_verifies() {
6774 let v = vectors();
6775 let signer = signer();
6776 let vf = VectorFields::from_json(&v["unknown_reason_vector"]);
6777 let fields = vf.as_payload();
6778 let (payload, sig, _pk) = refusal_payload(&fields, &signer);
6779 assert_eq!(
6780 crate::hex::lower(&sig),
6781 v["unknown_reason_vector"]["expected_signature_hex"]
6782 .as_str()
6783 .unwrap()
6784 );
6785
6786 let trusted = vec![
6787 crate::hex::decode(v["signer"]["ed25519_public_key_hex"].as_str().unwrap()).unwrap(),
6788 ];
6789 let verified = verify_signed_refusal(&payload, &trusted).expect("verifies");
6790 assert_eq!(verified.reason, "some_future_reason_v7");
6791 }
6792
6793 #[test]
6794 fn the_tampered_reason_vector_must_not_verify() {
6795 let v = vectors();
6796 let entry = v["must_not_verify"]
6797 .as_array()
6798 .unwrap()
6799 .iter()
6800 .find(|e| e["id"] == "tampered-reason")
6801 .unwrap();
6802 let payload = entry["full_payload"].as_str().unwrap().as_bytes();
6803 let trusted = vec![
6804 crate::hex::decode(v["signer"]["ed25519_public_key_hex"].as_str().unwrap()).unwrap(),
6805 ];
6806 assert!(verify_signed_refusal(payload, &trusted).is_none());
6807 }
6808
6809 #[test]
6816 fn the_untrusted_signer_vector_must_not_verify_against_the_main_signer() {
6817 let v = vectors();
6818 let entry = v["must_not_verify"]
6819 .as_array()
6820 .unwrap()
6821 .iter()
6822 .find(|e| e["id"] == "untrusted-signer")
6823 .unwrap();
6824 let payload = entry["full_payload"].as_str().unwrap().as_bytes();
6825
6826 let own_key =
6828 crate::hex::decode(entry["signed_by_public_key_hex"].as_str().unwrap()).unwrap();
6829 assert!(
6830 verify_signed_refusal(payload, &[own_key]).is_some(),
6831 "the untrusted-signer vector's payload must be internally consistent — a \
6832 genuinely valid signature over an out-of-allowlist key, not a malformed one"
6833 );
6834
6835 let main_trusted = vec![
6837 crate::hex::decode(v["signer"]["ed25519_public_key_hex"].as_str().unwrap()).unwrap(),
6838 ];
6839 assert!(verify_signed_refusal(payload, &main_trusted).is_none());
6840 }
6841}
6842
6843#[cfg(test)]
6848mod wallet_link_lifecycle_conformance_tests {
6849 #![allow(clippy::pedantic, clippy::nursery, missing_docs, clippy::unwrap_used)]
6850
6851 use serde_json::Value;
6852
6853 use super::*;
6854
6855 fn vectors() -> Value {
6856 serde_json::from_str(polyc_conformance_vectors::WALLET_LINK_LIFECYCLE).expect("valid JSON")
6857 }
6858
6859 fn signer() -> ApprovalSigner {
6860 let v = vectors();
6861 let key_hex = v["signer"]["ed25519_private_key_hex"].as_str().unwrap();
6862 ApprovalSigner::from_key_bytes(&crate::hex::decode(key_hex).unwrap()).expect("valid key")
6863 }
6864
6865 struct VectorFields {
6869 kind: String,
6870 transition: String,
6871 subject: String,
6872 wallet_address: String,
6873 currency: String,
6874 chain_id: String,
6875 limit_base_units: String,
6876 limit_human: String,
6877 period_secs: String,
6878 expiry_unix: String,
6879 recipients: String,
6880 conversation_id: String,
6881 timestamp: String,
6882 }
6883
6884 impl VectorFields {
6885 fn from_json(v: &Value) -> Self {
6886 let field = |name: &str| v["fields"][name].as_str().unwrap().to_owned();
6887 Self {
6888 kind: field("kind"),
6889 transition: field("transition"),
6890 subject: field("subject"),
6891 wallet_address: field("wallet_address"),
6892 currency: field("currency"),
6893 chain_id: field("chain_id"),
6894 limit_base_units: field("limit_base_units"),
6895 limit_human: field("limit_human"),
6896 period_secs: field("period_secs"),
6897 expiry_unix: field("expiry_unix"),
6898 recipients: field("recipients"),
6899 conversation_id: field("conversation_id"),
6900 timestamp: field("timestamp"),
6901 }
6902 }
6903
6904 fn as_payload(&self) -> WalletLinkLifecyclePayload<'_> {
6905 WalletLinkLifecyclePayload {
6906 kind: self.kind.as_str(),
6907 transition: self.transition.as_str(),
6908 subject: self.subject.as_str(),
6909 wallet_address: self.wallet_address.as_str(),
6910 currency: self.currency.as_str(),
6911 chain_id: self.chain_id.as_str(),
6912 limit_base_units: self.limit_base_units.as_str(),
6913 limit_human: self.limit_human.as_str(),
6914 period_secs: self.period_secs.as_str(),
6915 expiry_unix: self.expiry_unix.as_str(),
6916 recipients: self.recipients.as_str(),
6917 conversation_id: self.conversation_id.as_str(),
6918 timestamp: self.timestamp.as_str(),
6919 }
6920 }
6921 }
6922
6923 fn assert_vector_reproduces(vector_key: &str) {
6924 let v = vectors();
6925 let signer = signer();
6926 let vf = VectorFields::from_json(&v[vector_key]);
6927 let fields = vf.as_payload();
6928 let expected_canonical = crate::hex::decode(
6929 v[vector_key]["expected_canonical_bytes_hex"]
6930 .as_str()
6931 .unwrap(),
6932 )
6933 .unwrap();
6934 assert_eq!(canonical_bytes(&fields.canonical()), expected_canonical);
6935
6936 let (payload, sig, _pk) = wallet_link_lifecycle_payload(&fields, &signer);
6937 assert_eq!(
6938 crate::hex::lower(&sig),
6939 v[vector_key]["expected_signature_hex"].as_str().unwrap()
6940 );
6941 let expected_full = v[vector_key]["expected_full_payload"].as_str().unwrap();
6942 assert_eq!(String::from_utf8(payload.clone()).unwrap(), expected_full);
6943
6944 let trusted = vec![
6945 crate::hex::decode(v["signer"]["ed25519_public_key_hex"].as_str().unwrap()).unwrap(),
6946 ];
6947 let verified = verify_signed_wallet_link_lifecycle(&payload, &trusted).expect("verifies");
6948 assert_eq!(verified.transition, vf.transition);
6949 assert_eq!(verified.limit_base_units, vf.limit_base_units);
6950 assert_eq!(verified.limit_human, vf.limit_human);
6951 assert_eq!(verified.period_secs, vf.period_secs);
6952 assert_eq!(verified.expiry_unix, vf.expiry_unix);
6953 assert_eq!(verified.timestamp, vf.timestamp);
6954 }
6955
6956 #[test]
6957 fn the_known_good_linked_vector_reproduces_its_bytes_and_signature() {
6958 assert_vector_reproduces("linked_vector");
6959 }
6960
6961 #[test]
6965 fn the_known_good_renewed_vector_reproduces_its_bytes_and_signature() {
6966 assert_vector_reproduces("renewed_vector");
6967 }
6968
6969 #[test]
6972 fn the_known_good_revoked_vector_reproduces_its_bytes_and_signature() {
6973 assert_vector_reproduces("revoked_vector");
6974 }
6975
6976 #[test]
6979 fn the_unknown_transition_vector_still_verifies() {
6980 let v = vectors();
6981 let signer = signer();
6982 let vf = VectorFields::from_json(&v["unknown_transition_vector"]);
6983 let fields = vf.as_payload();
6984 let (payload, sig, _pk) = wallet_link_lifecycle_payload(&fields, &signer);
6985 assert_eq!(
6986 crate::hex::lower(&sig),
6987 v["unknown_transition_vector"]["expected_signature_hex"]
6988 .as_str()
6989 .unwrap()
6990 );
6991
6992 let trusted = vec![
6993 crate::hex::decode(v["signer"]["ed25519_public_key_hex"].as_str().unwrap()).unwrap(),
6994 ];
6995 let verified = verify_signed_wallet_link_lifecycle(&payload, &trusted).expect("verifies");
6996 assert_eq!(verified.transition, "some_future_transition_v7");
6997 }
6998
6999 #[test]
7000 fn the_tampered_transition_vector_must_not_verify() {
7001 let v = vectors();
7002 let entry = v["must_not_verify"]
7003 .as_array()
7004 .unwrap()
7005 .iter()
7006 .find(|e| e["id"] == "tampered-transition")
7007 .unwrap();
7008 let payload = entry["full_payload"].as_str().unwrap().as_bytes();
7009 let trusted = vec![
7010 crate::hex::decode(v["signer"]["ed25519_public_key_hex"].as_str().unwrap()).unwrap(),
7011 ];
7012 assert!(verify_signed_wallet_link_lifecycle(payload, &trusted).is_none());
7013 }
7014
7015 #[test]
7022 fn the_untrusted_signer_vector_must_not_verify_against_the_main_signer() {
7023 let v = vectors();
7024 let entry = v["must_not_verify"]
7025 .as_array()
7026 .unwrap()
7027 .iter()
7028 .find(|e| e["id"] == "untrusted-signer")
7029 .unwrap();
7030 let payload = entry["full_payload"].as_str().unwrap().as_bytes();
7031
7032 let own_key =
7034 crate::hex::decode(entry["signed_by_public_key_hex"].as_str().unwrap()).unwrap();
7035 assert!(
7036 verify_signed_wallet_link_lifecycle(payload, &[own_key]).is_some(),
7037 "the untrusted-signer vector's payload must be internally consistent — a \
7038 genuinely valid signature over an out-of-allowlist key, not a malformed one"
7039 );
7040
7041 let main_trusted = vec![
7043 crate::hex::decode(v["signer"]["ed25519_public_key_hex"].as_str().unwrap()).unwrap(),
7044 ];
7045 assert!(verify_signed_wallet_link_lifecycle(payload, &main_trusted).is_none());
7046 }
7047}
7048
7049#[cfg(test)]
7055mod payment_receipt_conformance_tests {
7056 #![allow(clippy::pedantic, clippy::nursery, missing_docs, clippy::unwrap_used)]
7057
7058 use serde_json::Value;
7059
7060 use super::*;
7061
7062 fn vectors() -> Value {
7063 serde_json::from_str(polyc_conformance_vectors::PAYMENT_RECEIPT).expect("valid JSON")
7064 }
7065
7066 fn signer() -> ApprovalSigner {
7067 let v = vectors();
7068 let key_hex = v["signer"]["ed25519_private_key_hex"].as_str().unwrap();
7069 ApprovalSigner::from_key_bytes(&crate::hex::decode(key_hex).unwrap()).expect("valid key")
7070 }
7071
7072 struct VectorFields {
7076 kind: String,
7077 reference: String,
7078 amount: String,
7079 currency: String,
7080 recipient: String,
7081 method: String,
7082 timestamp: String,
7083 tool_call_id: String,
7084 approval_pos: String,
7085 approved_args_hash: String,
7086 subject: String,
7087 payer_kind: String,
7088 paying_account: String,
7089 }
7090
7091 impl VectorFields {
7092 fn from_json(v: &Value) -> Self {
7096 let field = |name: &str| {
7097 v["fields"]
7098 .get(name)
7099 .and_then(Value::as_str)
7100 .unwrap_or("")
7101 .to_owned()
7102 };
7103 Self {
7104 kind: field("kind"),
7105 reference: field("reference"),
7106 amount: field("amount"),
7107 currency: field("currency"),
7108 recipient: field("recipient"),
7109 method: field("method"),
7110 timestamp: field("timestamp"),
7111 tool_call_id: field("tool_call_id"),
7112 approval_pos: field("approval_pos"),
7113 approved_args_hash: field("approved_args_hash"),
7114 subject: field("subject"),
7115 payer_kind: field("payer_kind"),
7116 paying_account: field("paying_account"),
7117 }
7118 }
7119
7120 fn as_payload(&self) -> ReceiptPayload<'_> {
7121 ReceiptPayload {
7122 kind: self.kind.as_str(),
7123 reference: self.reference.as_str(),
7124 amount: self.amount.as_str(),
7125 currency: self.currency.as_str(),
7126 recipient: self.recipient.as_str(),
7127 method: self.method.as_str(),
7128 timestamp: self.timestamp.as_str(),
7129 tool_call_id: self.tool_call_id.as_str(),
7130 approval_pos: self.approval_pos.as_str(),
7131 approved_args_hash: self.approved_args_hash.as_str(),
7132 subject: self.subject.as_str(),
7133 payer_kind: self.payer_kind.as_str(),
7134 paying_account: self.paying_account.as_str(),
7135 }
7136 }
7137 }
7138
7139 #[test]
7140 fn the_known_good_v3_vector_reproduces_its_bytes_and_signature() {
7141 let v = vectors();
7142 let signer = signer();
7143 let vf = VectorFields::from_json(&v["vector"]);
7144 let fields = vf.as_payload();
7145 let expected_canonical = crate::hex::decode(
7146 v["vector"]["expected_canonical_bytes_hex"]
7147 .as_str()
7148 .unwrap(),
7149 )
7150 .unwrap();
7151 assert_eq!(
7152 canonical_bytes(&fields.canonical_json_v3()),
7153 expected_canonical
7154 );
7155
7156 let (payload, sig, _pk) = receipt_payload(&fields, &signer);
7157 assert_eq!(
7158 crate::hex::lower(&sig),
7159 v["vector"]["expected_signature_hex"].as_str().unwrap()
7160 );
7161 let expected_full = v["vector"]["expected_full_payload"].as_str().unwrap();
7162 assert_eq!(String::from_utf8(payload.clone()).unwrap(), expected_full);
7163
7164 let trusted = vec![
7165 crate::hex::decode(v["signer"]["ed25519_public_key_hex"].as_str().unwrap()).unwrap(),
7166 ];
7167 let verified = verify_signed_receipt(&payload, &trusted).expect("verifies");
7168 assert_eq!(verified.version, 3);
7169 assert_eq!(verified.reference, vf.reference);
7170 assert_eq!(verified.payer_kind, vf.payer_kind);
7171 assert_eq!(verified.paying_account, vf.paying_account);
7172 }
7173
7174 #[test]
7178 fn the_frozen_v2_vector_still_verifies_with_payer_unknown() {
7179 let v = vectors();
7180 let entry = &v["frozen_v2_vector"];
7181 let full = entry["expected_full_payload"].as_str().unwrap();
7182 let trusted = vec![
7183 crate::hex::decode(v["signer"]["ed25519_public_key_hex"].as_str().unwrap()).unwrap(),
7184 ];
7185 let verified = verify_signed_receipt(full.as_bytes(), &trusted)
7186 .expect("the frozen v2 vector verifies");
7187 assert_eq!(verified.version, 2);
7188 assert_eq!(verified.reference, "tx-conformance-v2");
7189 assert!(verified.payer_kind.is_empty());
7190 assert!(verified.paying_account.is_empty());
7191 }
7192
7193 #[test]
7194 fn the_tampered_payer_vector_must_not_verify() {
7195 let v = vectors();
7196 let entry = v["must_not_verify"]
7197 .as_array()
7198 .unwrap()
7199 .iter()
7200 .find(|e| e["id"] == "tampered-payer")
7201 .unwrap();
7202 let payload = entry["full_payload"].as_str().unwrap().as_bytes();
7203 let trusted = vec![
7204 crate::hex::decode(v["signer"]["ed25519_public_key_hex"].as_str().unwrap()).unwrap(),
7205 ];
7206 assert!(verify_signed_receipt(payload, &trusted).is_none());
7207 }
7208
7209 #[test]
7215 fn the_untrusted_signer_vector_must_not_verify_against_the_main_signer() {
7216 let v = vectors();
7217 let entry = v["must_not_verify"]
7218 .as_array()
7219 .unwrap()
7220 .iter()
7221 .find(|e| e["id"] == "untrusted-signer")
7222 .unwrap();
7223 let payload = entry["full_payload"].as_str().unwrap().as_bytes();
7224
7225 let own_key =
7226 crate::hex::decode(entry["signed_by_public_key_hex"].as_str().unwrap()).unwrap();
7227 assert!(
7228 verify_signed_receipt(payload, &[own_key]).is_some(),
7229 "the untrusted-signer vector's payload must be internally consistent — a \
7230 genuinely valid signature over an out-of-allowlist key, not a malformed one"
7231 );
7232
7233 let main_trusted = vec![
7234 crate::hex::decode(v["signer"]["ed25519_public_key_hex"].as_str().unwrap()).unwrap(),
7235 ];
7236 assert!(verify_signed_receipt(payload, &main_trusted).is_none());
7237 }
7238}