Expose structured entry documents
This commit is contained in:
@@ -38,6 +38,9 @@ documented in [`docs/otp.md`](docs/otp.md).
|
||||
The complete command matrix, shell completion interface, deliberate no-process
|
||||
differences, and executable security audit are documented in
|
||||
[`docs/cli-parity.md`](docs/cli-parity.md).
|
||||
Lossless structured entry fields, semantic/sensitivity metadata, conflict
|
||||
tokens, and atomic frontend saves are documented in
|
||||
[`docs/entry-documents.md`](docs/entry-documents.md).
|
||||
The capability-scoped password-store layout and atomic mutation guarantees are
|
||||
documented in [`docs/repository-core.md`](docs/repository-core.md).
|
||||
The embedded OpenPGP backend, exported-key model, secret-provider boundary, and
|
||||
|
||||
565
crates/storage/src/document.rs
Normal file
565
crates/storage/src/document.rs
Normal file
@@ -0,0 +1,565 @@
|
||||
//! Lossless, presentation-neutral documents for arbitrary pass entries.
|
||||
|
||||
use std::{error::Error, fmt, ops::Range};
|
||||
|
||||
use sha2::{Digest as _, Sha256};
|
||||
|
||||
use crate::{
|
||||
command::EditRequest,
|
||||
crypto::{KeyStore, SecretProvider},
|
||||
otp::OtpUri,
|
||||
recipient::SigningPolicy,
|
||||
repository::{EntryPath, Repository, SecretBytes},
|
||||
write::{EditSession, EntryCommitter, VaultWriter, WriteError, WriteOutcome},
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
pub struct EntryFieldId(u64);
|
||||
|
||||
impl EntryFieldId {
|
||||
pub fn value(self) -> u64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum EntryFieldKind {
|
||||
Password,
|
||||
Username,
|
||||
Email,
|
||||
Url,
|
||||
OtpUri,
|
||||
Field,
|
||||
Note,
|
||||
Blank,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum EntrySensitivity {
|
||||
Sensitive,
|
||||
Ordinary,
|
||||
Empty,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct EntryFieldMetadata {
|
||||
kind: EntryFieldKind,
|
||||
sensitivity: EntrySensitivity,
|
||||
name: Option<String>,
|
||||
value: Range<usize>,
|
||||
}
|
||||
|
||||
impl EntryFieldMetadata {
|
||||
pub fn kind(&self) -> EntryFieldKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
pub fn sensitivity(&self) -> EntrySensitivity {
|
||||
self.sensitivity
|
||||
}
|
||||
|
||||
pub fn name(&self) -> Option<&str> {
|
||||
self.name.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct EntryField {
|
||||
id: EntryFieldId,
|
||||
contents: SecretBytes,
|
||||
ending: Vec<u8>,
|
||||
metadata: EntryFieldMetadata,
|
||||
}
|
||||
|
||||
impl EntryField {
|
||||
pub fn id(&self) -> EntryFieldId {
|
||||
self.id
|
||||
}
|
||||
|
||||
pub fn contents(&self) -> &SecretBytes {
|
||||
&self.contents
|
||||
}
|
||||
|
||||
pub fn value(&self) -> &[u8] {
|
||||
&self.contents.expose()[self.metadata.value.clone()]
|
||||
}
|
||||
|
||||
pub fn metadata(&self) -> &EntryFieldMetadata {
|
||||
&self.metadata
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for EntryField {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("EntryField")
|
||||
.field("id", &self.id)
|
||||
.field("metadata", &self.metadata)
|
||||
.field("contents", &"[REDACTED]")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct EntryFieldDraft {
|
||||
contents: SecretBytes,
|
||||
}
|
||||
|
||||
impl EntryFieldDraft {
|
||||
pub fn line(contents: Vec<u8>) -> Result<Self, DocumentError> {
|
||||
validate_line(&contents)?;
|
||||
Ok(Self {
|
||||
contents: SecretBytes::new(contents),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn field(name: impl Into<String>, value: Vec<u8>) -> Result<Self, DocumentError> {
|
||||
let name = name.into();
|
||||
validate_name(&name)?;
|
||||
validate_line(&value)?;
|
||||
let value = SecretBytes::new(value);
|
||||
let mut contents = Vec::with_capacity(name.len() + value.expose().len() + 2);
|
||||
contents.extend_from_slice(name.as_bytes());
|
||||
contents.extend_from_slice(b": ");
|
||||
contents.extend_from_slice(value.expose());
|
||||
Ok(Self {
|
||||
contents: SecretBytes::new(contents),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn otp_uri(uri: Vec<u8>) -> Result<Self, DocumentError> {
|
||||
validate_line(&uri)?;
|
||||
let encoded = SecretBytes::new(uri);
|
||||
OtpUri::parse(SecretBytes::new(encoded.expose().to_vec()))
|
||||
.map_err(|_| DocumentError::InvalidOtpUri)?;
|
||||
Ok(Self { contents: encoded })
|
||||
}
|
||||
|
||||
pub fn blank() -> Self {
|
||||
Self {
|
||||
contents: SecretBytes::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn render(self) -> SecretBytes {
|
||||
self.contents
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for EntryFieldDraft {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("EntryFieldDraft([REDACTED])")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Eq, Hash, PartialEq)]
|
||||
pub struct DocumentConflictToken([u8; 32]);
|
||||
|
||||
impl DocumentConflictToken {
|
||||
pub fn as_bytes(&self) -> &[u8; 32] {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for DocumentConflictToken {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("DocumentConflictToken([REDACTED])")
|
||||
}
|
||||
}
|
||||
|
||||
pub struct EntryDocument {
|
||||
path: EntryPath,
|
||||
fields: Vec<EntryField>,
|
||||
next_id: u64,
|
||||
conflict_token: DocumentConflictToken,
|
||||
session: EditSession,
|
||||
}
|
||||
|
||||
impl EntryDocument {
|
||||
fn from_session(session: EditSession) -> Self {
|
||||
let path = session.path().clone();
|
||||
let conflict_token = conflict_token(&session);
|
||||
let mut fields = parse_lines(session.plaintext().expose());
|
||||
classify_all(&mut fields);
|
||||
let next_id = fields.len() as u64;
|
||||
Self {
|
||||
path,
|
||||
fields,
|
||||
next_id,
|
||||
conflict_token,
|
||||
session,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &EntryPath {
|
||||
&self.path
|
||||
}
|
||||
|
||||
pub fn fields(&self) -> &[EntryField] {
|
||||
&self.fields
|
||||
}
|
||||
|
||||
pub fn field(&self, id: EntryFieldId) -> Option<&EntryField> {
|
||||
self.fields.iter().find(|field| field.id == id)
|
||||
}
|
||||
|
||||
pub fn password(&self) -> Option<&EntryField> {
|
||||
self.fields.first()
|
||||
}
|
||||
|
||||
pub fn conflict_token(&self) -> DocumentConflictToken {
|
||||
self.conflict_token
|
||||
}
|
||||
|
||||
pub fn add(
|
||||
&mut self,
|
||||
index: usize,
|
||||
draft: EntryFieldDraft,
|
||||
) -> Result<EntryFieldId, DocumentError> {
|
||||
if index > self.fields.len() {
|
||||
return Err(DocumentError::InvalidIndex { index });
|
||||
}
|
||||
let final_newline = self.has_final_newline();
|
||||
let default_ending = self.default_ending();
|
||||
let id = EntryFieldId(self.next_id);
|
||||
self.next_id = self
|
||||
.next_id
|
||||
.checked_add(1)
|
||||
.ok_or(DocumentError::FieldIdExhausted)?;
|
||||
self.fields.insert(
|
||||
index,
|
||||
EntryField {
|
||||
id,
|
||||
contents: draft.render(),
|
||||
ending: default_ending.clone(),
|
||||
metadata: blank_metadata(),
|
||||
},
|
||||
);
|
||||
normalize_endings(&mut self.fields, &default_ending, final_newline);
|
||||
classify_all(&mut self.fields);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub fn update(
|
||||
&mut self,
|
||||
id: EntryFieldId,
|
||||
draft: EntryFieldDraft,
|
||||
) -> Result<(), DocumentError> {
|
||||
let field = self
|
||||
.fields
|
||||
.iter_mut()
|
||||
.find(|field| field.id == id)
|
||||
.ok_or(DocumentError::UnknownField { id })?;
|
||||
field.contents = draft.render();
|
||||
classify_all(&mut self.fields);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, id: EntryFieldId) -> Result<EntryField, DocumentError> {
|
||||
let index = self
|
||||
.fields
|
||||
.iter()
|
||||
.position(|field| field.id == id)
|
||||
.ok_or(DocumentError::UnknownField { id })?;
|
||||
let final_newline = self.has_final_newline();
|
||||
let default_ending = self.default_ending();
|
||||
let removed = self.fields.remove(index);
|
||||
normalize_endings(&mut self.fields, &default_ending, final_newline);
|
||||
classify_all(&mut self.fields);
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
pub fn reorder(&mut self, id: EntryFieldId, index: usize) -> Result<(), DocumentError> {
|
||||
if index >= self.fields.len() {
|
||||
return Err(DocumentError::InvalidIndex { index });
|
||||
}
|
||||
let current = self
|
||||
.fields
|
||||
.iter()
|
||||
.position(|field| field.id == id)
|
||||
.ok_or(DocumentError::UnknownField { id })?;
|
||||
if current == index {
|
||||
return Ok(());
|
||||
}
|
||||
let final_newline = self.has_final_newline();
|
||||
let default_ending = self.default_ending();
|
||||
let field = self.fields.remove(current);
|
||||
self.fields.insert(index, field);
|
||||
normalize_endings(&mut self.fields, &default_ending, final_newline);
|
||||
classify_all(&mut self.fields);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn serialize(&self) -> SecretBytes {
|
||||
let capacity = self
|
||||
.fields
|
||||
.iter()
|
||||
.map(|field| field.contents.expose().len() + field.ending.len())
|
||||
.sum();
|
||||
let mut output = Vec::with_capacity(capacity);
|
||||
for field in &self.fields {
|
||||
output.extend_from_slice(field.contents.expose());
|
||||
output.extend_from_slice(&field.ending);
|
||||
}
|
||||
SecretBytes::new(output)
|
||||
}
|
||||
|
||||
fn has_final_newline(&self) -> bool {
|
||||
self.fields
|
||||
.last()
|
||||
.is_some_and(|field| !field.ending.is_empty())
|
||||
}
|
||||
|
||||
fn default_ending(&self) -> Vec<u8> {
|
||||
self.fields
|
||||
.iter()
|
||||
.find(|field| !field.ending.is_empty())
|
||||
.map(|field| field.ending.clone())
|
||||
.unwrap_or_else(|| b"\n".to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for EntryDocument {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("EntryDocument")
|
||||
.field("path", &self.path)
|
||||
.field("field_count", &self.fields.len())
|
||||
.field("conflict_token", &self.conflict_token)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct EntryDocumentService<'a> {
|
||||
repository: &'a Repository,
|
||||
keys: &'a KeyStore,
|
||||
}
|
||||
|
||||
impl<'a> EntryDocumentService<'a> {
|
||||
pub fn new(repository: &'a Repository, keys: &'a KeyStore) -> Self {
|
||||
Self { repository, keys }
|
||||
}
|
||||
|
||||
pub fn open(
|
||||
&self,
|
||||
entry: &str,
|
||||
secrets: &mut impl SecretProvider,
|
||||
) -> Result<EntryDocument, DocumentError> {
|
||||
let session = VaultWriter::new(self.repository, self.keys).begin_edit(
|
||||
&EditRequest {
|
||||
entry: entry.to_owned(),
|
||||
},
|
||||
secrets,
|
||||
)?;
|
||||
Ok(EntryDocument::from_session(session))
|
||||
}
|
||||
|
||||
pub fn save(
|
||||
&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(
|
||||
document.session,
|
||||
replacement,
|
||||
"IronStorage structured editor",
|
||||
signing,
|
||||
committer,
|
||||
)?)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum DocumentError {
|
||||
Write(WriteError),
|
||||
InvalidIndex { index: usize },
|
||||
UnknownField { id: EntryFieldId },
|
||||
LineBreak,
|
||||
InvalidFieldName,
|
||||
InvalidOtpUri,
|
||||
FieldIdExhausted,
|
||||
}
|
||||
|
||||
impl fmt::Display for DocumentError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Write(error) => error.fmt(formatter),
|
||||
Self::InvalidIndex { index } => write!(formatter, "invalid entry field index: {index}"),
|
||||
Self::UnknownField { id } => write!(formatter, "unknown entry field: {}", id.value()),
|
||||
Self::LineBreak => formatter.write_str("an entry field may not contain a line break"),
|
||||
Self::InvalidFieldName => formatter.write_str("entry field name is invalid"),
|
||||
Self::InvalidOtpUri => formatter.write_str("entry OTP URI is invalid"),
|
||||
Self::FieldIdExhausted => formatter.write_str("entry field identifiers are exhausted"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for DocumentError {}
|
||||
|
||||
impl From<WriteError> for DocumentError {
|
||||
fn from(error: WriteError) -> Self {
|
||||
Self::Write(error)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_lines(contents: &[u8]) -> Vec<EntryField> {
|
||||
let mut fields = Vec::new();
|
||||
let mut start = 0;
|
||||
while start < contents.len() {
|
||||
let newline = contents[start..]
|
||||
.iter()
|
||||
.position(|byte| *byte == b'\n')
|
||||
.map(|offset| start + offset);
|
||||
let (content_end, end, ending) = match newline {
|
||||
Some(newline) if newline > start && contents[newline - 1] == b'\r' => {
|
||||
(newline - 1, newline + 1, b"\r\n".to_vec())
|
||||
}
|
||||
Some(newline) => (newline, newline + 1, b"\n".to_vec()),
|
||||
None => (contents.len(), contents.len(), Vec::new()),
|
||||
};
|
||||
fields.push(EntryField {
|
||||
id: EntryFieldId(fields.len() as u64),
|
||||
contents: SecretBytes::new(contents[start..content_end].to_vec()),
|
||||
ending,
|
||||
metadata: blank_metadata(),
|
||||
});
|
||||
start = end;
|
||||
}
|
||||
fields
|
||||
}
|
||||
|
||||
fn classify_all(fields: &mut [EntryField]) {
|
||||
for (index, field) in fields.iter_mut().enumerate() {
|
||||
field.metadata = classify(index, field.contents.expose());
|
||||
}
|
||||
}
|
||||
|
||||
fn classify(index: usize, line: &[u8]) -> EntryFieldMetadata {
|
||||
if index == 0 {
|
||||
return EntryFieldMetadata {
|
||||
kind: EntryFieldKind::Password,
|
||||
sensitivity: EntrySensitivity::Sensitive,
|
||||
name: Some("password".to_owned()),
|
||||
value: 0..line.len(),
|
||||
};
|
||||
}
|
||||
if line.is_empty() {
|
||||
return blank_metadata();
|
||||
}
|
||||
if line.starts_with(b"otpauth://") && OtpUri::parse(SecretBytes::new(line.to_vec())).is_ok() {
|
||||
return EntryFieldMetadata {
|
||||
kind: EntryFieldKind::OtpUri,
|
||||
sensitivity: EntrySensitivity::Sensitive,
|
||||
name: Some("otp".to_owned()),
|
||||
value: 0..line.len(),
|
||||
};
|
||||
}
|
||||
if let Some(colon) = line.iter().position(|byte| *byte == b':') {
|
||||
let raw_name = trim_ascii(&line[..colon]);
|
||||
if !raw_name.is_empty()
|
||||
&& let Ok(name) = std::str::from_utf8(raw_name)
|
||||
{
|
||||
let value_start = colon + 1 + usize::from(line.get(colon + 1) == Some(&b' '));
|
||||
let kind = semantic_field_kind(name);
|
||||
return EntryFieldMetadata {
|
||||
kind,
|
||||
sensitivity: field_sensitivity(name, kind),
|
||||
name: Some(name.to_owned()),
|
||||
value: value_start..line.len(),
|
||||
};
|
||||
}
|
||||
}
|
||||
EntryFieldMetadata {
|
||||
kind: EntryFieldKind::Note,
|
||||
sensitivity: EntrySensitivity::Sensitive,
|
||||
name: None,
|
||||
value: 0..line.len(),
|
||||
}
|
||||
}
|
||||
|
||||
fn semantic_field_kind(name: &str) -> EntryFieldKind {
|
||||
match name.trim().to_ascii_lowercase().as_str() {
|
||||
"user" | "username" | "login" => EntryFieldKind::Username,
|
||||
"email" | "e-mail" => EntryFieldKind::Email,
|
||||
"url" | "uri" | "website" => EntryFieldKind::Url,
|
||||
_ => EntryFieldKind::Field,
|
||||
}
|
||||
}
|
||||
|
||||
fn field_sensitivity(name: &str, kind: EntryFieldKind) -> EntrySensitivity {
|
||||
if matches!(
|
||||
kind,
|
||||
EntryFieldKind::Username | EntryFieldKind::Email | EntryFieldKind::Url
|
||||
) {
|
||||
return EntrySensitivity::Ordinary;
|
||||
}
|
||||
match name.trim().to_ascii_lowercase().as_str() {
|
||||
"title" | "site" | "host" => EntrySensitivity::Ordinary,
|
||||
_ => EntrySensitivity::Sensitive,
|
||||
}
|
||||
}
|
||||
|
||||
fn blank_metadata() -> EntryFieldMetadata {
|
||||
EntryFieldMetadata {
|
||||
kind: EntryFieldKind::Blank,
|
||||
sensitivity: EntrySensitivity::Empty,
|
||||
name: None,
|
||||
value: 0..0,
|
||||
}
|
||||
}
|
||||
|
||||
fn trim_ascii(mut value: &[u8]) -> &[u8] {
|
||||
while value.first().is_some_and(u8::is_ascii_whitespace) {
|
||||
value = &value[1..];
|
||||
}
|
||||
while value.last().is_some_and(u8::is_ascii_whitespace) {
|
||||
value = &value[..value.len() - 1];
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
fn validate_line(value: &[u8]) -> Result<(), DocumentError> {
|
||||
if value.iter().any(|byte| matches!(byte, b'\r' | b'\n')) {
|
||||
Err(DocumentError::LineBreak)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_name(name: &str) -> Result<(), DocumentError> {
|
||||
if name.trim().is_empty() || name.contains([':', '\r', '\n']) || name.trim() != name {
|
||||
Err(DocumentError::InvalidFieldName)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_endings(fields: &mut [EntryField], default: &[u8], final_newline: bool) {
|
||||
let last = fields.len().saturating_sub(1);
|
||||
for (index, field) in fields.iter_mut().enumerate() {
|
||||
if index < last && field.ending.is_empty() {
|
||||
field.ending = default.to_vec();
|
||||
} else if index == last {
|
||||
if final_newline && field.ending.is_empty() {
|
||||
field.ending = default.to_vec();
|
||||
} else if !final_newline {
|
||||
field.ending.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn conflict_token(session: &EditSession) -> DocumentConflictToken {
|
||||
let mut digest = Sha256::new();
|
||||
digest.update(b"ironstorage-entry-document-v1\0");
|
||||
digest.update(session.path().to_string().as_bytes());
|
||||
digest.update(b"\0");
|
||||
match session.original_ciphertext() {
|
||||
Some(ciphertext) => {
|
||||
digest.update(b"present\0");
|
||||
digest.update(ciphertext.as_bytes());
|
||||
}
|
||||
None => digest.update(b"missing\0"),
|
||||
}
|
||||
DocumentConflictToken(digest.finalize().into())
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
pub mod command;
|
||||
pub mod config;
|
||||
pub mod crypto;
|
||||
pub mod document;
|
||||
pub mod generate;
|
||||
pub mod git;
|
||||
pub mod mutation;
|
||||
|
||||
@@ -180,6 +180,10 @@ impl EditSession {
|
||||
pub fn plaintext(&self) -> &SecretBytes {
|
||||
&self.plaintext
|
||||
}
|
||||
|
||||
pub(crate) fn original_ciphertext(&self) -> Option<&EncryptedEntry> {
|
||||
self.original_ciphertext.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for EditSession {
|
||||
|
||||
319
crates/storage/tests/entry_documents.rs
Normal file
319
crates/storage/tests/entry_documents.rs
Normal file
@@ -0,0 +1,319 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod support;
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use ironstorage::{
|
||||
crypto::{KeyInfo, KeyStore, SecretProvider, SecretProviderError},
|
||||
document::{
|
||||
DocumentError, EntryDocumentService, EntryFieldDraft, EntryFieldKind, EntrySensitivity,
|
||||
},
|
||||
recipient::RecipientPolicyManager,
|
||||
repository::{EntryPath, Repository, SecretBytes},
|
||||
write::{EntryCommit, EntryCommitError, EntryCommitter, WriteError},
|
||||
};
|
||||
use support::compatibility::{FixtureSet, TestResult};
|
||||
|
||||
struct FixtureSecrets(BTreeMap<String, Vec<u8>>);
|
||||
|
||||
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<SecretBytes, SecretProviderError> {
|
||||
self.0
|
||||
.get(key.fingerprint().as_str())
|
||||
.cloned()
|
||||
.map(SecretBytes::new)
|
||||
.ok_or(SecretProviderError::Unavailable)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Committer {
|
||||
changes: Vec<EntryCommit>,
|
||||
fail: bool,
|
||||
}
|
||||
|
||||
impl EntryCommitter for Committer {
|
||||
fn commit(&mut self, change: &EntryCommit) -> Result<(), EntryCommitError> {
|
||||
self.changes.push(change.clone());
|
||||
if self.fail {
|
||||
Err(EntryCommitError::new("simulated commit failure"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complex_documents_round_trip_with_storage_owned_metadata() -> 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 mut secrets = FixtureSecrets::all(&fixture);
|
||||
let plaintext = b"p\xc3\xa4ssw\xc3\xb6rd\r\nusername: alice\r\ncustom: one\r\ncustom: \r\notpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example\r\n\r\nfirst note\r\nsecond note\r\n\xe9\x8d\xb5: \xe5\x80\xbc\r\nunrecognized line";
|
||||
write_plaintext(&repository, &keys, "documents/complex", plaintext)?;
|
||||
|
||||
let service = EntryDocumentService::new(&repository, &keys);
|
||||
let document = service.open("documents/complex", &mut secrets)?;
|
||||
assert_eq!(document.serialize().expose(), plaintext);
|
||||
assert_eq!(
|
||||
document.password().expect("password").value(),
|
||||
"pässwörd".as_bytes()
|
||||
);
|
||||
assert_eq!(document.fields().len(), 10);
|
||||
|
||||
let kinds = document
|
||||
.fields()
|
||||
.iter()
|
||||
.map(|field| field.metadata().kind())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
kinds,
|
||||
[
|
||||
EntryFieldKind::Password,
|
||||
EntryFieldKind::Username,
|
||||
EntryFieldKind::Field,
|
||||
EntryFieldKind::Field,
|
||||
EntryFieldKind::OtpUri,
|
||||
EntryFieldKind::Blank,
|
||||
EntryFieldKind::Note,
|
||||
EntryFieldKind::Note,
|
||||
EntryFieldKind::Field,
|
||||
EntryFieldKind::Note,
|
||||
]
|
||||
);
|
||||
assert_eq!(document.fields()[1].metadata().name(), Some("username"));
|
||||
assert_eq!(
|
||||
document.fields()[1].metadata().sensitivity(),
|
||||
EntrySensitivity::Ordinary
|
||||
);
|
||||
assert_eq!(document.fields()[2].metadata().name(), Some("custom"));
|
||||
assert_eq!(document.fields()[2].value(), b"one");
|
||||
assert_eq!(document.fields()[3].metadata().name(), Some("custom"));
|
||||
assert!(document.fields()[3].value().is_empty());
|
||||
assert_ne!(document.fields()[2].id(), document.fields()[3].id());
|
||||
assert_eq!(document.fields()[8].metadata().name(), Some("鍵"));
|
||||
assert_eq!(document.fields()[8].value(), "值".as_bytes());
|
||||
assert_eq!(
|
||||
document.fields()[4].metadata().sensitivity(),
|
||||
EntrySensitivity::Sensitive
|
||||
);
|
||||
assert!(!format!("{document:?}").contains("pässwörd"));
|
||||
assert!(!format!("{:?}", document.fields()[4]).contains("JBSWY3DPEHPK3PXP"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_ids_survive_updates_removal_and_reordering() -> 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 mut secrets = FixtureSecrets::all(&fixture);
|
||||
write_plaintext(
|
||||
&repository,
|
||||
&keys,
|
||||
"documents/editable",
|
||||
b"password\nusername: alice\nnote one\nnote two\n",
|
||||
)?;
|
||||
let service = EntryDocumentService::new(&repository, &keys);
|
||||
let mut document = service.open("documents/editable", &mut secrets)?;
|
||||
let password_id = document.fields()[0].id();
|
||||
let username_id = document.fields()[1].id();
|
||||
let note_one_id = document.fields()[2].id();
|
||||
let note_two_id = document.fields()[3].id();
|
||||
let token = document.conflict_token();
|
||||
|
||||
document.update(
|
||||
username_id,
|
||||
EntryFieldDraft::field("username", b"bob".to_vec())?,
|
||||
)?;
|
||||
let email_id = document.add(
|
||||
2,
|
||||
EntryFieldDraft::field("email", "bob@例.test".as_bytes().to_vec())?,
|
||||
)?;
|
||||
document.reorder(note_two_id, 2)?;
|
||||
let removed = document.remove(note_one_id)?;
|
||||
assert_eq!(removed.id(), note_one_id);
|
||||
assert_eq!(document.conflict_token(), token);
|
||||
assert_eq!(document.fields()[0].id(), password_id);
|
||||
assert_eq!(
|
||||
document.field(username_id).expect("username").value(),
|
||||
b"bob"
|
||||
);
|
||||
assert_eq!(
|
||||
document.field(email_id).expect("email").metadata().kind(),
|
||||
EntryFieldKind::Email
|
||||
);
|
||||
assert_eq!(document.fields()[2].id(), note_two_id);
|
||||
assert_eq!(
|
||||
document.serialize().expose(),
|
||||
"password\nusername: bob\nnote two\nemail: bob@例.test\n".as_bytes()
|
||||
);
|
||||
|
||||
let mut empty = service.open("documents/missing", &mut secrets)?;
|
||||
assert!(empty.fields().is_empty());
|
||||
assert!(empty.password().is_none());
|
||||
empty.add(0, EntryFieldDraft::line(b"password".to_vec())?)?;
|
||||
empty.add(1, EntryFieldDraft::blank())?;
|
||||
assert_eq!(empty.fields().len(), 2);
|
||||
assert_eq!(empty.fields()[1].metadata().kind(), EntryFieldKind::Blank);
|
||||
assert_eq!(empty.serialize().expose(), b"password\n");
|
||||
|
||||
assert!(matches!(
|
||||
EntryFieldDraft::line(b"two\nlines".to_vec()),
|
||||
Err(DocumentError::LineBreak)
|
||||
));
|
||||
assert!(matches!(
|
||||
EntryFieldDraft::field(" invalid ", Vec::new()),
|
||||
Err(DocumentError::InvalidFieldName)
|
||||
));
|
||||
assert!(matches!(
|
||||
EntryFieldDraft::otp_uri(b"otpauth://totp/missing-secret".to_vec()),
|
||||
Err(DocumentError::InvalidOtpUri)
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atomic_save_creates_updates_and_rejects_stale_documents() -> 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 mut secrets = FixtureSecrets::all(&fixture);
|
||||
let service = EntryDocumentService::new(&repository, &keys);
|
||||
let mut committer = Committer::default();
|
||||
|
||||
let mut created = service.open("documents/new", &mut secrets)?;
|
||||
created.add(0, EntryFieldDraft::line(b"new password".to_vec())?)?;
|
||||
created.add(1, EntryFieldDraft::field("username", b"alice".to_vec())?)?;
|
||||
service.save(created, None, &mut committer)?;
|
||||
assert_eq!(committer.changes.len(), 1);
|
||||
assert_eq!(
|
||||
decrypt(&repository, &keys, "documents/new", &mut secrets)?.expose(),
|
||||
b"new password\nusername: alice"
|
||||
);
|
||||
|
||||
let mut first = service.open("documents/new", &mut secrets)?;
|
||||
let mut stale = service.open("documents/new", &mut secrets)?;
|
||||
assert_eq!(first.conflict_token(), stale.conflict_token());
|
||||
let first_password = first.password().expect("password").id();
|
||||
first.update(first_password, EntryFieldDraft::line(b"winner".to_vec())?)?;
|
||||
service.save(first, None, &mut committer)?;
|
||||
let winner = repository.read_entry(&EntryPath::parse("documents/new")?)?;
|
||||
|
||||
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),
|
||||
Err(DocumentError::Write(
|
||||
WriteError::ConcurrentModification { .. }
|
||||
))
|
||||
));
|
||||
assert_eq!(
|
||||
repository.read_entry(&EntryPath::parse("documents/new")?)?,
|
||||
winner
|
||||
);
|
||||
|
||||
let mut rollback = service.open("documents/new", &mut secrets)?;
|
||||
let password = rollback.password().expect("password").id();
|
||||
rollback.update(password, EntryFieldDraft::line(b"must roll back".to_vec())?)?;
|
||||
committer.fail = true;
|
||||
assert!(matches!(
|
||||
service.save(rollback, None, &mut committer),
|
||||
Err(DocumentError::Write(WriteError::Commit(_)))
|
||||
));
|
||||
assert_eq!(
|
||||
repository.read_entry(&EntryPath::parse("documents/new")?)?,
|
||||
winner
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_compatibility_entry_round_trips_as_a_document() -> TestResult {
|
||||
let fixture = FixtureSet::load()?;
|
||||
let stores = fixture
|
||||
.generated
|
||||
.entries
|
||||
.iter()
|
||||
.map(|entry| entry.store.as_str())
|
||||
.collect::<BTreeSet<_>>();
|
||||
for store_id in stores {
|
||||
let store = fixture.materialize_store(store_id)?;
|
||||
let repository = Repository::open(store.path())?;
|
||||
let keys = KeyStore::load(fixture.path("keys"))?;
|
||||
let mut secrets = FixtureSecrets::all(&fixture);
|
||||
let service = EntryDocumentService::new(&repository, &keys);
|
||||
for entry in fixture
|
||||
.generated
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|entry| entry.store == store_id)
|
||||
{
|
||||
let logical = entry
|
||||
.path
|
||||
.strip_suffix(".gpg")
|
||||
.expect("fixture entry suffix");
|
||||
let document = service.open(logical, &mut secrets)?;
|
||||
assert_eq!(
|
||||
document.serialize().expose(),
|
||||
fixture.read(
|
||||
std::path::Path::new("expected")
|
||||
.join(store_id)
|
||||
.join(&entry.plaintext),
|
||||
)?,
|
||||
"document round trip for {}/{}",
|
||||
store_id,
|
||||
entry.path
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_plaintext(
|
||||
repository: &Repository,
|
||||
keys: &KeyStore,
|
||||
path: &str,
|
||||
plaintext: &[u8],
|
||||
) -> TestResult {
|
||||
let path = EntryPath::parse(path)?;
|
||||
let recipients =
|
||||
RecipientPolicyManager::new(repository, keys).resolve_for_entry(&path, None)?;
|
||||
let encrypted = keys.encrypt(
|
||||
SecretBytes::new(plaintext.to_vec()),
|
||||
recipients.recipients(),
|
||||
)?;
|
||||
repository.write_entry(&path, &encrypted)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn decrypt(
|
||||
repository: &Repository,
|
||||
keys: &KeyStore,
|
||||
path: &str,
|
||||
secrets: &mut impl SecretProvider,
|
||||
) -> Result<SecretBytes, Box<dyn std::error::Error>> {
|
||||
Ok(keys.decrypt(&repository.read_entry(&EntryPath::parse(path)?)?, secrets)?)
|
||||
}
|
||||
43
docs/entry-documents.md
Normal file
43
docs/entry-documents.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Structured entry documents
|
||||
|
||||
`crates/storage::document` exposes arbitrary password-store entries as ordered,
|
||||
presentation-neutral documents. Frontends supply a logical entry path and a
|
||||
secret provider to `EntryDocumentService::open`; storage resolves and reads the
|
||||
entry, decrypts it, parses it, and returns fields with stable in-document IDs.
|
||||
Frontends never inspect `.gpg` files, select recipients, parse entry text, or
|
||||
write the repository.
|
||||
|
||||
The first physical line is always the conventional password field. Every later
|
||||
physical line remains independently addressable and is classified as a dynamic
|
||||
`name: value` field, valid `otpauth://` URI, blank line, or free-form note.
|
||||
Recognized username, email and URL names receive semantic kinds. Passwords,
|
||||
OTP URIs, notes and unknown/custom values are sensitive by default; ordinary
|
||||
identity/navigation fields and blank lines receive separate sensitivity
|
||||
metadata. Duplicate names are retained as distinct fields in their original
|
||||
order. Unicode names and values are supported, while non-UTF-8 or otherwise
|
||||
unrecognized lines remain lossless notes.
|
||||
|
||||
Line contents and their LF, CRLF or absent final endings are retained exactly.
|
||||
Parsing and serializing an untouched document therefore returns byte-identical
|
||||
plaintext, including mixed endings, empty values, blank lines and a missing
|
||||
first line. Updates replace only the selected line. Structural operations keep
|
||||
untouched line bytes and preserve the document's final-newline convention;
|
||||
new dynamic fields serialize as the upstream-compatible `name: value` form.
|
||||
No IDs or metadata are written to the password entry, so this does not create a
|
||||
new on-disk format. IDs are stable for the lifetime of the opened document and
|
||||
remain attached to fields through update and reorder operations.
|
||||
|
||||
Documents support indexed add, ID-based update and removal, ID-based reorder,
|
||||
lossless serialization and a redacted conflict token derived from the original
|
||||
ciphertext. `EntryDocumentService::save` consumes the edited document and uses
|
||||
the existing `VaultWriter` edit session. It rechecks the original ciphertext,
|
||||
resolves the current recipient policy in storage, encrypts, atomically replaces
|
||||
the `.gpg` entry, and performs the storage mutation commit. Concurrent changes
|
||||
are rejected before writing. A commit failure restores the original encrypted
|
||||
entry, so clients never receive a successful save for an uncommitted or partial
|
||||
mutation.
|
||||
|
||||
Compatibility tests round-trip every checked-in upstream-compatible fixture
|
||||
and separately cover duplicate keys, Unicode, CRLF, multiline notes, empty
|
||||
values, OTP URIs, blank and missing first lines, stable IDs, reorder, creation,
|
||||
stale documents and commit rollback.
|
||||
Reference in New Issue
Block a user