Implement structured iPhone entry viewer

This commit is contained in:
2026-08-11 19:59:18 +02:00
parent 873db91204
commit 900e62e523
9 changed files with 1728 additions and 92 deletions

View File

@@ -17,6 +17,10 @@ use crate::{
pub struct EntryFieldId(u64);
impl EntryFieldId {
pub fn from_value(value: u64) -> Self {
Self(value)
}
pub fn value(self) -> u64 {
self.0
}
@@ -629,7 +633,11 @@ fn classify(index: usize, line: &[u8]) -> EntryFieldMetadata {
if index == 0 {
return EntryFieldMetadata {
kind: EntryFieldKind::Password,
sensitivity: EntrySensitivity::Sensitive,
sensitivity: if line.is_empty() {
EntrySensitivity::Empty
} else {
EntrySensitivity::Sensitive
},
name: Some("password".to_owned()),
otp: None,
diagnostic: (!line.is_ascii() && std::str::from_utf8(line).is_err())
@@ -663,7 +671,7 @@ fn classify(index: usize, line: &[u8]) -> EntryFieldMetadata {
let kind = semantic_field_kind(name);
return EntryFieldMetadata {
kind,
sensitivity: field_sensitivity(name, kind),
sensitivity: EntrySensitivity::Ordinary,
name: Some(name.to_owned()),
otp: None,
diagnostic: std::str::from_utf8(&line[value_start..])
@@ -674,7 +682,7 @@ fn classify(index: usize, line: &[u8]) -> EntryFieldMetadata {
}
EntryFieldMetadata {
kind: EntryFieldKind::Note,
sensitivity: EntrySensitivity::Sensitive,
sensitivity: EntrySensitivity::Ordinary,
name: None,
otp: None,
diagnostic: std::str::from_utf8(line)
@@ -693,19 +701,6 @@ fn semantic_field_kind(name: &str) -> EntryFieldKind {
}
}
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();

View File

@@ -16,6 +16,7 @@ pub mod git;
pub mod kdbx;
pub mod mobile;
pub mod mobile_authentication;
pub mod mobile_entry;
pub mod mobile_home;
pub mod mobile_onboarding;
pub mod mobile_passwords;

View File

@@ -8,6 +8,9 @@ use crate::{
},
config::{Config, ConfigError},
crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider, SecretProviderError},
document::{DocumentError, EntryDocument, EntryDocumentService, EntryFieldId},
git::{AutomaticEntryCommitter, GitIdentity},
mobile_entry::{MobileEntryPage, MobileEntryValueError, field_value},
repository::{EntryPath, Repository, RepositoryError, SecretBytes},
secret_store::{SecretProtectionPolicy, SecretStoreError},
};
@@ -109,6 +112,22 @@ pub struct MobileAuthenticationState {
remaining_seconds: u64,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MobileEntryCopy {
value: String,
timeout_seconds: u64,
}
impl MobileEntryCopy {
pub fn value(&self) -> &str {
&self.value
}
pub fn timeout_seconds(&self) -> u64 {
self.timeout_seconds
}
}
impl MobileAuthenticationState {
pub fn unlocked(self) -> bool {
self.unlocked
@@ -328,6 +347,50 @@ impl MobileAuthentication {
.map_err(MobileAuthenticationError::authentication)
}
pub fn entry_page(&self, path: &str) -> Result<MobileEntryPage, MobileAuthenticationError> {
let document = self.open_active_document(path)?;
Ok(MobileEntryPage::from_document(&document))
}
pub fn reveal_entry_field(
&self,
path: &str,
field: u64,
) -> Result<String, MobileAuthenticationError> {
let document = self.open_active_document(path)?;
field_value(&document, field).map_err(value_error)
}
pub fn copy_entry_field(
&self,
path: &str,
field: u64,
) -> Result<MobileEntryCopy, MobileAuthenticationError> {
Ok(MobileEntryCopy {
value: self.reveal_entry_field(path, field)?,
timeout_seconds: self.config.clipboard_timeout().duration().as_secs(),
})
}
pub fn replace_entry_field(
&self,
path: &str,
field: u64,
value: String,
) -> Result<MobileEntryPage, MobileAuthenticationError> {
let mut document = self.open_active_document(path)?;
document
.replace_field_value(EntryFieldId::from_value(field), value.into_bytes())
.map_err(document_error)?;
let mut committer =
AutomaticEntryCommitter::for_entry(&self.repository, path, GitIdentity::ironstorage())
.map_err(|error| entry_detail("Password Entry Could Not Be Saved", error))?;
EntryDocumentService::new(&self.repository, &self.keys)
.save_recoverable(&document, None, &mut committer)
.map_err(document_error)?;
Ok(MobileEntryPage::from_document(&document))
}
pub fn manual_lock(&self) -> Result<(), MobileAuthenticationError> {
self.session
.manual_lock()
@@ -383,6 +446,21 @@ impl MobileAuthentication {
self.status()?.active = Some(ActiveMobileLease { handle, key });
Ok(())
}
fn open_active_document(&self, path: &str) -> Result<EntryDocument, MobileAuthenticationError> {
let (handle, key) = {
let status = self.status()?;
let active = status.active.as_ref().ok_or_else(locked_error)?;
(active.handle.clone(), active.key.clone())
};
handle
.ensure_active()
.map_err(MobileAuthenticationError::authentication)?;
let mut provider = KeyOnlyProvider::new(handle, &key);
EntryDocumentService::new(&self.repository, &self.keys)
.open(path, &mut provider)
.map_err(document_error)
}
}
struct KeyOnlyProvider<'a> {
@@ -431,3 +509,27 @@ fn key_error(error: CryptoError) -> MobileAuthenticationError {
error.to_string(),
)
}
fn locked_error() -> MobileAuthenticationError {
MobileAuthenticationError::new(
MobileAuthenticationErrorKind::Expired,
"IronStorage Locked",
"Authenticate before using protected content.",
)
}
fn document_error(error: DocumentError) -> MobileAuthenticationError {
entry_detail("Password Entry Is Unavailable", error)
}
fn value_error(error: MobileEntryValueError) -> MobileAuthenticationError {
entry_detail("Field Value Is Unavailable", error)
}
fn entry_detail(title: &str, error: impl fmt::Display) -> MobileAuthenticationError {
MobileAuthenticationError::new(
MobileAuthenticationErrorKind::Entry,
title,
error.to_string(),
)
}

View File

@@ -0,0 +1,305 @@
//! Storage-owned projection of lossless entry documents for native mobile viewers.
use std::{error::Error, fmt};
use crate::document::{
DocumentError, EntryDocument, EntryField, EntryFieldDiagnostic, EntryFieldId, EntryFieldKind,
EntrySensitivity,
};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MobileEntrySectionKind {
Password,
Details,
OneTimePassword,
Notes,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MobileEntryField {
id: u64,
label: String,
system_image: String,
value: Option<String>,
masked_value: String,
detail: Option<String>,
diagnostic: Option<String>,
sensitive: bool,
multiline: bool,
selectable: bool,
editable: bool,
}
impl MobileEntryField {
pub fn id(&self) -> u64 {
self.id
}
pub fn label(&self) -> &str {
&self.label
}
pub fn system_image(&self) -> &str {
&self.system_image
}
pub fn value(&self) -> Option<&str> {
self.value.as_deref()
}
pub fn masked_value(&self) -> &str {
&self.masked_value
}
pub fn detail(&self) -> Option<&str> {
self.detail.as_deref()
}
pub fn diagnostic(&self) -> Option<&str> {
self.diagnostic.as_deref()
}
pub fn sensitive(&self) -> bool {
self.sensitive
}
pub fn multiline(&self) -> bool {
self.multiline
}
pub fn selectable(&self) -> bool {
self.selectable
}
pub fn editable(&self) -> bool {
self.editable
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MobileEntrySection {
kind: MobileEntrySectionKind,
title: String,
fields: Vec<MobileEntryField>,
}
impl MobileEntrySection {
pub fn kind(&self) -> MobileEntrySectionKind {
self.kind
}
pub fn title(&self) -> &str {
&self.title
}
pub fn fields(&self) -> &[MobileEntryField] {
&self.fields
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MobileEntryPage {
id: String,
title: String,
sections: Vec<MobileEntrySection>,
}
impl MobileEntryPage {
pub fn from_document(document: &EntryDocument) -> Self {
let title = document
.path()
.as_path()
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("Password")
.to_owned();
let mut sections = Vec::new();
for (kind, section_title) in [
(MobileEntrySectionKind::Password, "Password"),
(MobileEntrySectionKind::Details, "Details"),
(MobileEntrySectionKind::OneTimePassword, "One-Time Password"),
(MobileEntrySectionKind::Notes, "Notes"),
] {
let fields = document
.display_fields()
.into_iter()
.filter(|field| section_kind(field) == kind)
.map(mobile_field)
.collect::<Vec<_>>();
if !fields.is_empty() {
sections.push(MobileEntrySection {
kind,
title: section_title.to_owned(),
fields,
});
}
}
Self {
id: format!("entry:{}", document.path()),
title,
sections,
}
}
pub fn id(&self) -> &str {
&self.id
}
pub fn title(&self) -> &str {
&self.title
}
pub fn sections(&self) -> &[MobileEntrySection] {
&self.sections
}
}
#[derive(Debug)]
pub enum MobileEntryValueError {
Document(DocumentError),
NonUtf8,
}
impl fmt::Display for MobileEntryValueError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Document(error) => error.fmt(formatter),
Self::NonUtf8 => formatter.write_str("the entry field is not valid UTF-8"),
}
}
}
impl Error for MobileEntryValueError {}
impl From<DocumentError> for MobileEntryValueError {
fn from(error: DocumentError) -> Self {
Self::Document(error)
}
}
pub fn field_value(document: &EntryDocument, id: u64) -> Result<String, MobileEntryValueError> {
let value = document.copy_field_value(EntryFieldId::from_value(id))?;
String::from_utf8(value.expose().to_vec()).map_err(|_| MobileEntryValueError::NonUtf8)
}
fn mobile_field(field: &EntryField) -> MobileEntryField {
let metadata = field.metadata();
let sensitive = metadata.sensitivity() == EntrySensitivity::Sensitive;
let value = if sensitive || metadata.sensitivity() == EntrySensitivity::Empty {
None
} else {
String::from_utf8(field.value().to_vec()).ok()
};
MobileEntryField {
id: field.id().value(),
label: label(field),
system_image: system_image(metadata.kind()).to_owned(),
value,
masked_value: if metadata.sensitivity() == EntrySensitivity::Empty {
"Empty"
} else if sensitive {
"Hidden"
} else {
"Unavailable"
}
.to_owned(),
detail: otp_detail(field),
diagnostic: metadata.diagnostic().map(diagnostic),
sensitive,
multiline: matches!(metadata.kind(), EntryFieldKind::Note)
|| field.value().contains(&b'\n'),
selectable: !sensitive && metadata.diagnostic() != Some(EntryFieldDiagnostic::NonUtf8Value),
editable: true,
}
}
fn section_kind(field: &EntryField) -> MobileEntrySectionKind {
if field.metadata().name().is_some_and(|name| {
["comment", "comments", "note", "notes"]
.iter()
.any(|candidate| name.eq_ignore_ascii_case(candidate))
}) {
return MobileEntrySectionKind::Notes;
}
match field.metadata().kind() {
EntryFieldKind::Password => MobileEntrySectionKind::Password,
EntryFieldKind::OtpUri => MobileEntrySectionKind::OneTimePassword,
EntryFieldKind::Note => MobileEntrySectionKind::Notes,
EntryFieldKind::Username
| EntryFieldKind::Email
| EntryFieldKind::Url
| EntryFieldKind::Field => MobileEntrySectionKind::Details,
}
}
fn label(field: &EntryField) -> String {
if field.metadata().kind() == EntryFieldKind::Password {
return "Password".to_owned();
}
if field.metadata().kind() == EntryFieldKind::OtpUri {
return "OTP URI".to_owned();
}
field
.metadata()
.name()
.map(display_name)
.unwrap_or_else(|| match field.metadata().kind() {
EntryFieldKind::Username => "Username".to_owned(),
EntryFieldKind::Email => "Email".to_owned(),
EntryFieldKind::Url => "Website".to_owned(),
EntryFieldKind::Field => "Field".to_owned(),
EntryFieldKind::Note => "Notes".to_owned(),
EntryFieldKind::Password | EntryFieldKind::OtpUri => unreachable!(),
})
}
fn display_name(name: &str) -> String {
let mut characters = name.chars();
match characters.next() {
Some(first) => first.to_uppercase().chain(characters).collect(),
None => "Field".to_owned(),
}
}
fn system_image(kind: EntryFieldKind) -> &'static str {
match kind {
EntryFieldKind::Password => "key.fill",
EntryFieldKind::Username => "person.fill",
EntryFieldKind::Email => "envelope.fill",
EntryFieldKind::Url => "globe",
EntryFieldKind::OtpUri => "timer",
EntryFieldKind::Field => "text.alignleft",
EntryFieldKind::Note => "note.text",
}
}
fn diagnostic(diagnostic: EntryFieldDiagnostic) -> String {
match diagnostic {
EntryFieldDiagnostic::MalformedOtpUri => {
"This OTP URI is malformed. Its original value is preserved.".to_owned()
}
EntryFieldDiagnostic::NonUtf8Value => {
"This field is not UTF-8 text. Its original bytes are preserved.".to_owned()
}
}
}
fn otp_detail(field: &EntryField) -> Option<String> {
let otp = field.metadata().otp()?;
let mut parts = vec![format!("{:?}", otp.kind()).to_uppercase()];
if let Some(issuer) = otp.issuer() {
parts.push(issuer.to_owned());
}
if !otp.account().is_empty() {
parts.push(otp.account().to_owned());
}
parts.push(format!("{:?}", otp.algorithm()).to_uppercase());
parts.push(format!("{} digits", otp.digits()));
if let Some(period) = otp.period() {
parts.push(format!("{period} seconds"));
}
if let Some(counter) = otp.counter() {
parts.push(format!("counter {counter}"));
}
Some(parts.join(" · "))
}