aster_forge_crypto/
secret_envelope.rs

1//! Versioned authenticated encryption for product-owned persisted secrets.
2
3use 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
18/// Encrypts bytes into a versioned AES-256-GCM secret envelope.
19///
20/// `context` is used as HKDF info and must be a stable, non-empty product-owned purpose string.
21/// `aad` is authenticated but not stored in the envelope. Both values are persistence contracts:
22/// changing either one makes existing ciphertext fail authentication.
23///
24/// # Errors
25///
26/// Returns an error when `context` is empty, key derivation fails, or authenticated encryption
27/// fails. Error values never contain the master key, context, AAD, plaintext, or ciphertext.
28pub 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
38/// Decrypts a strict `v1:<base64url nonce>:<base64url ciphertext>` secret envelope.
39///
40/// # Errors
41///
42/// Returns a classified error for an empty context, malformed envelope, unsupported version, key
43/// derivation failure, or authentication failure. Error values never contain secret material.
44pub 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}