Skip to main content

combs_mesh/
crypto.rs

1//! Key management + AEAD encryption (pure Rust — RustCrypto, no C deps, so
2//! wasm32 and mobile cross-builds stay clean; same algorithms as
3//! `@combs/zerotrust`'s WebCrypto stack).
4//!
5//! Layout of an encrypted payload: `nonce (12 bytes, random) || ciphertext
6//! || tag` — the nonce travels with the message, keys never leave the
7//! [`KeyRing`] (master key held in [`Zeroizing`]).
8//!
9//! A process-wide keyring lives behind `OnceLock<RwLock<…>>` with
10//! [`init`]/[`shutdown`]/[`global`] so the FFI crate (`combsmesh_init` /
11//! `combsmesh_shutdown`) is a thin shim over this module.
12
13use std::sync::{OnceLock, RwLock};
14
15use aes_gcm::aead::{Aead, AeadCore, KeyInit, OsRng};
16use aes_gcm::{Aes256Gcm, Key, Nonce};
17use hkdf::Hkdf;
18use sha2::Sha256;
19use zeroize::Zeroizing;
20
21use crate::blocks::EncryptionAlgorithm;
22use crate::error::{MeshError, Result};
23
24/// Default HKDF info string for emoji-at-rest encryption subkeys.
25pub const DEFAULT_HKDF_INFO: &str = "combsmesh-emoji-encryption";
26
27/// Nonce size in bytes (96-bit, the standard AEAD nonce).
28pub const NONCE_LEN: usize = 12;
29
30/// Holds the master key and derives purpose-specific subkeys via
31/// HKDF-SHA256. Key material is zeroized on drop.
32#[derive(Clone)]
33pub struct KeyRing {
34    master: Zeroizing<Vec<u8>>,
35}
36
37impl KeyRing {
38    /// Creates a keyring from `master`, or generates 32 random bytes when
39    /// `None` is given.
40    #[must_use]
41    pub fn new(master: Option<&[u8]>) -> Self {
42        let master = match master {
43            Some(bytes) => bytes.to_vec(),
44            None => Aes256Gcm::generate_key(&mut OsRng).to_vec(),
45        };
46        KeyRing {
47            master: Zeroizing::new(master),
48        }
49    }
50
51    /// Derives a 32-byte subkey via HKDF-SHA256 with the given info string.
52    pub fn subkey(&self, info: &str) -> Result<Zeroizing<[u8; 32]>> {
53        let hk = Hkdf::<Sha256>::new(None, &self.master);
54        let mut okm = [0u8; 32];
55        hk.expand(info.as_bytes(), &mut okm)
56            .map_err(|_| MeshError::Crypto("HKDF expand failed".into()))?;
57        Ok(Zeroizing::new(okm))
58    }
59
60    /// Encrypts with the default emoji-encryption subkey. Output is
61    /// `nonce(12) || ciphertext`.
62    pub fn encrypt(&self, data: &[u8], algorithm: EncryptionAlgorithm) -> Result<Vec<u8>> {
63        let subkey = self.subkey(DEFAULT_HKDF_INFO)?;
64        match algorithm {
65            EncryptionAlgorithm::Aes256Gcm => {
66                let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&subkey[..]));
67                let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
68                let ct = cipher
69                    .encrypt(&nonce, data)
70                    .map_err(|_| MeshError::Crypto("AES-256-GCM encrypt failed".into()))?;
71                Ok([&nonce[..], &ct[..]].concat())
72            }
73            EncryptionAlgorithm::ChaCha20Poly1305 => {
74                use chacha20poly1305::{ChaCha20Poly1305, Key as CKey};
75                let cipher = ChaCha20Poly1305::new(CKey::from_slice(&subkey[..]));
76                let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng);
77                let ct = cipher
78                    .encrypt(&nonce, data)
79                    .map_err(|_| MeshError::Crypto("ChaCha20-Poly1305 encrypt failed".into()))?;
80                Ok([&nonce[..], &ct[..]].concat())
81            }
82        }
83    }
84
85    /// Decrypts a `nonce(12) || ciphertext` payload produced by
86    /// [`KeyRing::encrypt`]. Wrong keys and tampering both surface as
87    /// [`MeshError::Crypto`].
88    pub fn decrypt(&self, data: &[u8], algorithm: EncryptionAlgorithm) -> Result<Vec<u8>> {
89        if data.len() < NONCE_LEN {
90            return Err(MeshError::Crypto(
91                "ciphertext shorter than the nonce".into(),
92            ));
93        }
94        let (nonce, ct) = data.split_at(NONCE_LEN);
95        let subkey = self.subkey(DEFAULT_HKDF_INFO)?;
96        match algorithm {
97            EncryptionAlgorithm::Aes256Gcm => {
98                let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&subkey[..]));
99                cipher
100                    .decrypt(Nonce::from_slice(nonce), ct)
101                    .map_err(|_| MeshError::Crypto("AES-256-GCM decrypt failed".into()))
102            }
103            EncryptionAlgorithm::ChaCha20Poly1305 => {
104                use chacha20poly1305::{ChaCha20Poly1305, Key as CKey, Nonce as CNonce};
105                let cipher = ChaCha20Poly1305::new(CKey::from_slice(&subkey[..]));
106                cipher
107                    .decrypt(CNonce::from_slice(nonce), ct)
108                    .map_err(|_| MeshError::Crypto("ChaCha20-Poly1305 decrypt failed".into()))
109            }
110        }
111    }
112}
113
114impl std::fmt::Debug for KeyRing {
115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        f.write_str("KeyRing(<redacted>)")
117    }
118}
119
120fn slot() -> &'static RwLock<Option<KeyRing>> {
121    static KEYRING: OnceLock<RwLock<Option<KeyRing>>> = OnceLock::new();
122    KEYRING.get_or_init(|| RwLock::new(None))
123}
124
125/// Initializes the process-wide keyring (`combsmesh_init` semantics).
126/// `None` generates a random master key. Replaces any existing keyring.
127pub fn init(master: Option<&[u8]>) -> Result<()> {
128    let mut guard = slot()
129        .write()
130        .map_err(|_| MeshError::Crypto("keyring lock poisoned".into()))?;
131    *guard = Some(KeyRing::new(master));
132    Ok(())
133}
134
135/// Drops the process-wide keyring, zeroizing the master key
136/// (`combsmesh_shutdown` semantics).
137pub fn shutdown() {
138    if let Ok(mut guard) = slot().write() {
139        *guard = None;
140    }
141}
142
143/// Returns a clone of the process-wide keyring, or
144/// [`MeshError::NotInitialized`] when [`init`] was never called.
145pub fn global() -> Result<KeyRing> {
146    let guard = slot()
147        .read()
148        .map_err(|_| MeshError::Crypto("keyring lock poisoned".into()))?;
149    guard.clone().ok_or(MeshError::NotInitialized)
150}