Skip to content

fix(desktop): stop losing imported identity on every launch#1568

Open
wpfleger96 wants to merge 1 commit into
mainfrom
wpfleger96/identity-import-keyring
Open

fix(desktop): stop losing imported identity on every launch#1568
wpfleger96 wants to merge 1 commit into
mainfrom
wpfleger96/identity-import-keyring

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

i dug into why Buzz keeps forcing re-onboarding every launch and why my imported identity silently vanishes each time. the root cause is three separate defects that chain together into the loop captured in journald:

22:26:50 buzz-desktop: imported identity pubkey 8e39cba681211b3782d0e4483e9343719b9b7be66515252da5491f26421896b1
22:27:06 buzz-desktop: persisted identity pubkey 423cc9ca3a8f1d37efbefb5cc6bf9a72206...
22:27:06 buzz-desktop: removed leftover identity.key (key is in keyring)
22:30:27 buzz-desktop: imported identity pubkey 8e39cba6...

Why this only reproduced on Linux (not macOS)

all three defects are platform-independent code — none is Linux-specific. what differs is the runtime conditions that trigger them, which are met on Linux and effectively never on macOS:

  • Keyring backend. macOS stores the identity as a single JSON blob in the always-reachable Keychain (SecKeychain), so a write during onboarding lands and persists across logins. Linux uses the keyring crate over Secret Service / D-Bus (gnome-keyring / KWallet), which is routinely unavailable (headless session, D-Bus down, keyring not unlocked) or empty across sessions. The KeyringProbe::ReachableButEmpty / Unreachable states essentially only occur on Linux — and ReachableButEmpty + marker + no file is exactly what fed defect 3 (silent regeneration). macOS Keychain never presents that state.
  • webview. The onboarding-complete localStorage race that fix(onboarding): guard against webkit2gtk WAL race with explicit profile-event signal #1508 hardened is a webkit2gtk (Linux webview) WAL-visibility issue; macOS uses WKWebView and doesn't hit it.
  • Net. Once a real key reaches the reliable macOS Keychain, resolution's Present branch returns it every boot with no shadow key left to win. The loop needs the imported key to persist file-only while a shadow key holds the keyring slot — a state the flaky Secret Service preserves and the Keychain resolves away. The fix closes all three regardless of platform; it'd bite a Linux .deb install the same way (this is unrelated to the AppImage packaging fix in fix(linux): make AppImage work on Mesa 25+ / GLib 2.88 distros #1567).

defect 1 — import_identity never reached the keyring

commands/identity.rs:import_identity wrote the imported nsec only to identity.key and updated in-memory state. it never called store.store(). so every boot, the keyring saw its previously generated shadow identity and "won". the fix: import now goes through a single persist_imported_identity policy — keyring-first (store → read-back verify → marker → delete file), falling back to the 0o600 file when the keyring is unavailable, and erroring only when both backends fail.

defect 2 — boot cleanup deleted the imported key without comparing pubkeys

app_state.rs:resolve_identity_with_store, Present branch: when the keyring had a key and identity.key existed, it called cleanup_leftover_identity_file unconditionally — no pubkey comparison. so the imported key in identity.key got deleted every boot, and the shadow key resolved instead. the fix: compare pubkeys first. if they match, it's a stale leftover from a failed prior migration — write the marker if it's missing (crash-safe ordering), then clean it up; a corrupt leftover logs a diagnostic before removal. if they're different, the file is the user's most recent explicit action (import): adopt it into the keyring (overwrite, read-back verify, marker before delete) and resolve as the file's key. a transient keyring failure during adoption no longer aborts the launch — the boot proceeds with the file key and adoption retries next boot. this also auto-heals installs already stuck in the loop.

defect 3 — no-identity states silently regenerated a new key (or refused to boot)

ReachableButEmpty + marker + no file used to fall through to generate_and_persist() — a fresh identity nobody asked for. and Unreachable + marker used to fail closed by refusing to launch entirely. both are now explicit recovery modes:

  • lost (keyring reachable but emptied after a successful migration): the key is gone. the app boots with an in-memory ephemeral key, get_identity reports lost: true, and onboarding opens directly on the nsec re-import step. re-importing restores the identity. choosing "go back to start a new identity" instead calls a new persist_current_identity command that makes the ephemeral key durable first — so a fresh identity created from this screen actually survives relaunch instead of evaporating. either way the app then lands on a blocking restart screen: the owner-keyed startup routines (event sync, launch-time agent restore, the pending-event flush loop) were skipped at boot and can't be restarted in-process, so continuing without a relaunch would look fine while silently not syncing.
  • keyring-locked (keyring unreachable but the marker says a key lives there): the key still exists, this session just can't reach it. instead of aborting launch, the app boots a blocking "unlock your system keyring" screen with a relaunch button. nothing offers "start a new identity" here on purpose — persisting the ephemeral key while the real one sits in an unreachable keyring would clobber the real key via the adoption path once the keyring comes back. persist_current_identity is lost-only and rejects the locked state for the same reason.

in either recovery state the backend refuses to sign: AppState::signing_keys() gates every signing/publishing surface (submit_event / submit_signed_event, Blossom upload auth, sign_event / create_auth_event / nip44_encrypt_to_self / observer control, agent auth tags and retention signing), so no events are ever published under a key the user doesn't own. import_identity clears both recovery flags; flag stores are Release-ordered after the keys mutex update and reads are Acquire, so any reader observing "not in recovery" is guaranteed to see the restored keys.

marker invariant: keyring-only implies marker exists

persist_identity_to_keyring had a gap: if the marker write fails after a verified keyring write and no identity.key exists (e.g. an import from lost state where the file was already deleted), the key was left keyring-only with no marker. a later keyring-unreachable boot would see no file + no marker and treat it as a fresh install, silently rotating identity. the fix: when the marker write fails, write identity.key as a fallback if it's absent, so the invariant holds regardless. the same crash-safe ordering (marker before file delete) now applies on every path that transitions from file-backed to keyring-backed.

why PR #1508 didn't and couldn't fix this

#1508 added has_profile_event and a localStorage re-read in useFirstRunOnboardingGate to survive a webkit2gtk WAL race where the onboarding-complete flag wasn't visible yet on first read. that hardening is legitimate and i've kept it in place. but the re-check still reads localStorage for the wrong pubkey — the shadow key 423cc9ca from the keyring rather than the user's imported 8e39cba6. there's no localStorage entry for the shadow key, so it can't help here regardless.

what this PR changes

  • import_identity: keyring-first persistence via persist_imported_identity (file fallback, errors only if both backends fail); updates keys before clearing both recovery flags (Release/Acquire pairing)
  • resolve_identity_with_store Present branch: pubkey-compare before cleanup; mismatched file key adopted into the keyring with marker-before-delete; adoption survives transient keyring failures (file key wins, retry next boot); same-pubkey cleanup writes the marker first if missing; corrupt leftovers log before removal
  • resolve_identity_with_store: ReachableButEmpty + marker + no file → lost recovery; Unreachable + marker + no file → keyring-locked recovery instead of a launch abort; internal RecoveryState enum keeps the two mutually exclusive
  • persist_identity_to_keyring: marker-write failure writes identity.key as fallback when absent, preserving the keyring-only-implies-marker-exists invariant
  • new persist_current_identity command: makes the lost-state ephemeral key durable when the user chooses to start fresh; rejects the locked state
  • AppState::signing_keys(): recovery-mode gate used by all signing/publishing commands; identity_lost + keyring_locked flags surfaced as lost / locked on IdentityInfo / Identity
  • setup(): skips run_event_sync (incl. the boot-time managed-agent reconcile), launch-time agent restore, and the pending-event flush loop in recovery mode
  • frontend: useAppOnboardingState gains sticky bootedLost, a relaunch-required stage (blocking RelaunchRequiredScreen with a Relaunch button), and a highest-precedence keyring-locked stage (KeyringLockedScreen); useProfileQuery stays disabled in both recovery states; lost-mode key-import back-path persists the ephemeral key before proceeding
  • OnboardingFlow: lost-mode copy promises the restart; import and persist paths both land on the restart screen
  • tests: 36 unit tests over the identity state machine (FakeIdentityStore), incl. adoption-failure, ephemeral-never-persisted for both recovery states, and signing_keys gating; 5 playwright smoke scenarios covering lost boot → re-import → restart screen, back → persist → restart screen, and locked boot → locked screen + restart invoke

proposed follow-up (not done here)

the deeper fix is to stop auto-generating a shadow identity before the user expresses any onboarding intent. that requires AppState.keys: Option<Keys> and threading None through all callers that currently assume a key always exists. it's a larger refactor and doesn't belong in this PR — i'd like to track it as a separate issue.

import_identity wrote only to identity.key and never to the keyring, so
the shadow key from first boot always won on every subsequent launch.
The Present-branch boot cleanup then deleted identity.key without
comparing pubkeys, discarding the imported key silently. On a clean
keyring with a migration marker but no file, a new identity was
generated without any user intent, completing the re-onboarding loop.

Import now persists keyring-first with read-back verify and file
fallback; boot resolution adopts a mismatched identity.key as the
user's explicit intent (surviving transient keyring failures); marker
+ empty keyring boots a lost-identity recovery flow, and an
unreachable keyring boots a blocking unlock-and-relaunch screen
instead of aborting launch. Recovery boots gate all signing commands,
skip owner-keyed startup routines, enforce a relaunch after re-import,
and persist "start new identity" durably.
@wpfleger96 wpfleger96 force-pushed the wpfleger96/identity-import-keyring branch from c49cbcd to 18f7391 Compare July 8, 2026 14:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant