lib

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

file.rs (17796B)


      1 //! Explicit file-backed secret adapters.
      2 
      3 use crate::SecretRef;
      4 use crate::envelope::Nonce;
      5 use crate::error::{Error, Operation};
      6 use crate::id::BackendKind;
      7 use crate::provider::{CapabilitySupport, ResidencySupport, SecretCapabilities, SecretProvider};
      8 use crate::wrapping::{
      9     BoxFuture, KeyWrapping, LegacyV1UnwrapRequest, SecretMaterial, UnwrapRequest, WrapRequest,
     10     WrappedSecret,
     11 };
     12 use alloc::string::String;
     13 use alloc::vec::Vec;
     14 use chacha20poly1305::aead::{Aead, KeyInit, Payload};
     15 use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce};
     16 use core::fmt;
     17 use std::fs::{self, File};
     18 use std::io::{Read, Write};
     19 use std::path::{Component, Path, PathBuf};
     20 use tempfile::NamedTempFile;
     21 
     22 const FILE_MAGIC: &[u8; 4] = b"RRK1";
     23 const FILE_VERSION: u8 = 1;
     24 const DATA_KEY_BYTES: usize = 32;
     25 const NONCE_BYTES: usize = 24;
     26 const MAX_FILE_BYTES: usize = 128 * 1024;
     27 
     28 /// Explicit root handling for a file provider.
     29 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
     30 #[non_exhaustive]
     31 pub enum FileOpenMode {
     32     /// Create a new root and fail if it already exists.
     33     CreateNew,
     34     /// Open an existing secure root without creating it.
     35     OpenExisting,
     36 }
     37 
     38 /// File-backed provider using a caller-supplied in-memory master key.
     39 pub struct FileProvider {
     40     root: PathBuf,
     41     master_key: SecretMaterial,
     42 }
     43 
     44 impl FileProvider {
     45     /// Creates or opens an absolute normalized provider root.
     46     pub fn open(
     47         root: impl AsRef<Path>,
     48         mode: FileOpenMode,
     49         master_key: SecretMaterial,
     50     ) -> Result<Self, Error> {
     51         validate_master_key(&master_key)?;
     52         let root = root.as_ref();
     53         validate_requested_root(root)?;
     54         match mode {
     55             FileOpenMode::CreateNew => {
     56                 fs::create_dir(root).map_err(|_| backend_failure(Operation::Open))?;
     57                 set_directory_permissions(root)?;
     58             }
     59             FileOpenMode::OpenExisting => validate_existing_root(root)?,
     60         }
     61         let root = fs::canonicalize(root).map_err(|_| backend_failure(Operation::Open))?;
     62         validate_existing_root(&root)?;
     63         Ok(Self { root, master_key })
     64     }
     65 
     66     /// Persists caller-owned material with an explicit unique nonce.
     67     pub fn provision(
     68         &self,
     69         reference: &SecretRef,
     70         material: &SecretMaterial,
     71         nonce: Nonce,
     72     ) -> Result<(), Error> {
     73         validate_file_reference(reference)?;
     74         let path = self.entry_path(reference);
     75         reject_existing_path(&path)?;
     76         let encoded = self.seal_entry(reference, material, nonce)?;
     77         self.persist_noclobber(path.as_path(), encoded.as_slice())
     78     }
     79 
     80     /// Resumes safely if the next version reached disk before old-version removal.
     81     pub fn rotate(
     82         &self,
     83         current: &SecretRef,
     84         next: &SecretRef,
     85         material: &SecretMaterial,
     86         nonce: Nonce,
     87     ) -> Result<(), Error> {
     88         validate_file_reference(current)?;
     89         validate_file_reference(next)?;
     90         if current.id().as_str() != next.id().as_str()
     91             || next.key_version() <= current.key_version()
     92         {
     93             return Err(Error::InvalidRotation);
     94         }
     95         if !self.contains(current)? {
     96             return Err(not_found(current));
     97         }
     98 
     99         if self.contains(next)? {
    100             let persisted = self.read_entry(next)?;
    101             let matches = persisted
    102                 .expose_secret(|expected| material.expose_secret(|actual| expected == actual));
    103             if !matches {
    104                 return Err(Error::SecretAlreadyExists {
    105                     backend: BackendKind::File,
    106                     key_version: next.key_version().get(),
    107                 });
    108             }
    109         } else {
    110             self.provision(next, material, nonce)?;
    111         }
    112         self.remove(current)?;
    113         Ok(())
    114     }
    115 
    116     /// Removes one exact key revision when present.
    117     pub fn remove(&self, reference: &SecretRef) -> Result<bool, Error> {
    118         validate_file_reference(reference)?;
    119         let path = self.entry_path(reference);
    120         match fs::symlink_metadata(&path) {
    121             Ok(metadata) => {
    122                 validate_entry_metadata(&metadata)?;
    123                 validate_file_permissions(&metadata)?;
    124                 fs::remove_file(path).map_err(|_| backend_failure(Operation::Remove))?;
    125                 sync_directory(&self.root)?;
    126                 Ok(true)
    127             }
    128             Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(false),
    129             Err(_) => Err(backend_failure(Operation::Remove)),
    130         }
    131     }
    132 
    133     /// Reports whether one exact regular-file entry exists.
    134     pub fn contains(&self, reference: &SecretRef) -> Result<bool, Error> {
    135         validate_file_reference(reference)?;
    136         match fs::symlink_metadata(self.entry_path(reference)) {
    137             Ok(metadata) => {
    138                 validate_entry_metadata(&metadata)?;
    139                 validate_file_permissions(&metadata)?;
    140                 Ok(true)
    141             }
    142             Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(false),
    143             Err(_) => Err(backend_failure(Operation::Read)),
    144         }
    145     }
    146 
    147     fn entry_path(&self, reference: &SecretRef) -> PathBuf {
    148         self.root.join(entry_name(reference))
    149     }
    150 
    151     fn seal_entry(
    152         &self,
    153         reference: &SecretRef,
    154         material: &SecretMaterial,
    155         nonce: Nonce,
    156     ) -> Result<Vec<u8>, Error> {
    157         let aad = entry_aad(reference);
    158         let ciphertext = self.master_key.expose_secret(|master_key| {
    159             material.expose_secret(|plaintext| {
    160                 let cipher = XChaCha20Poly1305::new(Key::from_slice(master_key));
    161                 cipher
    162                     .encrypt(
    163                         XNonce::from_slice(nonce.as_bytes()),
    164                         Payload {
    165                             msg: plaintext,
    166                             aad: aad.as_slice(),
    167                         },
    168                     )
    169                     .map_err(|_| Error::EncryptFailed)
    170             })
    171         })?;
    172         let ciphertext_len =
    173             u32::try_from(ciphertext.len()).map_err(|_| backend_failure(Operation::Write))?;
    174         let mut encoded = Vec::with_capacity(4 + 1 + NONCE_BYTES + 4 + ciphertext.len());
    175         encoded.extend_from_slice(FILE_MAGIC);
    176         encoded.push(FILE_VERSION);
    177         encoded.extend_from_slice(nonce.as_bytes());
    178         encoded.extend_from_slice(&ciphertext_len.to_be_bytes());
    179         encoded.extend_from_slice(ciphertext.as_slice());
    180         if encoded.len() > MAX_FILE_BYTES {
    181             return Err(Error::EnvelopeTooLarge {
    182                 actual_bytes: encoded.len(),
    183                 max_bytes: MAX_FILE_BYTES,
    184             });
    185         }
    186         Ok(encoded)
    187     }
    188 
    189     fn read_entry(&self, reference: &SecretRef) -> Result<SecretMaterial, Error> {
    190         let path = self.entry_path(reference);
    191         let metadata = fs::symlink_metadata(&path).map_err(|source| {
    192             if source.kind() == std::io::ErrorKind::NotFound {
    193                 not_found(reference)
    194             } else {
    195                 backend_failure(Operation::Read)
    196             }
    197         })?;
    198         validate_entry_metadata(&metadata)?;
    199         validate_file_permissions(&metadata)?;
    200         let file = File::open(path).map_err(|_| backend_failure(Operation::Read))?;
    201         let mut encoded = Vec::new();
    202         file.take((MAX_FILE_BYTES + 1) as u64)
    203             .read_to_end(&mut encoded)
    204             .map_err(|_| backend_failure(Operation::Read))?;
    205         if encoded.len() > MAX_FILE_BYTES {
    206             return Err(Error::EnvelopeTooLarge {
    207                 actual_bytes: encoded.len(),
    208                 max_bytes: MAX_FILE_BYTES,
    209             });
    210         }
    211         self.open_entry(reference, encoded.as_slice())
    212     }
    213 
    214     fn open_entry(&self, reference: &SecretRef, encoded: &[u8]) -> Result<SecretMaterial, Error> {
    215         let minimum = 4 + 1 + NONCE_BYTES + 4 + 16;
    216         if encoded.len() < minimum || &encoded[..4] != FILE_MAGIC {
    217             return Err(Error::EnvelopeMalformed);
    218         }
    219         if encoded[4] != FILE_VERSION {
    220             return Err(Error::UnsupportedEnvelopeVersion {
    221                 version: u16::from(encoded[4]),
    222             });
    223         }
    224         let nonce: [u8; NONCE_BYTES] = encoded[5..5 + NONCE_BYTES]
    225             .try_into()
    226             .map_err(|_| Error::EnvelopeMalformed)?;
    227         let length_offset = 5 + NONCE_BYTES;
    228         let ciphertext_len = u32::from_be_bytes(
    229             encoded[length_offset..length_offset + 4]
    230                 .try_into()
    231                 .map_err(|_| Error::EnvelopeMalformed)?,
    232         ) as usize;
    233         let ciphertext = &encoded[length_offset + 4..];
    234         if ciphertext.len() != ciphertext_len {
    235             return Err(Error::EnvelopeMalformed);
    236         }
    237         let aad = entry_aad(reference);
    238         let plaintext = self.master_key.expose_secret(|master_key| {
    239             let cipher = XChaCha20Poly1305::new(Key::from_slice(master_key));
    240             cipher
    241                 .decrypt(
    242                     XNonce::from_slice(&nonce),
    243                     Payload {
    244                         msg: ciphertext,
    245                         aad: aad.as_slice(),
    246                     },
    247                 )
    248                 .map_err(|_| Error::DecryptFailed)
    249         })?;
    250         SecretMaterial::from_owned(plaintext)
    251     }
    252 
    253     fn persist_noclobber(&self, path: &Path, encoded: &[u8]) -> Result<(), Error> {
    254         let mut temporary =
    255             NamedTempFile::new_in(&self.root).map_err(|_| backend_failure(Operation::Write))?;
    256         set_file_permissions(temporary.as_file())?;
    257         temporary
    258             .write_all(encoded)
    259             .map_err(|_| backend_failure(Operation::Write))?;
    260         temporary
    261             .as_file()
    262             .sync_all()
    263             .map_err(|_| backend_failure(Operation::Write))?;
    264         temporary.persist_noclobber(path).map_err(|error| {
    265             if error.error.kind() == std::io::ErrorKind::AlreadyExists {
    266                 Error::SecretAlreadyExists {
    267                     backend: BackendKind::File,
    268                     key_version: key_version_from_path(path).unwrap_or(0),
    269                 }
    270             } else {
    271                 backend_failure(Operation::Write)
    272             }
    273         })?;
    274         sync_directory(&self.root)
    275     }
    276 }
    277 
    278 impl fmt::Debug for FileProvider {
    279     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    280         formatter.write_str("FileProvider(<redacted>)")
    281     }
    282 }
    283 
    284 impl KeyWrapping for FileProvider {
    285     fn wrap<'a>(&'a self, request: WrapRequest<'a>) -> BoxFuture<'a, Result<WrappedSecret, Error>> {
    286         Box::pin(async move {
    287             validate_file_reference(request.reference())?;
    288             let persisted = self.read_entry(request.reference())?;
    289             let matches = persisted.expose_secret(|expected| {
    290                 request
    291                     .plaintext()
    292                     .expose_secret(|actual| expected == actual)
    293             });
    294             if !matches {
    295                 return Err(backend_failure(Operation::Wrap));
    296             }
    297             WrappedSecret::from_bytes(wrapping_token(request.reference(), request.context()))
    298         })
    299     }
    300 
    301     fn unwrap<'a>(
    302         &'a self,
    303         request: UnwrapRequest<'a>,
    304     ) -> BoxFuture<'a, Result<SecretMaterial, Error>> {
    305         Box::pin(async move {
    306             validate_file_reference(request.reference())?;
    307             if request.wrapped().as_bytes()
    308                 != wrapping_token(request.reference(), request.context()).as_slice()
    309             {
    310                 return Err(backend_failure(Operation::Unwrap));
    311             }
    312             self.read_entry(request.reference())
    313         })
    314     }
    315 
    316     fn unwrap_legacy_v1<'a>(
    317         &'a self,
    318         request: LegacyV1UnwrapRequest<'a>,
    319     ) -> BoxFuture<'a, Result<SecretMaterial, Error>> {
    320         Box::pin(async move {
    321             validate_file_reference(request.reference())?;
    322             if request.wrapped().as_bytes() != entry_token(request.reference()).as_slice() {
    323                 return Err(backend_failure(Operation::Unwrap));
    324             }
    325             self.read_entry(request.reference())
    326         })
    327     }
    328 }
    329 
    330 impl SecretProvider for FileProvider {
    331     fn backend_kind(&self) -> BackendKind {
    332         BackendKind::File
    333     }
    334 
    335     fn capabilities(&self) -> SecretCapabilities {
    336         SecretCapabilities::available(
    337             ResidencySupport::UserProfile,
    338             CapabilitySupport::Unavailable,
    339             CapabilitySupport::Unavailable,
    340         )
    341     }
    342 }
    343 
    344 fn validate_requested_root(root: &Path) -> Result<(), Error> {
    345     if !root.is_absolute()
    346         || root
    347             .components()
    348             .any(|component| matches!(component, Component::CurDir | Component::ParentDir))
    349     {
    350         return Err(Error::UnsafePath);
    351     }
    352     if let Ok(metadata) = fs::symlink_metadata(root)
    353         && metadata.file_type().is_symlink()
    354     {
    355         return Err(Error::UnsafePath);
    356     }
    357     Ok(())
    358 }
    359 
    360 fn validate_existing_root(root: &Path) -> Result<(), Error> {
    361     let metadata = fs::symlink_metadata(root).map_err(|_| backend_failure(Operation::Open))?;
    362     if metadata.file_type().is_symlink() || !metadata.is_dir() {
    363         return Err(Error::UnsafePath);
    364     }
    365     validate_directory_permissions(&metadata)
    366 }
    367 
    368 fn validate_entry_metadata(metadata: &fs::Metadata) -> Result<(), Error> {
    369     if metadata.file_type().is_symlink() || !metadata.is_file() {
    370         return Err(Error::UnsafePath);
    371     }
    372     Ok(())
    373 }
    374 
    375 fn validate_master_key(master_key: &SecretMaterial) -> Result<(), Error> {
    376     if master_key.len() != DATA_KEY_BYTES {
    377         return Err(Error::InvalidDataKeyLength {
    378             actual_bytes: master_key.len(),
    379         });
    380     }
    381     Ok(())
    382 }
    383 
    384 fn validate_file_reference(reference: &SecretRef) -> Result<(), Error> {
    385     if reference.backend() != BackendKind::File {
    386         return Err(Error::BackendMismatch {
    387             provider: BackendKind::File,
    388             reference: reference.backend(),
    389         });
    390     }
    391     Ok(())
    392 }
    393 
    394 fn reject_existing_path(path: &Path) -> Result<(), Error> {
    395     match fs::symlink_metadata(path) {
    396         Ok(metadata) => {
    397             validate_entry_metadata(&metadata)?;
    398             Err(Error::SecretAlreadyExists {
    399                 backend: BackendKind::File,
    400                 key_version: key_version_from_path(path).unwrap_or(0),
    401             })
    402         }
    403         Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(()),
    404         Err(_) => Err(backend_failure(Operation::Write)),
    405     }
    406 }
    407 
    408 fn entry_name(reference: &SecretRef) -> String {
    409     alloc::format!(
    410         "{}.v{}.rrk",
    411         hex_identifier(reference.id().as_str()),
    412         reference.key_version().get()
    413     )
    414 }
    415 
    416 fn hex_identifier(identifier: &str) -> String {
    417     const HEX: &[u8; 16] = b"0123456789abcdef";
    418     let mut encoded = String::with_capacity(identifier.len() * 2);
    419     for byte in identifier.as_bytes() {
    420         encoded.push(char::from(HEX[usize::from(byte >> 4)]));
    421         encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
    422     }
    423     encoded
    424 }
    425 
    426 fn entry_token(reference: &SecretRef) -> Vec<u8> {
    427     let mut token = Vec::from(b"radroots-file-key-v1\0".as_slice());
    428     token.extend_from_slice(&reference.key_version().get().to_be_bytes());
    429     token.extend_from_slice(reference.id().as_str().as_bytes());
    430     token
    431 }
    432 
    433 fn wrapping_token(reference: &SecretRef, context: &crate::context::EnvelopeContext) -> Vec<u8> {
    434     let mut token = entry_token(reference);
    435     token.extend_from_slice(&context.authentication_digest());
    436     token
    437 }
    438 
    439 fn entry_aad(reference: &SecretRef) -> Vec<u8> {
    440     entry_token(reference)
    441 }
    442 
    443 fn key_version_from_path(path: &Path) -> Option<u32> {
    444     let name = path.file_name()?.to_str()?;
    445     let (_, suffix) = name.rsplit_once(".v")?;
    446     suffix.strip_suffix(".rrk")?.parse().ok()
    447 }
    448 
    449 fn not_found(reference: &SecretRef) -> Error {
    450     Error::SecretNotFound {
    451         backend: BackendKind::File,
    452         key_version: reference.key_version().get(),
    453     }
    454 }
    455 
    456 const fn backend_failure(operation: Operation) -> Error {
    457     Error::BackendFailure {
    458         backend: BackendKind::File,
    459         operation,
    460     }
    461 }
    462 
    463 #[cfg(unix)]
    464 fn set_directory_permissions(path: &Path) -> Result<(), Error> {
    465     use std::os::unix::fs::PermissionsExt;
    466     fs::set_permissions(path, fs::Permissions::from_mode(0o700))
    467         .map_err(|_| backend_failure(Operation::Open))
    468 }
    469 
    470 #[cfg(not(unix))]
    471 fn set_directory_permissions(_path: &Path) -> Result<(), Error> {
    472     Ok(())
    473 }
    474 
    475 #[cfg(unix)]
    476 fn validate_directory_permissions(metadata: &fs::Metadata) -> Result<(), Error> {
    477     use std::os::unix::fs::PermissionsExt;
    478     if metadata.permissions().mode() & 0o077 != 0 {
    479         return Err(Error::InsecurePermissions);
    480     }
    481     Ok(())
    482 }
    483 
    484 #[cfg(not(unix))]
    485 fn validate_directory_permissions(_metadata: &fs::Metadata) -> Result<(), Error> {
    486     Ok(())
    487 }
    488 
    489 #[cfg(unix)]
    490 fn set_file_permissions(file: &File) -> Result<(), Error> {
    491     use std::os::unix::fs::PermissionsExt;
    492     file.set_permissions(fs::Permissions::from_mode(0o600))
    493         .map_err(|_| backend_failure(Operation::Write))
    494 }
    495 
    496 #[cfg(not(unix))]
    497 fn set_file_permissions(_file: &File) -> Result<(), Error> {
    498     Ok(())
    499 }
    500 
    501 #[cfg(unix)]
    502 fn validate_file_permissions(metadata: &fs::Metadata) -> Result<(), Error> {
    503     use std::os::unix::fs::PermissionsExt;
    504     if metadata.permissions().mode() & 0o077 != 0 {
    505         return Err(Error::InsecurePermissions);
    506     }
    507     Ok(())
    508 }
    509 
    510 #[cfg(not(unix))]
    511 fn validate_file_permissions(_metadata: &fs::Metadata) -> Result<(), Error> {
    512     Ok(())
    513 }
    514 
    515 #[cfg(unix)]
    516 fn sync_directory(root: &Path) -> Result<(), Error> {
    517     File::open(root)
    518         .and_then(|directory| directory.sync_all())
    519         .map_err(|_| backend_failure(Operation::Write))
    520 }
    521 
    522 #[cfg(not(unix))]
    523 fn sync_directory(_root: &Path) -> Result<(), Error> {
    524     Ok(())
    525 }