diff --git a/DEPENDENCIES.md b/DEPENDENCIES.md index b2c9a7b..6800f6f 100644 --- a/DEPENDENCIES.md +++ b/DEPENDENCIES.md @@ -21,6 +21,7 @@ The current direct dependencies are: | [pgp 0.20](https://crates.io/crates/pgp/0.20.0) | Embedded OpenPGP key import, encryption, decryption, and signatures | MIT OR Apache-2.0 | | [rand 0.8](https://crates.io/crates/rand/0.8.7) | Operating-system-backed cryptographic randomness for OpenPGP operations | MIT OR Apache-2.0 | | [regex 1.13](https://crates.io/crates/regex/1.13.1) | Linear-time byte-oriented decrypted grep matching | MIT OR Apache-2.0 | +| [tempfile 3](https://crates.io/crates/tempfile) | Permission-restricted CLI editor session directories and cleanup | MIT OR Apache-2.0 | | [Serde 1](https://crates.io/crates/serde), [TOML 0.9](https://crates.io/crates/toml), [shlex 1.3](https://crates.io/crates/shlex), [url 2.5](https://crates.io/crates/url) | Strict configuration and command values | MIT OR Apache-2.0 | | [UniFFI 0.32](https://crates.io/crates/uniffi/0.32.0) | Swift bridge | MPL-2.0 | | [zeroize 1.9](https://crates.io/crates/zeroize/1.9.0) | Clear decrypted bytes on drop | MIT OR Apache-2.0 | diff --git a/README.md b/README.md index 60d3ffa..bc9d56e 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ the rollback/commit contract are documented in [`docs/recipient-policies.md`](docs/recipient-policies.md). Typed list/show/find/decrypted-grep models and secret presentation selection are documented in [`docs/read-domains.md`](docs/read-domains.md). +Insert modes, concurrency-safe edit sessions, and the secure CLI editor-file +boundary are documented in [`docs/write-domains.md`](docs/write-domains.md). ## Project layout diff --git a/apps/cli/Cargo.toml b/apps/cli/Cargo.toml index 8af4c86..365ef4f 100644 --- a/apps/cli/Cargo.toml +++ b/apps/cli/Cargo.toml @@ -12,6 +12,6 @@ path = "src/main.rs" [dependencies] ironstorage.workspace = true +tempfile = "3" [dev-dependencies] -tempfile = "3" diff --git a/apps/cli/src/editor.rs b/apps/cli/src/editor.rs new file mode 100644 index 0000000..cd6f247 --- /dev/null +++ b/apps/cli/src/editor.rs @@ -0,0 +1,308 @@ +use std::{ + error::Error, + ffi::OsString, + fmt, fs, + io::{self, Read as _, Seek as _, SeekFrom, Write as _}, + path::{Path, PathBuf}, +}; + +use ironstorage::{config::ResolvedEditor, repository::SecretBytes}; + +const MAX_EDIT_BYTES: u64 = 16 * 1024 * 1024; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct EditorInvocation { + program: String, + arguments: Vec, + plaintext_path: PathBuf, +} + +impl EditorInvocation { + pub(crate) fn program(&self) -> &str { + &self.program + } + + pub(crate) fn arguments(&self) -> &[OsString] { + &self.arguments + } + + pub(crate) fn plaintext_path(&self) -> &Path { + &self.plaintext_path + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum EditorStatus { + Saved, + Cancelled, + Failed(i32), +} + +/// Host boundary for the one CLI-only editor exception. Storage and other applications never +/// receive an executable and tests can exercise the complete session without spawning a process. +pub(crate) trait EditorHost { + fn edit(&mut self, invocation: &EditorInvocation) -> Result; +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct EditorHostError; + +impl fmt::Display for EditorHostError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("editor host failed") + } +} + +impl Error for EditorHostError {} + +pub(crate) fn edit_replacement( + plaintext: &SecretBytes, + editor: &ResolvedEditor, + host: &mut impl EditorHost, +) -> Result { + let temporary = create_secure_directory()?; + let path = temporary.path().join("entry.txt"); + let mut file = create_private_file(&path)?; + let prepared = file + .write_all(plaintext.expose()) + .and_then(|()| file.sync_all()); + drop(file); + if prepared.is_err() { + let _ = wipe_and_remove(&path); + return Err(CliEditorError::TemporaryFile); + } + + let command = editor.command(); + let mut arguments = command + .arguments() + .iter() + .map(OsString::from) + .collect::>(); + arguments.push(path.clone().into_os_string()); + let invocation = EditorInvocation { + program: command.program().to_owned(), + arguments, + plaintext_path: path.clone(), + }; + let result = match host.edit(&invocation) { + Ok(EditorStatus::Saved) => { + restrict_file_permissions(&path).and_then(|()| read_replacement(&path)) + } + Ok(EditorStatus::Cancelled) => Err(CliEditorError::Cancelled), + Ok(EditorStatus::Failed(code)) => Err(CliEditorError::Failed(code)), + Err(error) => Err(CliEditorError::Host(error)), + }; + wipe_and_remove(&path)?; + result +} + +fn create_secure_directory() -> Result { + #[cfg(target_os = "linux")] + if Path::new("/dev/shm").is_dir() + && let Ok(directory) = tempfile::Builder::new() + .prefix("ironstorage-edit-") + .tempdir_in("/dev/shm") + { + return Ok(directory); + } + tempfile::Builder::new() + .prefix("ironstorage-edit-") + .tempdir() + .map_err(|_| CliEditorError::TemporaryDirectory) +} + +#[cfg(unix)] +fn create_private_file(path: &Path) -> Result { + use std::os::unix::fs::OpenOptionsExt as _; + + fs::OpenOptions::new() + .create_new(true) + .read(true) + .write(true) + .mode(0o600) + .open(path) + .map_err(|_| CliEditorError::TemporaryFile) +} + +#[cfg(not(unix))] +fn create_private_file(path: &Path) -> Result { + fs::OpenOptions::new() + .create_new(true) + .read(true) + .write(true) + .open(path) + .map_err(|_| CliEditorError::TemporaryFile) +} + +fn read_replacement(path: &Path) -> Result { + let metadata = fs::metadata(path).map_err(|_| CliEditorError::TemporaryFile)?; + if !metadata.is_file() || metadata.len() > MAX_EDIT_BYTES { + return Err(CliEditorError::ReplacementTooLarge); + } + let mut file = fs::File::open(path).map_err(|_| CliEditorError::TemporaryFile)?; + let mut bytes = Vec::with_capacity(metadata.len() as usize); + file.read_to_end(&mut bytes) + .map_err(|_| CliEditorError::TemporaryFile)?; + Ok(SecretBytes::new(bytes)) +} + +#[cfg(unix)] +fn restrict_file_permissions(path: &Path) -> Result<(), CliEditorError> { + use std::os::unix::fs::PermissionsExt as _; + + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) + .map_err(|_| CliEditorError::TemporaryFile) +} + +#[cfg(not(unix))] +fn restrict_file_permissions(_path: &Path) -> Result<(), CliEditorError> { + Ok(()) +} + +fn wipe_and_remove(path: &Path) -> Result<(), CliEditorError> { + let mut file = match fs::OpenOptions::new().read(true).write(true).open(path) { + Ok(file) => file, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(_) => return Err(CliEditorError::Cleanup), + }; + let length = file.metadata().map_err(|_| CliEditorError::Cleanup)?.len(); + file.seek(SeekFrom::Start(0)) + .map_err(|_| CliEditorError::Cleanup)?; + let zeros = [0_u8; 8192]; + let mut remaining = length; + while remaining > 0 { + let count = remaining.min(zeros.len() as u64) as usize; + file.write_all(&zeros[..count]) + .map_err(|_| CliEditorError::Cleanup)?; + remaining -= count as u64; + } + file.set_len(0).map_err(|_| CliEditorError::Cleanup)?; + file.sync_all().map_err(|_| CliEditorError::Cleanup)?; + drop(file); + fs::remove_file(path).map_err(|_| CliEditorError::Cleanup) +} + +#[derive(Debug)] +pub(crate) enum CliEditorError { + TemporaryDirectory, + TemporaryFile, + ReplacementTooLarge, + Host(EditorHostError), + Cancelled, + Failed(i32), + Cleanup, +} + +impl fmt::Display for CliEditorError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::TemporaryDirectory => formatter.write_str("cannot create secure edit directory"), + Self::TemporaryFile => formatter.write_str("cannot use secure edit file"), + Self::ReplacementTooLarge => formatter.write_str("edited entry is too large"), + Self::Host(error) => error.fmt(formatter), + Self::Cancelled => formatter.write_str("editor cancelled"), + Self::Failed(code) => write!(formatter, "editor exited unsuccessfully ({code})"), + Self::Cleanup => formatter.write_str("cannot securely clean up edit file"), + } + } +} + +impl Error for CliEditorError {} + +#[cfg(test)] +mod tests { + use super::*; + use ironstorage::{config::ConfigLoader, repository::SecretBytes}; + + #[derive(Default)] + struct RecordingHost { + replacement: Vec, + status: Option, + invocation: Option, + mode: Option, + } + + impl EditorHost for RecordingHost { + fn edit(&mut self, invocation: &EditorInvocation) -> Result { + self.invocation = Some(invocation.clone()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + self.mode = Some( + fs::metadata(invocation.plaintext_path()) + .unwrap() + .permissions() + .mode() + & 0o777, + ); + } + if self.status.unwrap_or(EditorStatus::Saved) == EditorStatus::Saved { + fs::write(invocation.plaintext_path(), &self.replacement).unwrap(); + } + Ok(self.status.unwrap_or(EditorStatus::Saved)) + } + } + + #[test] + fn secure_editor_file_uses_resolved_arguments_permissions_and_cleanup() + -> Result<(), Box> { + let temporary = tempfile::tempdir()?; + fs::create_dir(temporary.path().join("vault"))?; + fs::create_dir(temporary.path().join("keys"))?; + let config_path = temporary.path().join("config.toml"); + fs::write( + &config_path, + "vault='vault'\ndefault_key='alice'\nkey_material='keys'\neditor=['fixture-editor','--wait','--clean']\n", + )?; + let config = ConfigLoader::new(temporary.path().to_owned(), temporary.path().to_owned()) + .load(Some(&config_path))?; + let editor = config.resolve_editor_from(None, None)?; + + let plaintext = SecretBytes::new(b"original\n".to_vec()); + let mut host = RecordingHost { + replacement: b"replacement\n".to_vec(), + ..RecordingHost::default() + }; + let replacement = edit_replacement(&plaintext, &editor, &mut host)?; + assert_eq!(replacement.expose(), b"replacement\n"); + let invocation = host.invocation.expect("invocation"); + assert_eq!(invocation.program(), "fixture-editor"); + assert_eq!(invocation.arguments()[0].to_str(), Some("--wait")); + assert_eq!(invocation.arguments()[1].to_str(), Some("--clean")); + assert_eq!( + invocation.arguments().last().unwrap(), + invocation.plaintext_path().as_os_str() + ); + #[cfg(unix)] + assert_eq!(host.mode, Some(0o600)); + assert!(!invocation.plaintext_path().exists()); + Ok(()) + } + + #[test] + fn failure_and_cancellation_clean_up_without_returning_plaintext() -> Result<(), Box> + { + let temporary = tempfile::tempdir()?; + fs::create_dir(temporary.path().join("vault"))?; + fs::create_dir(temporary.path().join("keys"))?; + let config_path = temporary.path().join("config.toml"); + fs::write( + &config_path, + "vault='vault'\ndefault_key='alice'\nkey_material='keys'\n", + )?; + let config = ConfigLoader::new(temporary.path().to_owned(), temporary.path().to_owned()) + .load(Some(&config_path))?; + let editor = + config.resolve_editor_from(Some(std::ffi::OsStr::new("visual --flag")), None)?; + let plaintext = SecretBytes::new(b"secret".to_vec()); + for status in [EditorStatus::Failed(42), EditorStatus::Cancelled] { + let mut host = RecordingHost { + status: Some(status), + ..RecordingHost::default() + }; + assert!(edit_replacement(&plaintext, &editor, &mut host).is_err()); + assert!(!host.invocation.unwrap().plaintext_path().exists()); + } + Ok(()) + } +} diff --git a/apps/cli/src/main.rs b/apps/cli/src/main.rs index 7f9e2a6..04a3b35 100644 --- a/apps/cli/src/main.rs +++ b/apps/cli/src/main.rs @@ -11,6 +11,9 @@ use ironstorage::{ config::Config, }; +#[allow(dead_code)] +mod editor; + fn main() -> ExitCode { match run() { Ok(code) => ExitCode::from(code), diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index 2142086..4185f5b 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -11,6 +11,7 @@ pub mod crypto; pub mod read; pub mod recipient; pub mod repository; +pub mod write; /// Product name shared by the presentation adapters. pub const PRODUCT_NAME: &str = "IronStorage"; diff --git a/crates/storage/src/repository.rs b/crates/storage/src/repository.rs index c6a8431..f54001e 100644 --- a/crates/storage/src/repository.rs +++ b/crates/storage/src/repository.rs @@ -37,6 +37,10 @@ impl EntryPath { &self.0 } + pub fn parent_directory(&self) -> DirectoryPath { + self.parent() + } + fn parent(&self) -> DirectoryPath { DirectoryPath(self.0.parent().map_or_else(PathBuf::new, Path::to_path_buf)) } @@ -511,6 +515,20 @@ impl Repository { self.write_entry_with_checkpoint(path, ciphertext, |_| Ok(())) } + pub fn remove_entry(&self, path: &EntryPath) -> Result { + let original = self.read_entry(path)?; + let (parent, file_name) = self.open_entry_parent(path)?; + parent + .remove_file(&file_name) + .map_err(|error| io_error("remove encrypted entry", &path.0, error))?; + sync_directory(&parent, &path.parent().0).map_err(|_| { + RepositoryError::DurabilityUncertain { + path: path.0.clone(), + } + })?; + Ok(original) + } + fn write_entry_with_checkpoint( &self, path: &EntryPath, diff --git a/crates/storage/src/write.rs b/crates/storage/src/write.rs new file mode 100644 index 0000000..2996f6e --- /dev/null +++ b/crates/storage/src/write.rs @@ -0,0 +1,398 @@ +//! Insert and concurrency-safe in-process edit sessions. + +use std::{error::Error, fmt}; + +use crate::{ + command::{EditRequest, InsertInput, InsertRequest}, + crypto::{CryptoError, KeyStore, SecretProvider}, + recipient::{RecipientPolicyError, RecipientPolicyManager, SigningPolicy}, + repository::{EncryptedEntry, EntryPath, Repository, RepositoryError, SecretBytes}, +}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum OverwriteDecision { + Allow, + Decline, +} + +pub struct InsertContent { + mode: InsertInput, + secret: SecretBytes, +} + +impl InsertContent { + pub fn hidden(first: Vec, confirmation: Vec) -> Result { + validate_single_line(&first)?; + validate_single_line(&confirmation)?; + if first != confirmation { + return Err(WriteError::ConfirmationMismatch); + } + if first.is_empty() { + return Err(WriteError::EmptySingleLine); + } + Ok(Self { + mode: InsertInput::HiddenConfirmed, + secret: SecretBytes::new(first), + }) + } + + pub fn echoed(line: Vec) -> Result { + validate_single_line(&line)?; + if line.is_empty() { + return Err(WriteError::EmptySingleLine); + } + Ok(Self { + mode: InsertInput::EchoedLine, + secret: SecretBytes::new(line), + }) + } + + pub fn multiline(contents: Vec) -> Self { + Self { + mode: InsertInput::Multiline, + secret: SecretBytes::new(contents), + } + } + + pub fn expose(&self) -> &[u8] { + self.secret.expose() + } + + fn into_secret(self) -> SecretBytes { + self.secret + } +} + +impl fmt::Debug for InsertContent { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("InsertContent([REDACTED])") + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum EntryAction { + Insert, + Edit, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EntryCommit { + path: EntryPath, + action: EntryAction, + message: String, +} + +impl EntryCommit { + pub fn path(&self) -> &EntryPath { + &self.path + } + + pub fn action(&self) -> EntryAction { + self.action + } + + pub fn message(&self) -> &str { + &self.message + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EntryCommitError(String); + +impl EntryCommitError { + pub fn new(message: impl Into) -> Self { + Self(message.into()) + } +} + +impl fmt::Display for EntryCommitError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +impl Error for EntryCommitError {} + +pub trait EntryCommitter { + fn commit(&mut self, change: &EntryCommit) -> Result<(), EntryCommitError>; +} + +#[derive(Default)] +pub struct NoGitEntryCommitter; + +impl EntryCommitter for NoGitEntryCommitter { + fn commit(&mut self, _change: &EntryCommit) -> Result<(), EntryCommitError> { + Ok(()) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct WriteOutcome { + path: EntryPath, + action: EntryAction, +} + +impl WriteOutcome { + pub fn path(&self) -> &EntryPath { + &self.path + } + + pub fn action(&self) -> EntryAction { + self.action + } +} + +pub struct EditSession { + path: EntryPath, + original_ciphertext: Option, + plaintext: SecretBytes, +} + +impl EditSession { + pub fn path(&self) -> &EntryPath { + &self.path + } + + pub fn plaintext(&self) -> &SecretBytes { + &self.plaintext + } +} + +impl fmt::Debug for EditSession { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("EditSession") + .field("path", &self.path) + .field("plaintext", &self.plaintext) + .finish_non_exhaustive() + } +} + +pub struct VaultWriter<'a> { + repository: &'a Repository, + keys: &'a KeyStore, +} + +impl<'a> VaultWriter<'a> { + pub fn new(repository: &'a Repository, keys: &'a KeyStore) -> Self { + Self { repository, keys } + } + + #[allow(clippy::too_many_arguments)] + pub fn insert( + &self, + request: &InsertRequest, + contents: InsertContent, + overwrite: OverwriteDecision, + signing: Option<&SigningPolicy>, + committer: &mut impl EntryCommitter, + ) -> Result { + let path = EntryPath::parse(&request.entry)?; + if contents.mode != request.input { + return Err(WriteError::InputModeMismatch); + } + let original = match self.repository.read_entry(&path) { + Ok(original) => Some(original), + Err(RepositoryError::NotFound { .. }) => None, + Err(error) => return Err(error.into()), + }; + if original.is_some() && !request.force && overwrite == OverwriteDecision::Decline { + return Err(WriteError::Cancelled); + } + let recipients = RecipientPolicyManager::new(self.repository, self.keys) + .resolve_for_entry(&path, signing)?; + let ciphertext = self + .keys + .encrypt(contents.into_secret(), recipients.recipients())?; + self.repository.write_entry(&path, &ciphertext)?; + let change = EntryCommit { + path: path.clone(), + action: EntryAction::Insert, + message: format!("Add given password for {path} to store."), + }; + if let Err(error) = committer.commit(&change) { + if let Err(rollback) = self.restore(&path, original.as_ref()) { + return Err(WriteError::RollbackFailed { + operation: error, + rollback, + }); + } + return Err(WriteError::Commit(error)); + } + Ok(WriteOutcome { + path, + action: EntryAction::Insert, + }) + } + + pub fn begin_edit( + &self, + request: &EditRequest, + provider: &mut impl SecretProvider, + ) -> Result { + let path = EntryPath::parse(&request.entry)?; + let original_ciphertext = match self.repository.read_entry(&path) { + Ok(ciphertext) => Some(ciphertext), + Err(RepositoryError::NotFound { .. }) => None, + Err(error) => return Err(error.into()), + }; + let plaintext = if let Some(ciphertext) = &original_ciphertext { + self.keys.decrypt(ciphertext, provider)? + } else { + SecretBytes::new(Vec::new()) + }; + Ok(EditSession { + path, + original_ciphertext, + plaintext, + }) + } + + #[allow(clippy::too_many_arguments)] + pub fn finish_edit( + &self, + session: EditSession, + replacement: SecretBytes, + editor_name: &str, + signing: Option<&SigningPolicy>, + committer: &mut impl EntryCommitter, + ) -> Result { + if replacement.expose() == session.plaintext.expose() { + return Err(WriteError::Unchanged); + } + let concurrent = match ( + session.original_ciphertext.as_ref(), + self.repository.read_entry(&session.path), + ) { + (Some(original), Ok(current)) => ¤t != original, + (None, Err(RepositoryError::NotFound { .. })) => false, + (Some(_), Err(RepositoryError::NotFound { .. })) | (None, Ok(_)) => true, + (_, Err(error)) => return Err(error.into()), + }; + if concurrent { + return Err(WriteError::ConcurrentModification { + path: session.path.clone(), + }); + } + let recipients = RecipientPolicyManager::new(self.repository, self.keys) + .resolve_for_entry(&session.path, signing)?; + let ciphertext = self.keys.encrypt(replacement, recipients.recipients())?; + self.repository.write_entry(&session.path, &ciphertext)?; + let change = EntryCommit { + path: session.path.clone(), + action: EntryAction::Edit, + message: format!("Edit password for {} using {}.", session.path, editor_name), + }; + if let Err(error) = committer.commit(&change) { + if let Err(rollback) = self.restore(&session.path, session.original_ciphertext.as_ref()) + { + return Err(WriteError::RollbackFailed { + operation: error, + rollback, + }); + } + return Err(WriteError::Commit(error)); + } + Ok(WriteOutcome { + path: session.path, + action: EntryAction::Edit, + }) + } + + fn restore( + &self, + path: &EntryPath, + original: Option<&EncryptedEntry>, + ) -> Result<(), RepositoryError> { + if let Some(original) = original { + self.repository.write_entry(path, original)?; + } else { + self.repository.remove_entry(path)?; + self.repository + .cleanup_empty_directories(&path.parent_directory())?; + } + Ok(()) + } +} + +fn validate_single_line(line: &[u8]) -> Result<(), WriteError> { + if line.iter().any(|byte| matches!(byte, b'\n' | b'\r')) { + Err(WriteError::InvalidSingleLine) + } else { + Ok(()) + } +} + +#[derive(Debug)] +pub enum WriteError { + Repository(RepositoryError), + Crypto(CryptoError), + RecipientPolicy(RecipientPolicyError), + ConfirmationMismatch, + EmptySingleLine, + InvalidSingleLine, + InputModeMismatch, + Cancelled, + Unchanged, + ConcurrentModification { + path: EntryPath, + }, + Commit(EntryCommitError), + RollbackFailed { + operation: EntryCommitError, + rollback: RepositoryError, + }, +} + +impl fmt::Display for WriteError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Repository(error) => error.fmt(formatter), + Self::Crypto(error) => error.fmt(formatter), + Self::RecipientPolicy(error) => error.fmt(formatter), + Self::ConfirmationMismatch => { + formatter.write_str("password confirmation does not match") + } + Self::EmptySingleLine => formatter.write_str("password may not be empty"), + Self::InvalidSingleLine => { + formatter.write_str("single-line input contains a line break") + } + Self::InputModeMismatch => { + formatter.write_str("insert content does not match the requested input mode") + } + Self::Cancelled => formatter.write_str("entry overwrite was declined"), + Self::Unchanged => formatter.write_str("entry was not changed"), + Self::ConcurrentModification { path } => { + write!(formatter, "entry changed during editing: {path}") + } + Self::Commit(error) => write!(formatter, "cannot commit entry mutation: {error}"), + Self::RollbackFailed { + operation, + rollback, + } => write!( + formatter, + "entry commit failed ({operation}) and repository rollback failed ({rollback})" + ), + } + } +} + +impl Error for WriteError {} + +impl From for WriteError { + fn from(error: RepositoryError) -> Self { + Self::Repository(error) + } +} + +impl From for WriteError { + fn from(error: CryptoError) -> Self { + Self::Crypto(error) + } +} + +impl From for WriteError { + fn from(error: RecipientPolicyError) -> Self { + Self::RecipientPolicy(error) + } +} diff --git a/crates/storage/tests/write_domains.rs b/crates/storage/tests/write_domains.rs new file mode 100644 index 0000000..fd28a68 --- /dev/null +++ b/crates/storage/tests/write_domains.rs @@ -0,0 +1,374 @@ +#![forbid(unsafe_code)] + +mod support; + +use std::{collections::BTreeMap, fs}; + +use ironstorage::{ + command::{EditRequest, InsertInput, InsertRequest}, + crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider, SecretProviderError}, + repository::{EntryPath, Repository, SecretBytes}, + write::{ + EntryCommit, EntryCommitError, EntryCommitter, InsertContent, OverwriteDecision, + VaultWriter, WriteError, + }, +}; +use support::compatibility::{FixtureSet, TestResult}; + +struct FixtureSecrets(BTreeMap>); + +impl FixtureSecrets { + fn all(fixture: &FixtureSet) -> Self { + Self( + fixture + .generated + .keys + .iter() + .map(|key| { + ( + key.primary_fingerprint.clone(), + key.passphrase.as_bytes().to_vec(), + ) + }) + .collect(), + ) + } +} + +impl SecretProvider for FixtureSecrets { + fn secret_for(&mut self, key: &KeyInfo) -> Result { + self.0 + .get(key.fingerprint().as_str()) + .cloned() + .map(SecretBytes::new) + .ok_or(SecretProviderError::Unavailable) + } +} + +#[derive(Default)] +struct RecordingCommitter { + changes: Vec, + fail: bool, +} + +impl EntryCommitter for RecordingCommitter { + fn commit(&mut self, change: &EntryCommit) -> Result<(), EntryCommitError> { + self.changes.push(change.clone()); + if self.fail { + Err(EntryCommitError::new("simulated Git failure")) + } else { + Ok(()) + } + } +} + +#[test] +fn insert_content_modes_validate_and_redact_input() -> TestResult { + let hidden = InsertContent::hidden(b"secret".to_vec(), b"secret".to_vec())?; + assert_eq!(hidden.expose(), b"secret"); + assert!(!format!("{hidden:?}").contains("secret")); + assert!(matches!( + InsertContent::hidden(b"first".to_vec(), b"second".to_vec()), + Err(WriteError::ConfirmationMismatch) + )); + assert!(matches!( + InsertContent::echoed(Vec::new()), + Err(WriteError::EmptySingleLine) + )); + assert!(matches!( + InsertContent::echoed(b"two\nlines".to_vec()), + Err(WriteError::InvalidSingleLine) + )); + assert!(InsertContent::multiline(Vec::new()).expose().is_empty()); + Ok(()) +} + +#[test] +fn insert_hidden_echoed_and_multiline_use_inherited_recipients() -> TestResult { + let fixture = FixtureSet::load()?; + let store = fixture.materialize_store("basic")?; + let repository = Repository::open(store.path())?; + let keys = KeyStore::load(fixture.path("keys"))?; + let writer = VaultWriter::new(&repository, &keys); + let mut committer = RecordingCommitter::default(); + + for (path, input, contents) in [ + ( + "new/hidden", + InsertInput::HiddenConfirmed, + InsertContent::hidden(b"hidden".to_vec(), b"hidden".to_vec())?, + ), + ( + "new/echoed", + InsertInput::EchoedLine, + InsertContent::echoed(b"echoed".to_vec())?, + ), + ( + "new/multiline", + InsertInput::Multiline, + InsertContent::multiline(b"first\nsecond\n".to_vec()), + ), + ] { + writer.insert( + &insert(path, false, input), + contents, + OverwriteDecision::Decline, + None, + &mut committer, + )?; + } + assert_eq!(committer.changes.len(), 3); + assert_eq!( + committer.changes[2].message(), + "Add given password for new/multiline to store." + ); + let mut provider = FixtureSecrets::all(&fixture); + let plaintext = keys.decrypt( + &repository.read_entry(&EntryPath::parse("new/multiline")?)?, + &mut provider, + )?; + assert_eq!(plaintext.expose(), b"first\nsecond\n"); + let alice = fixture.key("alice")?; + assert!(keys.is_encrypted_for( + &repository.read_entry(&EntryPath::parse("new/hidden")?)?, + &[keys.resolve(&alice.primary_fingerprint)?] + )?); + Ok(()) +} + +#[test] +fn overwrite_decision_force_and_commit_failure_are_transactional() -> TestResult { + let fixture = FixtureSet::load()?; + let store = fixture.materialize_store("basic")?; + let repository = Repository::open(store.path())?; + let keys = KeyStore::load(fixture.path("keys"))?; + let writer = VaultWriter::new(&repository, &keys); + let path = EntryPath::parse("email/personal")?; + let original = repository.read_entry(&path)?; + let mut committer = RecordingCommitter::default(); + + assert!(matches!( + writer.insert( + &insert("email/personal", false, InsertInput::EchoedLine), + InsertContent::echoed(b"declined".to_vec())?, + OverwriteDecision::Decline, + None, + &mut committer, + ), + Err(WriteError::Cancelled) + )); + assert_eq!(repository.read_entry(&path)?, original); + + writer.insert( + &insert("email/personal", true, InsertInput::EchoedLine), + InsertContent::echoed(b"forced".to_vec())?, + OverwriteDecision::Decline, + None, + &mut committer, + )?; + let forced = repository.read_entry(&path)?; + assert_ne!(forced, original); + + committer.fail = true; + assert!(matches!( + writer.insert( + &insert("email/personal", true, InsertInput::EchoedLine), + InsertContent::echoed(b"rollback".to_vec())?, + OverwriteDecision::Allow, + None, + &mut committer, + ), + Err(WriteError::Commit(_)) + )); + assert_eq!(repository.read_entry(&path)?, forced); + + assert!(matches!( + writer.insert( + &insert("temporary/deep/entry", false, InsertInput::Multiline), + InsertContent::multiline(b"new".to_vec()), + OverwriteDecision::Allow, + None, + &mut committer, + ), + Err(WriteError::Commit(_)) + )); + assert!(!store.path().join("temporary").exists()); + Ok(()) +} + +#[test] +fn edit_session_handles_replacement_unchanged_and_commit_failure() -> TestResult { + let fixture = FixtureSet::load()?; + let store = fixture.materialize_store("basic")?; + let repository = Repository::open(store.path())?; + let keys = KeyStore::load(fixture.path("keys"))?; + let writer = VaultWriter::new(&repository, &keys); + let request = EditRequest { + entry: "email/personal".to_owned(), + }; + let path = EntryPath::parse("email/personal")?; + let mut provider = FixtureSecrets::all(&fixture); + let mut committer = RecordingCommitter::default(); + + let unchanged = writer.begin_edit(&request, &mut provider)?; + let same = SecretBytes::new(unchanged.plaintext().expose().to_vec()); + assert!(matches!( + writer.finish_edit(unchanged, same, "fixture-editor", None, &mut committer), + Err(WriteError::Unchanged) + )); + assert!(committer.changes.is_empty()); + + let session = writer.begin_edit(&request, &mut provider)?; + writer.finish_edit( + session, + SecretBytes::new(b"edited\nlogin: replacement\n".to_vec()), + "fixture-editor", + None, + &mut committer, + )?; + assert_eq!( + committer.changes[0].message(), + "Edit password for email/personal using fixture-editor." + ); + let edited = repository.read_entry(&path)?; + let plaintext = keys.decrypt(&edited, &mut provider)?; + assert_eq!(plaintext.expose(), b"edited\nlogin: replacement\n"); + + let session = writer.begin_edit(&request, &mut provider)?; + committer.fail = true; + assert!(matches!( + writer.finish_edit( + session, + SecretBytes::new(b"must roll back".to_vec()), + "fixture-editor", + None, + &mut committer, + ), + Err(WriteError::Commit(_)) + )); + assert_eq!(repository.read_entry(&path)?, edited); + Ok(()) +} + +#[test] +fn edit_detects_concurrent_ciphertext_change_before_writing() -> TestResult { + let fixture = FixtureSet::load()?; + let store = fixture.materialize_store("basic")?; + let repository = Repository::open(store.path())?; + let keys = KeyStore::load(fixture.path("keys"))?; + let writer = VaultWriter::new(&repository, &keys); + let request = EditRequest { + entry: "email/personal".to_owned(), + }; + let path = EntryPath::parse("email/personal")?; + let mut provider = FixtureSecrets::all(&fixture); + let session = writer.begin_edit(&request, &mut provider)?; + let mut committer = RecordingCommitter::default(); + writer.insert( + &insert("email/personal", true, InsertInput::EchoedLine), + InsertContent::echoed(b"concurrent".to_vec())?, + OverwriteDecision::Allow, + None, + &mut committer, + )?; + let concurrent = repository.read_entry(&path)?; + + assert!(matches!( + writer.finish_edit( + session, + SecretBytes::new(b"stale editor".to_vec()), + "fixture-editor", + None, + &mut committer, + ), + Err(WriteError::ConcurrentModification { .. }) + )); + assert_eq!(repository.read_entry(&path)?, concurrent); + Ok(()) +} + +#[test] +fn edit_session_can_create_a_new_entry_and_detect_one_appearing_concurrently() -> TestResult { + let fixture = FixtureSet::load()?; + let store = fixture.materialize_store("basic")?; + let repository = Repository::open(store.path())?; + let keys = KeyStore::load(fixture.path("keys"))?; + let writer = VaultWriter::new(&repository, &keys); + let request = EditRequest { + entry: "new/from-editor".to_owned(), + }; + let mut provider = FixtureSecrets::all(&fixture); + let session = writer.begin_edit(&request, &mut provider)?; + assert!(session.plaintext().expose().is_empty()); + let mut committer = RecordingCommitter::default(); + writer.finish_edit( + session, + SecretBytes::new(b"created by editor\n".to_vec()), + "fixture-editor", + None, + &mut committer, + )?; + assert!(store.path().join("new/from-editor.gpg").is_file()); + + let concurrent_request = EditRequest { + entry: "new/concurrent-editor".to_owned(), + }; + let session = writer.begin_edit(&concurrent_request, &mut provider)?; + writer.insert( + &insert("new/concurrent-editor", false, InsertInput::EchoedLine), + InsertContent::echoed(b"appeared".to_vec())?, + OverwriteDecision::Allow, + None, + &mut committer, + )?; + assert!(matches!( + writer.finish_edit( + session, + SecretBytes::new(b"stale".to_vec()), + "fixture-editor", + None, + &mut committer, + ), + Err(WriteError::ConcurrentModification { .. }) + )); + Ok(()) +} + +#[test] +fn invalid_recipient_policy_fails_before_entry_creation() -> TestResult { + let fixture = FixtureSet::load()?; + let store = fixture.materialize_store("basic")?; + fs::create_dir(store.path().join("invalid"))?; + fs::write( + store.path().join("invalid/.gpg-id"), + b"missing@ironstorage.invalid\n", + )?; + let repository = Repository::open(store.path())?; + let keys = KeyStore::load(fixture.path("keys"))?; + let writer = VaultWriter::new(&repository, &keys); + + assert!(matches!( + writer.insert( + &insert("invalid/entry", false, InsertInput::Multiline), + InsertContent::multiline(b"secret".to_vec()), + OverwriteDecision::Allow, + None, + &mut RecordingCommitter::default(), + ), + Err(WriteError::RecipientPolicy( + ironstorage::recipient::RecipientPolicyError::Crypto( + CryptoError::MissingIdentity { .. } + ) + )) + )); + assert!(!store.path().join("invalid/entry.gpg").exists()); + Ok(()) +} + +fn insert(entry: &str, force: bool, input: InsertInput) -> InsertRequest { + InsertRequest { + entry: entry.to_owned(), + input, + force, + } +} diff --git a/docs/write-domains.md b/docs/write-domains.md new file mode 100644 index 0000000..90cd5dc --- /dev/null +++ b/docs/write-domains.md @@ -0,0 +1,52 @@ +# Insert and edit domains + +`VaultWriter` in `crates/storage` owns insertion, edit concurrency, recipient +selection, encryption, repository mutation, rollback, and commit intent. +Frontends collect input and overwrite decisions but cannot write an encrypted +entry directly. + +## Insert + +`InsertContent` has distinct constructors for hidden confirmed input, echoed +single-line input, and exact multiline bytes. Hidden values must match their +confirmation; both single-line modes reject empty values and embedded line +breaks. Multiline input preserves every byte and may be empty. The content mode +must equal the parsed `InsertRequest`, preventing an adapter from silently +changing stdin semantics. + +An existing entry requires either `--force` or an explicit allow decision. A +decline returns before recipient resolution or encryption. The nearest signed +or unsigned recipient policy is resolved for the destination, plaintext is +consumed by embedded OpenPGP encryption, and repository replacement is atomic. +The storage commit boundary is invoked only afterward with the compatible +`Add given password ...` intent. Commit failure restores the exact old +ciphertext or removes a newly created entry and its empty parent directories. + +## Edit sessions + +Beginning a session snapshots the original encrypted bytes and decrypts an +existing entry into redacted `SecretBytes`; a missing entry starts with empty +contents so `pass edit` can create it. Finishing consumes replacement +`SecretBytes`. Unchanged content returns without encryption or commit. Before +writing, storage compares current encrypted bytes with the snapshot (or checks +that a new path is still absent), so concurrent replacement, removal, or +creation is rejected without overwriting the other writer. + +Successful replacement uses the destination's current recipient policy and +records compatible editor commit intent. A commit error restores the prior +state. Rollback failure retains both the commit and repository errors. + +## CLI editor boundary + +Editor precedence remains TOML configuration, `VISUAL`, `EDITOR`, then `vim`, +with the program and arguments parsed without a shell. The CLI adapter converts +that value to one `EditorInvocation` for a narrowly injected `EditorHost`; +storage, TUI, GUI, iOS, and watchOS never receive executable information and +continue to use the in-process replacement API. + +Before invoking the host, the CLI creates a mode-0600 plaintext file inside a +private temporary directory. Linux prefers `/dev/shm` when available; native +temporary storage is the fallback. The file is never inside the vault. A saved +file is bounded before reading. Saved, cancelled, failed, and host-error paths +all overwrite the file with zeros, truncate and sync it, remove it, and finally +drop the private directory. Returned replacement bytes remain zeroizing.