diff --git a/e2e/security-nudge.spec.ts b/e2e/security-nudge.spec.ts new file mode 100644 index 000000000..f9dcd73c9 --- /dev/null +++ b/e2e/security-nudge.spec.ts @@ -0,0 +1,74 @@ +import { test, expect, Page } from '@playwright/test'; +import { HS, ensureUser, hsReachable, loginUI, TestUser, uniq } from './localHs'; + +// [Gitea #110, #123] The device-security nudge. A fresh account has no key +// backup and no cross-signing → "Set up key backup", but only after the 24 h +// grace period on this device; "Not now" snoozes it. +const strip = (page: Page) => page.getByRole('status').filter({ hasText: /encryption keys/ }); + +/** Back-date this device's first-seen record past the 24 h grace period, then reload. */ +async function pastGrace(page: Page) { + await page.evaluate(() => { + const { deviceId } = JSON.parse(localStorage.getItem('cinny_session_v1') ?? '{}'); + localStorage.setItem( + `lotus-security-nudge-${deviceId}`, + JSON.stringify({ firstSeen: Date.now() - 2 * 86_400_000, dismissals: {} }), + ); + }); + await page.reload(); +} + +/** The nudge waits until sync has settled (after "Connecting…"). */ +async function settled(page: Page) { + await page + .getByText('Connecting...') + .first() + .waitFor({ timeout: 30_000 }) + .catch(() => undefined); + await page.getByText('Connecting...').first().waitFor({ state: 'detached', timeout: 90_000 }); + await page.waitForTimeout(2000); +} + +test.describe('security nudge (#110, #123)', () => { + let user: TestUser; + + test.beforeAll(async () => { + test.skip(!(await hsReachable()), `no local homeserver at ${HS} (set E2E_LOCAL_HS)`); + }); + + test.beforeEach(async () => { + user = await ensureUser(uniq('e2e_nudge_')); + }); + + test('nothing during the first 24 h on a device', async ({ page }) => { + test.setTimeout(150_000); + await loginUI(page, user); + await settled(page); + // Past the point where the nudge shows once the grace period is over (~5 s). + await page.waitForTimeout(8000); + await expect(strip(page)).toHaveCount(0); + }); + + test('no key backup: "Set up key backup" opens Settings → Devices', async ({ page }) => { + test.setTimeout(150_000); + await loginUI(page, user); + await pastGrace(page); + await settled(page); + const nudge = strip(page); + await expect(nudge).toContainText("Your encryption keys aren't backed up"); + await nudge.getByRole('button', { name: 'Set up' }).click(); + await expect(page.getByText('Device Verification').first()).toBeVisible(); + }); + + test('"Not now" snoozes it across a reload', async ({ page }) => { + test.setTimeout(200_000); + await loginUI(page, user); + await pastGrace(page); + await settled(page); + await strip(page).getByRole('button', { name: 'Not now' }).click(); + await expect(strip(page)).toHaveCount(0); + await page.reload(); + await settled(page); + await expect(strip(page)).toHaveCount(0); + }); +}); diff --git a/src/app/features/security-nudge/SecurityBanner.tsx b/src/app/features/security-nudge/SecurityBanner.tsx new file mode 100644 index 000000000..bfe7f23d4 --- /dev/null +++ b/src/app/features/security-nudge/SecurityBanner.tsx @@ -0,0 +1,137 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useAtomValue, useSetAtom } from 'jotai'; +import { SyncState } from 'matrix-js-sdk'; +import { CryptoApi } from 'matrix-js-sdk/lib/crypto-api'; +import { Icons } from 'folds'; +import { ErrorBoundary } from 'react-error-boundary'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useCrossSigningActive } from '../../hooks/useCrossSigning'; +import { + useDeviceVerificationStatus, + VerificationStatus, +} from '../../hooks/useDeviceVerificationStatus'; +import { useKeyBackupInfo, useKeyBackupStatusChange } from '../../hooks/useKeyBackup'; +import { useAlive } from '../../hooks/useAlive'; +import { useSyncState } from '../../hooks/useSyncState'; +import { settingsRequestAtom } from '../../state/settingsRequest'; +import { serverStatusAtom } from '../../state/serverStatus'; +import { pickBanner } from '../../utils/kumaStatus'; +import { + dismissNudge, + NUDGE_COPY, + NUDGE_KEY_PREFIX, + nudgeFor, + NudgeRecord, + parseNudgeRecord, + shouldShowNudge, +} from '../../utils/securityNudge'; +import { StatusStrip } from '../server-status/ServerStatusBanner'; + +/** This device's backup connection: undefined until known, then true/false. */ +const useBackupActive = (crypto: CryptoApi): boolean | undefined => { + const alive = useAlive(); + const [active, setActive] = useState(); + useEffect(() => { + crypto.getActiveSessionBackupVersion().then((v) => { + if (alive()) setActive(typeof v === 'string'); + }); + }, [crypto, alive]); + useKeyBackupStatusChange(useCallback((enabled: boolean) => setActive(enabled), [])); + return active; +}; + +const readRecord = (key: string): NudgeRecord => { + let raw: string | null = null; + try { + raw = localStorage.getItem(key); + } catch { + /* storage unavailable */ + } + const record = parseNudgeRecord(raw, Date.now()); + if (!raw) { + try { + localStorage.setItem(key, JSON.stringify(record)); + } catch { + /* best effort */ + } + } + return record; +}; + +function SecurityBannerFor({ crypto }: { crypto: CryptoApi }) { + const mx = useMatrixClient(); + const deviceId = mx.getDeviceId() ?? undefined; + const crossSigning = useCrossSigningActive(); + const status = useDeviceVerificationStatus(crypto, mx.getSafeUserId(), deviceId); + const backupInfo = useKeyBackupInfo(crypto); + const backupActive = useBackupActive(crypto); + const { status: serverStatus, syncLost } = useAtomValue(serverStatusAtom); + const requestSettings = useSetAtom(settingsRequestAtom); + // Not urgent: wait until sync has settled (two SYNCING in a row), so it + // never stacks under the "Connecting…" strip at startup. + const [settled, setSettled] = useState(false); + useSyncState( + mx, + useCallback((state: SyncState | null, prev?: SyncState | null) => { + setSettled(state === SyncState.Syncing && prev === SyncState.Syncing); + }, []), + ); + + const key = `${NUDGE_KEY_PREFIX}${deviceId ?? 'unknown'}`; + const [record, setRecord] = useState(() => readRecord(key)); + + let deviceVerified: boolean | undefined; + if (status === VerificationStatus.Verified) deviceVerified = true; + else if (status === VerificationStatus.Unverified) deviceVerified = false; + const kind = nudgeFor({ + crossSigning, + deviceVerified, + backupOnServer: backupInfo === undefined ? undefined : backupInfo !== null, + backupActive, + }); + + // One strip at a time: outages, maintenance and a lost connection win. + const otherStrip = syncLost || !!pickBanner(serverStatus, { syncLost, where: 'client' }); + if (!settled || !deviceId || !kind || otherStrip || !shouldShowNudge(kind, record, Date.now())) + return null; + + const copy = NUDGE_COPY[kind]; + const dismiss = () => { + const next = dismissNudge(kind, record, Date.now()); + try { + localStorage.setItem(key, JSON.stringify(next)); + } catch { + /* dismissed for this session only */ + } + setRecord(next); + }; + return ( + requestSettings('devices') }} + dismissLabel="Not now" + onDismiss={dismiss} + /> + ); +} + +/** + * [Gitea #110, #123] The device-security nudge: verify this device, connect + * it to the key backup, or set up key backup. See utils/securityNudge.ts. + */ +function SecurityBannerInner() { + const mx = useMatrixClient(); + const crypto = mx.getCrypto(); + if (!crypto) return null; + return ; +} + +// A nudge must never take the app down: any error just hides it. +export function SecurityBanner() { + return ( + + + + ); +} diff --git a/src/app/features/server-status/ServerStatusBanner.tsx b/src/app/features/server-status/ServerStatusBanner.tsx index 62d8b689b..b59e45fde 100644 --- a/src/app/features/server-status/ServerStatusBanner.tsx +++ b/src/app/features/server-status/ServerStatusBanner.tsx @@ -22,7 +22,7 @@ const readDismissed = (): string[] => { } }; -const bannerIcon = (b: StatusBanner): IconSrc => { +const bannerIcon = (b: StripContent): IconSrc => { if (b.kind === 'maintenance') return Icons.Setting; if (b.tone === 'Primary') return Icons.Info; return Icons.Warning; @@ -32,12 +32,28 @@ const bannerIcon = (b: StatusBanner): IconSrc => { * [Gitea #124] One status strip, in the same slot and style as the sync and * clock banners. Presentational: the caller picks the banner. */ +export type StripContent = Pick< + StatusBanner, + 'key' | 'tone' | 'text' | 'detail' | 'dismissable' +> & { + kind?: StatusBanner['kind']; +}; + export function StatusStrip({ banner, onDismiss, + icon, + action, + dismissLabel, }: { - banner: StatusBanner; + banner: StripContent; onDismiss?: () => void; + /** Overrides the icon picked from the banner's tone/kind. */ + icon?: IconSrc; + /** A primary action button (e.g. "Verify"). */ + action?: { label: string; onClick: () => void }; + /** A text button instead of the ✕ for dismissing (e.g. "Not now"). */ + dismissLabel?: string; }) { const [open, setOpen] = useState(false); const mobile = useScreenSizeContext() === ScreenSize.Mobile; @@ -59,8 +75,8 @@ export function StatusStrip({ gap="200" style={{ minHeight: config.size.X300 }} > - - + + {banner.text} {banner.detail && ( @@ -78,7 +94,31 @@ export function StatusStrip({ )} - {banner.dismissable && onDismiss && ( + {action && ( + + )} + {banner.dismissable && onDismiss && dismissLabel && ( + + )} + {banner.dismissable && onDismiss && !dismissLabel && ( ) : ( + {/* [Gitea #110/#123] Needs the client context (its hooks read it). */} + {!syncError && } {(serverConfigs) => ( diff --git a/src/app/state/plaintextCaches.ts b/src/app/state/plaintextCaches.ts index 83f640c0c..d707e4127 100644 --- a/src/app/state/plaintextCaches.ts +++ b/src/app/state/plaintextCaches.ts @@ -8,6 +8,7 @@ import { clearNavToActivePathStore } from './navToActivePath'; import { DRAFT_MSG_KEY_PREFIX } from '../utils/draft'; import { clearCallSession } from '../utils/callRejoin'; import { clearOutbox } from '../utils/outbox'; +import { NUDGE_KEY_PREFIX } from '../utils/securityNudge'; /** * [Gitea #41] Wipe every persisted composer draft (`draft-msg-`). Drafts @@ -77,7 +78,11 @@ const clearStatusMessage = (): void => { return; } keys.forEach((key) => { - if (key.startsWith('lotus-status-msg-') || key.startsWith('lotus-status-expiry-')) { + if ( + key.startsWith('lotus-status-msg-') || + key.startsWith('lotus-status-expiry-') || + key.startsWith(NUDGE_KEY_PREFIX) + ) { try { localStorage.removeItem(key); } catch { diff --git a/src/app/utils/securityNudge.test.ts b/src/app/utils/securityNudge.test.ts new file mode 100644 index 000000000..38be5a441 --- /dev/null +++ b/src/app/utils/securityNudge.test.ts @@ -0,0 +1,81 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + dismissNudge, + NUDGE_GRACE_MS, + NUDGE_SNOOZE_MS, + nudgeFor, + parseNudgeRecord, + SecurityState, + shouldShowNudge, +} from './securityNudge'; + +const healthy: SecurityState = { + crossSigning: true, + deviceVerified: true, + backupOnServer: true, + backupActive: true, +}; + +test('the #123 table: which nudge for which state', () => { + // E: healthy → nothing, ever. + assert.equal(nudgeFor(healthy), undefined); + // B: cross-signing, this device unverified, backup on server → verify first. + assert.equal(nudgeFor({ ...healthy, deviceVerified: false }), 'verify-device'); + // C: cross-signing, unverified, no backup → still verify first. + assert.equal( + nudgeFor({ ...healthy, deviceVerified: false, backupOnServer: false }), + 'verify-device', + ); + // D: verified, no backup → #110's set-up nudge. + assert.equal(nudgeFor({ ...healthy, backupOnServer: false }), 'setup-backup'); + // #110 state 2: backup exists, this device not using it. + assert.equal(nudgeFor({ ...healthy, backupActive: false }), 'connect-backup'); + // A: never set up cross-signing, no backup → set up (the flow does both). + assert.equal( + nudgeFor({ + crossSigning: false, + deviceVerified: false, + backupOnServer: false, + backupActive: false, + }), + 'setup-backup', + ); +}); + +test('nothing while crypto is still loading (F)', () => { + assert.equal(nudgeFor({ ...healthy, deviceVerified: undefined }), undefined); + assert.equal(nudgeFor({ ...healthy, backupOnServer: undefined }), undefined); + assert.equal(nudgeFor({ ...healthy, backupActive: undefined }), undefined); + // Unknown backup connection doesn't hide a known "no backup at all". + assert.equal( + nudgeFor({ ...healthy, backupOnServer: false, backupActive: undefined }), + 'setup-backup', + ); +}); + +test('timing: 24 h grace, 7-day snooze, stop after 3 dismissals', () => { + const t0 = 1_700_000_000_000; + let record = parseNudgeRecord(null, t0); + assert.equal(shouldShowNudge('verify-device', record, t0), false, 'first launch'); + assert.equal(shouldShowNudge('verify-device', record, t0 + NUDGE_GRACE_MS - 1), false); + let now = t0 + NUDGE_GRACE_MS; + assert.equal(shouldShowNudge('verify-device', record, now), true, 'after 24 h'); + for (let i = 1; i <= 3; i += 1) { + record = dismissNudge('verify-device', record, now); + assert.equal(shouldShowNudge('verify-device', record, now), false, `snoozed after #${i}`); + now += NUDGE_SNOOZE_MS; + assert.equal(shouldShowNudge('verify-device', record, now), i < 3, `after 7 days (#${i})`); + } + // Dismissing one nudge doesn't hide another. + assert.equal(shouldShowNudge('setup-backup', record, now), true); +}); + +test('stored record: round trip, and garbage starts a fresh grace period', () => { + const t0 = 1_700_000_000_000; + const r = dismissNudge('setup-backup', parseNudgeRecord(null, t0 - 5), t0); + assert.deepEqual(parseNudgeRecord(JSON.stringify(r), t0 + 1), r); + for (const bad of ['{', 'null', '"x"', '{"firstSeen":"yesterday"}', '{"firstSeen":1}']) { + assert.deepEqual(parseNudgeRecord(bad, t0), { firstSeen: t0, dismissals: {} }, bad); + } +}); diff --git a/src/app/utils/securityNudge.ts b/src/app/utils/securityNudge.ts new file mode 100644 index 000000000..6bfa89836 --- /dev/null +++ b/src/app/utils/securityNudge.ts @@ -0,0 +1,94 @@ +/** + * [Gitea #110, #123] One "security" nudge for this device, in priority order: + * + * - verify-device: the account has cross-signing but THIS device isn't signed. + * It can't unlock backed-up history and shows as untrusted to others. + * Verifying with the recovery key also connects the backup, so it comes first. + * - connect-backup: a key backup exists on the server but this device isn't + * backing up to it (`getActiveSessionBackupVersion()` null). + * - setup-backup: no key backup at all (includes accounts that never set up + * cross-signing: the setup flow does both). + * + * Anything not yet known (crypto still loading) → no nudge. Timing: not in + * the first 24 h on a device (onboarding), "Not now" snoozes 7 days, three + * dismissals of a nudge stop it for good; a healthy device never sees one. + */ + +export type NudgeKind = 'verify-device' | 'connect-backup' | 'setup-backup'; + +export type SecurityState = { + /** m.cross_signing.master account data present. */ + crossSigning: boolean; + /** This device cross-signing-verified; undefined while crypto isn't ready. */ + deviceVerified: boolean | undefined; + /** A key backup version on the server; undefined while loading. */ + backupOnServer: boolean | undefined; + /** This device is backing up to it; undefined while loading. */ + backupActive: boolean | undefined; +}; + +export const nudgeFor = (s: SecurityState): NudgeKind | undefined => { + if (s.crossSigning) { + if (s.deviceVerified === undefined) return undefined; + if (!s.deviceVerified) return 'verify-device'; + } + if (s.backupOnServer === undefined) return undefined; + if (!s.backupOnServer) return 'setup-backup'; + if (s.backupActive === undefined) return undefined; + return s.backupActive ? undefined : 'connect-backup'; +}; + +export const NUDGE_GRACE_MS = 24 * 60 * 60 * 1000; +export const NUDGE_SNOOZE_MS = 7 * 24 * 60 * 60 * 1000; +export const NUDGE_MAX_DISMISSALS = 3; + +export type NudgeRecord = { + /** When this device was first seen by the nudge (ms). */ + firstSeen: number; + dismissals: Partial>; +}; + +export const shouldShowNudge = (kind: NudgeKind, record: NudgeRecord, now: number): boolean => { + if (now - record.firstSeen < NUDGE_GRACE_MS) return false; + const d = record.dismissals[kind]; + if (!d) return true; + return d.count < NUDGE_MAX_DISMISSALS && now - d.last >= NUDGE_SNOOZE_MS; +}; + +export const dismissNudge = (kind: NudgeKind, record: NudgeRecord, now: number): NudgeRecord => ({ + ...record, + dismissals: { + ...record.dismissals, + [kind]: { count: (record.dismissals[kind]?.count ?? 0) + 1, last: now }, + }, +}); + +export const NUDGE_KEY_PREFIX = 'lotus-security-nudge-'; + +/** Parse a stored record; anything unexpected starts a fresh one (grace period from now). */ +export const parseNudgeRecord = (raw: string | null, now: number): NudgeRecord => { + try { + const v = JSON.parse(raw ?? ''); + if (v && typeof v.firstSeen === 'number' && v.dismissals && typeof v.dismissals === 'object') { + return { firstSeen: v.firstSeen, dismissals: v.dismissals }; + } + } catch { + /* fresh record below */ + } + return { firstSeen: now, dismissals: {} }; +}; + +export const NUDGE_COPY: Record = { + 'verify-device': { + text: 'Verify this device so you can read your older encrypted messages and others see it as trusted.', + action: 'Verify', + }, + 'connect-backup': { + text: "This device isn't using your key backup yet. Connect it so your encrypted messages stay readable if you lose it.", + action: 'Connect', + }, + 'setup-backup': { + text: "Your encryption keys aren't backed up. Set up key backup so you don't lose your encrypted messages if you log out or lose this device.", + action: 'Set up', + }, +};