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