//! 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, otp: Option, diagnostic: Option, value: Range, } 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 { self.diagnostic } } /// Non-secret presentation metadata parsed from a validated `otpauth` URI. #[derive(Clone, Debug, Eq, PartialEq)] pub struct EntryOtpMetadata { kind: OtpKind, issuer: Option, account: String, algorithm: OtpAlgorithm, digits: u32, period: Option, counter: Option, } 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 { self.period } pub fn counter(&self) -> Option { self.counter } } pub struct EntryField { id: EntryFieldId, contents: SecretBytes, ending: Vec, 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) -> Result { validate_line(&contents)?; Ok(Self { contents: SecretBytes::new(contents), }) } pub fn field(name: impl Into, value: Vec) -> Result { 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) -> Result { 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) -> 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, 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::>(); 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 { 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, ) -> 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 { 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 { 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 { 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 { 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 { 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 { 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 for DocumentError { fn from(error: WriteError) -> Self { Self::Write(error) } } fn parse_lines(contents: &[u8]) -> Vec { 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::::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, 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()) }