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 approval_assertion;
19pub mod canon;
20pub mod edge_identity;
21pub mod handoff;
22pub mod hex;
23pub mod mandate;
24pub mod question;
25pub mod routine_observation;
26mod salted_hash;
27pub mod sensitive;
28pub mod session;
29pub mod session_grant;
30mod signed;
31pub mod signing_role;
32pub mod subagent;
33pub mod tls;
34pub mod toolcall;
35
36pub use salted_hash::hash_salted_secret;
37
38/// Domain-separation namespace prepended to every polychrome signature.
39pub const NAMESPACE: &[u8] = b"polychrome.v1";
40
41/// Mint 32 cryptographically secure random bytes from the operating system's
42/// entropy source.
43///
44/// The canonical way to generate secret material in this workspace: an
45/// ed25519 private key for [`Signer::from_key_bytes`], or the secret half of
46/// a `pc_<id>_<secret>` transport bearer. Every byte is uniformly random —
47/// unlike an identifier such as a UUID, which fixes several bits to encode
48/// its own version and variant and so is not key material, however random the
49/// rest of it is.
50///
51/// # Panics
52///
53/// Panics if the OS entropy source is unavailable, which on a supported
54/// platform means the process cannot safely mint secrets at all — failing
55/// loudly beats returning predictable key material.
56#[must_use]
57pub fn random_secret_bytes() -> [u8; 32] {
58    use rand::RngCore as _;
59    let mut bytes = [0u8; 32];
60    rand::rngs::OsRng
61        .try_fill_bytes(&mut bytes)
62        .expect("the OS entropy source must be available to mint secrets");
63    bytes
64}
65
66/// Raised building a [`Signer`] from raw key-material bytes (`#784`) —
67/// e.g. loaded from a secret store — rather than the insecure deterministic
68/// [`Signer::from_seed`].
69#[derive(Debug, thiserror::Error)]
70pub enum SignerError {
71    /// `bytes` is not a validly-encoded ed25519 private key: wrong length
72    /// (must be exactly 32 bytes) or otherwise malformed.
73    #[error("invalid ed25519 private key material: {0}")]
74    InvalidKeyMaterial(#[from] commonware_codec::Error),
75}
76
77/// An ed25519 signing key.
78#[derive(Clone)]
79pub struct Signer(PrivateKey);
80
81impl Signer {
82    /// Build a [`Signer`] from a deterministic seed.
83    ///
84    /// **Insecure**: for tests and examples only. Production keys come from a
85    /// secret store / WIF, not a seed.
86    #[must_use]
87    pub fn from_seed(seed: u64) -> Self {
88        Self(PrivateKey::from_seed(seed))
89    }
90
91    /// Build a [`Signer`] from raw ed25519 private-key bytes (exactly 32
92    /// bytes), e.g. loaded from a secret store (`#784`). The production-safe
93    /// counterpart of [`Signer::from_seed`], which is deterministic and
94    /// reachable only in tests.
95    ///
96    /// # Errors
97    ///
98    /// Returns [`SignerError`] if `bytes` is not a validly-encoded ed25519
99    /// private key.
100    pub fn from_key_bytes(bytes: &[u8]) -> Result<Self, SignerError> {
101        Ok(Self(PrivateKey::decode(bytes)?))
102    }
103
104    /// The verifying public key, encoded as bytes (pass to [`verify`]).
105    #[must_use]
106    pub fn public_key_bytes(&self) -> Vec<u8> {
107        self.0.public_key().encode().to_vec()
108    }
109
110    /// Sign `msg` under [`NAMESPACE`]; returns the signature bytes (suitable
111    /// for a `signature` wire field).
112    #[must_use]
113    pub fn sign(&self, msg: &[u8]) -> Vec<u8> {
114        self.0.sign(NAMESPACE, msg).encode().to_vec()
115    }
116}
117
118/// Verify `sig` over `msg` against an encoded `public_key`.
119///
120/// Returns `false` on any decode failure or signature mismatch — never panics.
121#[must_use]
122pub fn verify(public_key: &[u8], msg: &[u8], sig: &[u8]) -> bool {
123    let Ok(pk) = PublicKey::decode(public_key) else {
124        return false;
125    };
126    let Ok(sig) = Signature::decode(sig) else {
127        return false;
128    };
129    pk.verify(NAMESPACE, msg, &sig)
130}
131
132#[cfg(test)]
133mod tests {
134    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
135
136    use super::*;
137
138    #[test]
139    fn sign_then_verify_round_trips() {
140        let signer = Signer::from_seed(1);
141        let pk = signer.public_key_bytes();
142        let msg = b"tool-call canonical bytes";
143        let sig = signer.sign(msg);
144        assert!(verify(&pk, msg, &sig));
145    }
146
147    #[test]
148    fn wrong_message_fails() {
149        let signer = Signer::from_seed(1);
150        let pk = signer.public_key_bytes();
151        let sig = signer.sign(b"original");
152        assert!(!verify(&pk, b"tampered", &sig));
153    }
154
155    #[test]
156    fn wrong_key_fails() {
157        let signer = Signer::from_seed(1);
158        let other = Signer::from_seed(2);
159        let msg = b"msg";
160        let sig = signer.sign(msg);
161        assert!(!verify(&other.public_key_bytes(), msg, &sig));
162    }
163
164    #[test]
165    fn garbage_inputs_return_false_not_panic() {
166        assert!(!verify(b"not-a-key", b"m", b"not-a-sig"));
167        assert!(!verify(&[], &[], &[]));
168    }
169
170    // #784: `from_key_bytes` is the production-safe loader for key material
171    // pulled from a secret store — it round-trips exactly like `from_seed`
172    // and mints a DIFFERENT key than the deterministic seed for the same
173    // bytes-as-seed coincidence would.
174    #[test]
175    fn from_key_bytes_round_trips_and_rejects_bad_length() {
176        let key_bytes = [7u8; 32];
177        let signer = Signer::from_key_bytes(&key_bytes).expect("valid 32-byte key material");
178        let pk = signer.public_key_bytes();
179        let msg = b"approval-response canonical bytes";
180        let sig = signer.sign(msg);
181        assert!(verify(&pk, msg, &sig));
182
183        // Wrong length is rejected rather than silently truncated/padded.
184        assert!(Signer::from_key_bytes(&[7u8; 31]).is_err());
185        assert!(Signer::from_key_bytes(&[7u8; 33]).is_err());
186        assert!(Signer::from_key_bytes(&[]).is_err());
187    }
188}