Skip to main content

polyc_crypto/
lib.rs

1//! Provenance signatures for polychrome.
2//!
3//! Wraps `commonware-cryptography` ed25519 so the harness can sign the bytes
4//! that back a tool call's `signature` field and the control plane can verify
5//! them. A fixed [`NAMESPACE`] provides cross-domain separation (a signature
6//! minted here cannot be replayed in another context).
7//!
8//! This primitive is runtime-agnostic — it does not pull in `commonware-runtime`
9//! or `commonware-p2p`.
10
11use commonware_codec::{DecodeExt, Encode};
12use commonware_cryptography::{
13    Signer as _, Verifier as _,
14    ed25519::{PrivateKey, PublicKey, Signature},
15};
16
17pub mod approval;
18pub mod canon;
19pub mod grant;
20pub mod handoff;
21pub mod hex;
22pub mod mandate;
23pub mod sensitive;
24pub mod subagent;
25pub mod tls;
26pub mod toolcall;
27
28/// Domain-separation namespace prepended to every polychrome signature.
29pub const NAMESPACE: &[u8] = b"polychrome.v1";
30
31/// Raised building a [`Signer`] from raw key-material bytes (`#784`) —
32/// e.g. loaded from a secret store — rather than the insecure deterministic
33/// [`Signer::from_seed`].
34#[derive(Debug, thiserror::Error)]
35pub enum SignerError {
36    /// `bytes` is not a validly-encoded ed25519 private key: wrong length
37    /// (must be exactly 32 bytes) or otherwise malformed.
38    #[error("invalid ed25519 private key material: {0}")]
39    InvalidKeyMaterial(#[from] commonware_codec::Error),
40}
41
42/// An ed25519 signing key.
43#[derive(Clone)]
44pub struct Signer(PrivateKey);
45
46impl Signer {
47    /// Build a [`Signer`] from a deterministic seed.
48    ///
49    /// **Insecure**: for tests and examples only. Production keys come from a
50    /// secret store / WIF, not a seed.
51    #[must_use]
52    pub fn from_seed(seed: u64) -> Self {
53        Self(PrivateKey::from_seed(seed))
54    }
55
56    /// Build a [`Signer`] from raw ed25519 private-key bytes (exactly 32
57    /// bytes), e.g. loaded from a secret store (`#784`). The production-safe
58    /// counterpart of [`Signer::from_seed`], which is deterministic and
59    /// reachable only in tests.
60    ///
61    /// # Errors
62    ///
63    /// Returns [`SignerError`] if `bytes` is not a validly-encoded ed25519
64    /// private key.
65    pub fn from_key_bytes(bytes: &[u8]) -> Result<Self, SignerError> {
66        Ok(Self(PrivateKey::decode(bytes)?))
67    }
68
69    /// The verifying public key, encoded as bytes (pass to [`verify`]).
70    #[must_use]
71    pub fn public_key_bytes(&self) -> Vec<u8> {
72        self.0.public_key().encode().to_vec()
73    }
74
75    /// Sign `msg` under [`NAMESPACE`]; returns the signature bytes (suitable
76    /// for a `signature` wire field).
77    #[must_use]
78    pub fn sign(&self, msg: &[u8]) -> Vec<u8> {
79        self.0.sign(NAMESPACE, msg).encode().to_vec()
80    }
81}
82
83/// Verify `sig` over `msg` against an encoded `public_key`.
84///
85/// Returns `false` on any decode failure or signature mismatch — never panics.
86#[must_use]
87pub fn verify(public_key: &[u8], msg: &[u8], sig: &[u8]) -> bool {
88    let Ok(pk) = PublicKey::decode(public_key) else {
89        return false;
90    };
91    let Ok(sig) = Signature::decode(sig) else {
92        return false;
93    };
94    pk.verify(NAMESPACE, msg, &sig)
95}
96
97#[cfg(test)]
98mod tests {
99    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
100
101    use super::*;
102
103    #[test]
104    fn sign_then_verify_round_trips() {
105        let signer = Signer::from_seed(1);
106        let pk = signer.public_key_bytes();
107        let msg = b"tool-call canonical bytes";
108        let sig = signer.sign(msg);
109        assert!(verify(&pk, msg, &sig));
110    }
111
112    #[test]
113    fn wrong_message_fails() {
114        let signer = Signer::from_seed(1);
115        let pk = signer.public_key_bytes();
116        let sig = signer.sign(b"original");
117        assert!(!verify(&pk, b"tampered", &sig));
118    }
119
120    #[test]
121    fn wrong_key_fails() {
122        let signer = Signer::from_seed(1);
123        let other = Signer::from_seed(2);
124        let msg = b"msg";
125        let sig = signer.sign(msg);
126        assert!(!verify(&other.public_key_bytes(), msg, &sig));
127    }
128
129    #[test]
130    fn garbage_inputs_return_false_not_panic() {
131        assert!(!verify(b"not-a-key", b"m", b"not-a-sig"));
132        assert!(!verify(&[], &[], &[]));
133    }
134
135    // #784: `from_key_bytes` is the production-safe loader for key material
136    // pulled from a secret store — it round-trips exactly like `from_seed`
137    // and mints a DIFFERENT key than the deterministic seed for the same
138    // bytes-as-seed coincidence would.
139    #[test]
140    fn from_key_bytes_round_trips_and_rejects_bad_length() {
141        let key_bytes = [7u8; 32];
142        let signer = Signer::from_key_bytes(&key_bytes).expect("valid 32-byte key material");
143        let pk = signer.public_key_bytes();
144        let msg = b"approval-response canonical bytes";
145        let sig = signer.sign(msg);
146        assert!(verify(&pk, msg, &sig));
147
148        // Wrong length is rejected rather than silently truncated/padded.
149        assert!(Signer::from_key_bytes(&[7u8; 31]).is_err());
150        assert!(Signer::from_key_bytes(&[7u8; 33]).is_err());
151        assert!(Signer::from_key_bytes(&[]).is_err());
152    }
153}