Files
IronStorage/crates/storage/src/document.rs

801 lines
24 KiB
Rust

//! 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::{OtpAlgorithm, OtpKind, 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,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum EntrySensitivity {
Sensitive,
Ordinary,
Empty,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum EntryFieldDiagnostic {
MalformedOtpUri,
NonUtf8Value,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct EntryFieldMetadata {
kind: EntryFieldKind,
sensitivity: EntrySensitivity,
name: Option<String>,
otp: Option<EntryOtpMetadata>,
diagnostic: Option<EntryFieldDiagnostic>,
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 fn otp(&self) -> Option<&EntryOtpMetadata> {
self.otp.as_ref()
}
pub fn diagnostic(&self) -> Option<EntryFieldDiagnostic> {
self.diagnostic
}
}
/// Non-secret presentation metadata parsed from a validated `otpauth` URI.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct EntryOtpMetadata {
kind: OtpKind,
issuer: Option<String>,
account: String,
algorithm: OtpAlgorithm,
digits: u32,
period: Option<u64>,
counter: Option<u64>,
}
impl EntryOtpMetadata {
pub fn kind(&self) -> OtpKind {
self.kind
}
pub fn issuer(&self) -> Option<&str> {
self.issuer.as_deref()
}
pub fn account(&self) -> &str {
&self.account
}
pub fn algorithm(&self) -> OtpAlgorithm {
self.algorithm
}
pub fn digits(&self) -> u32 {
self.digits
}
pub fn period(&self) -> Option<u64> {
self.period
}
pub fn counter(&self) -> Option<u64> {
self.counter
}
}
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)?;
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()),
}
}
pub fn multiline(contents: Vec<u8>) -> Self {
Self {
contents: SecretBytes::new(contents),
}
}
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,
modified: bool,
}
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,
modified: false,
}
}
pub fn path(&self) -> &EntryPath {
&self.path
}
pub fn fields(&self) -> &[EntryField] {
&self.fields
}
/// Fields in the shared presentation order without changing repository order.
pub fn display_fields(&self) -> Vec<&EntryField> {
let mut fields = self.fields.iter().collect::<Vec<_>>();
fields.sort_by_cached_key(|field| display_key(field));
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()
}
/// Copy one structured field value into an independently zeroizing buffer.
///
/// Frontends use this for explicit presentation actions without reparsing
/// the pass entry or copying label syntax alongside the selected value.
pub fn copy_field_value(&self, id: EntryFieldId) -> Result<SecretBytes, DocumentError> {
self.field(id)
.map(|field| SecretBytes::new(field.value().to_vec()))
.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 => EntryFieldDraft::line(value)?,
EntryFieldKind::Note => EntryFieldDraft::multiline(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
}
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: empty_note_metadata(),
},
);
normalize_endings(&mut self.fields, &default_ending, final_newline);
classify_all(&mut self.fields);
self.modified = true;
Ok(id)
}
pub fn update(
&mut self,
id: EntryFieldId,
draft: EntryFieldDraft,
) -> Result<(), DocumentError> {
let index = self
.fields
.iter()
.position(|field| field.id == id)
.ok_or(DocumentError::UnknownField { id })?;
self.fields[index].contents = draft.render();
classify_all(&mut self.fields);
self.modified = true;
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);
self.modified = true;
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);
self.modified = true;
Ok(())
}
pub fn serialize(&self) -> SecretBytes {
if !self.modified {
return SecretBytes::new(self.session.plaintext().expose().to_vec());
}
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 {
append_stable_contents(&mut output, field);
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,
)?)
}
/// 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)]
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: empty_note_metadata(),
});
start = end;
}
let mut logical = Vec::<EntryField>::new();
for field in fields {
let continuation = classify(logical.len(), field.contents.expose()).kind
== EntryFieldKind::Note
&& logical.last().is_some_and(|previous| {
let metadata =
classify(logical.len().saturating_sub(1), previous.contents.expose());
metadata.kind == EntryFieldKind::Note
|| (metadata.name.is_some()
&& !matches!(
metadata.kind,
EntryFieldKind::Password | EntryFieldKind::OtpUri
))
});
if continuation {
let previous = logical
.last_mut()
.expect("continuation has a previous field");
let mut contents = previous.contents.expose().to_vec();
contents.extend_from_slice(&previous.ending);
contents.extend_from_slice(strip_continuation_marker(field.contents.expose()));
previous.contents = SecretBytes::new(contents);
previous.ending = field.ending;
} else {
let mut field = field;
if classify(logical.len(), field.contents.expose()).kind == EntryFieldKind::Note
&& let Some(contents) = field.contents.expose().strip_prefix(b" ")
{
field.contents = SecretBytes::new(contents.to_vec());
}
logical.push(field);
}
}
for (id, field) in logical.iter_mut().enumerate() {
field.id = EntryFieldId(id as u64);
}
logical
}
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 line.starts_with(b"otpauth://")
&& let Ok(uri) = OtpUri::parse(SecretBytes::new(line.to_vec()))
{
let otp = EntryOtpMetadata {
kind: uri.kind(),
issuer: uri.issuer().map(str::to_owned),
account: uri.account().to_owned(),
algorithm: uri.algorithm(),
digits: uri.digits(),
period: uri.period(),
counter: uri.counter(),
};
return EntryFieldMetadata {
kind: EntryFieldKind::OtpUri,
sensitivity: EntrySensitivity::Sensitive,
name: Some("otp".to_owned()),
otp: Some(otp),
diagnostic: None,
value: 0..line.len(),
};
}
if index == 0 {
return EntryFieldMetadata {
kind: EntryFieldKind::Password,
sensitivity: EntrySensitivity::Sensitive,
name: Some("password".to_owned()),
otp: None,
diagnostic: (!line.is_ascii() && std::str::from_utf8(line).is_err())
.then_some(EntryFieldDiagnostic::NonUtf8Value),
value: 0..line.len(),
};
}
if line.is_empty() {
return empty_note_metadata();
}
if line.starts_with(b"otpauth://") {
return EntryFieldMetadata {
kind: EntryFieldKind::OtpUri,
sensitivity: EntrySensitivity::Sensitive,
name: Some("otp".to_owned()),
otp: None,
diagnostic: Some(EntryFieldDiagnostic::MalformedOtpUri),
value: 0..line.len(),
};
}
let first_line_end = line
.iter()
.position(|byte| *byte == b'\n')
.unwrap_or(line.len());
let first_line = &line[..first_line_end];
if let Some(colon) = first_line.iter().position(|byte| *byte == b':')
&& let Ok(name) = std::str::from_utf8(&first_line[..colon])
&& is_field_name(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()),
otp: None,
diagnostic: std::str::from_utf8(&line[value_start..])
.is_err()
.then_some(EntryFieldDiagnostic::NonUtf8Value),
value: value_start..line.len(),
};
}
EntryFieldMetadata {
kind: EntryFieldKind::Note,
sensitivity: EntrySensitivity::Sensitive,
name: None,
otp: None,
diagnostic: std::str::from_utf8(line)
.is_err()
.then_some(EntryFieldDiagnostic::NonUtf8Value),
value: 0..line.len(),
}
}
fn semantic_field_kind(name: &str) -> EntryFieldKind {
match name.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.to_ascii_lowercase().as_str() {
"title" | "site" | "host" | "autotype_enabled" | "icon" => EntrySensitivity::Ordinary,
_ => EntrySensitivity::Sensitive,
}
}
fn display_key(field: &EntryField) -> (u8, String) {
let metadata = field.metadata();
let name = metadata.name().unwrap_or_default().to_lowercase();
let priority = match metadata.kind() {
EntryFieldKind::Username => 0,
EntryFieldKind::Password => 1,
EntryFieldKind::Url => 2,
EntryFieldKind::OtpUri => 3,
EntryFieldKind::Note => 4,
_ if matches!(name.as_str(), "note" | "notes") => 4,
_ if name == "comments" => 5,
EntryFieldKind::Email | EntryFieldKind::Field => 6,
};
(priority, if priority == 6 { name } else { String::new() })
}
fn empty_note_metadata() -> EntryFieldMetadata {
EntryFieldMetadata {
kind: EntryFieldKind::Note,
sensitivity: EntrySensitivity::Empty,
name: None,
otp: None,
diagnostic: None,
value: 0..0,
}
}
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 !is_field_name(name) {
Err(DocumentError::InvalidFieldName)
} else {
Ok(())
}
}
fn is_field_name(name: &str) -> bool {
!name.is_empty() && !name.contains([':', '\r', '\n']) && !name.chars().any(char::is_whitespace)
}
fn strip_continuation_marker(contents: &[u8]) -> &[u8] {
contents.strip_prefix(b" ").unwrap_or(contents)
}
fn append_stable_contents(output: &mut Vec<u8>, field: &EntryField) {
if field.metadata().kind() == EntryFieldKind::Note {
output.push(b' ');
}
for byte in field.contents.expose() {
output.push(*byte);
if *byte == b'\n' {
output.push(b' ');
}
}
}
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())
}