Skip to main content

pas_external/session_liveness/
cipher.rs

1//! AES-256-GCM wrapper for protecting PAS refresh_tokens at rest.
2//!
3//! # Why encryption and not hashing
4//!
5//! PAS stores refresh_tokens as one-way hashes because it only needs to
6//! *verify* incoming tokens. Consumers of this SDK must present the
7//! *original* refresh_token back to PAS on every liveness check, so
8//! hashing is not an option — a reversible transform is required.
9//!
10//! # Format
11//!
12//! Ciphertext is `base64_standard(nonce[12] || ciphertext+tag)`. The 96-bit
13//! nonce is fresh on every encrypt call (CSPRNG), so repeated encryption of
14//! the same plaintext produces distinct ciphertexts.
15//!
16//! # Key source & rotation
17//!
18//! The key is supplied at startup via [`TokenCipher::from_base64_key`];
19//! consumers typically read it from an env var and validate length +
20//! encoding there so failures surface before the first request. Because
21//! every ciphertext is bound to the key that produced it, a key rotation
22//! strategy has two shapes:
23//!
24//! - "Revoke everyone, re-auth on next visit" — zero code, lose all
25//!   active sessions. This is the consumer default.
26//! - Dual-key window — decrypt-with-either / encrypt-with-new for a
27//!   transitional period. Consumer-owned; not shipped here because the
28//!   right cutoff is policy, not cryptography.
29
30use aes_gcm::aead::{Aead, AeadCore, Generate, KeyInit};
31use aes_gcm::{Aes256Gcm, Nonce};
32use base64::{Engine, engine::general_purpose::STANDARD};
33
34/// A PAS `refresh_token` that has been encrypted with [`TokenCipher::encrypt`].
35///
36/// This newtype is the *only* shape in which the middleware hands a refresh
37/// token to consumer code (`SessionStore::create`). Plaintext refresh tokens
38/// never cross the SDK→consumer boundary, so consumers cannot accidentally
39/// persist them in clear.
40///
41/// Construct via [`TokenCipher::encrypt_to_token`] when you need to wrap a
42/// known plaintext yourself (e.g., test fixtures).
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct EncryptedRefreshToken(String);
45
46impl EncryptedRefreshToken {
47    /// Get the base64 ciphertext as a string slice.
48    ///
49    /// Use this when persisting the value to a database column. Do not log.
50    #[must_use]
51    pub fn as_str(&self) -> &str {
52        &self.0
53    }
54
55    /// Consume and return the inner ciphertext.
56    #[must_use]
57    pub fn into_inner(self) -> String {
58        self.0
59    }
60
61    /// Build from a previously-stored ciphertext (e.g., loaded from the
62    /// database when running a liveness check).
63    #[must_use]
64    pub fn from_stored(ciphertext: String) -> Self {
65        Self(ciphertext)
66    }
67}
68
69impl std::fmt::Display for EncryptedRefreshToken {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        f.write_str(&self.0)
72    }
73}
74
75/// 96-bit nonce size per RFC 5116 / AES-GCM spec.
76const NONCE_SIZE: usize = 12;
77
78/// Errors produced by [`TokenCipher`].
79///
80/// Every variant is "unrecoverable for this session" from the consumer's
81/// point of view — the correct response is to drop the cached auth
82/// context and force the user to re-auth. There is no retry that will
83/// turn a cipher failure into success.
84#[derive(Debug, thiserror::Error)]
85#[non_exhaustive]
86pub enum CipherError {
87    #[error("key decode failed: {0}")]
88    KeyDecode(String),
89    #[error("key must be 32 bytes after base64 decode, got {0}")]
90    KeyWrongLength(usize),
91    #[error("encrypt failed: {0}")]
92    Encrypt(String),
93    #[error("ciphertext decode failed: {0}")]
94    CiphertextDecode(String),
95    #[error("ciphertext shorter than nonce")]
96    CiphertextTruncated,
97    #[error("nonce size mismatch")]
98    NonceSize,
99    #[error("decrypt failed: {0}")]
100    Decrypt(String),
101    #[error("plaintext is not valid utf-8: {0}")]
102    PlaintextUtf8(String),
103}
104
105/// AES-256-GCM cipher for PAS refresh_token at-rest encryption.
106///
107/// Cheap to [`Clone`] — the inner key schedule is wrapped in a shared
108/// `Aes256Gcm` instance. Build one at startup from the base64-encoded
109/// key env var and hand clones to every adapter that needs it.
110#[derive(Clone)]
111pub struct TokenCipher {
112    cipher: Aes256Gcm,
113}
114
115impl TokenCipher {
116    /// Build a cipher from a base64-encoded 32-byte key.
117    ///
118    /// Whitespace is trimmed so env-var values with trailing newlines
119    /// still parse cleanly.
120    pub fn from_base64_key(key_b64: &str) -> Result<Self, CipherError> {
121        let bytes = STANDARD
122            .decode(key_b64.trim())
123            .map_err(|e| CipherError::KeyDecode(e.to_string()))?;
124        let cipher = Aes256Gcm::new_from_slice(&bytes)
125            .map_err(|_| CipherError::KeyWrongLength(bytes.len()))?;
126        Ok(Self { cipher })
127    }
128
129    /// Encrypt a plaintext token. Returns `base64(nonce || ciphertext+tag)`.
130    pub fn encrypt(&self, plaintext: &str) -> Result<String, CipherError> {
131        // aes-gcm 0.11 (AEAD 0.6) dropped `generate_nonce(&mut OsRng)`; nonces are
132        // now produced through the `Generate` trait, whose `generate()` draws from
133        // OS entropy directly. Same 96-bit random nonce per encryption as before —
134        // never derived from the plaintext or a counter.
135        let nonce = Nonce::<<Aes256Gcm as AeadCore>::NonceSize>::generate();
136        let ciphertext = self
137            .cipher
138            .encrypt(&nonce, plaintext.as_bytes())
139            .map_err(|e| CipherError::Encrypt(e.to_string()))?;
140
141        let nonce_bytes: &[u8] = nonce.as_ref();
142        let mut combined = Vec::with_capacity(NONCE_SIZE + ciphertext.len());
143        combined.extend_from_slice(nonce_bytes);
144        combined.extend_from_slice(&ciphertext);
145        Ok(STANDARD.encode(&combined))
146    }
147
148    /// Encrypt a plaintext token directly into the [`EncryptedRefreshToken`]
149    /// newtype. Prefer this over [`Self::encrypt`] when interfacing with
150    /// SDK middleware types.
151    ///
152    /// # Errors
153    ///
154    /// Returns the underlying [`CipherError`] from [`Self::encrypt`].
155    pub fn encrypt_to_token(&self, plaintext: &str) -> Result<EncryptedRefreshToken, CipherError> {
156        Ok(EncryptedRefreshToken(self.encrypt(plaintext)?))
157    }
158
159    /// Decrypt a ciphertext produced by [`Self::encrypt`].
160    ///
161    /// Any tampering (wrong key, truncated ciphertext, altered nonce,
162    /// altered tag) fails AEAD authentication and returns
163    /// [`CipherError::Decrypt`]. Callers should treat any error here as
164    /// "session unrecoverable, force re-auth" rather than a transient
165    /// condition.
166    pub fn decrypt(&self, ciphertext_b64: &str) -> Result<String, CipherError> {
167        let combined = STANDARD
168            .decode(ciphertext_b64.trim())
169            .map_err(|e| CipherError::CiphertextDecode(e.to_string()))?;
170
171        if combined.len() <= NONCE_SIZE {
172            return Err(CipherError::CiphertextTruncated);
173        }
174
175        let (nonce_bytes, ct) = combined.split_at(NONCE_SIZE);
176        let nonce_array: [u8; NONCE_SIZE] =
177            nonce_bytes.try_into().map_err(|_| CipherError::NonceSize)?;
178        let nonce = Nonce::from(nonce_array);
179
180        let plaintext = self
181            .cipher
182            .decrypt(&nonce, ct)
183            .map_err(|e| CipherError::Decrypt(e.to_string()))?;
184        String::from_utf8(plaintext).map_err(|e| CipherError::PlaintextUtf8(e.to_string()))
185    }
186}
187
188#[cfg(test)]
189#[allow(clippy::unwrap_used)]
190mod tests {
191    use std::assert_matches;
192
193    use super::*;
194
195    fn zero_key() -> String {
196        STANDARD.encode([0u8; 32])
197    }
198
199    fn other_key() -> String {
200        let mut key = [0u8; 32];
201        key[0] = 1;
202        STANDARD.encode(key)
203    }
204
205    #[test]
206    fn roundtrip_preserves_plaintext() {
207        let cipher = TokenCipher::from_base64_key(&zero_key()).unwrap();
208        let plain = "rt_live_abc123def456";
209        let encrypted = cipher.encrypt(plain).unwrap();
210        assert_eq!(cipher.decrypt(&encrypted).unwrap(), plain);
211    }
212
213    #[test]
214    fn encryption_is_randomized() {
215        let cipher = TokenCipher::from_base64_key(&zero_key()).unwrap();
216        let plain = "same input";
217        let a = cipher.encrypt(plain).unwrap();
218        let b = cipher.encrypt(plain).unwrap();
219        assert_ne!(a, b);
220        assert_eq!(cipher.decrypt(&a).unwrap(), plain);
221        assert_eq!(cipher.decrypt(&b).unwrap(), plain);
222    }
223
224    #[test]
225    fn wrong_key_fails_authentication() {
226        let c1 = TokenCipher::from_base64_key(&zero_key()).unwrap();
227        let c2 = TokenCipher::from_base64_key(&other_key()).unwrap();
228        let ct = c1.encrypt("secret").unwrap();
229        assert_matches!(c2.decrypt(&ct), Err(CipherError::Decrypt(_)));
230    }
231
232    #[test]
233    fn tampered_ciphertext_fails_authentication() {
234        let cipher = TokenCipher::from_base64_key(&zero_key()).unwrap();
235        let ct = cipher.encrypt("secret").unwrap();
236        let mut bytes = STANDARD.decode(&ct).unwrap();
237        let last = bytes.len() - 1;
238        bytes[last] ^= 0x01;
239        let tampered = STANDARD.encode(&bytes);
240        assert_matches!(cipher.decrypt(&tampered), Err(CipherError::Decrypt(_)));
241    }
242
243    #[test]
244    fn short_input_is_rejected() {
245        let cipher = TokenCipher::from_base64_key(&zero_key()).unwrap();
246        let too_short = STANDARD.encode([0u8; 8]);
247        assert_matches!(
248            cipher.decrypt(&too_short),
249            Err(CipherError::CiphertextTruncated)
250        );
251    }
252
253    #[test]
254    fn invalid_base64_key_is_rejected() {
255        assert!(matches!(
256            TokenCipher::from_base64_key("not base64!!!"),
257            Err(CipherError::KeyDecode(_))
258        ));
259    }
260
261    #[test]
262    fn wrong_length_key_is_rejected() {
263        let too_short = STANDARD.encode([0u8; 16]);
264        assert!(matches!(
265            TokenCipher::from_base64_key(&too_short),
266            Err(CipherError::KeyWrongLength(16))
267        ));
268    }
269
270    #[test]
271    fn key_with_trailing_whitespace_still_parses() {
272        let mut key = zero_key();
273        key.push('\n');
274        key.push(' ');
275        assert!(TokenCipher::from_base64_key(&key).is_ok());
276    }
277
278    #[test]
279    fn encrypt_to_token_roundtrips_via_from_stored() {
280        let cipher = TokenCipher::from_base64_key(&zero_key()).unwrap();
281        let plaintext = "rt_live_xyz789";
282        let token = cipher.encrypt_to_token(plaintext).unwrap();
283
284        // Persisted as string, loaded back later via from_stored
285        let persisted: String = token.as_str().to_string();
286        let restored = EncryptedRefreshToken::from_stored(persisted);
287
288        assert_eq!(cipher.decrypt(restored.as_str()).unwrap(), plaintext);
289    }
290
291    #[test]
292    fn encrypted_refresh_token_display_matches_inner() {
293        let token = EncryptedRefreshToken::from_stored("base64-cipher".to_string());
294        assert_eq!(token.to_string(), "base64-cipher");
295        assert_eq!(token.as_str(), "base64-cipher");
296    }
297}