Skip to main content

codoseo_notify/
crypto.rs

1//! Encryption for channel targets (webhook URLs and their secrets).
2//!
3//! AES-256-GCM with a key derived from `SECRET_KEY`; the key itself is never stored. The stored
4//! form is a random 12-byte nonce followed by the ciphertext (which ends in the 16-byte tag).
5
6use aes_gcm::aead::Aead;
7use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
8use hmac::{Hmac, Mac};
9use rand::Rng;
10use sha2::Sha256;
11
12/// The fixed label the channel key is derived under, so the same `SECRET_KEY` can serve other
13/// purposes later without sharing a key.
14const KEY_LABEL: &[u8] = b"codoseo channel key v1";
15const NONCE_LEN: usize = 12;
16
17#[derive(Debug, thiserror::Error)]
18pub enum CryptoError {
19    #[error("the stored value is too short to be valid")]
20    TooShort,
21    #[error("the stored value could not be decrypted (wrong key or damaged data)")]
22    Decrypt,
23}
24
25/// The AES-256-GCM key for channel targets.
26#[derive(Clone)]
27pub struct ChannelKey {
28    cipher: Aes256Gcm,
29}
30
31impl std::fmt::Debug for ChannelKey {
32    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33        f.write_str("ChannelKey(..)")
34    }
35}
36
37impl ChannelKey {
38    /// HMAC-SHA256 of the fixed label, keyed by `secret_key`: 32 bytes.
39    pub fn derive(secret_key: &str) -> ChannelKey {
40        let mut mac = Hmac::<Sha256>::new_from_slice(secret_key.as_bytes())
41            .expect("HMAC accepts keys of any length");
42        mac.update(KEY_LABEL);
43        let key = mac.finalize().into_bytes();
44        ChannelKey {
45            cipher: Aes256Gcm::new_from_slice(&key).expect("HMAC-SHA256 output is 32 bytes"),
46        }
47    }
48
49    /// Nonce (12 random bytes) followed by the ciphertext and tag.
50    pub fn encrypt(&self, plaintext: &[u8]) -> Vec<u8> {
51        let mut nonce_bytes = [0u8; NONCE_LEN];
52        rand::rng().fill_bytes(&mut nonce_bytes);
53        let nonce = Nonce::try_from(&nonce_bytes[..]).expect("nonce is 12 bytes");
54        let sealed = self
55            .cipher
56            .encrypt(&nonce, plaintext)
57            .expect("AES-GCM encryption of an in-memory buffer cannot fail");
58        let mut out = Vec::with_capacity(NONCE_LEN + sealed.len());
59        out.extend_from_slice(&nonce_bytes);
60        out.extend_from_slice(&sealed);
61        out
62    }
63
64    /// Reverses [`ChannelKey::encrypt`]; fails on a wrong key or any changed byte.
65    pub fn decrypt(&self, bytes: &[u8]) -> Result<Vec<u8>, CryptoError> {
66        if bytes.len() <= NONCE_LEN {
67            return Err(CryptoError::TooShort);
68        }
69        let (nonce_bytes, sealed) = bytes.split_at(NONCE_LEN);
70        let nonce = Nonce::try_from(nonce_bytes).map_err(|_| CryptoError::TooShort)?;
71        self.cipher
72            .decrypt(&nonce, sealed)
73            .map_err(|_| CryptoError::Decrypt)
74    }
75}