Skip to main content

polyc_crypto/
toolcall.rs

1//! Canonical signing for tool-call provenance.
2//!
3//! A signed tool call commits to its *canonical bytes*: the buffa encoding of
4//! the message with its `signature` field cleared. Both the signer and the
5//! verifier clear the field before encoding, so the signature covers every
6//! other field (id, oneof body, ...) but not itself. buffa's encoding is
7//! deterministic for a given message, making the round-trip reproducible.
8//!
9//! Use [`sign_tool_call`] / [`sign_tool_result`] to mint a signature, the
10//! `*_into` helpers to fill the message's field in place, and
11//! [`verify_tool_call`] / [`verify_tool_result`] to check provenance. The
12//! verifiers never panic.
13
14use buffa::Message as _;
15use polyc_proto::proto::polychrome::agent::v1::{ToolCallContent, ToolResultContent};
16
17use crate::{Signer, verify};
18
19/// Canonical bytes for a [`ToolCallContent`]: the encoding with `signature`
20/// cleared, so the signature commits to everything *except* itself.
21fn tool_call_canonical_bytes(call: &ToolCallContent) -> Vec<u8> {
22    let mut canonical = call.clone();
23    canonical.signature.clear();
24    canonical.encode_to_vec()
25}
26
27/// Canonical bytes for a [`ToolResultContent`]: the encoding with `signature`
28/// cleared.
29fn tool_result_canonical_bytes(result: &ToolResultContent) -> Vec<u8> {
30    let mut canonical = result.clone();
31    canonical.signature.clear();
32    canonical.encode_to_vec()
33}
34
35/// Sign the canonical bytes of `call`; returns signature bytes suitable for
36/// [`ToolCallContent::signature`].
37#[must_use]
38pub fn sign_tool_call(signer: &Signer, call: &ToolCallContent) -> Vec<u8> {
39    signer.sign(&tool_call_canonical_bytes(call))
40}
41
42/// Sign `call` and store the signature in its `signature` field in place.
43pub fn sign_tool_call_into(signer: &Signer, call: &mut ToolCallContent) {
44    call.signature = sign_tool_call(signer, call);
45}
46
47/// Verify the provenance signature carried in `call.signature` against an
48/// encoded `public_key`.
49///
50/// Returns `false` on any decode failure or signature mismatch — never panics.
51#[must_use]
52pub fn verify_tool_call(public_key: &[u8], call: &ToolCallContent) -> bool {
53    verify(
54        public_key,
55        &tool_call_canonical_bytes(call),
56        &call.signature,
57    )
58}
59
60/// Sign the canonical bytes of `result`; returns signature bytes suitable for
61/// [`ToolResultContent::signature`].
62#[must_use]
63pub fn sign_tool_result(signer: &Signer, result: &ToolResultContent) -> Vec<u8> {
64    signer.sign(&tool_result_canonical_bytes(result))
65}
66
67/// Sign `result` and store the signature in its `signature` field in place.
68pub fn sign_tool_result_into(signer: &Signer, result: &mut ToolResultContent) {
69    result.signature = sign_tool_result(signer, result);
70}
71
72/// Verify the provenance signature carried in `result.signature` against an
73/// encoded `public_key`.
74///
75/// Returns `false` on any decode failure or signature mismatch — never panics.
76#[must_use]
77pub fn verify_tool_result(public_key: &[u8], result: &ToolResultContent) -> bool {
78    verify(
79        public_key,
80        &tool_result_canonical_bytes(result),
81        &result.signature,
82    )
83}
84
85#[cfg(test)]
86mod tests {
87    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
88
89    use polyc_proto::proto::polychrome::agent::v1::{
90        FunctionCallContent, FunctionResultContent, ToolCallContent, ToolResultContent,
91    };
92
93    use super::*;
94
95    fn function_call(name: &str) -> FunctionCallContent {
96        FunctionCallContent {
97            name: name.to_string(),
98            ..Default::default()
99        }
100    }
101
102    fn sample_call() -> ToolCallContent {
103        ToolCallContent {
104            id: "call-1".to_string(),
105            r#type: Some(function_call("web_search").into()),
106            ..Default::default()
107        }
108    }
109
110    fn sample_result() -> ToolResultContent {
111        ToolResultContent {
112            call_id: "call-1".to_string(),
113            r#type: Some(
114                FunctionResultContent {
115                    name: "web_search".to_string(),
116                    ..Default::default()
117                }
118                .into(),
119            ),
120            ..Default::default()
121        }
122    }
123
124    #[test]
125    fn tool_call_round_trips() {
126        let signer = Signer::from_seed(7);
127        let pk = signer.public_key_bytes();
128        let mut call = sample_call();
129        sign_tool_call_into(&signer, &mut call);
130        assert!(!call.signature.is_empty());
131        assert!(verify_tool_call(&pk, &call));
132    }
133
134    #[test]
135    fn tool_call_tampered_id_fails() {
136        let signer = Signer::from_seed(7);
137        let pk = signer.public_key_bytes();
138        let mut call = sample_call();
139        sign_tool_call_into(&signer, &mut call);
140        call.id = "call-2".to_string();
141        assert!(!verify_tool_call(&pk, &call));
142    }
143
144    #[test]
145    fn tool_call_tampered_args_fails() {
146        let signer = Signer::from_seed(7);
147        let pk = signer.public_key_bytes();
148        let mut call = sample_call();
149        sign_tool_call_into(&signer, &mut call);
150        call.r#type = Some(function_call("rm_rf").into());
151        assert!(!verify_tool_call(&pk, &call));
152    }
153
154    #[test]
155    fn tool_call_wrong_key_fails() {
156        let signer = Signer::from_seed(7);
157        let other = Signer::from_seed(8);
158        let mut call = sample_call();
159        sign_tool_call_into(&signer, &mut call);
160        assert!(!verify_tool_call(&other.public_key_bytes(), &call));
161    }
162
163    #[test]
164    fn tool_call_unsigned_fails() {
165        let signer = Signer::from_seed(7);
166        let call = sample_call();
167        assert!(!verify_tool_call(&signer.public_key_bytes(), &call));
168    }
169
170    #[test]
171    fn tool_result_round_trips() {
172        let signer = Signer::from_seed(9);
173        let pk = signer.public_key_bytes();
174        let mut result = sample_result();
175        sign_tool_result_into(&signer, &mut result);
176        assert!(!result.signature.is_empty());
177        assert!(verify_tool_result(&pk, &result));
178    }
179
180    #[test]
181    fn tool_result_tampered_call_id_fails() {
182        let signer = Signer::from_seed(9);
183        let pk = signer.public_key_bytes();
184        let mut result = sample_result();
185        sign_tool_result_into(&signer, &mut result);
186        result.call_id = "call-99".to_string();
187        assert!(!verify_tool_result(&pk, &result));
188    }
189
190    #[test]
191    fn tool_result_wrong_key_fails() {
192        let signer = Signer::from_seed(9);
193        let other = Signer::from_seed(10);
194        let mut result = sample_result();
195        sign_tool_result_into(&signer, &mut result);
196        assert!(!verify_tool_result(&other.public_key_bytes(), &result));
197    }
198}