95 lines
3.7 KiB
TypeScript
95 lines
3.7 KiB
TypeScript
/**
|
|||
|
|
* [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<Record<NudgeKind, { count: number; last: number }>>;
|
||
|
|
};
|
||
|
|
|
||
|
|
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<NudgeKind, { text: string; action: string }> = {
|
||
|
|
'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',
|
||
|
|
},
|
||
|
|
};
|