fix(desktop): stop losing imported identity on every launch#1568
Open
wpfleger96 wants to merge 1 commit into
Open
fix(desktop): stop losing imported identity on every launch#1568wpfleger96 wants to merge 1 commit into
wpfleger96 wants to merge 1 commit into
Conversation
f7fdb34 to
1cd9bae
Compare
This was referenced Jul 7, 2026
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.
c49cbcd to
18f7391
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
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:
SecKeychain), so a write during onboarding lands and persists across logins. Linux uses thekeyringcrate over Secret Service / D-Bus (gnome-keyring / KWallet), which is routinely unavailable (headless session, D-Bus down, keyring not unlocked) or empty across sessions. TheKeyringProbe::ReachableButEmpty/Unreachablestates essentially only occur on Linux — andReachableButEmpty+ marker + no file is exactly what fed defect 3 (silent regeneration). macOS Keychain never presents that state.Presentbranch 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.debinstall 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_identitynever reached the keyringcommands/identity.rs:import_identitywrote the imported nsec only toidentity.keyand updated in-memory state. it never calledstore.store(). so every boot, the keyring saw its previously generated shadow identity and "won". the fix: import now goes through a singlepersist_imported_identitypolicy — keyring-first (store → read-back verify → marker → delete file), falling back to the0o600file 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,Presentbranch: when the keyring had a key andidentity.keyexisted, it calledcleanup_leftover_identity_fileunconditionally — no pubkey comparison. so the imported key inidentity.keygot 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 togenerate_and_persist()— a fresh identity nobody asked for. andUnreachable+ marker used to fail closed by refusing to launch entirely. both are now explicit recovery modes:get_identityreportslost: 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 newpersist_current_identitycommand 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.persist_current_identityis 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_identityclears both recovery flags; flag stores areRelease-ordered after the keys mutex update and reads areAcquire, so any reader observing "not in recovery" is guaranteed to see the restored keys.marker invariant: keyring-only implies marker exists
persist_identity_to_keyringhad a gap: if the marker write fails after a verified keyring write and noidentity.keyexists (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, writeidentity.keyas 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_eventand a localStorage re-read inuseFirstRunOnboardingGateto 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 key423cc9cafrom the keyring rather than the user's imported8e39cba6. 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 viapersist_imported_identity(file fallback, errors only if both backends fail); updates keys before clearing both recovery flags (Release/Acquirepairing)resolve_identity_with_storePresentbranch: 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 removalresolve_identity_with_store:ReachableButEmpty+ marker + no file →lostrecovery;Unreachable+ marker + no file →keyring-lockedrecovery instead of a launch abort; internalRecoveryStateenum keeps the two mutually exclusivepersist_identity_to_keyring: marker-write failure writesidentity.keyas fallback when absent, preserving the keyring-only-implies-marker-exists invariantpersist_current_identitycommand: makes the lost-state ephemeral key durable when the user chooses to start fresh; rejects the locked stateAppState::signing_keys(): recovery-mode gate used by all signing/publishing commands;identity_lost+keyring_lockedflags surfaced aslost/lockedonIdentityInfo/Identitysetup(): skipsrun_event_sync(incl. the boot-time managed-agent reconcile), launch-time agent restore, and the pending-event flush loop in recovery modeuseAppOnboardingStategains stickybootedLost, arelaunch-requiredstage (blockingRelaunchRequiredScreenwith a Relaunch button), and a highest-precedencekeyring-lockedstage (KeyringLockedScreen);useProfileQuerystays disabled in both recovery states; lost-mode key-import back-path persists the ephemeral key before proceedingOnboardingFlow: lost-mode copy promises the restart; import and persist paths both land on the restart screenFakeIdentityStore), incl. adoption-failure, ephemeral-never-persisted for both recovery states, andsigning_keysgating; 5 playwright smoke scenarios covering lost boot → re-import → restart screen, back → persist → restart screen, and locked boot → locked screen + restart invokeproposed 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 threadingNonethrough 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.