Skip to main content

soma_auth/upstream/
encryption.rs

1//! chacha20poly1305 AEAD wrapper for token-at-rest encryption.
2//!
3//! The cipher operations live in the shared crate-internal `aead` core (one
4//! implementation for both at-rest stacks); this module owns key loading
5//! from `{PREFIX}_OAUTH_ENCRYPTION_KEY` and the upstream error taxonomy.
6//!
7//! Every `seal()` call generates a **fresh random 12-byte nonce**. Callers
8//! MUST store the returned nonce alongside the ciphertext and MUST NOT reuse
9//! it. The upsert path in `store.rs` always replaces the stored nonce with
10//! the one returned by `seal()`.
11
12use thiserror::Error;
13use zeroize::{Zeroize, ZeroizeOnDrop};
14
15use crate::aead::{self, AeadError};
16
17/// A loaded 32-byte encryption key ready for `seal` / `open`.  Wiped from
18/// memory on drop.
19#[derive(Clone, Zeroize, ZeroizeOnDrop)]
20pub struct EncryptionKey([u8; 32]);
21
22impl std::fmt::Debug for EncryptionKey {
23    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24        f.write_str("EncryptionKey(<redacted>)")
25    }
26}
27
28/// Errors from encryption/decryption operations.
29#[derive(Debug, Error)]
30pub enum EncryptionError {
31    #[error("decryption failed")]
32    DecryptionFailed,
33    #[error("invalid key: {0}")]
34    InvalidKey(String),
35    #[error("encryption failed")]
36    EncryptionFailed,
37}
38
39impl From<AeadError> for EncryptionError {
40    fn from(error: AeadError) -> Self {
41        match error {
42            AeadError::Seal => Self::EncryptionFailed,
43            AeadError::Open => Self::DecryptionFailed,
44        }
45    }
46}
47
48/// Load a 32-byte key from a base64-encoded string (e.g. `{PREFIX}_OAUTH_ENCRYPTION_KEY`).
49///
50/// Returns an error (not a panic) so callers can surface a clear operator message at
51/// startup and refuse to proceed rather than silently using a bad key.
52pub fn load_key(base64_str: &str) -> Result<EncryptionKey, EncryptionError> {
53    let mut bytes = base64::Engine::decode(
54        &base64::engine::general_purpose::STANDARD,
55        base64_str.trim(),
56    )
57    .map_err(|e| EncryptionError::InvalidKey(format!("base64 decode error: {e}")))?;
58
59    if bytes.len() != 32 {
60        bytes.zeroize();
61        return Err(EncryptionError::InvalidKey(format!(
62            "expected 32 bytes, got {}",
63            bytes.len()
64        )));
65    }
66
67    let mut key = [0u8; 32];
68    key.copy_from_slice(&bytes);
69    bytes.zeroize();
70    Ok(EncryptionKey(key))
71}
72
73/// Encrypt `plaintext` under `key`, returning `(ciphertext, nonce)`.
74///
75/// A fresh random 12-byte nonce is generated internally on every call.
76/// The caller MUST persist the returned nonce alongside the ciphertext.
77#[allow(dead_code)]
78pub fn seal(key: &EncryptionKey, plaintext: &[u8]) -> Result<(Vec<u8>, Vec<u8>), EncryptionError> {
79    seal_with_aad(key, plaintext, &[])
80}
81
82pub fn seal_with_aad(
83    key: &EncryptionKey,
84    plaintext: &[u8],
85    aad: &[u8],
86) -> Result<(Vec<u8>, Vec<u8>), EncryptionError> {
87    let (ciphertext, nonce_bytes) = aead::seal(&key.0, plaintext, aad)?;
88    Ok((ciphertext, nonce_bytes.to_vec()))
89}
90
91/// Decrypt `ciphertext` using `key` and `nonce`.
92///
93/// On failure (wrong key, wrong nonce, or tampered ciphertext) returns
94/// `EncryptionError::DecryptionFailed`. Callers MUST surface this as
95/// `oauth_needs_reauth`, not `internal_error`.
96#[allow(dead_code)]
97pub fn open(
98    key: &EncryptionKey,
99    ciphertext: &[u8],
100    nonce: &[u8],
101) -> Result<Vec<u8>, EncryptionError> {
102    open_with_aad(key, ciphertext, nonce, &[])
103}
104
105pub fn open_with_aad(
106    key: &EncryptionKey,
107    ciphertext: &[u8],
108    nonce: &[u8],
109    aad: &[u8],
110) -> Result<Vec<u8>, EncryptionError> {
111    aead::open(&key.0, nonce, ciphertext, aad).map_err(EncryptionError::from)
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    fn test_key() -> EncryptionKey {
119        load_key(&base64::Engine::encode(
120            &base64::engine::general_purpose::STANDARD,
121            [0u8; 32],
122        ))
123        .unwrap()
124    }
125
126    #[test]
127    fn round_trip_plaintext() {
128        let key = test_key();
129        let plaintext = b"hello, world";
130        let (ct, nonce) = seal(&key, plaintext).unwrap();
131        let pt = open(&key, &ct, &nonce).unwrap();
132        assert_eq!(pt, plaintext);
133    }
134
135    #[test]
136    fn wrong_key_fails_decryption() {
137        let key1 = test_key();
138        let key2 = load_key(&base64::Engine::encode(
139            &base64::engine::general_purpose::STANDARD,
140            [1u8; 32],
141        ))
142        .unwrap();
143        let (ct, nonce) = seal(&key1, b"secret").unwrap();
144        assert!(open(&key2, &ct, &nonce).is_err());
145    }
146
147    #[test]
148    fn wrong_nonce_fails_decryption() {
149        let key = test_key();
150        let (ct, _) = seal(&key, b"secret").unwrap();
151        let bad_nonce = vec![0u8; 12];
152        assert!(open(&key, &ct, &bad_nonce).is_err());
153    }
154
155    #[test]
156    fn two_seals_produce_different_nonces() {
157        let key = test_key();
158        let (_, nonce1) = seal(&key, b"same plaintext").unwrap();
159        let (_, nonce2) = seal(&key, b"same plaintext").unwrap();
160        assert_ne!(nonce1, nonce2, "nonce reuse detected");
161    }
162
163    #[test]
164    fn short_key_rejected() {
165        let short = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, [0u8; 16]);
166        assert!(load_key(&short).is_err());
167    }
168
169    #[test]
170    fn invalid_base64_rejected() {
171        assert!(load_key("not-valid-base64!!!").is_err());
172    }
173
174    #[test]
175    fn key_debug_is_redacted() {
176        let key = test_key();
177        let debug = format!("{key:?}");
178        assert_eq!(debug, "EncryptionKey(<redacted>)");
179    }
180
181    #[test]
182    fn decrypt_failure_maps_to_decryption_failed() {
183        let key = test_key();
184        let (ct, nonce) = seal_with_aad(&key, b"secret", b"alice").unwrap();
185        let err = open_with_aad(&key, &ct, &nonce, b"bob").unwrap_err();
186        assert!(matches!(err, EncryptionError::DecryptionFailed));
187    }
188
189    #[test]
190    fn aad_round_trip_plaintext() {
191        let key = test_key();
192        let aad = b"upstream=test\0subject=alice\0client=soma-client";
193        let plaintext = b"hello, world";
194        let (ct, nonce) = seal_with_aad(&key, plaintext, aad).unwrap();
195        let pt = open_with_aad(&key, &ct, &nonce, aad).unwrap();
196        assert_eq!(pt, plaintext);
197    }
198
199    #[test]
200    fn wrong_aad_fails_decryption() {
201        let key = test_key();
202        let (ct, nonce) = seal_with_aad(&key, b"secret", b"alice").unwrap();
203        assert!(open_with_aad(&key, &ct, &nonce, b"bob").is_err());
204    }
205}