Files
webmail/lib/devices/client.ts
T
Linus Rath 940a572844 feat: hide an account's mail when its login is refused, until the new password or 30 days
A refused login used to delete the account's data, and with it everyone's
S/MIME and OpenPGP keys, also with offline off. The usual cause is a password
changed on another device, which Stalwart 0.16 and 1.0 both answer with
`invalid_grant`, so that lost unsent mail for little protection.

- A refused login signs out at once and hides the data. A new password brings
  it back, unsent mail included. Otherwise it goes after 30 days. The sign-in
  page gives a count and a date. The 24-hour timer is gone.
- `organisation.devices.wipeOnRefusedLogin`, off by default, asks for deletion
  at once, of that account's own data only.
- The session limit no longer counts as a refusal on any route. It hides the
  data and says nothing.
- A wipe runs under a Web Lock, tells other windows when it starts and ends,
  and queues its report before deleting anything. It reports only once the
  deletion worked and no plugin database is left.
- More goes with a wipe: Pro tabs, search history, Files and WebDAV state,
  templates, pop-out drafts, shown notifications, push state, and Web Push
  with the last account.
- The unsent count is per account, and unknown rather than 0 when the store
  can't say. A new row is checked against the account that asked, and a
  sign-out drops its row.

Existing tests changed because the rule did: `auth-store-keep-local.test.ts`
expected a wipe on `invalid_grant` and at the session limit, and
`device-wipe.test.ts` expected shared keys to go on a refused login.
2026-11-03 15:11:47 +01:00

103 lines
4.2 KiB
TypeScript

/**
* This device's calls to the instance's registry. Only a clear answer counts:
* a wipe needs a 200 with a `wipe` in it, and "forgotten" needs the route's own
* word. An older instance without the routes, a proxy's error page or no
* network at all reads as `unavailable`, and nothing happens.
*/
import { apiFetch } from '@/lib/browser-navigation';
import { getDesktopBridge } from '@/lib/desktop/bridge';
import type { DeviceRecord } from '@/lib/store/client/keys';
import type { WipeConfirmation } from './wipe-state';
export type CheckInAnswer = 'wipe' | 'wiped' | 'clear' | 'forgotten' | 'unavailable';
/** A new row and who the instance gave it to: the device checks that's the account it asked for. */
export interface RegisteredRow {
id: string;
secret: string;
user: string;
server: string;
}
/** Short enough for one row in a list. Nothing identifying beyond what a browser sends anyway. */
export function describePlatform(): string {
const bridge = getDesktopBridge();
if (bridge) return `Desktop app, ${({ win32: 'Windows', darwin: 'macOS', linux: 'Linux' } as const)[bridge.platform] ?? bridge.platform}`;
if (typeof navigator === 'undefined') return '';
const ua = navigator.userAgent;
const os = /Android/.test(ua) ? 'Android'
: /iPhone|iPad/.test(ua) ? 'iOS'
: /Windows/.test(ua) ? 'Windows'
: /Mac OS X/.test(ua) ? 'macOS'
: /Linux/.test(ua) ? 'Linux'
: 'Unknown system';
const browser = /Edg\//.test(ua) ? 'Edge'
: /Firefox\//.test(ua) ? 'Firefox'
: /Chrome\//.test(ua) ? 'Chrome'
: /Safari\//.test(ua) ? 'Safari'
: 'Browser';
return `${browser}, ${os}`;
}
function deviceInfo() {
return { platform: describePlatform(), appVersion: process.env.NEXT_PUBLIC_APP_VERSION ?? '' };
}
async function readJson(response: Response): Promise<Record<string, unknown> | null> {
try {
const body = await response.clone().json() as unknown;
return body && typeof body === 'object' ? body as Record<string, unknown> : null;
} catch {
return null;
}
}
function post(path: string, body: unknown, signal?: AbortSignal, headers: Record<string, string> = {}): Promise<Response> {
return apiFetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify(body),
signal,
});
}
/** A new row, for the account signed in on this cookie slot. Null if that can't be done now. */
export async function registerWithInstance(slot: number, signal?: AbortSignal): Promise<RegisteredRow | null> {
try {
const response = await post('/api/devices', deviceInfo(), signal, { 'X-JMAP-Cookie-Slot': String(slot) });
if (!response.ok) return null;
const body = await readJson(response);
if (typeof body?.id !== 'string' || typeof body.secret !== 'string' || typeof body.user !== 'string' || typeof body.server !== 'string') return null;
return { id: body.id, secret: body.secret, user: body.user, server: body.server };
} catch {
return null;
}
}
export async function checkInWithInstance(record: Pick<DeviceRecord, 'id' | 'secret'>, signal?: AbortSignal): Promise<CheckInAnswer> {
try {
const response = await post('/api/devices', { id: record.id, secret: record.secret, ...deviceInfo() }, signal);
const body = await readJson(response);
if (response.status === 404) return body?.error === 'unknown_device' ? 'forgotten' : 'unavailable';
if (!response.ok || !body || !('wipe' in body)) return 'unavailable';
if (body.wipe && typeof body.wipe === 'object') return 'wipe';
return body.wiped === true ? 'wiped' : 'clear';
} catch {
return 'unavailable';
}
}
/** True when there's nothing more to say: it was taken, or the row no longer exists. */
export async function reportWipeToInstance(entry: WipeConfirmation, signal?: AbortSignal): Promise<boolean> {
try {
const { id, secret, reason, unsent, drop } = entry;
const response = await post('/api/devices/wiped', drop ? { id, secret, drop: true } : { id, secret, reason, unsent }, signal);
if (response.ok) return true;
const error = (await readJson(response))?.error;
return (response.status === 404 && error === 'unknown_device') || (response.status === 409 && error === 'no_wipe_asked');
} catch {
return false;
}
}