Skip to content

Commit 022f9e3

Browse files
authored
release: merge 0.16.0 back to main (OpenPGP silent-fork fix + bug fixes) (#405)
## 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.
1 parent 5768074 commit 022f9e3

65 files changed

Lines changed: 2888 additions & 435 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,31 @@ All notable changes to Fluux Messenger are documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.16.0] - 2026-05-18
9+
10+
### Added
11+
12+
- OpenPGP end-to-end encryption (XEP-0373 / XEP-0374) — encrypted 1:1 messaging with passphrase-protected key storage and secret-key backup/restore
13+
- OpenPGP end-to-end encryption support in web version
14+
- Multi-TSK (Transferable Secret Key) handling in the XEP-0373 backup restore flow for accounts with multiple OpenPGP keys
15+
16+
### Changed
17+
18+
- XMPP Console hides Stream Management packets by default for less noise (toggle remains available)
19+
- Significant render-performance pass: cut store over-subscription in ConversationList, CommandPalette, RoomConfig, ContactSelector, ContactItem, and room modals
20+
- Simplified Chinese translation updated
21+
22+
### Fixed
23+
24+
- SASL2 inline Stream Management resumption handled correctly; duplicate <enable/> suppressed
25+
- Proxy/auth: forward <open/> from= attribute so SASL2/FAST works through the desktop proxy, plus keychain fallback
26+
- SDK: client-side FAST token cleared on logout to prevent silent re-authentication
27+
- Wake and reconnect resilience: stale-timer detection, DarkWake handling, reload cooldown, and settle-time scaling
28+
- Activity log: subscription events now navigate to the contact profile
29+
- Sidebar user panel: prevent status label truncation
30+
- RTL sidebar lists: truncate Latin names at the end instead of the start
31+
- Blockquote decorative quote marks no longer clipped at the edge
32+
833
## [0.15.2] - 2026-04-21
934

1035
### Added

RELEASE_NOTES.md

Lines changed: 15 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,27 @@
1-
## What's New in v0.15.2
1+
## What's New in v0.16.0
22

33
### Added
44

5-
- Right-to-left (RTL) layout support for RTL languages
6-
- Arabic and Hebrew translations (beta quality, please report any error or issue to help improve them)
7-
- Decorative quotation marks for blockquotes
5+
- OpenPGP end-to-end encryption (XEP-0373 / XEP-0374) — encrypted 1:1 messaging with passphrase-protected key storage and secret-key backup/restore
6+
- OpenPGP end-to-end encryption support in web version
7+
- Multi-TSK (Transferable Secret Key) handling in the XEP-0373 backup restore flow for accounts with multiple OpenPGP keys
88

99
### Changed
1010

11-
- SASL2 user-agent identifier and server-side FAST token invalidation on logout
12-
- Faster reconnection: skip redundant MAM queries on stream-management resume
13-
- Perf: Per-conversation typing and draft subscriptions for smoother list rendering during background sync
14-
- Security updates for several dependencies (brace-expansion, rustls-webpki, tar, rand, serialize-javascript; trust-dns-resolver migrated to hickory-resolver)
11+
- XMPP Console hides Stream Management packets by default for less noise (toggle remains available)
12+
- Significant render-performance pass: cut store over-subscription in ConversationList, CommandPalette, RoomConfig, ContactSelector, ContactItem, and room modals
13+
- Simplified Chinese translation updated
1514

1615
### Fixed
1716

18-
- Preserve MUC room state across stream-management resume and interrupted fresh sessions
19-
- Prevent reconnection loops and UI freezes after system sleep/wake
20-
- Keep FAST token rotation working across page-reload reconnect
21-
- Retry FAST token authentication when the server field was initially empty
22-
- Suppress spurious FAST token deletion log message on first login
23-
- Set websocket stream "from" attribute so SASL2 is accepted on compliant servers
24-
- Hydrate outbound stream-management state on resume to avoid ackQueue crash
25-
- Recover Tauri reconnect stalls via native keepalive with proxy fallback
26-
- fetchBookmarks no longer wipes stored room messages on reconnect
27-
- Write live room messages directly to IndexedDB to prevent loss on reconnect
28-
- Restore saved rooms through the connect call so history loads after SM resume
29-
- Skip unnecessary webview reload when the app was hidden but the machine stayed awake
30-
- Lightbox displays the full-resolution original without upscaling past its natural size
31-
- Run discovery calls before the serial session-setup chain
32-
- Recover when post-wake auto-connect stalls after SASL
33-
- Handle superseded connection attempts with a dedicated error class
34-
- Grow reconnect attempt counter past the backoff ceiling
35-
- Probe runtime before reloading on dynamic import failure; auto-reload otherwise
36-
- Fall back to direct URL when the web image cache fetch fails
37-
- Display upload errors in the UI and allow HTTP upload URLs
38-
- Use inert instead of aria-hidden on the scroll-to-bottom FAB (accessibility)
39-
- Use ServiceWorker.showNotification() on web for reliable notifications
40-
- Fix vertical alignment of the message toolbar "more" menu button
17+
- SASL2 inline Stream Management resumption handled correctly; duplicate <enable/> suppressed
18+
- Proxy/auth: forward <open/> from= attribute so SASL2/FAST works through the desktop proxy, plus keychain fallback
19+
- SDK: client-side FAST token cleared on logout to prevent silent re-authentication
20+
- Wake and reconnect resilience: stale-timer detection, DarkWake handling, reload cooldown, and settle-time scaling
21+
- Activity log: subscription events now navigate to the contact profile
22+
- Sidebar user panel: prevent status label truncation
23+
- RTL sidebar lists: truncate Latin names at the end instead of the start
24+
- Blockquote decorative quote marks no longer clipped at the edge
4125

4226
---
4327
[Full Changelog](https://github.com/processone/fluux-messenger/blob/main/CHANGELOG.md)

apps/fluux/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@xmpp/fluux",
3-
"version": "0.15.2",
3+
"version": "0.16.0",
44
"private": true,
55
"type": "module",
66
"scripts": {

apps/fluux/src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "fluux"
3-
version = "0.15.2"
3+
version = "0.16.0"
44
description = "A powerful, productive messaging client that's pleasant to use."
55
authors = ["ProcessOne"]
66
license = "AGPL-3.0-or-later"

apps/fluux/src-tauri/tauri.conf.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://schema.tauri.app/config/2",
33
"productName": "Fluux Messenger",
4-
"version": "0.15.2",
4+
"version": "0.16.0",
55
"identifier": "com.processone.fluux",
66
"plugins": {
77
"deep-link": {
@@ -68,7 +68,7 @@
6868
],
6969
"macOS": {
7070
"minimumSystemVersion": "10.13",
71-
"bundleVersion": "201a389",
71+
"bundleVersion": "5768074e",
7272
"entitlements": "Entitlements.plist",
7373
"signingIdentity": null
7474
},

apps/fluux/src/App.tsx

Lines changed: 155 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
1-
import { useState, useEffect } from 'react'
1+
import { useState, useEffect, useCallback } from 'react'
22
import { Routes, Route, Navigate } from 'react-router-dom'
3+
import { useTranslation } from 'react-i18next'
34
import { useConnection, useXMPPContext, hasFastToken } from '@fluux/sdk'
45
import { registerE2EEPlugins } from './e2ee/registerPlugins'
56
import { isKeyLocked } from './e2ee/webPassphraseStore'
7+
import { probeRemoteIdentityState } from './e2ee/secretKeyProbe'
68
import { isOpenpgpEnabled } from './stores/encryptionSettingsStore'
9+
import { useToastStore } from './stores/toastStore'
710
import { UnlockEncryptionDialog } from './components/UnlockEncryptionDialog'
11+
import { IdentityChoiceDialog } from './components/IdentityChoiceDialog'
12+
import { RestorePassphraseDialog } from './components/RestorePassphraseDialog'
813
import { detectRenderLoop } from '@/utils/renderLoopDetector'
914
import { LoginScreen } from './components/LoginScreen'
1015
import { ChatLayout } from './components/ChatLayout'
@@ -50,8 +55,10 @@ function App() {
5055
// Detect render loops before they freeze the UI
5156
detectRenderLoop('App')
5257

53-
const { status } = useConnection()
58+
const { status, jid } = useConnection()
5459
const { client } = useXMPPContext()
60+
const { t } = useTranslation()
61+
const addToast = useToastStore((s) => s.addToast)
5562
const tabCoordination = useTabCoordination(() => {
5663
// When another tab takes over, disconnect this client
5764
void client.disconnect()
@@ -148,6 +155,22 @@ function App() {
148155
const [hasBeenOnline, setHasBeenOnline] = useState(false)
149156

150157
const [showWebUnlockDialog, setShowWebUnlockDialog] = useState(false)
158+
// Set when auto-init detects a server-side OpenPGP identity for this
159+
// account but no local key. Forces the user through IdentityChoiceDialog
160+
// instead of the standard unlock dialog (which would otherwise reach
161+
// ensureKeyMaterial, hit the crypto guard, and surface an opaque error).
162+
// Keeping this state lifted here matches the existing pattern for
163+
// showWebUnlockDialog: App is the single owner of the connect-time
164+
// E2EE bootstrap flow.
165+
const [pendingIdentityChoice, setPendingIdentityChoice] = useState<{
166+
accountJid: string
167+
hasBackup: boolean
168+
publishedFingerprints: string[]
169+
} | null>(null)
170+
// Holds the armored file content while the user types the file
171+
// passphrase. Decoupled from `pendingIdentityChoice` so the choice
172+
// dialog can dismiss as soon as the file is picked.
173+
const [pendingImportFile, setPendingImportFile] = useState<string | null>(null)
151174

152175
// Auto-reconnect on page reload if session exists
153176
useSessionPersistence(tabCoordination.claimConnection)
@@ -167,8 +190,42 @@ function App() {
167190
// Fire-and-forget: a failure must not block the chat path.
168191
// On web, after registration the key may be in locked state — show the
169192
// unlock dialog so the user can supply the session passphrase.
170-
void registerE2EEPlugins(client).then(() => {
171-
if (!isTauri && isOpenpgpEnabled() && isKeyLocked()) {
193+
void registerE2EEPlugins(client).then(async () => {
194+
if (isTauri || !isOpenpgpEnabled()) return
195+
// Web auto-init: if the server already advertises an OpenPGP
196+
// identity but the local IndexedDB has no key (cleared cookies,
197+
// new browser profile, fresh install of Fluux web on the same
198+
// account), route the user to IdentityChoiceDialog up-front
199+
// instead of through the unlock dialog. The crypto guard would
200+
// refuse silent generation either way, but a clean dialog is
201+
// friendlier than a generic unlock failure.
202+
const accountJid = jid ? jid.split('/')[0] : null
203+
const plugin = client.e2ee?.getPlugin('openpgp') as
204+
| { hasNoLocalKey?: () => Promise<boolean> }
205+
| null
206+
| undefined
207+
if (accountJid && plugin?.hasNoLocalKey) {
208+
try {
209+
const hasNoLocal = await plugin.hasNoLocalKey()
210+
if (hasNoLocal) {
211+
const state = await probeRemoteIdentityState(client, accountJid)
212+
if (state.hasServerIdentity) {
213+
setPendingIdentityChoice({
214+
accountJid,
215+
hasBackup: state.backupMessage !== null,
216+
publishedFingerprints: state.publishedFingerprints,
217+
})
218+
return
219+
}
220+
}
221+
} catch {
222+
// Probe failure (transient network, server down): fall
223+
// through to the unlock dialog. The crypto guard remains
224+
// effective; the worst case is a confused error message
225+
// until the user re-toggles via Settings.
226+
}
227+
}
228+
if (isKeyLocked()) {
172229
setShowWebUnlockDialog(true)
173230
}
174231
})
@@ -180,7 +237,81 @@ function App() {
180237
setIsAutoReconnecting(false)
181238
}
182239
}
183-
}, [status, client])
240+
}, [status, client, jid])
241+
242+
// --- Identity-choice handlers (web first-login safety net) ---
243+
// Each resolves `pendingIdentityChoice` with one explicit recovery
244+
// path. Failures stay inside the dialog (the dialog's try/catch
245+
// surfaces the error to the user); success closes the dialog and
246+
// emits a toast. Toast strings reuse settings.encryption.restoreSuccess
247+
// — the user-visible outcome is identical regardless of which path
248+
// ran (the account is now usable for E2EE).
249+
250+
const handleIdentityRestoreFromServer = useCallback(
251+
async (passphrase: string) => {
252+
const plugin = client.e2ee?.getPlugin('openpgp') as
253+
| {
254+
restoreSecretKey?: (pp: string) => Promise<unknown>
255+
}
256+
| null
257+
| undefined
258+
if (!plugin?.restoreSecretKey) {
259+
throw new Error(t('settings.encryption.backupPluginUnavailable'))
260+
}
261+
await plugin.restoreSecretKey(passphrase)
262+
setPendingIdentityChoice(null)
263+
client.notifyE2EEKeyUnlocked?.()
264+
addToast('success', t('settings.encryption.restoreSuccess'))
265+
},
266+
[client, t, addToast],
267+
)
268+
269+
const handleIdentityImportFromFile = useCallback(async () => {
270+
const plugin = client.e2ee?.getPlugin('openpgp') as
271+
| { pickKeyFile?: () => Promise<string | null> }
272+
| null
273+
| undefined
274+
if (!plugin?.pickKeyFile) return
275+
const content = await plugin.pickKeyFile()
276+
if (!content) return
277+
// Close the choice dialog and hand off to the passphrase dialog.
278+
setPendingIdentityChoice(null)
279+
setPendingImportFile(content)
280+
}, [client])
281+
282+
const handleImportFilePassphrase = useCallback(
283+
async (passphrase: string) => {
284+
if (!pendingImportFile) return
285+
const plugin = client.e2ee?.getPlugin('openpgp') as
286+
| {
287+
importKeyFromFile?: (armored: string, pp: string) => Promise<unknown>
288+
}
289+
| null
290+
| undefined
291+
if (!plugin?.importKeyFromFile) {
292+
throw new Error(t('settings.encryption.backupPluginUnavailable'))
293+
}
294+
await plugin.importKeyFromFile(pendingImportFile, passphrase)
295+
setPendingImportFile(null)
296+
client.notifyE2EEKeyUnlocked?.()
297+
addToast('success', t('settings.encryption.restoreSuccess'))
298+
},
299+
[client, pendingImportFile, t, addToast],
300+
)
301+
302+
const handleIdentityReplaceIdentity = useCallback(async () => {
303+
const plugin = client.e2ee?.getPlugin('openpgp') as
304+
| { retireAndGenerateIdentity?: () => Promise<unknown> }
305+
| null
306+
| undefined
307+
if (!plugin?.retireAndGenerateIdentity) {
308+
throw new Error(t('settings.encryption.backupPluginUnavailable'))
309+
}
310+
await plugin.retireAndGenerateIdentity()
311+
setPendingIdentityChoice(null)
312+
client.notifyE2EEKeyUnlocked?.()
313+
addToast('success', t('settings.encryption.restoreSuccess'))
314+
}, [client, t, addToast])
184315

185316
// Check if we have a stored session (for reconnect scenarios)
186317
const hasSession = getSession() !== null
@@ -267,6 +398,25 @@ function App() {
267398
onClose={() => setShowWebUnlockDialog(false)}
268399
/>
269400
)}
401+
{pendingIdentityChoice && (
402+
<IdentityChoiceDialog
403+
hasServerBackup={pendingIdentityChoice.hasBackup}
404+
publishedFingerprints={pendingIdentityChoice.publishedFingerprints}
405+
onRestoreFromServer={handleIdentityRestoreFromServer}
406+
onImportFromFile={handleIdentityImportFromFile}
407+
onReplaceIdentity={handleIdentityReplaceIdentity}
408+
onCancel={() => setPendingIdentityChoice(null)}
409+
/>
410+
)}
411+
{pendingImportFile && (
412+
<RestorePassphraseDialog
413+
title={t('settings.encryption.importFileDialogTitle')}
414+
body={t('settings.encryption.importFileDialogBody')}
415+
confirmLabel={t('settings.encryption.importFileAction')}
416+
onConfirm={handleImportFilePassphrase}
417+
onCancel={() => setPendingImportFile(null)}
418+
/>
419+
)}
270420
</>
271421
)
272422
}

0 commit comments

Comments
 (0)