1use buffa::Message as _;
18use polyc_proto::proto::polychrome::subagent::v1::{SubagentResult, SubagentSpawn};
19
20use crate::{Signer, verify};
21
22fn spawn_canonical_bytes(spawn: &SubagentSpawn) -> Vec<u8> {
26 let mut canonical = spawn.clone();
27 canonical.signature_hex.clear();
28 canonical.encode_to_vec()
29}
30
31fn result_canonical_bytes(result: &SubagentResult) -> Vec<u8> {
34 let mut canonical = result.clone();
35 canonical.signature_hex.clear();
36 canonical.encode_to_vec()
37}
38
39pub fn sign_subagent_spawn_into(signer: &Signer, spawn: &mut SubagentSpawn) {
42 spawn.signed_by = signer.public_key_bytes();
43 spawn.signature_hex.clear();
44 let sig = signer.sign(&spawn_canonical_bytes(spawn));
45 spawn.signature_hex = crate::hex::lower(&sig);
46}
47
48pub fn sign_subagent_result_into(signer: &Signer, result: &mut SubagentResult) {
51 result.signed_by = signer.public_key_bytes();
52 result.signature_hex.clear();
53 let sig = signer.sign(&result_canonical_bytes(result));
54 result.signature_hex = crate::hex::lower(&sig);
55}
56
57#[must_use]
63pub fn verify_subagent_spawn(public_key: &[u8], spawn: &SubagentSpawn) -> bool {
64 let Some(sig) = crate::hex::decode(&spawn.signature_hex) else {
65 return false;
66 };
67 verify(public_key, &spawn_canonical_bytes(spawn), &sig)
68}
69
70#[must_use]
76pub fn verify_subagent_result(public_key: &[u8], result: &SubagentResult) -> bool {
77 let Some(sig) = crate::hex::decode(&result.signature_hex) else {
78 return false;
79 };
80 verify(public_key, &result_canonical_bytes(result), &sig)
81}
82
83#[cfg(test)]
84mod tests {
85 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
86
87 use super::*;
88
89 fn sample_spawn() -> SubagentSpawn {
90 SubagentSpawn {
91 sub_agent_id: "call-7".to_owned(),
92 target_agent_id: "researcher".to_owned(),
93 resolved_provider: "vertex".to_owned(),
94 resolved_model: "fable-pro".to_owned(),
95 task: "find prior art".to_owned(),
96 ..Default::default()
97 }
98 }
99
100 fn sample_result() -> SubagentResult {
101 SubagentResult {
102 sub_agent_id: "call-7".to_owned(),
103 target_agent_id: "researcher".to_owned(),
104 succeeded: true,
105 error: String::new(),
106 input_tokens: 42,
107 output_tokens: 7,
108 first_party: true,
109 ..Default::default()
110 }
111 }
112
113 #[test]
114 fn spawn_round_trips() {
115 let signer = Signer::from_seed(21);
116 let mut s = sample_spawn();
117 sign_subagent_spawn_into(&signer, &mut s);
118 assert!(!s.signature_hex.is_empty());
119 assert_eq!(s.signed_by, signer.public_key_bytes());
120 assert!(verify_subagent_spawn(&s.signed_by, &s));
121 }
122
123 #[test]
124 fn spawn_tampered_task_fails() {
125 let signer = Signer::from_seed(21);
126 let mut s = sample_spawn();
127 sign_subagent_spawn_into(&signer, &mut s);
128 s.task = "leaked task".to_owned();
129 assert!(!verify_subagent_spawn(&s.signed_by, &s));
130 }
131
132 #[test]
133 fn spawn_wrong_key_fails() {
134 let signer = Signer::from_seed(21);
135 let other = Signer::from_seed(22);
136 let mut s = sample_spawn();
137 sign_subagent_spawn_into(&signer, &mut s);
138 assert!(!verify_subagent_spawn(&other.public_key_bytes(), &s));
139 }
140
141 #[test]
142 fn spawn_unsigned_fails() {
143 let signer = Signer::from_seed(21);
144 let s = sample_spawn();
145 assert!(!verify_subagent_spawn(&signer.public_key_bytes(), &s));
146 }
147
148 #[test]
149 fn result_round_trips() {
150 let signer = Signer::from_seed(23);
151 let mut r = sample_result();
152 sign_subagent_result_into(&signer, &mut r);
153 assert!(!r.signature_hex.is_empty());
154 assert_eq!(r.signed_by, signer.public_key_bytes());
155 assert!(verify_subagent_result(&r.signed_by, &r));
156 }
157
158 #[test]
159 fn result_tampered_usage_fails() {
160 let signer = Signer::from_seed(23);
161 let mut r = sample_result();
162 sign_subagent_result_into(&signer, &mut r);
163 r.input_tokens = 999_999;
164 assert!(!verify_subagent_result(&r.signed_by, &r));
165 }
166
167 #[test]
168 fn result_tampered_succeeded_fails() {
169 let signer = Signer::from_seed(23);
170 let mut r = sample_result();
171 sign_subagent_result_into(&signer, &mut r);
172 r.succeeded = false;
173 r.error = "worker turn failed: forged".to_owned();
174 assert!(!verify_subagent_result(&r.signed_by, &r));
175 }
176
177 #[test]
178 fn tampered_first_party_fails_verification() {
179 let signer = Signer::from_seed(23);
180 let mut r = sample_result();
181 sign_subagent_result_into(&signer, &mut r);
182 r.first_party = false;
183 assert!(!verify_subagent_result(&r.signed_by, &r));
184 }
185
186 #[test]
187 fn result_wrong_key_fails() {
188 let signer = Signer::from_seed(23);
189 let other = Signer::from_seed(24);
190 let mut r = sample_result();
191 sign_subagent_result_into(&signer, &mut r);
192 assert!(!verify_subagent_result(&other.public_key_bytes(), &r));
193 }
194
195 #[test]
196 fn result_unsigned_fails() {
197 let signer = Signer::from_seed(23);
198 let r = sample_result();
199 assert!(!verify_subagent_result(&signer.public_key_bytes(), &r));
200 }
201
202 #[test]
203 fn garbage_signature_hex_returns_false() {
204 let mut s = sample_spawn();
205 s.signature_hex = "not-hex!".to_owned();
206 assert!(!verify_subagent_spawn(b"any-key", &s));
207 s.signature_hex = "abcd".to_owned();
208 assert!(!verify_subagent_spawn(b"any-key", &s));
209 }
210}