lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

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 }