Skip to content

release: merge 0.16.0 back to main (OpenPGP silent-fork fix + bug fixes)#405

Merged
mremond merged 10 commits into
mainfrom
release/0.16.0
May 19, 2026
Merged

release: merge 0.16.0 back to main (OpenPGP silent-fork fix + bug fixes)#405
mremond merged 10 commits into
mainfrom
release/0.16.0

Conversation

@mremond
Copy link
Copy Markdown
Member

@mremond mremond commented May 19, 2026

Summary

Merge release/0.16.0 back 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.

mremond added 10 commits May 18, 2026 14:37
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.
@mremond mremond added this to the 0.16.0 milestone May 19, 2026
@mremond mremond merged commit 022f9e3 into main May 19, 2026
1 check passed
@mremond mremond deleted the release/0.16.0 branch May 19, 2026 13:10
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