Skip to main content

brainwires_network/identity/
credentials.rs

1use anyhow::{Context, Result};
2use chacha20poly1305::aead::{Aead, KeyInit};
3use chacha20poly1305::{ChaCha20Poly1305, Nonce};
4use rand::RngExt;
5use sha2::{Digest, Sha256};
6use zeroize::Zeroizing;
7
8/// Domain separator for network identity key derivation.
9const KEY_DOMAIN: &str = "brainwires-network-identity-v1:";
10
11/// A signing key derived from a shared secret.
12///
13/// Uses ChaCha20-Poly1305 for authenticated encryption of messages,
14/// reusing the same crypto primitives as the IPC layer (`ipc/crypto.rs`).
15#[derive(Clone)]
16pub struct SigningKey {
17    cipher: ChaCha20Poly1305,
18}
19
20impl SigningKey {
21    /// Derive a signing key from a shared secret (e.g. API key, session token).
22    pub fn from_secret(secret: &str) -> Self {
23        let key = derive_key(secret);
24        let cipher = ChaCha20Poly1305::new(key.as_ref().into());
25        Self { cipher }
26    }
27
28    /// Sign (encrypt + authenticate) a message payload.
29    pub fn sign(&self, plaintext: &[u8]) -> Result<Vec<u8>> {
30        let mut nonce_bytes = [0u8; 12];
31        rand::rng().fill(&mut nonce_bytes);
32        let nonce = Nonce::from_slice(&nonce_bytes);
33
34        let ciphertext = self
35            .cipher
36            .encrypt(nonce, plaintext)
37            .map_err(|e| anyhow::anyhow!("signing failed: {e}"))?;
38
39        // Wire format: [12-byte nonce][ciphertext + 16-byte auth tag]
40        let mut output = Vec::with_capacity(12 + ciphertext.len());
41        output.extend_from_slice(&nonce_bytes);
42        output.extend_from_slice(&ciphertext);
43        Ok(output)
44    }
45}
46
47impl std::fmt::Debug for SigningKey {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        f.debug_struct("SigningKey")
50            .field("cipher", &"<redacted>")
51            .finish()
52    }
53}
54
55/// A verifying key that can authenticate signed messages.
56///
57/// Constructed from the same shared secret as the [`SigningKey`].
58#[derive(Clone)]
59pub struct VerifyingKey {
60    cipher: ChaCha20Poly1305,
61}
62
63impl VerifyingKey {
64    /// Derive a verifying key from the same shared secret used to create the
65    /// [`SigningKey`].
66    pub fn from_secret(secret: &str) -> Self {
67        let key = derive_key(secret);
68        let cipher = ChaCha20Poly1305::new(key.as_ref().into());
69        Self { cipher }
70    }
71
72    /// Verify and decrypt a signed message.
73    pub fn verify(&self, signed: &[u8]) -> Result<Vec<u8>> {
74        if signed.len() < 12 {
75            anyhow::bail!("signed message too short");
76        }
77        let (nonce_bytes, ciphertext) = signed.split_at(12);
78        let nonce = Nonce::from_slice(nonce_bytes);
79
80        self.cipher
81            .decrypt(nonce, ciphertext)
82            .map_err(|e| anyhow::anyhow!("verification failed: {e}"))
83            .context("message authentication failed")
84    }
85}
86
87impl std::fmt::Debug for VerifyingKey {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        f.debug_struct("VerifyingKey")
90            .field("cipher", &"<redacted>")
91            .finish()
92    }
93}
94
95/// Derive a 256-bit key from a secret string using SHA-256 with a domain
96/// separator.
97fn derive_key(secret: &str) -> Zeroizing<[u8; 32]> {
98    let mut hasher = Sha256::new();
99    hasher.update(KEY_DOMAIN.as_bytes());
100    hasher.update(secret.as_bytes());
101    Zeroizing::new(hasher.finalize().into())
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn sign_and_verify_roundtrip() {
110        let secret = "test-secret-key";
111        let signer = SigningKey::from_secret(secret);
112        let verifier = VerifyingKey::from_secret(secret);
113
114        let message = b"hello, agent network";
115        let signed = signer.sign(message).unwrap();
116        let recovered = verifier.verify(&signed).unwrap();
117
118        assert_eq!(recovered, message);
119    }
120
121    #[test]
122    fn wrong_secret_fails_verification() {
123        let signer = SigningKey::from_secret("secret-a");
124        let verifier = VerifyingKey::from_secret("secret-b");
125
126        let signed = signer.sign(b"test").unwrap();
127        assert!(verifier.verify(&signed).is_err());
128    }
129
130    #[test]
131    fn tampered_message_fails() {
132        let secret = "test-secret";
133        let signer = SigningKey::from_secret(secret);
134        let verifier = VerifyingKey::from_secret(secret);
135
136        let mut signed = signer.sign(b"test").unwrap();
137        // Flip a byte in the ciphertext
138        if let Some(byte) = signed.last_mut() {
139            *byte ^= 0xFF;
140        }
141        assert!(verifier.verify(&signed).is_err());
142    }
143
144    #[test]
145    fn too_short_message_fails() {
146        let verifier = VerifyingKey::from_secret("test");
147        assert!(verifier.verify(&[0u8; 5]).is_err());
148    }
149
150    #[test]
151    fn debug_redacts_key_material() {
152        let signer = SigningKey::from_secret("secret");
153        let debug = format!("{signer:?}");
154        assert!(debug.contains("<redacted>"));
155        assert!(!debug.contains("secret"));
156    }
157}