From ea4e02950bb5c88a6e748c59513f81161c0db4d8 Mon Sep 17 00:00:00 2001 From: ramo-dev Date: Sat, 31 Oct 2026 17:36:59 +0300 Subject: [PATCH 1/3] feat(security): add OMEMO repair/reset and balance settings UI - Add Repair keys: top up pre-keys and republish the bundle/device list without changing identity, fingerprint, or trust. - Add Reset OMEMO: guarded by a fresh passphrase, replaces identity/device, clears remembered unlock, and removes the old device from the PEP list. Validation happens before any key material is deleted; reset requires an unlocked account. - Add a nested dialog overlay so Reset properly dims and blocks Settings. - Balance Security settings: warnings, current state, and irreversible consequences stay visible; only passphrase-storage mechanics remain in a tooltip. - Fix the group header encryption badge to be a real sibling button (icon-only) instead of a nested interactive element, and extract conversation status. - Split OMEMO account test fixtures and maintenance helpers to keep files under the size limit. --- CHANGELOG.md | 10 ++ docs/threat-model.md | 2 +- .../conversation-header.tsx | 114 ++++++------ .../message-reactions/message-reactions.tsx | 2 +- .../common/info-tooltip/info-tooltip.tsx | 50 ++++++ .../settings-chats/settings-chats.tsx | 12 -- .../settings-omemo-card.tsx | 79 +++++++-- .../settings-omemo-preferences.tsx | 87 ++++++--- .../settings-omemo-reset-dialog.tsx | 138 +++++++++++++++ .../settings-security/settings-security.tsx | 23 +-- src/components/ui/dialog.tsx | 14 +- src/components/ui/dropdown-menu.tsx | 2 +- src/hooks/omemo-unlock-helpers.ts | 59 +++++++ src/hooks/use-omemo-session.ts | 109 ++++++------ src/lib/format/conversation-status.ts | 32 ++++ src/lib/omemo/account.fixtures.ts | 97 ++++++++++ src/lib/omemo/account.test.ts | 166 ++++++++---------- src/lib/omemo/account.ts | 67 ++++--- src/lib/omemo/device-maintenance.ts | 29 +++ src/lib/omemo/devices.ts | 34 ++++ src/providers/omemo-provider.tsx | 6 + 21 files changed, 825 insertions(+), 307 deletions(-) create mode 100644 src/components/common/info-tooltip/info-tooltip.tsx create mode 100644 src/components/settings/settings-omemo-reset-dialog/settings-omemo-reset-dialog.tsx create mode 100644 src/hooks/omemo-unlock-helpers.ts create mode 100644 src/lib/format/conversation-status.ts create mode 100644 src/lib/omemo/account.fixtures.ts create mode 100644 src/lib/omemo/device-maintenance.ts create mode 100644 src/lib/omemo/devices.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 69210aa..76f586f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,16 @@ without a deprecation period. - Read state survives a refresh: delivered/read receipts are stored locally and re-applied when MAM replays the conversation. Add a Settings > Chats toggle to stop sending read receipts. +- "Encrypt new direct chats by default" is now a guided switch: turning it on + before OMEMO is ready scrolls to setup/unlock and focuses the passphrase, and + the default is saved only once OMEMO can encrypt. +- Settings > Security keeps only secondary passphrase-storage mechanics in a + tooltip. Warnings, current state, and irreversible consequences stay visible. +- Added Repair keys (top up pre-keys and republish without changing identity) and + Reset OMEMO (new identity/device, fresh passphrase, old device removed from + the PEP list) to Security settings. +- Nested dialogs opened from Settings (such as Reset OMEMO) now render a + dimming overlay above the parent dialog instead of inheriting its muted layer. - Reaction tooltips expose tooltip semantics, and the group "Not encrypted" badge becomes icon-only on narrow screens so group names fit. - README diagrams now show the federation trust boundary and the message/receipt diff --git a/docs/threat-model.md b/docs/threat-model.md index a78778b..a1a2679 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -58,7 +58,7 @@ Trust boundaries: | T10 | Lost passphrase | Keys are unrecoverable by design. No backup. | Operational. Users must understand this before setup. | | T11 | Contact's device key changes (MITM or new device) | Trust is bound to the reviewed fingerprint. Identity changes block reuse until reviewed. | Trust-on-review is only as good as the user's comparison. | | T16 | Group messages inferred as encrypted | Group chats show a "Not encrypted" badge with a tooltip; the composer exposes no group encryption toggle. | None for group confidentiality until group OMEMO lands. | -| T12 | Silent downgrade to plaintext for OMEMO chats | No automatic plaintext fallback. Sending unencrypted requires a confirmation dialog and shows an **Unencrypted** marker. Per-chat encryption choices persist per account, including explicit off, so a refresh cannot silently reset a chat to the default. | Users can still choose plaintext. | +| T12 | Silent downgrade to plaintext for OMEMO chats | No automatic plaintext fallback. Sending unencrypted requires a confirmation dialog and shows an **Unencrypted** marker. Per-chat encryption choices persist per account, including explicit off, so a refresh cannot silently reset a chat to the default. The default itself can only be enabled once OMEMO is set up and unlocked. | Users can still choose plaintext. | | T13 | Dependency compromise (`libomemo.js`, GPLv3, unaudited) | Isolated in `src/lib/omemo`. OMEMO labeled experimental. | High until audited. | | T14 | Secrets in logs | Production code does not log passwords, passphrases, or raw stanzas. The render error boundary logs the error and component stack (`console.error`). | Low. Error objects could contain user text. Review before release. | | T15 | Cross-account replay of OMEMO envelopes or unlock keys | Account JID bound as AAD; remembered keys are per account and fail on another account. | Low. Covered by tests. | diff --git a/src/components/chat/conversation/header/conversation-header/conversation-header.tsx b/src/components/chat/conversation/header/conversation-header/conversation-header.tsx index dcf692f..d1f38cc 100644 --- a/src/components/chat/conversation/header/conversation-header/conversation-header.tsx +++ b/src/components/chat/conversation/header/conversation-header/conversation-header.tsx @@ -5,17 +5,15 @@ import { } from "@remixicon/react"; import { HeaderActions } from "#/components/chat/conversation/header/header-actions/header-actions"; import { UserAvatar } from "#/components/common/user-avatar/user-avatar"; -import { Badge } from "#/components/ui/badge"; import { Button } from "#/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger, } from "#/components/ui/tooltip"; +import { conversationStatus } from "#/lib/format/conversation-status"; import { presenceDotClassName } from "#/lib/format/presence"; -import { formatLastSeen } from "#/lib/format/time"; import { cn } from "#/lib/utils"; -import { presenceLabel } from "#/lib/xmpp/presence"; import { useRightSidebar } from "#/providers/right-sidebar-provider"; import type { ConversationKind } from "#/types/chat"; import type { PresenceState } from "#/types/xmpp"; @@ -45,17 +43,13 @@ export function ConversationHeader({ }) { const { open } = useRightSidebar(); const isGroup = kind === "group"; - const status = isGroup - ? memberPreview - ? "Invite-only group" - : memberCount === 1 - ? "1 member" - : `${memberCount ?? 0} members` - : typing - ? "typing…" - : presence.show === "offline" && presence.lastSeen - ? formatLastSeen(presence.lastSeen) - : presence.status || presenceLabel(presence); + const status = conversationStatus({ + kind, + memberPreview, + memberCount, + typing, + presence, + }); function openInfo() { open(isGroup ? { kind: "room", jid } : { kind: "contact", jid }); @@ -74,54 +68,56 @@ export function ConversationHeader({ )} - +
+

{name}

+

{status}

+
+ + + {isGroup && ( + + + } + > + + + + Group messages are sent without end-to-end encryption. + + + )} + {emoji} {users.length > 1 && ( - + {users.length} )} diff --git a/src/components/common/info-tooltip/info-tooltip.tsx b/src/components/common/info-tooltip/info-tooltip.tsx new file mode 100644 index 0000000..1ec3835 --- /dev/null +++ b/src/components/common/info-tooltip/info-tooltip.tsx @@ -0,0 +1,50 @@ +import { RiInformationLine } from "@remixicon/react"; +import type { ReactNode } from "react"; +import { Button } from "#/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "#/components/ui/tooltip"; +import { cn } from "#/lib/utils"; + +/** + * Small info affordance for details that should not take permanent space in a + * dense settings panel. The trigger is a real button so keyboard and screen + * readers can reveal the same text. + */ +export function InfoTooltip({ + label, + children, + className, + side = "top", +}: { + label: string; + children: ReactNode; + className?: string; + side?: "top" | "bottom" | "left" | "right"; +}) { + return ( + + + } + > + + + + {children} + + + ); +} diff --git a/src/components/settings/settings-chats/settings-chats.tsx b/src/components/settings/settings-chats/settings-chats.tsx index 398d3df..b31bd62 100644 --- a/src/components/settings/settings-chats/settings-chats.tsx +++ b/src/components/settings/settings-chats/settings-chats.tsx @@ -4,7 +4,6 @@ import { Switch } from "#/components/ui/switch"; import { useSettings } from "#/hooks/use-settings"; import { setEnterToSend, - setReadReceipts, setTypingIndicators, } from "#/lib/settings/preferences"; @@ -38,17 +37,6 @@ export function SettingsChats() { /> } /> - - } - /> ); } diff --git a/src/components/settings/settings-omemo-card/settings-omemo-card.tsx b/src/components/settings/settings-omemo-card/settings-omemo-card.tsx index cd7ef57..0194064 100644 --- a/src/components/settings/settings-omemo-card/settings-omemo-card.tsx +++ b/src/components/settings/settings-omemo-card/settings-omemo-card.tsx @@ -8,6 +8,8 @@ import { import { type FormEvent, useState } from "react"; import { toast } from "react-hot-toast"; import { ContactInfoOmemo } from "#/components/chat/conversation/header/contact-info-panel/contact-info-omemo"; +import { InfoTooltip } from "#/components/common/info-tooltip/info-tooltip"; +import { SettingsOmemoResetDialog } from "#/components/settings/settings-omemo-reset-dialog/settings-omemo-reset-dialog"; import { Badge } from "#/components/ui/badge"; import { Button } from "#/components/ui/button"; import { Input } from "#/components/ui/input"; @@ -51,12 +53,26 @@ export function SettingsOmemoCard() { const [passphrase, setPassphrase] = useState(""); const [confirmation, setConfirmation] = useState(""); const [busy, setBusy] = useState(false); + const [maintenance, setMaintenance] = useState(false); + const [resetOpen, setResetOpen] = useState(false); const setupMode = omemo.status === "setup"; const unlockMode = omemo.status === "locked"; const ready = omemo.status === "ready"; const working = busy || omemo.status === "unlocking"; + async function repairKeys() { + setMaintenance(true); + try { + const ok = await omemo.repair(); + if (ok) { + toast.success("Device keys republished.", { id: "omemo-repair" }); + } + } finally { + setMaintenance(false); + } + } + async function submitSetup(event: FormEvent) { event.preventDefault(); const issue = passphraseIssue(passphrase); @@ -109,21 +125,25 @@ export function SettingsOmemoCard() { {STATUS_LABELS[omemo.status]} -

- Experimental and unaudited. Direct messages only. Encryption is off - by default and never falls back to plaintext. +

+ Experimental · unaudited · direct chats only

-
- -

+

+ +

{security.rememberOmemoUnlock - ? "Your passphrase is never stored. This browser keeps a non-exportable key that unlocks OMEMO until you lock, log out, or the session auto-locks. It expires after 24 hours. The passphrase cannot be recovered if lost." - : "Your passphrase is never stored and cannot be recovered. Losing it locks this device's OMEMO keys."}{" "} - Sessions lock after 10 minutes of inactivity. + ? "Passphrase cannot be recovered. Remembered unlock expires after 24 hours." + : "Passphrase cannot be recovered. Sessions lock after 10 minutes."}

+ + The passphrase is never stored.{" "} + {security.rememberOmemoUnlock + ? "This browser keeps a non-exportable key until you lock, log out, or the session auto-locks." + : "Losing it locks this device's OMEMO keys."} +
{omemo.status === "disabled" && ( @@ -215,16 +235,35 @@ export function SettingsOmemoCard() { button in the composer, then trust the contact's device in their contact info before sending.

- +
+ + + +
{ownJid && ( )} + +
); } diff --git a/src/components/settings/settings-omemo-card/settings-omemo-preferences.tsx b/src/components/settings/settings-omemo-card/settings-omemo-preferences.tsx index 4b920b0..1e46189 100644 --- a/src/components/settings/settings-omemo-card/settings-omemo-preferences.tsx +++ b/src/components/settings/settings-omemo-card/settings-omemo-preferences.tsx @@ -1,42 +1,89 @@ +import { useEffect, useRef } from "react"; +import { toast } from "react-hot-toast"; import { SettingRow } from "#/components/settings/setting-row/setting-row"; -import { - NativeSelect, - NativeSelectOption, -} from "#/components/ui/native-select"; import { Switch } from "#/components/ui/switch"; import { useSettings } from "#/hooks/use-settings"; import { setDefaultEncryption, setRememberOmemoUnlock, } from "#/lib/settings/preferences"; +import { useOmemo } from "#/providers/omemo-provider"; -/** Default encryption behavior and tab-scoped unlock preference. */ +/** + * Guided default-encryption switch. Turning it on before OMEMO is ready moves + * the user to setup/unlock and only saves the default once OMEMO can encrypt. + */ export function SettingsOmemoPreferences() { const { security } = useSettings(); + const omemo = useOmemo(); + const pendingDefault = useRef(false); + const enabled = security.defaultEncryption === "omemo"; + const ready = omemo.status === "ready"; + const connectable = omemo.status !== "disabled"; + + useEffect(() => { + if (!pendingDefault.current || omemo.status !== "ready") return; + pendingDefault.current = false; + setDefaultEncryption("omemo"); + toast.success("New direct chats will start encrypted.", { + id: "omemo-default", + }); + }, [omemo.status]); + + function toggleDefault(next: boolean) { + if (!connectable) return; + if (!next) { + pendingDefault.current = false; + setDefaultEncryption("none"); + return; + } + if (ready) { + setDefaultEncryption("omemo"); + return; + } + pendingDefault.current = true; + toast("Set up or unlock OMEMO to turn this on.", { + id: "omemo-default", + }); + window.requestAnimationFrame(() => { + document + .getElementById("settings-omemo") + ?.scrollIntoView({ behavior: "smooth", block: "center" }); + document + .querySelector( + "#omemo-setup-passphrase, #omemo-unlock-passphrase", + ) + ?.focus(); + }); + } + + const description = !connectable + ? "Connect to XMPP first." + : !enabled + ? "Off. Turning this on will ask you to set up OMEMO." + : ready + ? "On. New one-to-one chats start encrypted." + : omemo.status === "locked" + ? "On. Unlock OMEMO to send encrypted messages." + : "On. Finish setting up OMEMO to send encrypted messages."; return (
{ - setDefaultEncryption( - event.target.value === "omemo" ? "omemo" : "none", - ); - }} - > - None - OMEMO - + } /> void; +}) { + const omemo = useOmemo(); + const [passphrase, setPassphrase] = useState(""); + const [confirmation, setConfirmation] = useState(""); + const [busy, setBusy] = useState(false); + + function changeOpen(next: boolean) { + if (busy) return; + if (!next) { + setPassphrase(""); + setConfirmation(""); + } + onOpenChange(next); + } + + async function submit(event: FormEvent) { + event.preventDefault(); + const issue = passphraseIssue(passphrase); + if (issue) { + toast.error(passphraseIssueMessage(issue), { id: "omemo-reset" }); + return; + } + if (passphrase !== confirmation) { + toast.error("The passphrases do not match.", { id: "omemo-reset" }); + return; + } + setBusy(true); + try { + const ok = await omemo.reset(passphrase); + if (!ok) return; + toast.success("OMEMO reinitialized with a new identity.", { + id: "omemo-reset", + }); + setPassphrase(""); + setConfirmation(""); + onOpenChange(false); + } finally { + setBusy(false); + } + } + + return ( + + + + Reset OMEMO? + + This replaces your encryption identity and cannot be undone. + + + +
+ +
    +
  • Your device ID and fingerprint change.
  • +
  • Contacts must verify your new device before trusting it.
  • +
  • Old encrypted history on this device cannot be read.
  • +
  • Remembered unlock is cleared.
  • +
+
+ +
+ + + New passphrase + + setPassphrase(event.target.value)} + placeholder={`At least ${MIN_PASSPHRASE_LENGTH} characters`} + disabled={busy} + /> + + + + Confirm new passphrase + + setConfirmation(event.target.value)} + placeholder="Repeat the passphrase" + disabled={busy} + /> + +
+ + +
+
+ + +
+
+ ); +} diff --git a/src/components/settings/settings-security/settings-security.tsx b/src/components/settings/settings-security/settings-security.tsx index ede4645..6f7ab25 100644 --- a/src/components/settings/settings-security/settings-security.tsx +++ b/src/components/settings/settings-security/settings-security.tsx @@ -48,21 +48,18 @@ export function SettingsSecurity({ anchor }: { anchor?: "omemo" }) { title="Transport" value="TLS" tone="default" - description="Messages travel over WSS or HTTPS when your server uses a secure endpoint." /> } title="End-to-end encryption" value="Opt-in" tone="secondary" - description="OMEMO can be enabled per direct conversation after you set up and unlock it below." /> } title="OpenPGP" value="Deferred" tone="outline" - description="XEP-0374 support is a later interoperability option, not enabled here." />
@@ -78,9 +75,8 @@ export function SettingsSecurity({ anchor }: { anchor?: "omemo" }) {

- Do not use this development build for sensitive conversations. TLS - protects the connection, not the message contents. OMEMO is experimental - and has not been independently audited. + Development preview. OMEMO is experimental and unaudited. Do not use + this build for sensitive conversations.

); @@ -90,28 +86,21 @@ function StatusRow({ icon, title, value, - description, tone, }: { icon: ReactNode; title: string; value: string; - description: string; tone: "default" | "secondary" | "outline" | "destructive"; }) { return ( -
+
{icon} -
-
-

{title}

- {value} -
-

- {description} -

+
+

{title}

+ {value}
); diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 613abec..7c04260 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -42,17 +42,29 @@ function DialogContent({ className, children, showCloseButton = true, + nestedOverlay = false, ...props }: DialogPrimitive.Popup.Props & { showCloseButton?: boolean; + /** Dims a parent dialog; Base UI suppresses inner backdrops for nested dialogs. */ + nestedOverlay?: boolean; }) { return ( - + {nestedOverlay ? ( +