core_auth/
password_wrap.rs

1//! Password-wrap blob: AES-256-GCM wrapped master key under Argon2id-derived KEK.
2//!
3//! Binary format:
4//! ```text
5//! Version byte (1 byte): 0x01
6//! Nonce (12 bytes): random
7//! Ciphertext + GCM tag (48 bytes): AES-256-GCM(Argon2id(password, salt), master_key)
8//! ```
9//!
10//! Total: 61 bytes.
11//!
12//! The KEK is `Argon2id(password, salt)` — the same KDF used previously to
13//! derive the master key directly. In multi-factor mode, however, the master
14//! key is random (`getrandom(32)`) and the Argon2id output wraps it rather
15//! than being it.
16
17use crate::AuthError;
18use zeroize::Zeroize;
19
20/// Version of the password-wrap blob format.
21pub const PASSWORD_WRAP_VERSION: u8 = 0x01;
22
23/// Expected ciphertext length: 32-byte master key + 16-byte GCM tag.
24const CIPHERTEXT_LEN: usize = 48;
25
26/// Total blob size: 1 (version) + 12 (nonce) + 48 (ciphertext).
27const BLOB_LEN: usize = 1 + 12 + CIPHERTEXT_LEN;
28
29/// Parsed password-wrap blob.
30pub struct PasswordWrapBlob {
31    pub version: u8,
32    pub nonce: [u8; 12],
33    /// 32 bytes master key + 16 bytes GCM tag = 48 bytes.
34    pub ciphertext: Vec<u8>,
35}
36
37impl Drop for PasswordWrapBlob {
38    fn drop(&mut self) {
39        self.nonce.zeroize();
40        self.ciphertext.zeroize();
41    }
42}
43
44impl PasswordWrapBlob {
45    /// Serialize to the binary format.
46    #[must_use]
47    pub fn serialize(&self) -> Vec<u8> {
48        let mut buf = Vec::with_capacity(BLOB_LEN);
49        buf.push(self.version);
50        buf.extend_from_slice(&self.nonce);
51        buf.extend_from_slice(&self.ciphertext);
52        buf
53    }
54
55    /// Deserialize from the binary format.
56    ///
57    /// # Errors
58    ///
59    /// Returns `AuthError::InvalidBlob` if the data is truncated or has an
60    /// unsupported version.
61    pub fn deserialize(data: &[u8]) -> Result<Self, AuthError> {
62        if data.len() < BLOB_LEN {
63            return Err(AuthError::InvalidBlob(format!(
64                "password-wrap blob too short: {} bytes, expected {BLOB_LEN}",
65                data.len()
66            )));
67        }
68
69        let version = data[0];
70        if version != PASSWORD_WRAP_VERSION {
71            return Err(AuthError::InvalidBlob(format!(
72                "unsupported password-wrap version {version:#04x}, expected {PASSWORD_WRAP_VERSION:#04x}"
73            )));
74        }
75
76        let mut nonce = [0u8; 12];
77        nonce.copy_from_slice(&data[1..13]);
78
79        let ciphertext = data[13..13 + CIPHERTEXT_LEN].to_vec();
80
81        Ok(Self {
82            version,
83            nonce,
84            ciphertext,
85        })
86    }
87
88    /// Create a new blob by wrapping a master key under a password-derived KEK.
89    ///
90    /// `kek_bytes` must be 32 bytes (the Argon2id output). Zeroized after use.
91    ///
92    /// # Errors
93    ///
94    /// Returns an error if encryption fails or random nonce generation fails.
95    pub fn wrap(master_key: &[u8], kek_bytes: &mut [u8; 32]) -> Result<Self, AuthError> {
96        let encryption_key = core_crypto::EncryptionKey::from_bytes(kek_bytes)
97            .map_err(|_| AuthError::UnwrapFailed)?;
98
99        let mut nonce = [0u8; 12];
100        getrandom::getrandom(&mut nonce).map_err(|e| AuthError::Io(std::io::Error::other(e)))?;
101
102        let ciphertext = encryption_key
103            .encrypt(&nonce, master_key)
104            .map_err(|_| AuthError::UnwrapFailed)?;
105
106        kek_bytes.zeroize();
107
108        Ok(Self {
109            version: PASSWORD_WRAP_VERSION,
110            nonce,
111            ciphertext,
112        })
113    }
114
115    /// Unwrap the master key using a password-derived KEK.
116    ///
117    /// `kek_bytes` must be 32 bytes (the Argon2id output). Zeroized after use.
118    ///
119    /// # Errors
120    ///
121    /// Returns `AuthError::UnwrapFailed` if the KEK is wrong (GCM auth fails).
122    pub fn unwrap(&self, kek_bytes: &mut [u8; 32]) -> Result<core_crypto::SecureBytes, AuthError> {
123        let encryption_key = core_crypto::EncryptionKey::from_bytes(kek_bytes)
124            .map_err(|_| AuthError::UnwrapFailed)?;
125        kek_bytes.zeroize();
126
127        encryption_key
128            .decrypt(&self.nonce, &self.ciphertext)
129            .map_err(|_| AuthError::UnwrapFailed)
130    }
131
132    /// Path to the password-wrap blob for a profile.
133    #[must_use]
134    pub fn path(
135        config_dir: &std::path::Path,
136        profile: &core_types::TrustProfileName,
137    ) -> std::path::PathBuf {
138        config_dir
139            .join("vaults")
140            .join(format!("{profile}.password-wrap"))
141    }
142
143    /// Read blob from disk.
144    ///
145    /// # Errors
146    ///
147    /// Returns an error if the file does not exist or is corrupt.
148    pub fn load(
149        config_dir: &std::path::Path,
150        profile: &core_types::TrustProfileName,
151    ) -> Result<Self, AuthError> {
152        let path = Self::path(config_dir, profile);
153        let data = std::fs::read(&path).map_err(|e| {
154            AuthError::Io(std::io::Error::new(
155                e.kind(),
156                format!("failed to read password-wrap blob {}: {e}", path.display()),
157            ))
158        })?;
159        Self::deserialize(&data)
160    }
161
162    /// Write blob to disk atomically with restrictive permissions.
163    ///
164    /// # Errors
165    ///
166    /// Returns an I/O error if the write fails.
167    pub fn save(
168        &self,
169        config_dir: &std::path::Path,
170        profile: &core_types::TrustProfileName,
171    ) -> Result<(), AuthError> {
172        let path = Self::path(config_dir, profile);
173        let vaults_dir = config_dir.join("vaults");
174        std::fs::create_dir_all(&vaults_dir)?;
175
176        let tmp_path = path.with_extension("password-wrap.tmp");
177        std::fs::write(&tmp_path, self.serialize())?;
178
179        #[cfg(unix)]
180        {
181            use std::os::unix::fs::PermissionsExt;
182            std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o600))?;
183        }
184
185        std::fs::rename(&tmp_path, &path)?;
186        Ok(())
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    #[test]
195    fn round_trip_serialize() {
196        let blob = PasswordWrapBlob {
197            version: PASSWORD_WRAP_VERSION,
198            nonce: [0xAA; 12],
199            ciphertext: vec![0xBB; CIPHERTEXT_LEN],
200        };
201
202        let data = blob.serialize();
203        assert_eq!(data.len(), BLOB_LEN);
204
205        let parsed = PasswordWrapBlob::deserialize(&data).unwrap();
206        assert_eq!(parsed.version, PASSWORD_WRAP_VERSION);
207        assert_eq!(parsed.nonce, [0xAA; 12]);
208        assert_eq!(parsed.ciphertext.len(), CIPHERTEXT_LEN);
209    }
210
211    #[test]
212    fn rejects_truncated() {
213        let result = PasswordWrapBlob::deserialize(&[0x01; 10]);
214        assert!(matches!(result, Err(AuthError::InvalidBlob(_))));
215    }
216
217    #[test]
218    fn rejects_wrong_version() {
219        let mut data = vec![0xFF];
220        data.extend_from_slice(&[0u8; 12 + CIPHERTEXT_LEN]);
221        let result = PasswordWrapBlob::deserialize(&data);
222        assert!(matches!(result, Err(AuthError::InvalidBlob(_))));
223    }
224
225    #[test]
226    fn wrap_and_unwrap_round_trip() {
227        let master_key = [0x42u8; 32];
228        let mut kek = [0xDE; 32];
229
230        let blob = PasswordWrapBlob::wrap(&master_key, &mut kek).unwrap();
231        // KEK should be zeroized after wrap.
232        assert_eq!(kek, [0u8; 32]);
233
234        // Re-derive KEK for unwrap.
235        let mut kek2 = [0xDE; 32];
236        let unwrapped = blob.unwrap(&mut kek2).unwrap();
237        assert_eq!(unwrapped.as_bytes(), &master_key);
238        // KEK should be zeroized after unwrap.
239        assert_eq!(kek2, [0u8; 32]);
240    }
241
242    #[test]
243    fn wrong_kek_fails_unwrap() {
244        let master_key = [0x42u8; 32];
245        let mut kek = [0xDE; 32];
246
247        let blob = PasswordWrapBlob::wrap(&master_key, &mut kek).unwrap();
248
249        let mut wrong_kek = [0xAB; 32];
250        let result = blob.unwrap(&mut wrong_kek);
251        assert!(matches!(result, Err(AuthError::UnwrapFailed)));
252    }
253
254    #[test]
255    fn save_and_load_round_trip() {
256        let dir = tempfile::tempdir().unwrap();
257        let profile = core_types::TrustProfileName::try_from("test-profile").unwrap();
258
259        let master_key = [0x42u8; 32];
260        let mut kek = [0xDE; 32];
261        let blob = PasswordWrapBlob::wrap(&master_key, &mut kek).unwrap();
262
263        blob.save(dir.path(), &profile).unwrap();
264        let loaded = PasswordWrapBlob::load(dir.path(), &profile).unwrap();
265
266        let mut kek2 = [0xDE; 32];
267        let unwrapped = loaded.unwrap(&mut kek2).unwrap();
268        assert_eq!(unwrapped.as_bytes(), &master_key);
269    }
270
271    #[test]
272    fn file_permissions_are_restrictive() {
273        let dir = tempfile::tempdir().unwrap();
274        let profile = core_types::TrustProfileName::try_from("test-profile").unwrap();
275
276        let blob = PasswordWrapBlob {
277            version: PASSWORD_WRAP_VERSION,
278            nonce: [0; 12],
279            ciphertext: vec![0; CIPHERTEXT_LEN],
280        };
281        blob.save(dir.path(), &profile).unwrap();
282
283        #[cfg(unix)]
284        {
285            use std::os::unix::fs::PermissionsExt;
286            let path = PasswordWrapBlob::path(dir.path(), &profile);
287            let perms = std::fs::metadata(&path).unwrap().permissions();
288            assert_eq!(perms.mode() & 0o777, 0o600);
289        }
290    }
291}