release: merge 0.16.0 back to main (OpenPGP silent-fork fix + bug fixes)#405
Merged
Conversation
… resilience updates
The auto-scroll effect in useListKeyboardNav fired on every selectedIndex change, including those triggered by mouse hover. During touchpad 2-finger scroll, items passing under the cursor caused setSelectedIndex via onMouseEnter, triggering scrollIntoView that fought the scroll momentum and made the list jump back up. Track the source of each selection change and only auto-scroll for keyboard and external (activeItemId) sources, never for mouse hover.
…essage (#367) When a contact sends the first message, the conversation was created with the JID local part as the name instead of the roster display name, even though the roster data was already available.
Replace the native context menu on image attachments with a custom menu that copies the original XMPP server URL instead of the local cached URL (asset.localhost on Tauri, blob: on web).
…ntity When a Fluux web session encountered an empty IndexedDB while the account already had a public key (or backup) published in PEP, the WebOpenPGPPlugin silently generated a fresh keypair and republished its fingerprint — overwriting the metadata that peers were encrypting to. The sibling device still holding the matching private key kept seeing live messages, but every peer who had pinned the previous fingerprint now hit "Could not decrypt". Add a safety guard in WebOpenPGPPlugin.ensureKeyMaterial: before the silent-generation branch, probe PEP for any existing identity material via the new probeRemoteIdentityState. Throw needs-identity-decision when the server has either a public key or a backup so the host can route the user to a deliberate resolution (import the matching private key, or explicitly retire the published identity). OpenPGPPluginBase.init swallows the new error the same way it swallows key-locked, so the plugin stays registered for the recovery dialogs to drive. The new probeRemotePublishedFingerprints / probeRemoteIdentityState helpers wrap the existing probeRemoteSecretKeyBackup so the toggle and auto-init flows can probe both nodes in one shot. The composer deduplicates v4/v6 fingerprints and propagates SecretKeyBackupProbeError on any transient sub-probe failure. 20 new unit tests cover the probe extension, the guard behaviour, and init's error-swallowing.
…lds a key
Phase 2 of the silent-fork fix. The previous commit added a crypto-layer
guard that refused to silent-generate when the server already held an
OpenPGP identity for the account; this commit wires the user-facing
resolution path so the user can recover without leaving Settings.
When the EncryptionSettings toggle (web branch) is flipped ON and the
local IndexedDB has no key, the handler now probes PEP for both the
secret-key backup and the published public-key metadata via the new
probeRemoteIdentityState. If either is present, it defers registration
and opens the IdentityChoiceDialog with three explicit options:
- **Restore from server backup** — calls plugin.restoreSecretKey,
enabled only when a backup actually exists. Routes to the
existing key-picker for multi-TSK backups.
- **Import from a file** — opens the platform file picker and
chains into the existing RestorePassphraseDialog.
- **Replace the published identity** — calls the new
OpenPGPPluginBase.retireAndGenerateIdentity, which enumerates
every published fingerprint, retracts each data node plus the
metadata node, clears the local key material, regenerates a
fresh key (bypassing the safety guard via the new
_allowSilentRegenerate flag), republishes the new public key,
and clears the own-key-conflict banner.
The dialog refuses to close on backdrop click during async actions and
backs out to the chooser on Escape during sub-phases — explicit user
intent is required to leave the resolution flow, matching the
defence-in-depth posture of the crypto guard.
i18n keys added to all 33 locales (settings.encryption.identityChoice.*).
Tests: 3 new for retireAndGenerateIdentity (retract enumeration,
own-key-conflict clearance, retract-failure resilience). Existing 210
e2ee tests still pass.
App.tsx auto-init wiring (showing the dialog when the plugin is
registered into needs-identity-decision after a page reload) is
deferred to a follow-up — for now those users can resolve via Settings
toggle. The crypto guard remains effective in both paths.
Closes the last gap in the silent-fork fix UX. Until now, a returning user whose IndexedDB had no local key (cleared site data, new browser profile, etc.) but whose account already had a published OpenPGP identity would land on the UnlockEncryptionDialog at auto-init. Entering their passphrase would then hit the crypto guard inside ensureKeyMaterial and propagate a `needs-identity-decision` error inline — recoverable only by going to Settings and re-toggling encryption. App.tsx now probes the same way EncryptionSettings.handleToggle does: after `registerE2EEPlugins` succeeds on the web path, it asks the plugin if there's no local key and runs `probeRemoteIdentityState`. When the server holds either a backup or a published public key, it opens IdentityChoiceDialog directly instead of UnlockEncryptionDialog, so the user lands on the right resolution flow up-front. The three handlers mirror the EncryptionSettings ones but stay focused on the auto-init context (no openpgpEnabled toggle to revert; the user either resolves or dismisses the dialog and can retry by interacting with Settings). On success the new `notifyE2EEKeyUnlocked` call mirrors the unlock dialog so pending deferred decrypts retry against the newly-loaded key. "Import from file" requires a second prompt (file picker → passphrase dialog); App.tsx now also holds the `pendingImportFile` armored content and renders the existing RestorePassphraseDialog with the file-import strings to collect the passphrase, then calls `plugin.importKeyFromFile`. Probe failure (transient network) falls back to the unlock dialog as before; the crypto guard remains the safety net for any case where auto-init can't make a clean decision.
Closes the symmetric latent bug on desktop. Previously only
WebOpenPGPPlugin checked PEP before silent-generating; SequoiaPgpPlugin
delegated directly to `openpgp_ensure_key` Rust IPC, which would
generate a fresh keypair regardless of any pre-existing published
public key on the server. A user installing Fluux desktop on a new
machine (with no OS-keychain key for the account) would have hit the
same fork-by-silent-generation as a user opening Fluux web in a new
browser.
The shared guard logic is extracted to
`OpenPGPPluginBase.assertSilentGenerationAllowed(jid)`. Both
subclasses now call it from their `ensureKeyMaterial` impls before
their respective generation paths:
- WebOpenPGPPlugin: unconditionally (the web branch only reaches
the generate code when no IndexedDB key is present).
- SequoiaPgpPlugin: conditionally on `hasNoLocalKey()`, so returning
devices (key already in the OS keychain) pay no extra IPC.
`SequoiaPgpPlugin.hasNoLocalKey()` (the inverse of the existing
`hasPersistedKey()`) is added for parity with the web plugin, so
consumers (App.tsx auto-init, EncryptionSettings toggle handler,
the choice-dialog router) can use the same method name across
platforms.
EncryptionSettings.handleToggle's desktop branch is rewritten to
mirror the web flow: register the plugin first (now safe — init
swallows `needs-identity-decision` exactly like `key-locked`), check
the plugin's `hasNoLocalKey()`, and if true defer to the unified
IdentityChoiceDialog. Replaces the previous backup-only probe
(`probeRemoteSecretKeyBackup` + `EnableWithBackupDialog`) which
missed the public-key-but-no-backup case; that legacy dialog and the
`pendingEnableBackup` state remain in the file as dead code for now,
to be removed in a follow-up.
Existing Sequoia tests that exercised the
checkOwnPublishedKeyConsistency post-generation conflict path now
seed the local key via `openpgp_ensure_key` before running init, so
they hit the primary-mismatch detection that's the actual subject of
the test (rather than the new pre-generation guard, which correctly
bails out earlier for fresh devices).
3 new SequoiaPgpPlugin tests cover the guard:
- refuses silent generation when only a public key is published,
- refuses silent generation when only a backup exists,
- still generates silently when neither exists.
Full e2ee suite: 216 tests passing. typecheck + lint clean.
13 React Testing Library tests covering the dialog's state machine
and conditional UI:
- initial render shows the three choice buttons + cancel
- "Restore from server backup" disabled + "unavailable" copy when
hasServerBackup=false
- published fingerprint surfaced in the header band (with "+N"
suffix when multiple are listed)
- choosing restore enters the passphrase phase
- restore submit disabled until passphrase is non-empty
- submit calls onRestoreFromServer with the typed value
- rejection from the handler surfaces the error inline and keeps
the dialog open for retry
- import-from-file triggers onImportFromFile immediately
- replace requires the explicit confirm step (warning banner first)
- Back button returns to chooser from the restore phase
- Escape during sub-phase returns to chooser instead of dismissing
- Escape on chooser dismisses via onCancel
i18n is mocked to surface keys verbatim, so the assertions target
the stable key names rather than translated copy.
The desktop branch of EncryptionSettings.handleToggle was rewired to route through IdentityChoiceDialog in be9728c, leaving EnableWithBackupDialog with no live caller. Drop: - The component file itself (EnableWithBackupDialog.tsx). - The import + pendingEnableBackup state + handleEnableRestore / handleEnableUseFresh / handleEnableCancel handlers in EncryptionSettings.tsx. - The JSX rendering of the dialog. - The four i18n keys exclusive to the deleted dialog — enableBackupFoundTitle, enableBackupFoundBody, enableBackupFoundWarning, enableBackupFoundUseFresh — from all 33 locales. Keys reused by RestorePassphraseDialog / UnlockEncryptionDialog (restorePassphraseLabel, restorePassphrasePlaceholder, restoreAction) are kept. No behavioural change: IdentityChoiceDialog is a strict superset of the deleted dialog's UX (3 explicit options vs. 2). typecheck + lint clean, all 2762 app + 3800 SDK tests still pass.
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.
Summary
Merge
release/0.16.0back to main.OpenPGP silent-fork prevention
A fresh browser / desktop install with no local OpenPGP key was silently generating a fresh keypair and publishing its fingerprint to PEP, overwriting whatever metadata peers had pinned — leaving any sibling device that still held the matching private key (or any peer whose pinning hadn't refreshed) unable to deliver or decrypt.