1use aes_gcm::{
4 Aes256Gcm, Nonce,
5 aead::{Aead, AeadCore, Generate, KeyInit, Payload},
6};
7use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
8use hkdf::Hkdf;
9use sha2::Sha256;
10
11use crate::{CryptoError, Result};
12
13const ENVELOPE_VERSION: &str = "v1";
14const NONCE_LEN: usize = 12;
15const KEY_LEN: usize = 32;
16type AesNonce = Nonce<<Aes256Gcm as AeadCore>::NonceSize>;
17
18pub fn encrypt_secret(
29 master_key: &[u8],
30 context: &[u8],
31 aad: &[u8],
32 plaintext: &[u8],
33) -> Result<String> {
34 let nonce = AesNonce::generate();
35 encrypt_secret_with_nonce(master_key, context, aad, plaintext, nonce.as_slice())
36}
37
38pub fn decrypt_secret(
45 master_key: &[u8],
46 context: &[u8],
47 aad: &[u8],
48 envelope: &str,
49) -> Result<Vec<u8>> {
50 if context.is_empty() {
51 return Err(CryptoError::InvalidSecretEnvelopePolicy);
52 }
53 let (nonce, ciphertext) = parse_envelope(envelope)?;
54 let cipher = cipher(master_key, context)?;
55 let nonce =
56 AesNonce::try_from(nonce.as_slice()).map_err(|_| CryptoError::InvalidSecretEnvelope)?;
57 cipher
58 .decrypt(
59 &nonce,
60 Payload {
61 msg: &ciphertext,
62 aad,
63 },
64 )
65 .map_err(|_| CryptoError::SecretEnvelopeAuthentication)
66}
67
68fn cipher(master_key: &[u8], context: &[u8]) -> Result<Aes256Gcm> {
69 if context.is_empty() {
70 return Err(CryptoError::InvalidSecretEnvelopePolicy);
71 }
72 let hkdf = Hkdf::<Sha256>::new(None, master_key);
73 let mut key = [0_u8; KEY_LEN];
74 hkdf.expand(context, &mut key)
75 .map_err(|_| CryptoError::SecretEnvelopeKeyDerivation)?;
76 Aes256Gcm::new_from_slice(&key).map_err(|_| CryptoError::SecretEnvelopeKeyDerivation)
77}
78
79fn parse_envelope(envelope: &str) -> Result<([u8; NONCE_LEN], Vec<u8>)> {
80 let mut parts = envelope.split(':');
81 let version = parts.next().ok_or(CryptoError::InvalidSecretEnvelope)?;
82 let nonce = parts.next().ok_or(CryptoError::InvalidSecretEnvelope)?;
83 let ciphertext = parts.next().ok_or(CryptoError::InvalidSecretEnvelope)?;
84 if parts.next().is_some() || nonce.is_empty() || ciphertext.is_empty() {
85 return Err(CryptoError::InvalidSecretEnvelope);
86 }
87 if version != ENVELOPE_VERSION {
88 return Err(CryptoError::UnsupportedSecretEnvelopeVersion);
89 }
90
91 let nonce = URL_SAFE_NO_PAD
92 .decode(nonce)
93 .map_err(|_| CryptoError::InvalidSecretEnvelope)?;
94 let nonce: [u8; NONCE_LEN] = nonce
95 .try_into()
96 .map_err(|_| CryptoError::InvalidSecretEnvelope)?;
97 let ciphertext = URL_SAFE_NO_PAD
98 .decode(ciphertext)
99 .map_err(|_| CryptoError::InvalidSecretEnvelope)?;
100 if ciphertext.is_empty() {
101 return Err(CryptoError::InvalidSecretEnvelope);
102 }
103 Ok((nonce, ciphertext))
104}
105
106fn encrypt_secret_with_nonce(
107 master_key: &[u8],
108 context: &[u8],
109 aad: &[u8],
110 plaintext: &[u8],
111 nonce: &[u8],
112) -> Result<String> {
113 let nonce: [u8; NONCE_LEN] = nonce
114 .try_into()
115 .map_err(|_| CryptoError::InvalidSecretEnvelopePolicy)?;
116 let nonce = AesNonce::try_from(nonce.as_slice())
117 .map_err(|_| CryptoError::InvalidSecretEnvelopePolicy)?;
118 let cipher = cipher(master_key, context)?;
119 let ciphertext = cipher
120 .encrypt(
121 &nonce,
122 Payload {
123 msg: plaintext,
124 aad,
125 },
126 )
127 .map_err(|_| CryptoError::SecretEnvelopeEncryption)?;
128 Ok(format!(
129 "{ENVELOPE_VERSION}:{}:{}",
130 URL_SAFE_NO_PAD.encode(nonce),
131 URL_SAFE_NO_PAD.encode(ciphertext)
132 ))
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138
139 const MASTER_KEY: &[u8] = b"forge-secret-envelope-test-master-key";
140 const MFA_CONTEXT: &[u8] = b"asterdrive:mfa-secret:v1";
141 const STORAGE_CONTEXT: &[u8] = b"asterdrive:storage-credential-token:v1";
142 const FIXED_NONCE: [u8; NONCE_LEN] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
143
144 fn fixture(context: &[u8], aad: &[u8], plaintext: &[u8]) -> String {
145 encrypt_secret_with_nonce(MASTER_KEY, context, aad, plaintext, &FIXED_NONCE)
146 .expect("fixed fixture encryption should succeed")
147 }
148
149 #[test]
150 fn round_trips_empty_binary_and_large_plaintext() {
151 for plaintext in [Vec::new(), vec![0, 255, 1, 128, 2], vec![0x5a; 1024 * 1024]] {
152 let envelope = encrypt_secret(MASTER_KEY, MFA_CONTEXT, b"factor:7", &plaintext)
153 .expect("secret encryption should succeed");
154 assert_eq!(
155 decrypt_secret(MASTER_KEY, MFA_CONTEXT, b"factor:7", &envelope)
156 .expect("secret decryption should succeed"),
157 plaintext
158 );
159 }
160 }
161
162 #[test]
163 fn fixed_drive_context_fixtures_are_stable_and_decryptable() {
164 let mfa = fixture(MFA_CONTEXT, b"mfa_factor:7:totp", b"JBSWY3DPEHPK3PXP");
165 let storage = fixture(
166 STORAGE_CONTEXT,
167 b"storage_policy_credential:9:microsoft_graph:access",
168 b"opaque-access-token",
169 );
170
171 assert_eq!(
172 mfa,
173 "v1:AAECAwQFBgcICQoL:pt1VIrNAcBeWaV0OT5oopWy1VJSEeAF3WeFu3yRJ_EE"
174 );
175 assert_eq!(
176 storage,
177 "v1:AAECAwQFBgcICQoL:lVf2A9KFG97Bm8ru8l9wF-i_taTNtAbZ-MYT3Kujr95sOUE"
178 );
179 assert_eq!(
180 decrypt_secret(MASTER_KEY, MFA_CONTEXT, b"mfa_factor:7:totp", &mfa)
181 .expect("MFA fixture should decrypt"),
182 b"JBSWY3DPEHPK3PXP"
183 );
184 assert_eq!(
185 decrypt_secret(
186 MASTER_KEY,
187 STORAGE_CONTEXT,
188 b"storage_policy_credential:9:microsoft_graph:access",
189 &storage,
190 )
191 .expect("storage fixture should decrypt"),
192 b"opaque-access-token"
193 );
194 }
195
196 #[test]
197 fn rejects_wrong_key_context_and_aad() {
198 let envelope = fixture(MFA_CONTEXT, b"aad-one", b"secret-value");
199
200 for result in [
201 decrypt_secret(b"wrong-key", MFA_CONTEXT, b"aad-one", &envelope),
202 decrypt_secret(MASTER_KEY, STORAGE_CONTEXT, b"aad-one", &envelope),
203 decrypt_secret(MASTER_KEY, MFA_CONTEXT, b"aad-two", &envelope),
204 ] {
205 assert!(matches!(
206 result,
207 Err(CryptoError::SecretEnvelopeAuthentication)
208 ));
209 }
210 }
211
212 #[test]
213 fn rejects_malformed_unknown_version_and_invalid_nonce() {
214 for envelope in [
215 "",
216 "v1",
217 "v1::ciphertext",
218 "v1:nonce:",
219 "v1:a:b:extra",
220 "v1:not_base64!:ciphertext",
221 "v1:AA:ciphertext",
222 ] {
223 assert!(matches!(
224 decrypt_secret(MASTER_KEY, MFA_CONTEXT, b"aad", envelope),
225 Err(CryptoError::InvalidSecretEnvelope)
226 ));
227 }
228 assert!(matches!(
229 decrypt_secret(MASTER_KEY, MFA_CONTEXT, b"aad", "v2:AA:AA"),
230 Err(CryptoError::UnsupportedSecretEnvelopeVersion)
231 ));
232 assert!(matches!(
233 decrypt_secret(MASTER_KEY, b"", b"aad", "v1:AA:AA"),
234 Err(CryptoError::InvalidSecretEnvelopePolicy)
235 ));
236 assert!(matches!(
237 encrypt_secret(MASTER_KEY, b"", b"aad", b"secret"),
238 Err(CryptoError::InvalidSecretEnvelopePolicy)
239 ));
240 }
241
242 #[test]
243 fn rejects_truncated_and_tampered_ciphertext() {
244 let envelope = fixture(MFA_CONTEXT, b"aad", b"secret-value");
245 let (version_and_nonce, encoded_ciphertext) = envelope
246 .rsplit_once(':')
247 .expect("fixture should contain ciphertext");
248 let ciphertext = URL_SAFE_NO_PAD
249 .decode(encoded_ciphertext)
250 .expect("fixture ciphertext should decode");
251
252 for changed in [ciphertext[..ciphertext.len() - 1].to_vec(), {
253 let mut tampered = ciphertext.clone();
254 tampered[0] ^= 0x80;
255 tampered
256 }] {
257 let changed = format!("{version_and_nonce}:{}", URL_SAFE_NO_PAD.encode(changed));
258 assert!(matches!(
259 decrypt_secret(MASTER_KEY, MFA_CONTEXT, b"aad", &changed),
260 Err(CryptoError::SecretEnvelopeAuthentication)
261 ));
262 }
263 }
264
265 #[test]
266 fn errors_do_not_expose_secret_material() {
267 let secret = "SENSITIVE_MASTER_KEY_AND_PLAINTEXT";
268 let envelope = fixture(MFA_CONTEXT, b"aad", secret.as_bytes());
269 let error = decrypt_secret(secret.as_bytes(), MFA_CONTEXT, b"wrong-aad", &envelope)
270 .expect_err("wrong key and AAD should fail authentication");
271
272 assert!(!error.to_string().contains(secret));
273 assert!(!format!("{error:?}").contains(secret));
274 assert!(!error.to_string().contains(&envelope));
275 assert!(!format!("{error:?}").contains(&envelope));
276 }
277}