From 875c8f34681b2a2c341b19bec1a1a75d64199e78 Mon Sep 17 00:00:00 2001 From: Duncan Date: Tue, 18 Aug 2026 10:36:19 -0400 Subject: [PATCH] feat(desktop): classify imported-identity persistence into three durable outcomes (P27-C1) The base persistence kernel returns a binary Result, but it is not transactional: a returned Err does not prove no durable key write happened. A coordinator that compensates on any Err can leave durable identity B on disk beside live in-memory identity A, and a later reachable-keyring restart activates B (the P25/P26 split-state class). Add persist_imported_identity_classified, which wraps the proven kernel and derives a three-valued PersistenceOutcome from durable fact: Committed (B proven canonical, compensation forbidden), DefinitelyUnchanged (B never landed, the only compensable outcome), and Indeterminate (neither proven, fail closed). On a kernel Err it re-reads the keyring and identity.key under the caller's held transition guards and classifies from what they hold; a keyring unreachable on re-read fails closed. The C5 coordinator consumes this in the barrier commit. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/src-tauri/src/app_state.rs | 6 +- desktop/src-tauri/src/identity_persistence.rs | 296 ++++++++++++++++++ desktop/src-tauri/src/lib.rs | 1 + 3 files changed, 300 insertions(+), 3 deletions(-) create mode 100644 desktop/src-tauri/src/identity_persistence.rs diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index 936b474cc3..eedb6689ee 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -393,7 +393,7 @@ mod keyring_config; pub(crate) use keyring_config::keyring_service; /// Keyring key name for the human identity nsec. -const IDENTITY_KEY_NAME: &str = "identity"; +pub(crate) const IDENTITY_KEY_NAME: &str = "identity"; /// Filename of the marker written once a successful keyring migration deletes /// the legacy `identity.key`. Its presence is the only durable signal that a @@ -405,7 +405,7 @@ const MIGRATION_MARKER_NAME: &str = "identity.migrated"; /// The keyring operations the identity resolution flow needs. Abstracted so the /// corrupt-keyring recovery decision ([`recover_from_keyring`]) can be /// unit-tested against a fake without touching the live OS keyring. -trait IdentityKeyStore { +pub(crate) trait IdentityKeyStore { fn probe(&self, name: &str) -> crate::secret_store::KeyringProbe; fn load(&self, name: &str) -> Result, String>; fn store(&self, name: &str, value: &str) -> Result<(), String>; @@ -1031,7 +1031,7 @@ fn quarantine_corrupt_key(key_path: &std::path::Path, data_dir: &std::path::Path } } -fn load_key_file(path: &std::path::Path) -> Result { +pub(crate) fn load_key_file(path: &std::path::Path) -> Result { let content = std::fs::read_to_string(path).map_err(|e| format!("read identity.key: {e}"))?; let trimmed = content.trim(); if trimmed.is_empty() { diff --git a/desktop/src-tauri/src/identity_persistence.rs b/desktop/src-tauri/src/identity_persistence.rs new file mode 100644 index 0000000000..d6ed351633 --- /dev/null +++ b/desktop/src-tauri/src/identity_persistence.rs @@ -0,0 +1,296 @@ +//! Three-valued imported-identity persistence outcome (P27-C1). +//! +//! The base persistence primitive +//! [`persist_imported_identity`](crate::app_state::persist_imported_identity) +//! returns a binary `Result`: `Ok(storage)` on success, `Err` otherwise. That +//! shape is unsafe for the identity-transition coordinator, because the kernel +//! is NOT transactional — a returned `Err` does NOT prove no durable key write +//! happened. `persist_identity_to_keyring` calls `store.store(...)` FIRST and +//! can still return `Err` after that durable mutation succeeded (read-back +//! verify failure, or marker creation failing with the emergency file write +//! also failing). A coordinator that compensates the drained runtimes on any +//! `Err` can leave durable identity B on disk beside live in-memory identity A +//! — and a later reachable-keyring restart activates B (the P25/P26 +//! split-state class, now originating INSIDE the persistence call). +//! +//! This module wraps the proven kernel and classifies its result against +//! DURABLE FACT — never the helper's `Ok`/`Err` — into three outcomes: +//! +//! - [`PersistenceOutcome::DefinitelyUnchanged`] — proven: no durable B written +//! / durable A still canonical. The ONLY outcome the coordinator may +//! compensate. +//! - [`PersistenceOutcome::Committed`] — durable B proven canonical. The +//! coordinator MUST finish the in-memory B commit + readiness/scope clear; +//! compensation is forbidden. Marker/file-cleanup degradation is reported +//! without unwinding the swap. +//! - [`PersistenceOutcome::Indeterminate`] — neither proven. The coordinator +//! latches the durable fail-closed state (P28-C1): no compensation, no scope +//! clear, drained runtimes stay down, and the process must not treat either +//! identity as committed until a later reconciliation or restart proves one +//! durable state. +//! +//! On a kernel `Err`, [`persist_imported_identity_classified`] performs +//! read-after-error reconciliation UNDER the caller's still-held transition +//! guards: it re-reads the durable stores (keyring read-back + `identity.key`) +//! and classifies from what it finds. A backend unreachable on re-read yields +//! `Indeterminate` — the fail-closed default. + +use crate::app_state::{load_key_file, IdentityKeyStore, IdentityStorage, IDENTITY_KEY_NAME}; +use nostr::{Keys, ToBech32}; + +/// The durable outcome of an imported-identity persistence attempt, classified +/// from durable fact rather than the kernel's `Ok`/`Err` (P27-C1). +#[derive(Debug)] +pub(crate) enum PersistenceOutcome { + /// No durable B was written; durable A is still canonical. The only outcome + /// the coordinator may compensate. + DefinitelyUnchanged, + /// Durable B is proven canonical. The coordinator must finish the B commit; + /// compensation is forbidden. `storage` records where B durably landed. + Committed(IdentityStorage), + /// Neither state is proven. The coordinator latches the durable fail-closed + /// state (P28-C1). `reason` is a diagnostic, never a control signal. + // Consumed by C5 (P28 latch); remove allow when the coordinator wires it. + #[allow(dead_code)] + Indeterminate(String), +} + +/// Persist an imported identity and classify the result against durable fact. +/// +/// `persist` runs the proven kernel +/// ([`persist_imported_identity`](crate::app_state::persist_imported_identity)) +/// which stores B, verifies the read-back, writes the marker, and deletes the +/// legacy file — falling back to the `0o600` file when the keyring is +/// unavailable. On `Ok`, B is durably proven and the outcome is `Committed`. +/// On `Err`, the kernel may have durably written B before failing, so this +/// re-reads the durable stores under the caller's held guards and classifies: +/// +/// - keyring read-back matches B, or `identity.key` holds B ⇒ `Committed`; +/// - keyring reachable-and-not-B AND `identity.key` absent-or-A ⇒ +/// `DefinitelyUnchanged` (B never landed, A intact); +/// - anything still ambiguous (keyring unreachable on re-read) ⇒ +/// `Indeterminate` (fail closed). +/// +/// `expected` is the identity being imported (B). `store` and `legacy_path` +/// name the durable backends to re-read. +// Consumed by C5 (P25/P28 coordinator); remove allow when the coordinator +// routes its persist through this classifier. +#[allow(dead_code)] +pub(crate) fn persist_imported_identity_classified( + keys: &Keys, + store: &impl IdentityKeyStore, + legacy_path: &std::path::Path, + persist: impl FnOnce() -> Result, +) -> PersistenceOutcome { + match persist() { + Ok(storage) => PersistenceOutcome::Committed(storage), + Err(kernel_err) => reconcile_after_error(keys, store, legacy_path, &kernel_err), + } +} + +/// Read-after-error reconciliation (P27-C1): the kernel returned `Err`, which +/// does not prove B was not durably written. Re-read the durable stores and +/// classify from what they hold. Runs under the caller's held transition +/// guards, so no concurrent transition can move the durable state mid-read. +fn reconcile_after_error( + expected: &Keys, + store: &impl IdentityKeyStore, + legacy_path: &std::path::Path, + kernel_err: &str, +) -> PersistenceOutcome { + let expected_nsec = match expected.secret_key().to_bech32() { + Ok(nsec) => nsec, + // Cannot encode the target for comparison — cannot prove either state. + Err(e) => { + return PersistenceOutcome::Indeterminate(format!( + "persist failed ({kernel_err}); could not encode target key for \ + read-after-error reconciliation: {e}" + )); + } + }; + let expected_pubkey = expected.public_key(); + + // (1) Durable file: an `identity.key` holding B proves B landed durably — + // the kernel's file fallback (or emergency write) succeeded before failing + // on a later best-effort step. A file holding A (or no file) means B did + // not reach the file backend. + let file_holds_b = match legacy_path.exists() { + false => false, + true => match load_key_file(legacy_path) { + Ok(file_keys) => file_keys.public_key() == expected_pubkey, + // A corrupt file proves nothing about B's durability. + Err(_) => { + return PersistenceOutcome::Indeterminate(format!( + "persist failed ({kernel_err}); identity.key present but unreadable \ + on read-after-error reconciliation" + )); + } + }, + }; + if file_holds_b { + return PersistenceOutcome::Committed(IdentityStorage::LocalFile); + } + + // (2) Durable keyring: a read-back matching B proves B is canonical in the + // keyring even though a later step (marker/file cleanup) failed. + match store.verify_stored(IDENTITY_KEY_NAME, &expected_nsec) { + Ok(true) => PersistenceOutcome::Committed(IdentityStorage::SystemKeyring), + // Keyring reachable and does NOT hold B, and the file does not hold B + // (checked above): B never landed durably, A is intact. + Ok(false) => PersistenceOutcome::DefinitelyUnchanged, + // Keyring unreachable on re-read: cannot prove either state. Fail + // closed — the coordinator latches Indeterminate. + Err(e) => PersistenceOutcome::Indeterminate(format!( + "persist failed ({kernel_err}); durable state unprovable — keyring \ + unreachable on read-after-error reconciliation: {e}" + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::secret_store::KeyringProbe; + use std::cell::RefCell; + + /// Minimal fake exercising only the durable re-reads the classifier makes. + /// `keyring`/`file` model what each backend HOLDS after the kernel `Err`; + /// `keyring_unreachable` makes the read-back re-read fail. + struct ReReadStore { + keyring: RefCell>, + keyring_unreachable: bool, + } + + impl ReReadStore { + fn holding(nsec: Option<&str>) -> Self { + Self { + keyring: RefCell::new(nsec.map(str::to_string)), + keyring_unreachable: false, + } + } + fn unreachable() -> Self { + Self { + keyring: RefCell::new(None), + keyring_unreachable: true, + } + } + } + + impl IdentityKeyStore for ReReadStore { + fn probe(&self, _name: &str) -> KeyringProbe { + KeyringProbe::Present + } + fn load(&self, _name: &str) -> Result, String> { + Ok(self.keyring.borrow().clone()) + } + fn store(&self, _name: &str, value: &str) -> Result<(), String> { + *self.keyring.borrow_mut() = Some(value.to_string()); + Ok(()) + } + fn delete(&self, _name: &str) -> Result<(), String> { + *self.keyring.borrow_mut() = None; + Ok(()) + } + fn verify_stored(&self, _name: &str, expected: &str) -> Result { + if self.keyring_unreachable { + return Err("keyring unreachable".to_string()); + } + Ok(self.keyring.borrow().as_deref() == Some(expected)) + } + } + + fn nsec_of(keys: &Keys) -> String { + keys.secret_key().to_bech32().unwrap() + } + + fn tmp_file(dir: &tempfile::TempDir) -> std::path::PathBuf { + dir.path().join("identity.key") + } + + #[test] + fn ok_kernel_is_committed() { + let b = Keys::generate(); + let store = ReReadStore::holding(Some(&nsec_of(&b))); + let dir = tempfile::tempdir().unwrap(); + let outcome = persist_imported_identity_classified(&b, &store, &tmp_file(&dir), || { + Ok(IdentityStorage::SystemKeyring) + }); + assert!(matches!( + outcome, + PersistenceOutcome::Committed(IdentityStorage::SystemKeyring) + )); + } + + /// P27-C1 fixture (a): store(B) success + verify error + file-fallback + /// failure. The kernel returns `Err`, but the keyring durably holds B — + /// read-after-error reconciliation must classify Committed, NOT compensate. + #[test] + fn err_but_keyring_holds_b_is_committed() { + let b = Keys::generate(); + let store = ReReadStore::holding(Some(&nsec_of(&b))); + let dir = tempfile::tempdir().unwrap(); + let outcome = persist_imported_identity_classified(&b, &store, &tmp_file(&dir), || { + Err("verify error; file fallback failed".to_string()) + }); + assert!( + matches!( + outcome, + PersistenceOutcome::Committed(IdentityStorage::SystemKeyring) + ), + "durable B in keyring must classify Committed even on kernel Err" + ); + } + + /// P27-C1 fixture (b): store/verify success + marker failure + both file + /// attempts failing. The emergency file write LANDED B before the outer + /// fallback error, so `identity.key` holds B — reconciliation classifies + /// Committed(LocalFile). + #[test] + fn err_but_file_holds_b_is_committed() { + let b = Keys::generate(); + // Keyring does not hold B (marker/keyring path failed); the emergency + // file write landed B. + let store = ReReadStore::holding(None); + let dir = tempfile::tempdir().unwrap(); + let path = tmp_file(&dir); + crate::app_state::save_key_file(&path, &b).unwrap(); + let outcome = persist_imported_identity_classified(&b, &store, &path, || { + Err("marker + file attempts failed".to_string()) + }); + assert!(matches!( + outcome, + PersistenceOutcome::Committed(IdentityStorage::LocalFile) + )); + } + + /// Kernel `Err` with A intact everywhere: keyring reachable and NOT holding + /// B, no B file. B never landed — the only outcome permitted to compensate. + #[test] + fn err_and_a_intact_is_definitely_unchanged() { + let a = Keys::generate(); + let b = Keys::generate(); + // Keyring still holds A (B never stored); no leftover file. + let store = ReReadStore::holding(Some(&nsec_of(&a))); + let dir = tempfile::tempdir().unwrap(); + let outcome = persist_imported_identity_classified(&b, &store, &tmp_file(&dir), || { + Err("store(B) never succeeded".to_string()) + }); + assert!(matches!(outcome, PersistenceOutcome::DefinitelyUnchanged)); + } + + /// Kernel `Err` with the keyring unreachable on re-read and no B file: + /// neither state is provable — fail closed to Indeterminate. + #[test] + fn err_and_keyring_unreachable_is_indeterminate() { + let b = Keys::generate(); + let store = ReReadStore::unreachable(); + let dir = tempfile::tempdir().unwrap(); + let outcome = persist_imported_identity_classified(&b, &store, &tmp_file(&dir), || { + Err("store then verify unreachable".to_string()) + }); + assert!( + matches!(outcome, PersistenceOutcome::Indeterminate(_)), + "unprovable durable state must fail closed" + ); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index e202bb0664..46f11d60e2 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -9,6 +9,7 @@ mod egress_guard; mod event_sync; mod events; mod huddle; +mod identity_persistence; mod identity_storage; mod initial_window; mod key_backup;