1use 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
41const ENC_PREFIX: &str = "enc:";
43
44const ENC2_PREFIX: &str = "enc2:";
46
47#[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 pub fn from_encoded(s: &str) -> Result<Self, AuthError> {
60 let s = s.trim();
61 if s.len() == 64 {
62 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 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 #[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
115fn 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 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
133fn 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
162pub fn encrypt_provider_token(
168 key: &TokenEncryptionKey,
169 plaintext: &str,
170) -> Result<String, AuthError> {
171 seal_to_string(key, plaintext, &[], ENC_PREFIX)
172}
173
174pub 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
187pub fn decrypt_provider_token(key: &TokenEncryptionKey, stored: &str) -> Result<String, AuthError> {
193 decrypt_provider_token_bound(key, stored, &[])
194}
195
196pub 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 open_from_encoded(key, encoded, &[])
213 } else {
214 Ok(stored.to_string())
216 }
217}
218
219pub 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
229pub 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
243pub fn maybe_decrypt(key: Option<&TokenEncryptionKey>, stored: &str) -> Result<String, AuthError> {
246 maybe_decrypt_bound(key, stored, &[])
247}
248
249pub 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 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 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 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}