@@ -262,6 +262,33 @@ impl EntryDocument {
|
||||
.ok_or(DocumentError::UnknownField { id })
|
||||
}
|
||||
|
||||
/// Replace only a structured field's value while preserving its storage-
|
||||
/// supplied name and syntax.
|
||||
pub fn replace_field_value(
|
||||
&mut self,
|
||||
id: EntryFieldId,
|
||||
value: Vec<u8>,
|
||||
) -> Result<(), DocumentError> {
|
||||
let field = self.field(id).ok_or(DocumentError::UnknownField { id })?;
|
||||
let draft = match field.metadata().kind() {
|
||||
EntryFieldKind::OtpUri => EntryFieldDraft::otp_uri(value)?,
|
||||
EntryFieldKind::Password | EntryFieldKind::Note | EntryFieldKind::Blank => {
|
||||
EntryFieldDraft::line(value)?
|
||||
}
|
||||
EntryFieldKind::Username
|
||||
| EntryFieldKind::Email
|
||||
| EntryFieldKind::Url
|
||||
| EntryFieldKind::Field => EntryFieldDraft::field(
|
||||
field
|
||||
.metadata()
|
||||
.name()
|
||||
.ok_or(DocumentError::InvalidFieldName)?,
|
||||
value,
|
||||
)?,
|
||||
};
|
||||
self.update(id, draft)
|
||||
}
|
||||
|
||||
pub fn conflict_token(&self) -> DocumentConflictToken {
|
||||
self.conflict_token
|
||||
}
|
||||
@@ -424,6 +451,26 @@ impl<'a> EntryDocumentService<'a> {
|
||||
committer,
|
||||
)?)
|
||||
}
|
||||
|
||||
/// Save without consuming the document, allowing an in-process editor to
|
||||
/// present a conflict or encryption failure and retain the user's draft.
|
||||
pub fn save_recoverable(
|
||||
&self,
|
||||
document: &EntryDocument,
|
||||
signing: Option<&SigningPolicy>,
|
||||
committer: &mut impl EntryCommitter,
|
||||
) -> Result<WriteOutcome, DocumentError> {
|
||||
let replacement = document.serialize();
|
||||
Ok(
|
||||
VaultWriter::new(self.repository, self.keys).finish_edit_recoverable(
|
||||
&document.session,
|
||||
replacement,
|
||||
"IronStorage structured editor",
|
||||
signing,
|
||||
committer,
|
||||
)?,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
@@ -46,6 +46,31 @@ impl GeneratorConfig {
|
||||
pub fn character_set(&self) -> &[char] {
|
||||
&self.character_set
|
||||
}
|
||||
|
||||
/// Generate a zeroizing password for an in-process structured editor
|
||||
/// without mutating a repository.
|
||||
pub fn generate_secret(
|
||||
&self,
|
||||
length: Option<NonZeroUsize>,
|
||||
no_symbols: bool,
|
||||
) -> Result<SecretBytes, GenerateError> {
|
||||
self.generate_secret_with_rng(length, no_symbols, &mut OsRng)
|
||||
}
|
||||
|
||||
fn generate_secret_with_rng<R: RngCore + CryptoRng>(
|
||||
&self,
|
||||
length: Option<NonZeroUsize>,
|
||||
no_symbols: bool,
|
||||
rng: &mut R,
|
||||
) -> Result<SecretBytes, GenerateError> {
|
||||
let length = validate_length(length.unwrap_or(self.default_length).get())?;
|
||||
let characters = if no_symbols {
|
||||
&self.alphanumeric_set
|
||||
} else {
|
||||
&self.character_set
|
||||
};
|
||||
generate_password(rng, length, characters)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
@@ -126,14 +151,9 @@ impl<'a> PasswordGenerator<'a> {
|
||||
if request.force && request.in_place {
|
||||
return Err(GenerateError::IncompatibleFlags);
|
||||
}
|
||||
let length = request.length.unwrap_or(self.config.default_length);
|
||||
let length = validate_length(length.get())?;
|
||||
let characters = if request.no_symbols {
|
||||
&self.config.alphanumeric_set
|
||||
} else {
|
||||
&self.config.character_set
|
||||
};
|
||||
let password = generate_password(rng, length, characters)?;
|
||||
let password =
|
||||
self.config
|
||||
.generate_secret_with_rng(request.length, request.no_symbols, rng)?;
|
||||
let path = EntryPath::parse(&request.entry)?;
|
||||
let contents = if request.in_place {
|
||||
let ciphertext = self.repository.read_entry(&path)?;
|
||||
|
||||
@@ -25,7 +25,7 @@ use crate::{
|
||||
mutation::{TreeCommit, TreeCommitError, TreeCommitter},
|
||||
recipient::{PolicyCommit, PolicyCommitError, PolicyCommitter},
|
||||
repository::{EncryptedEntry, Repository, SecretBytes},
|
||||
write::{EntryCommit, EntryCommitError, EntryCommitter},
|
||||
write::{EntryCommit, EntryCommitError, EntryCommitter, NoGitEntryCommitter},
|
||||
};
|
||||
|
||||
const DEFAULT_BRANCH: &str = "main";
|
||||
@@ -38,6 +38,11 @@ pub struct GitIdentity {
|
||||
}
|
||||
|
||||
impl GitIdentity {
|
||||
pub fn ironstorage() -> Self {
|
||||
Self::new("IronStorage", "ironstorage@localhost")
|
||||
.expect("the built-in Git identity is valid")
|
||||
}
|
||||
|
||||
pub fn new(name: impl Into<String>, email: impl Into<String>) -> Result<Self, GitError> {
|
||||
let name = name.into();
|
||||
let email = email.into();
|
||||
@@ -257,6 +262,35 @@ pub struct GitRepository {
|
||||
identity: GitIdentity,
|
||||
}
|
||||
|
||||
/// Storage-owned selection of pass-compatible automatic Git commits.
|
||||
pub enum AutomaticEntryCommitter {
|
||||
Git(Box<GitRepository>),
|
||||
None(NoGitEntryCommitter),
|
||||
}
|
||||
|
||||
impl AutomaticEntryCommitter {
|
||||
pub fn for_entry(
|
||||
repository: &Repository,
|
||||
entry: &str,
|
||||
identity: GitIdentity,
|
||||
) -> Result<Self, GitError> {
|
||||
match GitRepository::open_innermost(repository, Path::new(entry), identity) {
|
||||
Ok(git) => Ok(Self::Git(Box::new(git))),
|
||||
Err(GitError::NotRepository) => Ok(Self::None(NoGitEntryCommitter)),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EntryCommitter for AutomaticEntryCommitter {
|
||||
fn commit(&mut self, change: &EntryCommit) -> Result<(), EntryCommitError> {
|
||||
match self {
|
||||
Self::Git(git) => EntryCommitter::commit(git.as_mut(), change),
|
||||
Self::None(committer) => committer.commit(change),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GitCredential {
|
||||
username: String,
|
||||
password: crate::repository::SecretBytes,
|
||||
|
||||
@@ -337,6 +337,20 @@ impl<'a> VaultWriter<'a> {
|
||||
editor_name: &str,
|
||||
signing: Option<&SigningPolicy>,
|
||||
committer: &mut impl EntryCommitter,
|
||||
) -> Result<WriteOutcome, WriteError> {
|
||||
self.finish_edit_recoverable(&session, replacement, editor_name, signing, committer)
|
||||
}
|
||||
|
||||
/// Finish an edit while retaining the session when validation, encryption,
|
||||
/// conflict detection, or commit orchestration fails.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn finish_edit_recoverable(
|
||||
&self,
|
||||
session: &EditSession,
|
||||
replacement: SecretBytes,
|
||||
editor_name: &str,
|
||||
signing: Option<&SigningPolicy>,
|
||||
committer: &mut impl EntryCommitter,
|
||||
) -> Result<WriteOutcome, WriteError> {
|
||||
if replacement.expose() == session.plaintext.expose() {
|
||||
return Err(WriteError::Unchanged);
|
||||
@@ -375,7 +389,7 @@ impl<'a> VaultWriter<'a> {
|
||||
return Err(WriteError::Commit(error));
|
||||
}
|
||||
Ok(WriteOutcome {
|
||||
path: session.path,
|
||||
path: session.path.clone(),
|
||||
action: EntryAction::Edit,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -238,11 +238,12 @@ fn atomic_save_creates_updates_and_rejects_stale_documents() -> TestResult {
|
||||
let stale_password = stale.password().expect("password").id();
|
||||
stale.update(stale_password, EntryFieldDraft::line(b"stale".to_vec())?)?;
|
||||
assert!(matches!(
|
||||
service.save(stale, None, &mut committer),
|
||||
service.save_recoverable(&stale, None, &mut committer),
|
||||
Err(DocumentError::Write(
|
||||
WriteError::ConcurrentModification { .. }
|
||||
))
|
||||
));
|
||||
assert_eq!(stale.serialize().expose(), b"stale\nusername: alice");
|
||||
assert_eq!(
|
||||
repository.read_entry(&EntryPath::parse("documents/new")?)?,
|
||||
winner
|
||||
@@ -253,9 +254,31 @@ fn atomic_save_creates_updates_and_rejects_stale_documents() -> TestResult {
|
||||
rollback.update(password, EntryFieldDraft::line(b"must roll back".to_vec())?)?;
|
||||
committer.fail = true;
|
||||
assert!(matches!(
|
||||
service.save(rollback, None, &mut committer),
|
||||
service.save_recoverable(&rollback, None, &mut committer),
|
||||
Err(DocumentError::Write(WriteError::Commit(_)))
|
||||
));
|
||||
assert!(
|
||||
rollback
|
||||
.serialize()
|
||||
.expose()
|
||||
.starts_with(b"must roll back\n")
|
||||
);
|
||||
assert_eq!(
|
||||
repository.read_entry(&EntryPath::parse("documents/new")?)?,
|
||||
winner
|
||||
);
|
||||
committer.fail = false;
|
||||
std::fs::write(
|
||||
store.path().join(".gpg-id"),
|
||||
b"FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF\n",
|
||||
)?;
|
||||
let retained = rollback.serialize();
|
||||
assert!(
|
||||
service
|
||||
.save_recoverable(&rollback, None, &mut committer)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(rollback.serialize().expose(), retained.expose());
|
||||
assert_eq!(
|
||||
repository.read_entry(&EntryPath::parse("documents/new")?)?,
|
||||
winner
|
||||
|
||||
@@ -131,6 +131,21 @@ fn deterministic_generation_uses_only_requested_characters_and_length() -> TestR
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_process_generation_returns_a_zeroizing_unstored_secret() -> TestResult {
|
||||
let config = GeneratorConfig::new(12, "abc123")?;
|
||||
let generated = config.generate_secret(NonZeroUsize::new(48), false)?;
|
||||
assert_eq!(generated.expose().len(), 48);
|
||||
assert!(
|
||||
generated
|
||||
.expose()
|
||||
.iter()
|
||||
.all(|byte| b"abc123".contains(byte))
|
||||
);
|
||||
assert!(!format!("{generated:?}").contains(std::str::from_utf8(generated.expose())?));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_no_symbols_and_presentation_actions_are_typed() -> TestResult {
|
||||
let fixture = FixtureSet::load()?;
|
||||
|
||||
Reference in New Issue
Block a user