Skip to main content

soma_auth/
at_rest.rs

1/// At-rest encryption for upstream provider refresh tokens.
2///
3/// Provider tokens (e.g. Google refresh tokens) are encrypted with
4/// ChaCha20-Poly1305 before being written to SQLite so that a copied
5/// `auth.db` cannot be used as an upstream-account pivot.  The cipher
6/// operations themselves live in the crate-internal `aead` core, shared with
7/// the upstream OAuth credential store.
8///
9/// # Key management
10///
11/// The encryption key is a 32-byte value derived from the
12/// `{PREFIX}_TOKEN_ENCRYPTION_KEY` environment variable, which must be
13/// either 64 hex digits or 43 base64url-no-pad characters.  When the env
14/// var is absent the helper functions are no-ops: data is stored as-is
15/// (backward-compatible with existing deployments that haven't opted in to
16/// at-rest protection yet).
17///
18/// # Storage formats
19///
20/// Two sentineled formats exist; both wrap `base64url(nonce || ciphertext+tag)`
21/// where:
22/// - `nonce` is a randomly generated 12-byte ChaCha20-Poly1305 nonce
23/// - `ciphertext+tag` is the output of ChaCha20-Poly1305 AEAD encryption:
24///   the ciphertext bytes followed by the 16-byte authentication tag appended
25///   by the AEAD library
26///
27/// The `"enc2:"` prefix marks the current format: the ciphertext is sealed
28/// with associated data (AAD) binding it to its row identity, so a blob
29/// transplanted onto a different row fails authentication.  The legacy
30/// `"enc:"` prefix marks ciphertexts sealed without AAD; they still decrypt
31/// (back-compat) but are never written by new code.  Values with neither
32/// prefix are legacy plaintext (or rows from databases without encryption)
33/// and are returned as-is.
34use base64::Engine;
35use base64::engine::general_purpose::URL_SAFE_NO_PAD;
36use zeroize::{Zeroize, ZeroizeOnDrop};
37
38use crate::aead::{self, AeadError, NONCE_LEN};
39use crate::error::AuthError;
40
41/// Legacy sentinel prefix: ciphertext sealed without AAD.
42const ENC_PREFIX: &str = "enc:";
43
44/// Current sentinel prefix: ciphertext sealed with row-identity AAD.
45const ENC2_PREFIX: &str = "enc2:";
46
47/// 32-byte ChaCha20-Poly1305 key.  Wiped from memory on drop.
48#[derive(Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)]
49pub struct TokenEncryptionKey([u8; 32]);
50
51impl std::fmt::Debug for TokenEncryptionKey {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        f.write_str("TokenEncryptionKey(<redacted>)")
54    }
55}
56
57impl TokenEncryptionKey {
58    /// Parse a 32-byte key from a hex (64 chars) or base64url-no-pad (43 chars) string.
59    pub fn from_encoded(s: &str) -> Result<Self, AuthError> {
60        let s = s.trim();
61        if s.len() == 64 {
62            // 64-char hex string
63            let mut key = [0u8; 32];
64            for (i, chunk) in s.as_bytes().chunks(2).enumerate() {
65                let Ok(hex) = std::str::from_utf8(chunk) else {
66                    key.zeroize();
67                    return Err(AuthError::Config(
68                        "TOKEN_ENCRYPTION_KEY hex string contains invalid UTF-8".to_string(),
69                    ));
70                };
71                let Ok(byte) = u8::from_str_radix(hex, 16) else {
72                    key.zeroize();
73                    return Err(AuthError::Config(format!(
74                        "TOKEN_ENCRYPTION_KEY hex string contains invalid character `{hex}`"
75                    )));
76                };
77                key[i] = byte;
78            }
79            Ok(Self(key))
80        } else {
81            // Try base64url decode
82            let mut bytes = URL_SAFE_NO_PAD.decode(s).map_err(|_| {
83                AuthError::Config(
84                    "TOKEN_ENCRYPTION_KEY must be 64 hex digits or 43 base64url characters"
85                        .to_string(),
86                )
87            })?;
88            if bytes.len() != 32 {
89                bytes.zeroize();
90                return Err(AuthError::Config(format!(
91                    "TOKEN_ENCRYPTION_KEY base64-decoded to {} bytes, expected 32",
92                    bytes.len()
93                )));
94            }
95            let mut key = [0u8; 32];
96            key.copy_from_slice(&bytes);
97            bytes.zeroize();
98            Ok(Self(key))
99        }
100    }
101
102    /// Derive a deterministic 32-byte key from an arbitrary passphrase using
103    /// SHA-256.  Not for production use — provided so tests can create keys
104    /// without managing hex strings.
105    #[cfg(test)]
106    pub fn from_passphrase(passphrase: &str) -> Self {
107        use sha2::{Digest, Sha256};
108        let hash = Sha256::digest(passphrase.as_bytes());
109        let mut key = [0u8; 32];
110        key.copy_from_slice(&hash);
111        Self(key)
112    }
113}
114
115/// Seal `plaintext` with `aad` and encode as `<prefix><base64url(nonce||ct)>`.
116fn seal_to_string(
117    key: &TokenEncryptionKey,
118    plaintext: &str,
119    aad: &[u8],
120    prefix: &str,
121) -> Result<String, AuthError> {
122    let (ciphertext, nonce_bytes) = aead::seal(&key.0, plaintext.as_bytes(), aad)
123        .map_err(|_| AuthError::Storage("token encryption failed".to_string()))?;
124
125    // Layout: nonce (12 bytes) || ciphertext+tag
126    let mut blob = Vec::with_capacity(NONCE_LEN + ciphertext.len());
127    blob.extend_from_slice(&nonce_bytes);
128    blob.extend_from_slice(&ciphertext);
129
130    Ok(format!("{prefix}{}", URL_SAFE_NO_PAD.encode(&blob)))
131}
132
133/// Decode a `base64url(nonce||ct)` payload and open it with `aad`.
134fn open_from_encoded(
135    key: &TokenEncryptionKey,
136    encoded: &str,
137    aad: &[u8],
138) -> Result<String, AuthError> {
139    let blob = URL_SAFE_NO_PAD.decode(encoded).map_err(|_| {
140        AuthError::Storage("provider token ciphertext is not valid base64".to_string())
141    })?;
142
143    if blob.len() < NONCE_LEN {
144        return Err(AuthError::Storage(
145            "provider token ciphertext blob too short".to_string(),
146        ));
147    }
148
149    let (nonce_bytes, ciphertext) = blob.split_at(NONCE_LEN);
150    let plaintext_bytes =
151        aead::open(&key.0, nonce_bytes, ciphertext, aad).map_err(|_: AeadError| {
152            AuthError::Storage(
153                "provider token decryption failed (wrong key, wrong row binding, or corrupted data)"
154                    .to_string(),
155            )
156        })?;
157
158    String::from_utf8(plaintext_bytes)
159        .map_err(|_| AuthError::Storage("decrypted provider token is not valid UTF-8".to_string()))
160}
161
162/// Encrypt a provider refresh token in the **legacy** unbound format.
163///
164/// Returns `"enc:<base64url(nonce||ciphertext)>"`.  New write paths should
165/// use [`encrypt_provider_token_bound`] so the ciphertext is tied to its row
166/// identity; this function remains for compatibility and tests.
167pub fn encrypt_provider_token(
168    key: &TokenEncryptionKey,
169    plaintext: &str,
170) -> Result<String, AuthError> {
171    seal_to_string(key, plaintext, &[], ENC_PREFIX)
172}
173
174/// Encrypt a provider refresh token with AAD binding it to its row identity.
175///
176/// Returns `"enc2:<base64url(nonce||ciphertext)>"`.  The same `aad` bytes
177/// must be supplied to [`decrypt_provider_token_bound`]; a ciphertext moved
178/// to a row with a different identity fails decryption.
179pub fn encrypt_provider_token_bound(
180    key: &TokenEncryptionKey,
181    plaintext: &str,
182    aad: &[u8],
183) -> Result<String, AuthError> {
184    seal_to_string(key, plaintext, aad, ENC2_PREFIX)
185}
186
187/// Decrypt a provider refresh token that was encrypted by
188/// [`encrypt_provider_token`].
189///
190/// Equivalent to [`decrypt_provider_token_bound`] with empty AAD: `"enc2:"`
191/// values sealed with a non-empty binding fail closed here.
192pub fn decrypt_provider_token(key: &TokenEncryptionKey, stored: &str) -> Result<String, AuthError> {
193    decrypt_provider_token_bound(key, stored, &[])
194}
195
196/// Decrypt a stored provider refresh token, verifying `aad` for the current
197/// `"enc2:"` format.
198///
199/// Legacy `"enc:"` values carry no binding and decrypt regardless of `aad`
200/// (back-compat with rows written before AAD binding existed).  If the
201/// stored value has neither prefix it is returned as-is — this handles
202/// legacy plaintext rows and the case where no encryption key is configured.
203pub fn decrypt_provider_token_bound(
204    key: &TokenEncryptionKey,
205    stored: &str,
206    aad: &[u8],
207) -> Result<String, AuthError> {
208    if let Some(encoded) = stored.strip_prefix(ENC2_PREFIX) {
209        open_from_encoded(key, encoded, aad)
210    } else if let Some(encoded) = stored.strip_prefix(ENC_PREFIX) {
211        // Legacy unbound ciphertext — no AAD to verify.
212        open_from_encoded(key, encoded, &[])
213    } else {
214        // Not encrypted (legacy row or no-key path) — return plaintext.
215        Ok(stored.to_string())
216    }
217}
218
219/// Attempt to encrypt `value` in the legacy unbound format if a key is
220/// available.  If `key` is `None` the value is stored as plaintext.  New
221/// write paths should use [`maybe_encrypt_bound`].
222pub fn maybe_encrypt(key: Option<&TokenEncryptionKey>, value: &str) -> Result<String, AuthError> {
223    match key {
224        Some(k) => encrypt_provider_token(k, value),
225        None => Ok(value.to_string()),
226    }
227}
228
229/// Attempt to encrypt `value` with row-identity AAD if a key is available,
230/// returning the stored representation.  If `key` is `None` the value is
231/// stored as plaintext.
232pub fn maybe_encrypt_bound(
233    key: Option<&TokenEncryptionKey>,
234    value: &str,
235    aad: &[u8],
236) -> Result<String, AuthError> {
237    match key {
238        Some(k) => encrypt_provider_token_bound(k, value, aad),
239        None => Ok(value.to_string()),
240    }
241}
242
243/// Attempt to decrypt `stored` if it carries an encryption sentinel.
244/// Equivalent to [`maybe_decrypt_bound`] with empty AAD.
245pub fn maybe_decrypt(key: Option<&TokenEncryptionKey>, stored: &str) -> Result<String, AuthError> {
246    maybe_decrypt_bound(key, stored, &[])
247}
248
249/// Attempt to decrypt `stored` if it carries the `"enc2:"` or `"enc:"`
250/// prefix, verifying `aad` for the bound format.  If no key is provided but
251/// the value is encrypted, return an error — the caller should not silently
252/// return ciphertext as the token value.
253pub fn maybe_decrypt_bound(
254    key: Option<&TokenEncryptionKey>,
255    stored: &str,
256    aad: &[u8],
257) -> Result<String, AuthError> {
258    if stored.starts_with(ENC2_PREFIX) || stored.starts_with(ENC_PREFIX) {
259        let k = key.ok_or_else(|| {
260            AuthError::Config(
261                "provider token is encrypted but no TOKEN_ENCRYPTION_KEY is configured".to_string(),
262            )
263        })?;
264        decrypt_provider_token_bound(k, stored, aad)
265    } else {
266        // Plaintext — return as-is regardless of whether a key is present.
267        Ok(stored.to_string())
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    fn test_key() -> TokenEncryptionKey {
276        TokenEncryptionKey::from_passphrase("test-key-for-unit-tests")
277    }
278
279    #[test]
280    fn encrypt_decrypt_round_trip() {
281        let key = test_key();
282        let token = "fake-google-refresh-token-for-tests";
283        let encrypted = encrypt_provider_token(&key, token).unwrap();
284        assert!(
285            encrypted.starts_with(ENC_PREFIX),
286            "ciphertext should carry enc: prefix"
287        );
288        assert_ne!(encrypted, token, "ciphertext must not equal plaintext");
289        let decrypted = decrypt_provider_token(&key, &encrypted).unwrap();
290        assert_eq!(decrypted, token);
291    }
292
293    #[test]
294    fn bound_encrypt_decrypt_round_trip() {
295        let key = test_key();
296        let token = "fake-google-refresh-token-for-tests";
297        let aad = b"refresh_token_hash=abc123";
298        let encrypted = encrypt_provider_token_bound(&key, token, aad).unwrap();
299        assert!(
300            encrypted.starts_with(ENC2_PREFIX),
301            "bound ciphertext should carry enc2: prefix"
302        );
303        assert_ne!(encrypted, token, "ciphertext must not equal plaintext");
304        let decrypted = decrypt_provider_token_bound(&key, &encrypted, aad).unwrap();
305        assert_eq!(decrypted, token);
306    }
307
308    #[test]
309    fn bound_ciphertext_fails_with_wrong_aad() {
310        let key = test_key();
311        let encrypted =
312            encrypt_provider_token_bound(&key, "secret", b"refresh_token_hash=row-a").unwrap();
313        let err = decrypt_provider_token_bound(&key, &encrypted, b"refresh_token_hash=row-b")
314            .unwrap_err();
315        assert!(
316            err.to_string().contains("decryption failed"),
317            "transplanted ciphertext must fail closed, got: {err}"
318        );
319        assert_eq!(err.kind(), "internal_error");
320    }
321
322    #[test]
323    fn bound_ciphertext_fails_via_unbound_decrypt() {
324        let key = test_key();
325        let encrypted =
326            encrypt_provider_token_bound(&key, "secret", b"refresh_token_hash=row-a").unwrap();
327        assert!(
328            decrypt_provider_token(&key, &encrypted).is_err(),
329            "enc2 ciphertext must not decrypt without its AAD"
330        );
331    }
332
333    #[test]
334    fn legacy_enc_rows_decrypt_regardless_of_aad() {
335        let key = test_key();
336        let token = "legacy-token-written-before-aad-binding";
337        let encrypted = encrypt_provider_token(&key, token).unwrap();
338        // Bound decrypt with a row identity still accepts legacy blobs.
339        let decrypted =
340            decrypt_provider_token_bound(&key, &encrypted, b"refresh_token_hash=whatever").unwrap();
341        assert_eq!(decrypted, token);
342    }
343
344    #[test]
345    fn different_encryptions_of_same_token_differ() {
346        let key = test_key();
347        let token = "test-provider-token-same-value";
348        let enc1 = encrypt_provider_token_bound(&key, token, b"aad").unwrap();
349        let enc2 = encrypt_provider_token_bound(&key, token, b"aad").unwrap();
350        assert_ne!(
351            enc1, enc2,
352            "fresh nonce per encrypt must produce distinct ciphertexts"
353        );
354    }
355
356    #[test]
357    fn legacy_plaintext_returned_unchanged_via_decrypt() {
358        let key = test_key();
359        let plaintext = "legacy-plaintext-no-prefix";
360        let result = decrypt_provider_token_bound(&key, plaintext, b"aad").unwrap();
361        assert_eq!(result, plaintext);
362    }
363
364    #[test]
365    fn maybe_encrypt_without_key_is_identity() {
366        let value = "some-token";
367        let result = maybe_encrypt_bound(None, value, b"aad").unwrap();
368        assert_eq!(result, value);
369    }
370
371    #[test]
372    fn maybe_encrypt_bound_with_key_writes_enc2() {
373        let key = test_key();
374        let stored = maybe_encrypt_bound(Some(&key), "some-token", b"aad").unwrap();
375        assert!(
376            stored.starts_with(ENC2_PREFIX),
377            "new writes must use the AAD-bound sentinel, got: {stored}"
378        );
379    }
380
381    #[test]
382    fn maybe_decrypt_without_key_returns_plaintext() {
383        let result = maybe_decrypt_bound(None, "plaintext-token", b"aad").unwrap();
384        assert_eq!(result, "plaintext-token");
385    }
386
387    #[test]
388    fn maybe_decrypt_without_key_fails_on_encrypted_value() {
389        let key = test_key();
390        for encrypted in [
391            encrypt_provider_token(&key, "secret").unwrap(),
392            encrypt_provider_token_bound(&key, "secret", b"aad").unwrap(),
393        ] {
394            let err = maybe_decrypt_bound(None, &encrypted, b"aad").unwrap_err();
395            assert!(
396                err.to_string().contains("TOKEN_ENCRYPTION_KEY"),
397                "should tell operator to configure key, got: {err}"
398            );
399        }
400    }
401
402    #[test]
403    fn key_from_str_accepts_hex() {
404        let hex = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20";
405        let key = TokenEncryptionKey::from_encoded(hex).unwrap();
406        assert_eq!(key.0[0], 0x01);
407        assert_eq!(key.0[31], 0x20);
408    }
409
410    #[test]
411    fn key_from_str_rejects_short_hex() {
412        let err = TokenEncryptionKey::from_encoded("aabbcc").unwrap_err();
413        assert!(err.to_string().contains("TOKEN_ENCRYPTION_KEY"));
414    }
415
416    #[test]
417    fn key_debug_is_redacted() {
418        let key = test_key();
419        let debug = format!("{key:?}");
420        assert_eq!(debug, "TokenEncryptionKey(<redacted>)");
421    }
422
423    #[test]
424    fn tampered_ciphertext_fails_decryption() {
425        let key = test_key();
426        let encrypted = encrypt_provider_token_bound(&key, "secret", b"aad").unwrap();
427        // Decode the base64url payload, flip a byte in the binary blob, re-encode.
428        let encoded = encrypted.strip_prefix(ENC2_PREFIX).unwrap();
429        let mut blob = URL_SAFE_NO_PAD.decode(encoded).unwrap();
430        if blob.len() > 20 {
431            blob[20] ^= 0xff;
432        }
433        let tampered = format!("{ENC2_PREFIX}{}", URL_SAFE_NO_PAD.encode(&blob));
434        let result = decrypt_provider_token_bound(&key, &tampered, b"aad");
435        assert!(
436            result.is_err(),
437            "tampered ciphertext should fail to decrypt"
438        );
439    }
440}