pas_external/session_liveness/
cipher.rs1use aes_gcm::aead::{Aead, AeadCore, Generate, KeyInit};
31use aes_gcm::{Aes256Gcm, Nonce};
32use base64::{Engine, engine::general_purpose::STANDARD};
33
34#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct EncryptedRefreshToken(String);
45
46impl EncryptedRefreshToken {
47 #[must_use]
51 pub fn as_str(&self) -> &str {
52 &self.0
53 }
54
55 #[must_use]
57 pub fn into_inner(self) -> String {
58 self.0
59 }
60
61 #[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
75const NONCE_SIZE: usize = 12;
77
78#[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#[derive(Clone)]
111pub struct TokenCipher {
112 cipher: Aes256Gcm,
113}
114
115impl TokenCipher {
116 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 pub fn encrypt(&self, plaintext: &str) -> Result<String, CipherError> {
131 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 pub fn encrypt_to_token(&self, plaintext: &str) -> Result<EncryptedRefreshToken, CipherError> {
156 Ok(EncryptedRefreshToken(self.encrypt(plaintext)?))
157 }
158
159 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 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}