lib

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

commit 832b1cf579c5a03d62ef880086a4c1bdb6e627dc
parent a7202e832a15f52984ffa12be920e1faac275d5e
Author: triesap <tyson@radroots.org>
Date:   Sat,  1 Aug 2026 08:48:14 +0000

secrets: implement the file secret adapter

- Encrypt file-backed material with caller-supplied master keys and nonces.
- Require absolute normalized roots and reject symlinks or insecure permissions.
- Persist through secure same-directory no-clobber writes and directory sync.
- Verify traversal, replacement, interruption, rotation, clippy, no_std, wasm, and architecture.

Diffstat:
MCargo.lock | 1+
Mcrates/secrets/Cargo.toml | 3++-
Mcrates/secrets/src/error.rs | 14++++++++++++++
Mcrates/secrets/src/file.rs | 502+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/secrets/tests/file_adapter.rs | 146+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/secrets/tests/package_boundary.rs | 2+-
6 files changed, 666 insertions(+), 2 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -4977,6 +4977,7 @@ dependencies = [ "hex", "serde", "serde_json", + "tempfile", "zeroize", ] diff --git a/crates/secrets/Cargo.toml b/crates/secrets/Cargo.toml @@ -20,7 +20,7 @@ default = ["std", "serde"] std = [] serde = ["dep:serde"] memory = ["std"] -file = ["std"] +file = ["std", "dep:tempfile"] keyring = ["std"] [dependencies] @@ -29,6 +29,7 @@ serde = { workspace = true, default-features = false, features = [ "alloc", "derive", ], optional = true } +tempfile = { workspace = true, optional = true } zeroize = { workspace = true } [dev-dependencies] diff --git a/crates/secrets/src/error.rs b/crates/secrets/src/error.rs @@ -39,12 +39,18 @@ pub enum PolicyRequirement { #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[non_exhaustive] pub enum Operation { + /// Create or open a provider root. + Open, /// Provision caller-supplied material. Provision, /// Rotate caller-supplied material. Rotate, /// Remove provider-owned material. Remove, + /// Read protected provider state. + Read, + /// Persist protected provider state. + Write, /// Wrap plaintext key material. Wrap, /// Unwrap protected key material. @@ -115,6 +121,10 @@ pub enum Error { }, /// A key rotation did not preserve identity or advance the version. InvalidRotation, + /// A filesystem path was relative, traversing, symlinked, or not a file. + UnsafePath, + /// Filesystem permissions allowed access outside the current user. + InsecurePermissions, /// Envelope data exceeded the package-wide bound. EnvelopeTooLarge { /// Observed byte length. @@ -231,6 +241,10 @@ impl fmt::Display for Error { Self::InvalidRotation => { formatter.write_str("secret rotation must preserve identity and advance version") } + Self::UnsafePath => formatter.write_str("secret provider path is unsafe"), + Self::InsecurePermissions => { + formatter.write_str("secret provider permissions are insecure") + } Self::EnvelopeTooLarge { actual_bytes, max_bytes, diff --git a/crates/secrets/src/file.rs b/crates/secrets/src/file.rs @@ -1 +1,503 @@ //! Explicit file-backed secret adapters. + +use crate::SecretRef; +use crate::envelope::Nonce; +use crate::error::{Error, Operation}; +use crate::id::BackendKind; +use crate::provider::{CapabilitySupport, ResidencySupport, SecretCapabilities, SecretProvider}; +use crate::wrapping::{ + BoxFuture, KeyWrapping, SecretMaterial, UnwrapRequest, WrapRequest, WrappedSecret, +}; +use alloc::string::String; +use alloc::vec::Vec; +use chacha20poly1305::aead::{Aead, KeyInit, Payload}; +use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce}; +use core::fmt; +use std::fs::{self, File}; +use std::io::{Read, Write}; +use std::path::{Component, Path, PathBuf}; +use tempfile::NamedTempFile; + +const FILE_MAGIC: &[u8; 4] = b"RRK1"; +const FILE_VERSION: u8 = 1; +const DATA_KEY_BYTES: usize = 32; +const NONCE_BYTES: usize = 24; +const MAX_FILE_BYTES: usize = 128 * 1024; + +/// Explicit root handling for a file provider. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum FileOpenMode { + /// Create a new root and fail if it already exists. + CreateNew, + /// Open an existing secure root without creating it. + OpenExisting, +} + +/// File-backed provider using a caller-supplied in-memory master key. +pub struct FileProvider { + root: PathBuf, + master_key: SecretMaterial, +} + +impl FileProvider { + /// Creates or opens an absolute normalized provider root. + pub fn open( + root: impl AsRef<Path>, + mode: FileOpenMode, + master_key: SecretMaterial, + ) -> Result<Self, Error> { + validate_master_key(&master_key)?; + let root = root.as_ref(); + validate_requested_root(root)?; + match mode { + FileOpenMode::CreateNew => { + fs::create_dir(root).map_err(|_| backend_failure(Operation::Open))?; + set_directory_permissions(root)?; + } + FileOpenMode::OpenExisting => validate_existing_root(root)?, + } + let root = fs::canonicalize(root).map_err(|_| backend_failure(Operation::Open))?; + validate_existing_root(&root)?; + Ok(Self { root, master_key }) + } + + /// Persists caller-owned material with an explicit unique nonce. + pub fn provision( + &self, + reference: &SecretRef, + material: &SecretMaterial, + nonce: Nonce, + ) -> Result<(), Error> { + validate_file_reference(reference)?; + let path = self.entry_path(reference); + reject_existing_path(&path)?; + let encoded = self.seal_entry(reference, material, nonce)?; + self.persist_noclobber(path.as_path(), encoded.as_slice()) + } + + /// Resumes safely if the next version reached disk before old-version removal. + pub fn rotate( + &self, + current: &SecretRef, + next: &SecretRef, + material: &SecretMaterial, + nonce: Nonce, + ) -> Result<(), Error> { + validate_file_reference(current)?; + validate_file_reference(next)?; + if current.id().as_str() != next.id().as_str() + || next.key_version() <= current.key_version() + { + return Err(Error::InvalidRotation); + } + if !self.contains(current)? { + return Err(not_found(current)); + } + + if self.contains(next)? { + let persisted = self.read_entry(next)?; + let matches = persisted + .expose_secret(|expected| material.expose_secret(|actual| expected == actual)); + if !matches { + return Err(Error::SecretAlreadyExists { + backend: BackendKind::File, + key_version: next.key_version().get(), + }); + } + } else { + self.provision(next, material, nonce)?; + } + self.remove(current)?; + Ok(()) + } + + /// Removes one exact key revision when present. + pub fn remove(&self, reference: &SecretRef) -> Result<bool, Error> { + validate_file_reference(reference)?; + let path = self.entry_path(reference); + match fs::symlink_metadata(&path) { + Ok(metadata) => { + validate_entry_metadata(&metadata)?; + validate_file_permissions(&metadata)?; + fs::remove_file(path).map_err(|_| backend_failure(Operation::Remove))?; + sync_directory(&self.root)?; + Ok(true) + } + Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(_) => Err(backend_failure(Operation::Remove)), + } + } + + /// Reports whether one exact regular-file entry exists. + pub fn contains(&self, reference: &SecretRef) -> Result<bool, Error> { + validate_file_reference(reference)?; + match fs::symlink_metadata(self.entry_path(reference)) { + Ok(metadata) => { + validate_entry_metadata(&metadata)?; + validate_file_permissions(&metadata)?; + Ok(true) + } + Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(_) => Err(backend_failure(Operation::Read)), + } + } + + fn entry_path(&self, reference: &SecretRef) -> PathBuf { + self.root.join(entry_name(reference)) + } + + fn seal_entry( + &self, + reference: &SecretRef, + material: &SecretMaterial, + nonce: Nonce, + ) -> Result<Vec<u8>, Error> { + let aad = entry_aad(reference); + let ciphertext = self.master_key.expose_secret(|master_key| { + material.expose_secret(|plaintext| { + let cipher = XChaCha20Poly1305::new(Key::from_slice(master_key)); + cipher + .encrypt( + XNonce::from_slice(nonce.as_bytes()), + Payload { + msg: plaintext, + aad: aad.as_slice(), + }, + ) + .map_err(|_| Error::EncryptFailed) + }) + })?; + let ciphertext_len = + u32::try_from(ciphertext.len()).map_err(|_| backend_failure(Operation::Write))?; + let mut encoded = Vec::with_capacity(4 + 1 + NONCE_BYTES + 4 + ciphertext.len()); + encoded.extend_from_slice(FILE_MAGIC); + encoded.push(FILE_VERSION); + encoded.extend_from_slice(nonce.as_bytes()); + encoded.extend_from_slice(&ciphertext_len.to_be_bytes()); + encoded.extend_from_slice(ciphertext.as_slice()); + if encoded.len() > MAX_FILE_BYTES { + return Err(Error::EnvelopeTooLarge { + actual_bytes: encoded.len(), + max_bytes: MAX_FILE_BYTES, + }); + } + Ok(encoded) + } + + fn read_entry(&self, reference: &SecretRef) -> Result<SecretMaterial, Error> { + let path = self.entry_path(reference); + let metadata = fs::symlink_metadata(&path).map_err(|source| { + if source.kind() == std::io::ErrorKind::NotFound { + not_found(reference) + } else { + backend_failure(Operation::Read) + } + })?; + validate_entry_metadata(&metadata)?; + validate_file_permissions(&metadata)?; + let file = File::open(path).map_err(|_| backend_failure(Operation::Read))?; + let mut encoded = Vec::new(); + file.take((MAX_FILE_BYTES + 1) as u64) + .read_to_end(&mut encoded) + .map_err(|_| backend_failure(Operation::Read))?; + if encoded.len() > MAX_FILE_BYTES { + return Err(Error::EnvelopeTooLarge { + actual_bytes: encoded.len(), + max_bytes: MAX_FILE_BYTES, + }); + } + self.open_entry(reference, encoded.as_slice()) + } + + fn open_entry(&self, reference: &SecretRef, encoded: &[u8]) -> Result<SecretMaterial, Error> { + let minimum = 4 + 1 + NONCE_BYTES + 4 + 16; + if encoded.len() < minimum || &encoded[..4] != FILE_MAGIC { + return Err(Error::EnvelopeMalformed); + } + if encoded[4] != FILE_VERSION { + return Err(Error::UnsupportedEnvelopeVersion { + version: u16::from(encoded[4]), + }); + } + let nonce: [u8; NONCE_BYTES] = encoded[5..5 + NONCE_BYTES] + .try_into() + .map_err(|_| Error::EnvelopeMalformed)?; + let length_offset = 5 + NONCE_BYTES; + let ciphertext_len = u32::from_be_bytes( + encoded[length_offset..length_offset + 4] + .try_into() + .map_err(|_| Error::EnvelopeMalformed)?, + ) as usize; + let ciphertext = &encoded[length_offset + 4..]; + if ciphertext.len() != ciphertext_len { + return Err(Error::EnvelopeMalformed); + } + let aad = entry_aad(reference); + let plaintext = self.master_key.expose_secret(|master_key| { + let cipher = XChaCha20Poly1305::new(Key::from_slice(master_key)); + cipher + .decrypt( + XNonce::from_slice(&nonce), + Payload { + msg: ciphertext, + aad: aad.as_slice(), + }, + ) + .map_err(|_| Error::DecryptFailed) + })?; + SecretMaterial::from_slice(plaintext.as_slice()) + } + + fn persist_noclobber(&self, path: &Path, encoded: &[u8]) -> Result<(), Error> { + let mut temporary = + NamedTempFile::new_in(&self.root).map_err(|_| backend_failure(Operation::Write))?; + set_file_permissions(temporary.as_file())?; + temporary + .write_all(encoded) + .map_err(|_| backend_failure(Operation::Write))?; + temporary + .as_file() + .sync_all() + .map_err(|_| backend_failure(Operation::Write))?; + temporary.persist_noclobber(path).map_err(|error| { + if error.error.kind() == std::io::ErrorKind::AlreadyExists { + Error::SecretAlreadyExists { + backend: BackendKind::File, + key_version: key_version_from_path(path).unwrap_or(0), + } + } else { + backend_failure(Operation::Write) + } + })?; + sync_directory(&self.root) + } +} + +impl fmt::Debug for FileProvider { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("FileProvider(<redacted>)") + } +} + +impl KeyWrapping for FileProvider { + fn wrap<'a>(&'a self, request: WrapRequest<'a>) -> BoxFuture<'a, Result<WrappedSecret, Error>> { + Box::pin(async move { + validate_file_reference(request.reference())?; + let persisted = self.read_entry(request.reference())?; + let matches = persisted.expose_secret(|expected| { + request + .plaintext() + .expose_secret(|actual| expected == actual) + }); + if !matches { + return Err(backend_failure(Operation::Wrap)); + } + WrappedSecret::from_bytes(entry_token(request.reference())) + }) + } + + fn unwrap<'a>( + &'a self, + request: UnwrapRequest<'a>, + ) -> BoxFuture<'a, Result<SecretMaterial, Error>> { + Box::pin(async move { + validate_file_reference(request.reference())?; + if request.wrapped().as_bytes() != entry_token(request.reference()).as_slice() { + return Err(backend_failure(Operation::Unwrap)); + } + self.read_entry(request.reference()) + }) + } +} + +impl SecretProvider for FileProvider { + fn backend_kind(&self) -> BackendKind { + BackendKind::File + } + + fn capabilities(&self) -> SecretCapabilities { + SecretCapabilities::available( + ResidencySupport::UserProfile, + CapabilitySupport::Unavailable, + CapabilitySupport::Unavailable, + ) + } +} + +fn validate_requested_root(root: &Path) -> Result<(), Error> { + if !root.is_absolute() + || root + .components() + .any(|component| matches!(component, Component::CurDir | Component::ParentDir)) + { + return Err(Error::UnsafePath); + } + if let Ok(metadata) = fs::symlink_metadata(root) + && metadata.file_type().is_symlink() + { + return Err(Error::UnsafePath); + } + Ok(()) +} + +fn validate_existing_root(root: &Path) -> Result<(), Error> { + let metadata = fs::symlink_metadata(root).map_err(|_| backend_failure(Operation::Open))?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(Error::UnsafePath); + } + validate_directory_permissions(&metadata) +} + +fn validate_entry_metadata(metadata: &fs::Metadata) -> Result<(), Error> { + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(Error::UnsafePath); + } + Ok(()) +} + +fn validate_master_key(master_key: &SecretMaterial) -> Result<(), Error> { + if master_key.len() != DATA_KEY_BYTES { + return Err(Error::InvalidDataKeyLength { + actual_bytes: master_key.len(), + }); + } + Ok(()) +} + +fn validate_file_reference(reference: &SecretRef) -> Result<(), Error> { + if reference.backend() != BackendKind::File { + return Err(Error::BackendMismatch { + provider: BackendKind::File, + reference: reference.backend(), + }); + } + Ok(()) +} + +fn reject_existing_path(path: &Path) -> Result<(), Error> { + match fs::symlink_metadata(path) { + Ok(metadata) => { + validate_entry_metadata(&metadata)?; + Err(Error::SecretAlreadyExists { + backend: BackendKind::File, + key_version: key_version_from_path(path).unwrap_or(0), + }) + } + Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(_) => Err(backend_failure(Operation::Write)), + } +} + +fn entry_name(reference: &SecretRef) -> String { + alloc::format!( + "{}.v{}.rrk", + hex_identifier(reference.id().as_str()), + reference.key_version().get() + ) +} + +fn hex_identifier(identifier: &str) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut encoded = String::with_capacity(identifier.len() * 2); + for byte in identifier.as_bytes() { + encoded.push(char::from(HEX[usize::from(byte >> 4)])); + encoded.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + encoded +} + +fn entry_token(reference: &SecretRef) -> Vec<u8> { + let mut token = Vec::from(b"radroots-file-key-v1\0".as_slice()); + token.extend_from_slice(&reference.key_version().get().to_be_bytes()); + token.extend_from_slice(reference.id().as_str().as_bytes()); + token +} + +fn entry_aad(reference: &SecretRef) -> Vec<u8> { + entry_token(reference) +} + +fn key_version_from_path(path: &Path) -> Option<u32> { + let name = path.file_name()?.to_str()?; + let (_, suffix) = name.rsplit_once(".v")?; + suffix.strip_suffix(".rrk")?.parse().ok() +} + +fn not_found(reference: &SecretRef) -> Error { + Error::SecretNotFound { + backend: BackendKind::File, + key_version: reference.key_version().get(), + } +} + +const fn backend_failure(operation: Operation) -> Error { + Error::BackendFailure { + backend: BackendKind::File, + operation, + } +} + +#[cfg(unix)] +fn set_directory_permissions(path: &Path) -> Result<(), Error> { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o700)) + .map_err(|_| backend_failure(Operation::Open)) +} + +#[cfg(not(unix))] +fn set_directory_permissions(_path: &Path) -> Result<(), Error> { + Ok(()) +} + +#[cfg(unix)] +fn validate_directory_permissions(metadata: &fs::Metadata) -> Result<(), Error> { + use std::os::unix::fs::PermissionsExt; + if metadata.permissions().mode() & 0o077 != 0 { + return Err(Error::InsecurePermissions); + } + Ok(()) +} + +#[cfg(not(unix))] +fn validate_directory_permissions(_metadata: &fs::Metadata) -> Result<(), Error> { + Ok(()) +} + +#[cfg(unix)] +fn set_file_permissions(file: &File) -> Result<(), Error> { + use std::os::unix::fs::PermissionsExt; + file.set_permissions(fs::Permissions::from_mode(0o600)) + .map_err(|_| backend_failure(Operation::Write)) +} + +#[cfg(not(unix))] +fn set_file_permissions(_file: &File) -> Result<(), Error> { + Ok(()) +} + +#[cfg(unix)] +fn validate_file_permissions(metadata: &fs::Metadata) -> Result<(), Error> { + use std::os::unix::fs::PermissionsExt; + if metadata.permissions().mode() & 0o077 != 0 { + return Err(Error::InsecurePermissions); + } + Ok(()) +} + +#[cfg(not(unix))] +fn validate_file_permissions(_metadata: &fs::Metadata) -> Result<(), Error> { + Ok(()) +} + +#[cfg(unix)] +fn sync_directory(root: &Path) -> Result<(), Error> { + File::open(root) + .and_then(|directory| directory.sync_all()) + .map_err(|_| backend_failure(Operation::Write)) +} + +#[cfg(not(unix))] +fn sync_directory(_root: &Path) -> Result<(), Error> { + Ok(()) +} diff --git a/crates/secrets/tests/file_adapter.rs b/crates/secrets/tests/file_adapter.rs @@ -0,0 +1,146 @@ +#![cfg(feature = "file")] + +use futures_executor::block_on; +use radroots_secrets::envelope::Nonce; +use radroots_secrets::file::{FileOpenMode, FileProvider}; +use radroots_secrets::id::{BackendKind, KeyVersion}; +use radroots_secrets::wrapping::{SecretMaterial, UnwrapRequest, WrapRequest}; +use radroots_secrets::{Error, KeyWrapping, SecretId, SecretProvider, SecretRef}; +use std::fs; + +fn reference(id: &str, version: u32) -> SecretRef { + SecretRef::new( + SecretId::parse(id).expect("valid id"), + BackendKind::File, + KeyVersion::new(version).expect("valid version"), + ) +} + +fn provider(root: &std::path::Path, mode: FileOpenMode) -> FileProvider { + FileProvider::open( + root, + mode, + SecretMaterial::from_slice(&[0xA5; 32]).expect("master key"), + ) + .expect("open provider") +} + +#[test] +fn file_provider_round_trips_encrypted_material_with_atomic_creation() { + let temp = tempfile::tempdir().expect("tempdir"); + let root = temp.path().join("vault"); + let provider = provider(&root, FileOpenMode::CreateNew); + let reference = reference("file-key", 1); + let material = SecretMaterial::from_slice(&[0x41; 32]).expect("material"); + provider + .provision(&reference, &material, Nonce::new([0x11; 24])) + .expect("provision"); + + let persisted = fs::read(root.join("66696c652d6b6579.v1.rrk")).expect("read entry"); + assert!(!persisted.windows(32).any(|window| window == [0x41; 32])); + assert_eq!(fs::read_dir(&root).expect("read root").count(), 1); + + let wrapped = block_on(provider.wrap(WrapRequest::new(&reference, &material))).expect("wrap"); + let opened = + block_on(provider.unwrap(UnwrapRequest::new(&reference, &wrapped))).expect("unwrap"); + opened.expose_secret(|bytes| assert_eq!(bytes, &[0x41; 32])); + assert_eq!(provider.backend_kind(), BackendKind::File); + assert_eq!(format!("{provider:?}"), "FileProvider(<redacted>)"); +} + +#[test] +fn traversal_existing_replacement_and_truncated_files_fail_closed() { + let temp = tempfile::tempdir().expect("tempdir"); + let traversal = temp.path().join("nested/../vault"); + assert!(matches!( + FileProvider::open( + &traversal, + FileOpenMode::CreateNew, + SecretMaterial::from_slice(&[0xA5; 32]).expect("master key"), + ), + Err(Error::UnsafePath) + )); + + let root = temp.path().join("safe-vault"); + let provider = provider(&root, FileOpenMode::CreateNew); + let reference = reference("file-key", 1); + let material = SecretMaterial::from_slice(&[0x41; 32]).expect("material"); + provider + .provision(&reference, &material, Nonce::new([0x11; 24])) + .expect("provision"); + assert!(matches!( + provider.provision(&reference, &material, Nonce::new([0x12; 24])), + Err(Error::SecretAlreadyExists { .. }) + )); + + fs::write(root.join("66696c652d6b6579.v1.rrk"), b"truncated").expect("truncate"); + assert!(matches!( + block_on(provider.wrap(WrapRequest::new(&reference, &material))), + Err(Error::EnvelopeMalformed) + )); +} + +#[test] +fn rotation_resumes_after_new_version_commit_and_removes_old_version() { + let temp = tempfile::tempdir().expect("tempdir"); + let root = temp.path().join("vault"); + let provider = provider(&root, FileOpenMode::CreateNew); + let current = reference("rotating-key", 1); + let next = reference("rotating-key", 2); + let current_material = SecretMaterial::from_slice(&[0x11; 32]).expect("material"); + let next_material = SecretMaterial::from_slice(&[0x22; 32]).expect("material"); + provider + .provision(&current, &current_material, Nonce::new([0x31; 24])) + .expect("current"); + + // Simulate interruption after the new revision commits but before old cleanup. + provider + .provision(&next, &next_material, Nonce::new([0x32; 24])) + .expect("next commit"); + provider + .rotate(&current, &next, &next_material, Nonce::new([0x32; 24])) + .expect("resume rotation"); + assert!(!provider.contains(&current).expect("old contains")); + assert!(provider.contains(&next).expect("new contains")); + + let wrapped = block_on(provider.wrap(WrapRequest::new(&next, &next_material))).expect("wrap"); + assert!(block_on(provider.unwrap(UnwrapRequest::new(&next, &wrapped))).is_ok()); +} + +#[cfg(unix)] +#[test] +fn symlink_entries_and_insecure_permissions_are_rejected() { + use std::os::unix::fs::{PermissionsExt, symlink}; + + let temp = tempfile::tempdir().expect("tempdir"); + let insecure = temp.path().join("insecure"); + fs::create_dir(&insecure).expect("create insecure root"); + fs::set_permissions(&insecure, fs::Permissions::from_mode(0o755)).expect("chmod"); + assert!(matches!( + FileProvider::open( + &insecure, + FileOpenMode::OpenExisting, + SecretMaterial::from_slice(&[0xA5; 32]).expect("master key"), + ), + Err(Error::InsecurePermissions) + )); + + let root = temp.path().join("vault"); + let provider = provider(&root, FileOpenMode::CreateNew); + let outside = temp.path().join("outside"); + fs::write(&outside, b"not a secret entry").expect("outside file"); + symlink(&outside, root.join("6c696e6b65642d6b6579.v1.rrk")).expect("symlink"); + let linked = reference("linked-key", 1); + assert!(matches!(provider.contains(&linked), Err(Error::UnsafePath))); + + let linked_root = temp.path().join("linked-root"); + symlink(&root, &linked_root).expect("root symlink"); + assert!(matches!( + FileProvider::open( + &linked_root, + FileOpenMode::OpenExisting, + SecretMaterial::from_slice(&[0xA5; 32]).expect("master key"), + ), + Err(Error::UnsafePath) + )); +} diff --git a/crates/secrets/tests/package_boundary.rs b/crates/secrets/tests/package_boundary.rs @@ -13,7 +13,7 @@ fn manifest_has_final_identity_features_and_no_radroots_dependencies() { "[lib]\nname = \"radroots_secrets\"", "default = [\"std\", \"serde\"]", "memory = [\"std\"]", - "file = [\"std\"]", + "file = [\"std\", \"dep:tempfile\"]", "keyring = [\"std\"]", ] { assert!(