Skip to main content

polyc_crypto/
subagent.rs

1//! Canonical signing for sub-agent delegation provenance (`#872`).
2//!
3//! A signed `SubagentSpawn` / `SubagentResult` commits to its *canonical
4//! bytes*: the buffa encoding of the message with `signature_hex` cleared.
5//! The signer sets `signed_by` to its encoded public key before signing, so
6//! the encoded key is covered too. The verifier clears `signature_hex` only.
7//! Everything else, including `signed_by`, must match exactly.
8//!
9//! The canonical bytes never reach the key directly. The sub-agent role frames
10//! them with its issuer and with the artifact kind first. See
11//! [`crate::signing_role::RoleSigner::sign_subagent_spawn`]. A spawn signature
12//! therefore never verifies as a result, and neither verifies as a handoff
13//! artifact.
14//!
15//! Use [`sign_subagent_spawn_into`] / [`sign_subagent_result_into`] to mint
16//! and attach a signature in place. Use [`classify_subagent_spawn`] /
17//! [`classify_subagent_result`] to read provenance against a deployment trust
18//! set. The classifiers never panic. Bad hex and a bad signature read as
19//! [`SignatureVerdict::Invalid`]. A good signature from a key the deployment
20//! does not hold reads as [`SignatureVerdict::Untrusted`].
21
22use buffa::Message as _;
23use polyc_proto::proto::polychrome::subagent::v1::{SubagentResult, SubagentSpawn};
24
25use crate::signing_role::{RoleTrustSet, SignatureVerdict, SubagentRole, SubagentSigner};
26
27/// Canonical bytes for a [`SubagentSpawn`]: the encoding with
28/// `signature_hex` cleared, so the signature commits to everything *except*
29/// itself (including the originator's `signed_by`).
30fn spawn_canonical_bytes(spawn: &SubagentSpawn) -> Vec<u8> {
31    let mut canonical = spawn.clone();
32    canonical.signature_hex.clear();
33    canonical.encode_to_vec()
34}
35
36/// Canonical bytes for a [`SubagentResult`]: the encoding with
37/// `signature_hex` cleared.
38fn result_canonical_bytes(result: &SubagentResult) -> Vec<u8> {
39    let mut canonical = result.clone();
40    canonical.signature_hex.clear();
41    canonical.encode_to_vec()
42}
43
44/// Signs the canonical bytes of `spawn` in place, in the spawn artifact
45/// domain, and sets `signed_by` to the signer's encoded public key.
46pub fn sign_subagent_spawn_into(signer: &SubagentSigner, spawn: &mut SubagentSpawn) {
47    spawn.signed_by = signer.public_key_bytes();
48    spawn.signature_hex.clear();
49    let signature = signer.sign_subagent_spawn(&spawn_canonical_bytes(spawn));
50    spawn.signature_hex = crate::hex::lower(&signature);
51}
52
53/// Signs the canonical bytes of `result` in place, in the result artifact
54/// domain, and sets `signed_by` to the signer's encoded public key.
55pub fn sign_subagent_result_into(signer: &SubagentSigner, result: &mut SubagentResult) {
56    result.signed_by = signer.public_key_bytes();
57    result.signature_hex.clear();
58    let signature = signer.sign_subagent_result(&result_canonical_bytes(result));
59    result.signature_hex = crate::hex::lower(&signature);
60}
61
62/// Classifies the provenance signature on `spawn` against `trust`.
63///
64/// The verdict is [`SignatureVerdict::Verified`] only when the signature
65/// checks out against the embedded `signed_by` key AND `trust` holds that key
66/// for the sub-agent role. A good signature from a key outside `trust` reads
67/// as [`SignatureVerdict::Untrusted`]. Bad hex, a bad signature, and a
68/// malformed key all read as [`SignatureVerdict::Invalid`]. The function never
69/// panics.
70#[must_use]
71pub fn classify_subagent_spawn(
72    trust: &RoleTrustSet<SubagentRole>,
73    spawn: &SubagentSpawn,
74) -> SignatureVerdict {
75    let Some(signature) = crate::hex::decode(&spawn.signature_hex) else {
76        return SignatureVerdict::Invalid;
77    };
78    trust.classify_subagent_spawn(&spawn.signed_by, &spawn_canonical_bytes(spawn), &signature)
79}
80
81/// Classifies the provenance signature on `result` against `trust`.
82///
83/// The verdicts read exactly as [`classify_subagent_spawn`] describes. The
84/// function never panics.
85#[must_use]
86pub fn classify_subagent_result(
87    trust: &RoleTrustSet<SubagentRole>,
88    result: &SubagentResult,
89) -> SignatureVerdict {
90    let Some(signature) = crate::hex::decode(&result.signature_hex) else {
91        return SignatureVerdict::Invalid;
92    };
93    trust.classify_subagent_result(
94        &result.signed_by,
95        &result_canonical_bytes(result),
96        &signature,
97    )
98}
99
100#[cfg(test)]
101mod tests {
102    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
103
104    use super::*;
105
106    /// The deployment trust set that holds exactly `signer`'s key.
107    fn trust(signer: &SubagentSigner) -> RoleTrustSet<SubagentRole> {
108        RoleTrustSet::current(signer)
109    }
110
111    fn sample_spawn() -> SubagentSpawn {
112        SubagentSpawn {
113            sub_agent_id: "call-7".to_owned(),
114            target_agent_id: "researcher".to_owned(),
115            resolved_provider: "vertex".to_owned(),
116            resolved_model: "fable-pro".to_owned(),
117            task: "find prior art".to_owned(),
118            ..Default::default()
119        }
120    }
121
122    fn sample_result() -> SubagentResult {
123        SubagentResult {
124            sub_agent_id: "call-7".to_owned(),
125            target_agent_id: "researcher".to_owned(),
126            succeeded: true,
127            error: String::new(),
128            input_tokens: 42,
129            output_tokens: 7,
130            first_party: true,
131            ..Default::default()
132        }
133    }
134
135    #[test]
136    fn spawn_round_trips() {
137        let signer = SubagentSigner::from_seed(21);
138        let mut s = sample_spawn();
139        sign_subagent_spawn_into(&signer, &mut s);
140        assert!(!s.signature_hex.is_empty());
141        assert_eq!(s.signed_by, signer.public_key_bytes());
142        assert_eq!(
143            classify_subagent_spawn(&trust(&signer), &s),
144            SignatureVerdict::Verified
145        );
146    }
147
148    #[test]
149    fn spawn_tampered_task_fails() {
150        let signer = SubagentSigner::from_seed(21);
151        let mut s = sample_spawn();
152        sign_subagent_spawn_into(&signer, &mut s);
153        s.task = "leaked task".to_owned();
154        assert_eq!(
155            classify_subagent_spawn(&trust(&signer), &s),
156            SignatureVerdict::Invalid
157        );
158    }
159
160    #[test]
161    fn spawn_from_an_untrusted_signer_reads_untrusted() {
162        let signer = SubagentSigner::from_seed(21);
163        let other = SubagentSigner::from_seed(22);
164        let mut s = sample_spawn();
165        sign_subagent_spawn_into(&signer, &mut s);
166        assert_eq!(
167            classify_subagent_spawn(&trust(&other), &s),
168            SignatureVerdict::Untrusted
169        );
170    }
171
172    #[test]
173    fn spawn_unsigned_fails() {
174        let signer = SubagentSigner::from_seed(21);
175        let s = sample_spawn();
176        assert_eq!(
177            classify_subagent_spawn(&trust(&signer), &s),
178            SignatureVerdict::Invalid
179        );
180    }
181
182    #[test]
183    fn result_round_trips() {
184        let signer = SubagentSigner::from_seed(23);
185        let mut r = sample_result();
186        sign_subagent_result_into(&signer, &mut r);
187        assert!(!r.signature_hex.is_empty());
188        assert_eq!(r.signed_by, signer.public_key_bytes());
189        assert_eq!(
190            classify_subagent_result(&trust(&signer), &r),
191            SignatureVerdict::Verified
192        );
193    }
194
195    #[test]
196    fn result_tampered_usage_fails() {
197        let signer = SubagentSigner::from_seed(23);
198        let mut r = sample_result();
199        sign_subagent_result_into(&signer, &mut r);
200        r.input_tokens = 999_999;
201        assert_eq!(
202            classify_subagent_result(&trust(&signer), &r),
203            SignatureVerdict::Invalid
204        );
205    }
206
207    #[test]
208    fn result_tampered_succeeded_fails() {
209        let signer = SubagentSigner::from_seed(23);
210        let mut r = sample_result();
211        sign_subagent_result_into(&signer, &mut r);
212        r.succeeded = false;
213        r.error = "worker turn failed: forged".to_owned();
214        assert_eq!(
215            classify_subagent_result(&trust(&signer), &r),
216            SignatureVerdict::Invalid
217        );
218    }
219
220    #[test]
221    fn tampered_first_party_fails_verification() {
222        let signer = SubagentSigner::from_seed(23);
223        let mut r = sample_result();
224        sign_subagent_result_into(&signer, &mut r);
225        r.first_party = false;
226        assert_eq!(
227            classify_subagent_result(&trust(&signer), &r),
228            SignatureVerdict::Invalid
229        );
230    }
231
232    #[test]
233    fn result_from_an_untrusted_signer_reads_untrusted() {
234        let signer = SubagentSigner::from_seed(23);
235        let other = SubagentSigner::from_seed(24);
236        let mut r = sample_result();
237        sign_subagent_result_into(&signer, &mut r);
238        assert_eq!(
239            classify_subagent_result(&trust(&other), &r),
240            SignatureVerdict::Untrusted
241        );
242    }
243
244    #[test]
245    fn result_unsigned_fails() {
246        let signer = SubagentSigner::from_seed(23);
247        let r = sample_result();
248        assert_eq!(
249            classify_subagent_result(&trust(&signer), &r),
250            SignatureVerdict::Invalid
251        );
252    }
253
254    #[test]
255    fn garbage_signature_hex_reads_invalid() {
256        let pinned = trust(&SubagentSigner::from_seed(25));
257        let mut s = sample_spawn();
258        s.signature_hex = "not-hex!".to_owned();
259        assert_eq!(
260            classify_subagent_spawn(&pinned, &s),
261            SignatureVerdict::Invalid
262        );
263        s.signature_hex = "abcd".to_owned();
264        assert_eq!(
265            classify_subagent_spawn(&pinned, &s),
266            SignatureVerdict::Invalid
267        );
268    }
269
270    /// A spawn signature never verifies as a result, over the same key.
271    ///
272    /// Both messages share tag numbers and wire types on their leading
273    /// fields, so their canonical bytes can collide. The artifact domain in
274    /// the signed frame is what separates them.
275    #[test]
276    fn a_spawn_signature_does_not_verify_as_a_result() {
277        let signer = SubagentSigner::from_seed(26);
278        let mut spawn = sample_spawn();
279        sign_subagent_spawn_into(&signer, &mut spawn);
280
281        let replayed = SubagentResult {
282            sub_agent_id: spawn.sub_agent_id.clone(),
283            target_agent_id: spawn.target_agent_id.clone(),
284            signed_by: spawn.signed_by.clone(),
285            signature_hex: spawn.signature_hex.clone(),
286            ..Default::default()
287        };
288        assert_eq!(
289            classify_subagent_result(&trust(&signer), &replayed),
290            SignatureVerdict::Invalid
291        );
292        assert_eq!(
293            classify_subagent_spawn(&trust(&signer), &spawn),
294            SignatureVerdict::Verified
295        );
296
297        let mut result = sample_result();
298        sign_subagent_result_into(&signer, &mut result);
299        let replayed = SubagentSpawn {
300            sub_agent_id: result.sub_agent_id.clone(),
301            target_agent_id: result.target_agent_id.clone(),
302            signed_by: result.signed_by.clone(),
303            signature_hex: result.signature_hex.clone(),
304            ..Default::default()
305        };
306        assert_eq!(
307            classify_subagent_spawn(&trust(&signer), &replayed),
308            SignatureVerdict::Invalid
309        );
310    }
311}