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//! (the rest, including `signed_by`, must match exactly). Mirrors
8//! [`crate::handoff`]'s pattern — the reserved `__delegate_to` primitive's
9//! spawn/result events are the delegation-lifecycle counterpart of
10//! `Handoff`/`HandoffReturn`, signed the same way.
11//!
12//! Use [`sign_subagent_spawn_into`] / [`sign_subagent_result_into`] to mint
13//! and attach a signature in place; [`verify_subagent_spawn`] /
14//! [`verify_subagent_result`] to check provenance. The verifiers never
15//! panic — bad hex, bad signature, wrong key all surface as `false`.
16
17use buffa::Message as _;
18use polyc_proto::proto::polychrome::subagent::v1::{SubagentResult, SubagentSpawn};
19
20use crate::{Signer, verify};
21
22/// Canonical bytes for a [`SubagentSpawn`]: the encoding with
23/// `signature_hex` cleared, so the signature commits to everything *except*
24/// itself (including the originator's `signed_by`).
25fn 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
31/// Canonical bytes for a [`SubagentResult`]: the encoding with
32/// `signature_hex` cleared.
33fn 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
39/// Sign the canonical bytes of `spawn`, storing the hex signature in place
40/// and setting `signed_by` to the signer's encoded public key.
41pub 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
48/// Sign the canonical bytes of `result`, storing the hex signature in place
49/// and setting `signed_by` to the signer's encoded public key.
50pub 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/// Verify the provenance signature on `spawn` against an encoded
58/// `public_key`. Use `&spawn.signed_by` for self-verifying signatures.
59///
60/// Returns `false` on any decode failure or signature mismatch — never
61/// panics.
62#[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/// Verify the provenance signature on `result` against an encoded
71/// `public_key`.
72///
73/// Returns `false` on any decode failure or signature mismatch — never
74/// panics.
75#[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}