file.rs (11049B)
1 use crate::{ 2 RADROOTS_PROTECTED_STORE_KEY_LENGTH, RADROOTS_PROTECTED_STORE_NONCE_LENGTH, 3 RadrootsProtectedStoreEnvelope, error::RadrootsProtectedStoreError, 4 }; 5 use alloc::borrow::ToOwned; 6 use alloc::format; 7 use alloc::string::{String, ToString}; 8 use alloc::vec::Vec; 9 use chacha20poly1305::aead::{Aead, KeyInit, Payload}; 10 use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce}; 11 use getrandom::getrandom; 12 use radroots_secret_vault::{ 13 RadrootsSecretKeyWrapping, RadrootsSecretVault, RadrootsSecretVaultAccessError, 14 }; 15 use std::ffi::OsString; 16 use std::fs; 17 use std::path::{Path, PathBuf}; 18 use zeroize::Zeroize; 19 20 pub const RADROOTS_PROTECTED_FILE_SECRET_SUFFIX: &str = ".secret.json"; 21 pub const RADROOTS_PROTECTED_FILE_WRAPPING_KEY_FILE: &str = ".vault.key"; 22 pub const RADROOTS_PROTECTED_FILE_WRAPPED_KEY_VERSION: u8 = 1; 23 24 #[derive(Debug, Clone)] 25 pub struct RadrootsProtectedFileKeySource { 26 key_path: PathBuf, 27 } 28 29 impl RadrootsProtectedFileKeySource { 30 #[must_use] 31 pub fn new(path: impl AsRef<Path>) -> Self { 32 Self { 33 key_path: path.as_ref().to_path_buf(), 34 } 35 } 36 37 #[must_use] 38 pub fn from_sidecar_suffix(path: impl AsRef<Path>, suffix: &str) -> Self { 39 Self::new(sidecar_path(path, suffix)) 40 } 41 42 #[must_use] 43 pub fn key_path(&self) -> &Path { 44 self.key_path.as_path() 45 } 46 47 fn load_or_create_wrapping_key( 48 &self, 49 ) -> Result<[u8; RADROOTS_PROTECTED_STORE_KEY_LENGTH], RadrootsSecretVaultAccessError> { 50 if self.key_path.exists() { 51 return self.load_wrapping_key(); 52 } 53 54 if let Some(parent) = self.key_path.parent() 55 && !parent.as_os_str().is_empty() 56 { 57 fs::create_dir_all(parent).map_err(io_backend_error)?; 58 } 59 60 let mut key = [0_u8; RADROOTS_PROTECTED_STORE_KEY_LENGTH]; 61 getrandom(&mut key) 62 .map_err(|_| RadrootsSecretVaultAccessError::Backend("entropy unavailable".into()))?; 63 fs::write(&self.key_path, key.as_slice()).map_err(io_backend_error)?; 64 set_secret_permissions(&self.key_path)?; 65 Ok(key) 66 } 67 68 fn load_wrapping_key( 69 &self, 70 ) -> Result<[u8; RADROOTS_PROTECTED_STORE_KEY_LENGTH], RadrootsSecretVaultAccessError> { 71 let raw = fs::read(&self.key_path).map_err(io_backend_error)?; 72 if raw.len() != RADROOTS_PROTECTED_STORE_KEY_LENGTH { 73 return Err(RadrootsSecretVaultAccessError::Backend(format!( 74 "protected file wrapping key {} has invalid length {}", 75 self.key_path.display(), 76 raw.len() 77 ))); 78 } 79 80 let mut key = [0_u8; RADROOTS_PROTECTED_STORE_KEY_LENGTH]; 81 key.copy_from_slice(&raw); 82 Ok(key) 83 } 84 } 85 86 impl RadrootsSecretKeyWrapping for RadrootsProtectedFileKeySource { 87 type Error = RadrootsSecretVaultAccessError; 88 89 fn wrap_data_key(&self, key_slot: &str, plaintext_key: &[u8]) -> Result<Vec<u8>, Self::Error> { 90 let mut master_key = self.load_or_create_wrapping_key()?; 91 let mut nonce = [0_u8; RADROOTS_PROTECTED_STORE_NONCE_LENGTH]; 92 getrandom(&mut nonce) 93 .map_err(|_| RadrootsSecretVaultAccessError::Backend("entropy unavailable".into()))?; 94 let cipher = XChaCha20Poly1305::new(Key::from_slice(&master_key)); 95 let ciphertext = cipher 96 .encrypt( 97 XNonce::from_slice(&nonce), 98 Payload { 99 msg: plaintext_key, 100 aad: key_slot.as_bytes(), 101 }, 102 ) 103 .map_err(|_| { 104 RadrootsSecretVaultAccessError::Backend( 105 "failed to wrap protected file data key".into(), 106 ) 107 })?; 108 master_key.zeroize(); 109 110 let mut encoded = Vec::with_capacity(1 + nonce.len() + ciphertext.len()); 111 encoded.push(RADROOTS_PROTECTED_FILE_WRAPPED_KEY_VERSION); 112 encoded.extend_from_slice(&nonce); 113 encoded.extend_from_slice(ciphertext.as_slice()); 114 Ok(encoded) 115 } 116 117 fn unwrap_data_key(&self, key_slot: &str, wrapped_key: &[u8]) -> Result<Vec<u8>, Self::Error> { 118 if wrapped_key.len() <= 1 + RADROOTS_PROTECTED_STORE_NONCE_LENGTH { 119 return Err(RadrootsSecretVaultAccessError::Backend( 120 "wrapped protected file data key is truncated".into(), 121 )); 122 } 123 if wrapped_key[0] != RADROOTS_PROTECTED_FILE_WRAPPED_KEY_VERSION { 124 return Err(RadrootsSecretVaultAccessError::Backend(format!( 125 "unsupported protected file wrapped data key version {}", 126 wrapped_key[0] 127 ))); 128 } 129 130 let mut master_key = self.load_wrapping_key()?; 131 let nonce_offset = 1; 132 let ciphertext_offset = nonce_offset + RADROOTS_PROTECTED_STORE_NONCE_LENGTH; 133 let cipher = XChaCha20Poly1305::new(Key::from_slice(&master_key)); 134 let plaintext = cipher 135 .decrypt( 136 XNonce::from_slice(&wrapped_key[nonce_offset..ciphertext_offset]), 137 Payload { 138 msg: &wrapped_key[ciphertext_offset..], 139 aad: key_slot.as_bytes(), 140 }, 141 ) 142 .map_err(|_| { 143 RadrootsSecretVaultAccessError::Backend( 144 "failed to unwrap protected file data key".into(), 145 ) 146 })?; 147 master_key.zeroize(); 148 Ok(plaintext) 149 } 150 } 151 152 #[derive(Debug, Clone)] 153 pub struct RadrootsProtectedFileSecretVault { 154 secrets_dir: PathBuf, 155 secret_suffix: String, 156 key_source: RadrootsProtectedFileKeySource, 157 } 158 159 impl RadrootsProtectedFileSecretVault { 160 #[must_use] 161 pub fn new(path: impl AsRef<Path>) -> Self { 162 let secrets_dir = path.as_ref().to_path_buf(); 163 let key_source = RadrootsProtectedFileKeySource::new( 164 secrets_dir.join(RADROOTS_PROTECTED_FILE_WRAPPING_KEY_FILE), 165 ); 166 Self { 167 secrets_dir, 168 secret_suffix: RADROOTS_PROTECTED_FILE_SECRET_SUFFIX.to_owned(), 169 key_source, 170 } 171 } 172 173 #[must_use] 174 pub fn secret_suffix(&self) -> &str { 175 self.secret_suffix.as_str() 176 } 177 178 #[must_use] 179 pub fn key_source(&self) -> &RadrootsProtectedFileKeySource { 180 &self.key_source 181 } 182 183 fn secret_file_path(&self, slot: &str) -> PathBuf { 184 self.secrets_dir 185 .join(format!("{slot}{}", self.secret_suffix)) 186 } 187 } 188 189 impl RadrootsSecretVault for RadrootsProtectedFileSecretVault { 190 fn store_secret(&self, slot: &str, secret: &str) -> Result<(), RadrootsSecretVaultAccessError> { 191 fs::create_dir_all(&self.secrets_dir).map_err(io_backend_error)?; 192 let envelope = RadrootsProtectedStoreEnvelope::seal_with_wrapped_key( 193 &self.key_source, 194 slot, 195 secret.as_bytes(), 196 ) 197 .map_err(protected_store_backend_error)?; 198 let encoded = envelope 199 .encode_json() 200 .map_err(protected_store_backend_error)?; 201 let path = self.secret_file_path(slot); 202 fs::write(&path, encoded).map_err(io_backend_error)?; 203 set_secret_permissions(&path)?; 204 Ok(()) 205 } 206 207 fn load_secret(&self, slot: &str) -> Result<Option<String>, RadrootsSecretVaultAccessError> { 208 let path = self.secret_file_path(slot); 209 let encoded = match fs::read(&path) { 210 Ok(bytes) => bytes, 211 Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(None), 212 Err(source) => return Err(io_backend_error(source)), 213 }; 214 let envelope = RadrootsProtectedStoreEnvelope::decode_json(&encoded) 215 .map_err(protected_store_backend_error)?; 216 let plaintext = envelope 217 .open_with_wrapped_key(&self.key_source) 218 .map_err(protected_store_backend_error)?; 219 String::from_utf8(plaintext) 220 .map(Some) 221 .map_err(|source| RadrootsSecretVaultAccessError::Backend(source.to_string())) 222 } 223 224 fn remove_secret(&self, slot: &str) -> Result<(), RadrootsSecretVaultAccessError> { 225 match fs::remove_file(self.secret_file_path(slot)) { 226 Ok(()) => Ok(()), 227 Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(()), 228 Err(source) => Err(io_backend_error(source)), 229 } 230 } 231 } 232 233 #[must_use] 234 pub fn sidecar_path(path: impl AsRef<Path>, suffix: &str) -> PathBuf { 235 let mut value = OsString::from(path.as_ref().as_os_str()); 236 value.push(suffix); 237 PathBuf::from(value) 238 } 239 240 fn io_backend_error(source: std::io::Error) -> RadrootsSecretVaultAccessError { 241 RadrootsSecretVaultAccessError::Backend(source.to_string()) 242 } 243 244 fn protected_store_backend_error( 245 source: RadrootsProtectedStoreError, 246 ) -> RadrootsSecretVaultAccessError { 247 RadrootsSecretVaultAccessError::Backend(source.to_string()) 248 } 249 250 #[cfg(unix)] 251 fn set_secret_permissions(path: &Path) -> Result<(), RadrootsSecretVaultAccessError> { 252 use std::os::unix::fs::PermissionsExt; 253 254 let permissions = fs::Permissions::from_mode(0o600); 255 fs::set_permissions(path, permissions).map_err(io_backend_error) 256 } 257 258 #[cfg(not(unix))] 259 fn set_secret_permissions(_path: &Path) -> Result<(), RadrootsSecretVaultAccessError> { 260 Ok(()) 261 } 262 263 #[cfg(test)] 264 mod tests { 265 use super::*; 266 267 #[test] 268 fn sidecar_path_appends_suffix() { 269 let path = sidecar_path("/tmp/demo.enc.json", ".key"); 270 assert_eq!(path, PathBuf::from("/tmp/demo.enc.json.key")); 271 } 272 273 #[test] 274 fn file_key_source_wraps_and_unwraps() { 275 let temp = tempfile::tempdir().expect("tempdir"); 276 let key_source = RadrootsProtectedFileKeySource::new(temp.path().join("vault.key")); 277 let wrapped = key_source 278 .wrap_data_key("acct_demo", b"deadbeefdeadbeefdeadbeefdeadbeef") 279 .expect("wrap"); 280 let unwrapped = key_source 281 .unwrap_data_key("acct_demo", &wrapped) 282 .expect("unwrap"); 283 assert_eq!(unwrapped, b"deadbeefdeadbeefdeadbeefdeadbeef"); 284 } 285 286 #[test] 287 fn file_secret_vault_round_trips_secret() { 288 let temp = tempfile::tempdir().expect("tempdir"); 289 let vault = RadrootsProtectedFileSecretVault::new(temp.path()); 290 291 vault.store_secret("acct_demo", "deadbeef").expect("store"); 292 let loaded = vault.load_secret("acct_demo").expect("load"); 293 assert_eq!(loaded.as_deref(), Some("deadbeef")); 294 295 let raw = fs::read_to_string(temp.path().join("acct_demo.secret.json")).expect("raw file"); 296 assert!(!raw.contains("deadbeef")); 297 assert!(temp.path().join(".vault.key").is_file()); 298 } 299 300 #[test] 301 fn file_secret_vault_removes_secret() { 302 let temp = tempfile::tempdir().expect("tempdir"); 303 let vault = RadrootsProtectedFileSecretVault::new(temp.path()); 304 305 vault.store_secret("acct_demo", "deadbeef").expect("store"); 306 vault.remove_secret("acct_demo").expect("remove"); 307 assert!(vault.load_secret("acct_demo").expect("load").is_none()); 308 } 309 }