core_auth/
password_wrap.rs1use crate::AuthError;
18use zeroize::Zeroize;
19
20pub const PASSWORD_WRAP_VERSION: u8 = 0x01;
22
23const CIPHERTEXT_LEN: usize = 48;
25
26const BLOB_LEN: usize = 1 + 12 + CIPHERTEXT_LEN;
28
29pub struct PasswordWrapBlob {
31 pub version: u8,
32 pub nonce: [u8; 12],
33 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 #[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 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 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 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 #[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 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 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 assert_eq!(kek, [0u8; 32]);
233
234 let mut kek2 = [0xDE; 32];
236 let unwrapped = blob.unwrap(&mut kek2).unwrap();
237 assert_eq!(unwrapped.as_bytes(), &master_key);
238 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}