/** * [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). * * - stale-sessions (#113, lowest priority): sessions of this account not used * for 90+ days; Review opens Settings → Privacy & security with them ticked to sign out. * * 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' | 'stale-sessions'; 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'; }; /** [Gitea #113] A session counts as stale after this long unused. */ export const STALE_SESSION_MS = 90 * 24 * 60 * 60 * 1000; type SessionLike = { device_id: string; last_seen_ts?: number | null }; /** * Pure: other sessions of this account not seen for STALE_SESSION_MS. Never * the current session; a session with no last-seen time is left alone (we * can't tell it's unused). */ export const staleSessions = ( devices: T[], currentDeviceId: string | undefined, now: number, ): T[] => devices.filter( (d) => d.device_id !== currentDeviceId && typeof d.last_seen_ts === 'number' && now - d.last_seen_ts >= STALE_SESSION_MS, ); /** "1 session" / "3 sessions you haven't used in 3 months". */ export const staleSessionsText = (count: number): string => `You have ${count === 1 ? 'a session' : `${count} sessions`} you haven't used in 3 months. Signing ${ count === 1 ? 'it' : 'them' } out keeps your account safe if an old device is lost.`; /** * The nudge to show: the device-security one first, then stale sessions, each * only if its own snooze allows (so a snoozed security nudge doesn't hide the * stale-sessions one, and vice versa). */ export const pickNudge = ( security: NudgeKind | undefined, staleCount: number, record: NudgeRecord, now: number, ): NudgeKind | undefined => { const candidates: NudgeKind[] = []; if (security) candidates.push(security); if (staleCount > 0) candidates.push('stale-sessions'); return candidates.find((k) => shouldShowNudge(k, record, now)); }; 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', }, 'stale-sessions': { // The text depends on the count: see staleSessionsText. text: '', action: 'Review', }, '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', }, };