fix: log a device wipe only when its row changes, and keep rows from piling up

This commit is contained in:
Linus Rath
2026-11-03 15:11:44 +01:00
parent bd7d12f801
commit d1227bfac6
6 changed files with 378 additions and 28 deletions
@@ -0,0 +1,91 @@
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { NextRequest } from 'next/server';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// The real admin check, not a stand-in: a wipe request from another site carries
// the admin's cookie, and only the same-origin check stands in its way.
const jar = vi.hoisted(() => new Map<string, string>());
vi.mock('next/headers', () => ({
cookies: async () => ({
get: (name: string) => (jar.has(name) ? { name, value: jar.get(name) } : undefined),
set: (name: string, value: string) => { jar.set(name, value); },
delete: (name: string) => { jar.delete(name); },
}),
}));
let dir: string;
function post(body: unknown, headers: Record<string, string>): NextRequest {
return new NextRequest('https://mail.example.org/api/admin/devices', {
method: 'POST',
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json', ...headers },
});
}
function audit(): string {
try {
return readFileSync(path.join(dir, 'audit.log'), 'utf8');
} catch {
return '';
}
}
describe('/api/admin/devices with the real admin check', () => {
let route: typeof import('../route');
let registry: typeof import('@/lib/devices/registry');
let row: { id: string };
beforeEach(async () => {
vi.resetModules();
dir = mkdtempSync(path.join(tmpdir(), 'bw-device-admin-'));
vi.stubEnv('ADMIN_STATE_DIR', dir);
vi.stubEnv('ADMIN_CONFIG_DIR', dir);
vi.stubEnv('SESSION_SECRET', 'x'.repeat(64));
jar.clear();
route = await import('../route');
registry = await import('@/lib/devices/registry');
row = await registry.registerDevice('alice@example.org', 'https://mail.example.org', { platform: 'x', appVersion: '1' });
const session = await import('@/lib/admin/session');
const { getAdminSessionGeneration } = await import('@/lib/admin/password');
jar.set('admin_session', session.createAdminSession(await getAdminSessionGeneration()));
});
afterEach(() => {
vi.unstubAllEnvs();
rmSync(dir, { recursive: true, force: true });
});
it.each([
['another site says so', { 'sec-fetch-site': 'cross-site' }],
['a page on another origin', { origin: 'https://evil.example.net' }],
])('refuses a wipe with the admin\'s own cookie when %s', async (_name, headers) => {
const response = await route.POST(post({ action: 'wipe', id: row.id }, headers));
expect(response.status).toBe(403);
expect((await registry.getDevice(row.id))?.wipe).toBeNull();
expect(audit()).toBe('');
});
it('refuses wiping a whole user from another site', async () => {
const response = await route.POST(post({ action: 'wipe-user', userKey: row.id.split('/')[0] }, { 'sec-fetch-site': 'cross-site' }));
expect(response.status).toBe(403);
expect((await registry.getDevice(row.id))?.wipe).toBeNull();
});
it('takes it from the same origin with the cookie', async () => {
const response = await route.POST(post({ action: 'wipe', id: row.id }, { 'sec-fetch-site': 'same-origin' }));
expect(response.status).toBe(200);
expect((await registry.getDevice(row.id))?.wipe).toMatchObject({ by: 'admin' });
expect(audit()).toContain('device.wipe.request');
});
it('refuses without the cookie, same origin or not', async () => {
jar.clear();
const response = await route.POST(post({ action: 'wipe', id: row.id }, { 'sec-fetch-site': 'same-origin' }));
expect(response.status).toBe(401);
expect((await registry.getDevice(row.id))?.wipe).toBeNull();
});
});
+120 -3
View File
@@ -8,15 +8,17 @@ const creds = vi.hoisted(() => ({ value: null as null | { accountName: string; s
vi.mock('@/lib/stalwart/credentials', () => ({ getStalwartCredentials: async () => creds.value }));
const admin = vi.hoisted(() => ({ allowed: true }));
// Rate limits are per address and outlive a test: each test is its own address.
const client = vi.hoisted(() => ({ ip: '', count: 0 }));
vi.mock('@/lib/admin/session', async (original) => ({
...await original<typeof import('@/lib/admin/session')>(),
requireAdminAuth: async () => (admin.allowed
? { session: { role: 'admin' } }
: { error: new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 }) }),
getClientIP: () => '203.0.113.9',
getClientIP: () => client.ip,
}));
import { POST as checkIn } from '../route';
import { POST as checkIn, REGISTER_PER_HOUR } from '../route';
import { POST as wiped } from '../wiped/route';
import { GET as adminList, POST as adminAct } from '../../admin/devices/route';
import * as registry from '@/lib/devices/registry';
@@ -49,7 +51,11 @@ async function register(): Promise<{ id: string; secret: string }> {
return body;
}
vi.setConfig({ testTimeout: 60_000 });
beforeEach(() => {
client.count += 1;
client.ip = `203.0.113.${client.count}`;
dir = mkdtempSync(path.join(tmpdir(), 'bw-device-routes-'));
vi.stubEnv('ADMIN_STATE_DIR', dir);
admin.allowed = true;
@@ -97,6 +103,45 @@ describe('POST /api/devices', () => {
expect(await wrong.json()).toEqual(await missing.json());
});
it('tells the device whose row was wiped, so it can start over', async () => {
const { id, secret } = await register();
await registry.requestDeviceWipe(id, 'admin');
await registry.confirmDeviceWipe(id, secret, 'admin');
const answer = await checkIn(request('/api/devices', { id, secret, platform: 'x', appVersion: '1' }));
expect(await answer.json()).toEqual({ wipe: null, wiped: true });
});
it('gives a new row\'s owner with the row, so the device can check it', async () => {
creds.value = { accountName: 'alice@example.org', serverUrl: 'https://mail.example.org' };
const response = await checkIn(request('/api/devices', { platform: 'x', appVersion: '1' }));
expect(await response.json()).toMatchObject({ user: 'alice@example.org', server: 'https://mail.example.org' });
});
it('holds new rows to a much lower limit per address than check-ins, and a 429 changes nothing', async () => {
creds.value = { accountName: 'alice@example.org', serverUrl: 'https://mail.example.org' };
const statuses: number[] = [];
for (let i = 0; i <= REGISTER_PER_HOUR; i += 1) {
statuses.push((await checkIn(request('/api/devices', { platform: 'x', appVersion: '1' }))).status);
}
expect(statuses.slice(0, REGISTER_PER_HOUR).every((status) => status === 200)).toBe(true);
expect(statuses.at(-1)).toBe(429);
expect((await registry.listDevices()).total).toBe(REGISTER_PER_HOUR);
// A device that's already registered still hears about its wipe.
const [{ id }] = (await registry.listDevices()).devices;
await registry.requestDeviceWipe(id, 'admin');
const answer = await checkIn(request('/api/devices', { id, secret: 'f'.repeat(64) }));
expect(answer.status).toBe(404);
});
it('turns down a new row past the cap when every row has a wipe waiting', async () => {
creds.value = { accountName: 'alice@example.org', serverUrl: 'https://mail.example.org' };
const spy = vi.spyOn(registry, 'registerDevice').mockRejectedValueOnce(new registry.TooManyDevicesError());
const response = await checkIn(request('/api/devices', { platform: 'x', appVersion: '1' }));
spy.mockRestore();
expect(response.status).toBe(429);
});
it('won\'t store a platform with control characters or at any length', async () => {
creds.value = { accountName: 'alice@example.org', serverUrl: 'https://mail.example.org' };
const response = await checkIn(request('/api/devices', { platform: `a\nb${'x'.repeat(200)}`, appVersion: '1' }));
@@ -120,7 +165,79 @@ describe('POST /api/devices/wiped', () => {
expect(device?.wipedAt).not.toBeNull();
const entry = audit().find((line) => line.action === 'device.wipe.confirm');
expect(entry?.detail).toMatchObject({ device: id, user: 'alice@example.org', reason: 'admin', unsentDeleted: 2 });
expect(entry?.ip).toBe('203.0.113.9');
expect(entry?.ip).toBe(client.ip);
});
it('sent again, writes no second entry: a row that didn\'t change leaves no trace', async () => {
const { id, secret } = await register();
await registry.requestDeviceWipe(id, 'admin');
for (let i = 0; i < 3; i += 1) {
expect((await wiped(request('/api/devices/wiped', { id, secret, reason: 'admin', unsent: 2 }))).status).toBe(200);
}
expect(audit().filter((line) => line.action === 'device.wipe.confirm')).toHaveLength(1);
});
it('won\'t let any signed-in user fill the log: repeating a user wipe writes one entry', async () => {
const { id, secret } = await register();
for (let i = 0; i < 3; i += 1) await wiped(request('/api/devices/wiped', { id, secret, reason: 'user', unsent: 0 }));
expect(audit().filter((line) => line.action === 'device.wipe.confirm')).toHaveLength(1);
});
it('refuses reason admin when no wipe was asked, changes nothing and writes nothing', async () => {
const { id, secret } = await register();
const response = await wiped(request('/api/devices/wiped', { id, secret, reason: 'admin', unsent: 0 }));
expect(response.status).toBe(409);
expect(await response.json()).toEqual({ error: 'no_wipe_asked' });
expect((await registry.getDevice(id))?.wipedAt).toBeNull();
expect(audit().filter((line) => line.action === 'device.wipe.confirm')).toEqual([]);
});
it('turns down a reason it doesn\'t know instead of calling it the admin\'s', async () => {
const { id, secret } = await register();
await registry.requestDeviceWipe(id, 'admin');
expect((await wiped(request('/api/devices/wiped', { id, secret, reason: 'mischief' }))).status).toBe(400);
expect((await registry.getDevice(id))?.wipe).not.toBeNull();
});
it('records an unknown count as unknown, not as none', async () => {
const { id, secret } = await register();
await registry.requestDeviceWipe(id, 'admin');
await wiped(request('/api/devices/wiped', { id, secret, reason: 'admin', unsent: null }));
expect(audit().find((line) => line.action === 'device.wipe.confirm')?.detail).toMatchObject({ unsentDeleted: 'unknown' });
});
it('a sign-out drops the row with its secret, and writes nothing', async () => {
const { id, secret } = await register();
expect((await wiped(request('/api/devices/wiped', { id, secret, drop: true }))).status).toBe(200);
expect(await registry.getDevice(id)).toBeNull();
expect(audit()).toEqual([]);
});
it('a sign-out with a wipe waiting answers that wipe: the row stays, confirmed and on the record', async () => {
const { id, secret } = await register();
await registry.requestDeviceWipe(id, 'admin');
expect((await wiped(request('/api/devices/wiped', { id, secret, drop: true }))).status).toBe(200);
const device = await registry.getDevice(id);
expect(device?.wipe).toBeNull();
expect(device?.wipedAt).not.toBeNull();
expect(audit().filter((line) => line.action === 'device.wipe.confirm')).toHaveLength(1);
});
it('won\'t drop a row without the right secret', async () => {
const { id } = await register();
expect((await wiped(request('/api/devices/wiped', { id, secret: 'f'.repeat(64), drop: true }))).status).toBe(404);
expect(await registry.getDevice(id)).not.toBeNull();
});
it('answers 429 past the limit and takes nothing', async () => {
const { id } = await register();
await registry.requestDeviceWipe(id, 'admin');
let last = 200;
for (let i = 0; i <= 300 && last !== 429; i += 1) {
last = (await wiped(request('/api/devices/wiped', { id, secret: 'f'.repeat(64), reason: 'admin' }))).status;
}
expect(last).toBe(429);
expect((await registry.getDevice(id))?.wipe).not.toBeNull();
});
it('takes nothing without the right secret and writes no entry', async () => {
+17 -2
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { getClientIP } from '@/lib/admin/session';
import { checkInDevice, DEVICE_ID_RE, DEVICE_SECRET_RE, registerDevice } from '@/lib/devices/registry';
import { checkInDevice, DEVICE_ID_RE, DEVICE_SECRET_RE, registerDevice, TooManyDevicesError } from '@/lib/devices/registry';
import { logger } from '@/lib/logger';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
import { getStore } from '@/lib/shared-store';
@@ -11,6 +11,12 @@ const NO_STORE = { 'Cache-Control': 'no-store' };
const MAX_BODY_BYTES = 2048;
// Per address: a whole office can sit behind one, checking in as it starts up.
const PER_MINUTE = 300;
/**
* A new row is written to the state dir and stays half a year, for anyone with a
* session. Enough for an office turning this on in one morning: a device turned
* away tries again in a quarter of an hour.
*/
export const REGISTER_PER_HOUR = 60;
function cleanText(value: unknown): string {
if (typeof value !== 'string') return '';
@@ -54,14 +60,23 @@ export async function POST(request: NextRequest) {
}
const device = await checkInDevice(body.id, body.secret, info);
if (!device) return NextResponse.json({ error: 'unknown_device' }, { status: 404, headers: NO_STORE });
return NextResponse.json({ wipe: device.wipe ? { requestedAt: device.wipe.requestedAt } : null }, { headers: NO_STORE });
if (device.wipe) return NextResponse.json({ wipe: { requestedAt: device.wipe.requestedAt } }, { headers: NO_STORE });
// Its data went already. A device still holding this row starts over.
return NextResponse.json(device.wipedAt ? { wipe: null, wiped: true } : { wipe: null }, { headers: NO_STORE });
}
const creds = await getStalwartCredentials(request);
if (!creds) return NextResponse.json({ error: 'Not authenticated' }, { status: 401, headers: NO_STORE });
const newRows = await getStore().counters.reserve([
{ key: `device-register:${getClientIP(request)}`, max: REGISTER_PER_HOUR, windowMs: 60 * 60 * 1000 },
]).catch(() => ({ ok: true }));
if (!newRows.ok) return NextResponse.json({ error: 'rate limited' }, { status: 429, headers: NO_STORE });
const created = await registerDevice(creds.accountName, creds.serverUrl, info);
return NextResponse.json({ ...created, wipe: null }, { headers: NO_STORE });
} catch (error) {
if (error instanceof Error && error.name === new TooManyDevicesError().name) {
return NextResponse.json({ error: 'too_many_devices' }, { status: 429, headers: NO_STORE });
}
if (isStoreUnavailable(error)) {
return NextResponse.json({ error: 'Temporarily unavailable' }, { status: 503, headers: { ...NO_STORE, 'Retry-After': '30' } });
}
+24 -9
View File
@@ -1,7 +1,8 @@
import { NextRequest, NextResponse } from 'next/server';
import { auditLog } from '@/lib/admin/audit';
import { getClientIP } from '@/lib/admin/session';
import { confirmDeviceWipe, DEVICE_ID_RE, DEVICE_SECRET_RE } from '@/lib/devices/registry';
import { confirmDeviceWipe, DEVICE_ID_RE, DEVICE_SECRET_RE, dropDevice } from '@/lib/devices/registry';
import type { DeviceView } from '@/lib/devices/types';
import { AUDIT_DEVICE_WIPE_CONFIRM, WIPE_REASONS, type WipeReason } from '@/lib/devices/types';
import { logger } from '@/lib/logger';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
@@ -16,7 +17,10 @@ const PER_MINUTE = 300;
/**
* POST /api/devices/wiped: a device says its wipe is done. By then it has signed
* out or is about to, so its secret is the proof, not a session. Sent again at
* the next contact until it gets through, so it answers the same each time.
* the next contact until it gets through, so it answers the same each time, and
* only a row that really changes goes in the log.
*
* With `drop`: the account signed out here, with its data, so the row goes too.
*/
export async function POST(request: NextRequest) {
const crossOrigin = rejectCrossOriginRequest(request);
@@ -39,19 +43,30 @@ export async function POST(request: NextRequest) {
if (typeof body?.id !== 'string' || !DEVICE_ID_RE.test(body.id) || typeof body.secret !== 'string' || !DEVICE_SECRET_RE.test(body.secret)) {
return NextResponse.json({ error: 'unknown_device' }, { status: 404, headers: NO_STORE });
}
const reason: WipeReason = WIPE_REASONS.includes(body.reason as WipeReason) ? body.reason as WipeReason : 'admin';
const unsent = typeof body.unsent === 'number' && Number.isFinite(body.unsent) && body.unsent >= 0 ? Math.floor(body.unsent) : 0;
const device = await confirmDeviceWipe(body.id, body.secret, reason);
if (!device) return NextResponse.json({ error: 'unknown_device' }, { status: 404, headers: NO_STORE });
await auditLog(AUDIT_DEVICE_WIPE_CONFIRM, {
const write = (device: DeviceView, reason: WipeReason, unsentDeleted: number | 'unknown') => auditLog(AUDIT_DEVICE_WIPE_CONFIRM, {
device: device.id,
user: device.user,
server: device.server,
platform: device.platform,
reason,
unsentDeleted: unsent,
unsentDeleted,
}, getClientIP(request));
if (body.drop === true) {
const dropped = await dropDevice(body.id, body.secret);
if (!dropped) return NextResponse.json({ error: 'unknown_device' }, { status: 404, headers: NO_STORE });
if (dropped.confirmed) await write(dropped.confirmed, 'user', 'unknown');
return NextResponse.json({ ok: true }, { headers: NO_STORE });
}
if (!WIPE_REASONS.includes(body.reason as WipeReason)) return NextResponse.json({ error: 'bad request' }, { status: 400, headers: NO_STORE });
const reason = body.reason as WipeReason;
const unsent = typeof body.unsent === 'number' && Number.isFinite(body.unsent) && body.unsent >= 0 ? Math.floor(body.unsent) : 'unknown';
const answer = await confirmDeviceWipe(body.id, body.secret, reason);
if (!answer) return NextResponse.json({ error: 'unknown_device' }, { status: 404, headers: NO_STORE });
if (answer === 'no_wipe_asked') return NextResponse.json({ error: 'no_wipe_asked' }, { status: 409, headers: NO_STORE });
if (answer.changed) await write(answer.device, reason, unsent);
return NextResponse.json({ ok: true }, { headers: NO_STORE });
} catch (error) {
if (isStoreUnavailable(error)) {
+58
View File
@@ -7,6 +7,8 @@ import { setStoreForTests } from '@/lib/shared-store';
type Registry = typeof import('../registry');
vi.setConfig({ testTimeout: 60_000 });
let dir: string;
const info = { platform: 'Chrome, Windows', appVersion: '1.2.3' };
@@ -166,6 +168,62 @@ describe('the rules', () => {
expect(ids).not.toContain(next.id);
});
it('tells a confirmation that changed the row from one that didn\'t', async () => {
const registry = await import('../registry');
const { id, secret } = await registry.registerDevice('alice@example.org', 'https://mail.example.org', info);
await registry.requestDeviceWipe(id, 'admin');
const first = await registry.confirmDeviceWipe(id, secret, 'admin');
expect(first).toMatchObject({ changed: true });
const again = await registry.confirmDeviceWipe(id, secret, 'admin');
expect(again).toMatchObject({ changed: false });
});
it('won\'t take a confirmation from the admin without a request', async () => {
const registry = await import('../registry');
const { id, secret } = await registry.registerDevice('alice@example.org', 'https://mail.example.org', info);
expect(await registry.confirmDeviceWipe(id, secret, 'admin')).toBe('no_wipe_asked');
expect((await registry.getDevice(id))?.wipedAt).toBeNull();
});
it('prunes a user\'s abandoned rows when a new one registers, not ones with a wipe waiting', async () => {
const registry = await import('../registry');
const start = Date.now() + 365 * 24 * 60 * 60 * 1000;
vi.useFakeTimers({ now: start, toFake: ['Date'] });
const old = await registry.registerDevice('alice@example.org', 'https://mail.example.org', info);
const waiting = await registry.registerDevice('alice@example.org', 'https://mail.example.org', info);
await registry.requestDeviceWipe(waiting.id, 'admin');
vi.setSystemTime(start + registry.DEVICE_PRUNE_MS + 60_000);
await registry.registerDevice('alice@example.org', 'https://mail.example.org', info);
vi.useRealTimers();
expect(await registry.getDevice(old.id)).toBeNull();
expect(await registry.getDevice(waiting.id)).not.toBeNull();
});
it('abandoned rows expire with no admin looking, others\' too, through any registration', async () => {
const registry = await import('../registry');
const start = Date.now() + 365 * 24 * 60 * 60 * 1000;
vi.useFakeTimers({ now: start, toFake: ['Date'] });
const abandoned = await registry.registerDevice('bob@example.org', 'https://mail.example.org', info);
const waiting = await registry.registerDevice('bob@example.org', 'https://mail.example.org', info);
await registry.requestDeviceWipe(waiting.id, 'admin');
vi.setSystemTime(start + registry.DEVICE_PRUNE_MS + 2 * 60 * 60 * 1000);
await registry.registerDevice('alice@example.org', 'https://mail.example.org', info);
vi.useRealTimers();
expect(await registry.getDevice(abandoned.id)).toBeNull();
expect(await registry.getDevice(waiting.id)).not.toBeNull();
});
it('keeps the cap with every row waiting for a wipe: a new row is refused, the wipes stay', async () => {
const registry = await import('../registry');
const first = await registry.registerDevice('alice@example.org', 'https://mail.example.org', info);
const userKey = first.id.split('/')[0];
for (let i = 1; i < registry.MAX_DEVICES_PER_USER; i += 1) await registry.registerDevice('alice@example.org', 'https://mail.example.org', info);
expect(await registry.requestUserWipe(userKey, 'admin')).toHaveLength(registry.MAX_DEVICES_PER_USER);
await expect(registry.registerDevice('alice@example.org', 'https://mail.example.org', info)).rejects.toThrow(registry.TooManyDevicesError);
expect((await registry.listDevices({ limit: 200 })).total).toBe(registry.MAX_DEVICES_PER_USER);
expect((await registry.getDevice(first.id))?.wipe).not.toBeNull();
});
it('finds a user by part of the address', async () => {
const registry: Registry = await import('../registry');
await registry.registerDevice('alice@example.org', 'https://mail.example.org', info);
+68 -14
View File
@@ -4,7 +4,8 @@
* replica sees the same list and a pending wipe outlives a restart.
*
* No `ttlMs`: a machine that stays off for a year still has its wipe waiting.
* Old documents without a pending wipe go through `pruneDevices`.
* Rows nobody has seen for half a year and with no wipe waiting go when a new
* row registers (at most hourly for the whole registry) or an admin lists them.
*
* A check-in proves itself with the device's secret, not with a session, and
* nothing here asks the mail server. A device whose password was revoked has
@@ -25,11 +26,12 @@ const DEVICE_FILE_RE = /^[0-9a-f]{64}\/[0-9a-f]{32}\.json$/;
/** A user with more rows than this loses the oldest one that has no wipe waiting. */
export const MAX_DEVICES_PER_USER = 100;
/** Not seen for this long and no wipe waiting: forgotten by the next admin listing. */
/** Not seen for this long and no wipe waiting: abandoned, and forgotten. */
export const DEVICE_PRUNE_MS = 180 * 24 * 60 * 60 * 1000;
/** A check-in within this time of the last write doesn't write again. */
const LAST_SEEN_WRITE_MS = 5 * 60 * 1000;
const READ_CONCURRENCY = 50;
const PRUNE_EVERY_MS = 60 * 60 * 1000;
export const DEVICES_COLLECTION: CollectionOptions = {
local: {
@@ -88,13 +90,29 @@ async function readMany(ids: string[]): Promise<Array<[string, StoredDevice]>> {
return found;
}
async function makeRoomFor(userKey: string): Promise<void> {
const ids = await collection().list(`${userKey}/`);
if (ids.length < MAX_DEVICES_PER_USER) return;
const removable = (await readMany(ids))
/** Every row of this user has a wipe waiting, which can't be dropped, and the cap is reached. */
export class TooManyDevicesError extends Error {
constructor() {
super('Too many devices');
this.name = 'TooManyDevicesError';
}
}
async function makeRoomFor(userKey: string, now: number): Promise<void> {
const rows = await pruned(await readMany(await collection().list(`${userKey}/`)), now);
const over = rows.length - MAX_DEVICES_PER_USER + 1;
if (over <= 0) return;
const removable = rows
.filter(([, device]) => !device.wipe)
.sort(([, a], [, b]) => a.lastSeen.localeCompare(b.lastSeen));
for (const [id] of removable.slice(0, ids.length - MAX_DEVICES_PER_USER + 1)) await collection().delete(id);
if (removable.length < over) throw new TooManyDevicesError();
for (const [id] of removable.slice(0, over)) await collection().delete(id);
}
/** Abandoned rows of everyone, however long no admin has looked. One replica, once an hour. */
async function pruneAbandoned(now: number): Promise<void> {
const { ok } = await getStore().counters.reserve([{ key: 'device-prune', max: 1, windowMs: PRUNE_EVERY_MS }]);
if (ok) await pruned(await readMany(await collection().list('')), now);
}
export interface DeviceInfo {
@@ -103,9 +121,10 @@ export interface DeviceInfo {
}
/** A new row for this browser profile and account. The secret goes back to the device once and is kept only as a hash. */
export async function registerDevice(user: string, server: string, info: DeviceInfo): Promise<{ id: string; secret: string }> {
export async function registerDevice(user: string, server: string, info: DeviceInfo): Promise<{ id: string; secret: string; user: string; server: string }> {
const userKey = settingsId(user, server);
await makeRoomFor(userKey);
await makeRoomFor(userKey, Date.now());
await pruneAbandoned(Date.now()).catch(() => {});
const id = `${userKey}/${randomBytes(16).toString('hex')}`;
const secret = randomBytes(32).toString('hex');
const now = new Date().toISOString();
@@ -122,7 +141,7 @@ export async function registerDevice(user: string, server: string, info: DeviceI
secretHash: hashSecret(secret),
};
await collection().put(id, JSON.stringify(device), { ifVersion: null });
return { id, secret };
return { id, secret, user, server };
}
/** Null when the row is gone or the secret doesn't fit. Both read the same to the caller. */
@@ -140,18 +159,52 @@ export async function checkInDevice(id: string, secret: string, info: DeviceInfo
return device ? toView(id, device) : toView(id, current);
}
/** The device says it wiped. Null when the row is gone or the secret doesn't fit. */
export async function confirmDeviceWipe(id: string, secret: string, reason: WipeReason, now = Date.now()): Promise<DeviceView | null> {
export type WipeConfirmation = { device: DeviceView; changed: boolean } | 'no_wipe_asked';
/**
* The device says it wiped. Null when the row is gone or the secret doesn't fit.
* Only a change is news: a device sends this again until it gets through, and
* anyone with a row could send it for ever. `admin` needs a request the row
* still has. Anyone can say they wiped themselves, but not that an admin told them.
*/
export async function confirmDeviceWipe(id: string, secret: string, reason: WipeReason, now = Date.now()): Promise<WipeConfirmation | null> {
const current = await readDevice(id);
if (!current || !secretMatches(current, secret)) return null;
const at = new Date(now).toISOString();
let refused = false;
const { data } = await collection().update(id, (raw) => {
refused = false;
const device = parse(raw);
if (!device) return undefined;
if (device.wipedAt && !device.wipe) return undefined;
if (reason === 'admin' && !device.wipe) {
refused = true;
return undefined;
}
return JSON.stringify({ ...device, wipe: null, wipedAt: at, wipeReason: reason, lastSeen: at });
});
if (refused) return 'no_wipe_asked';
const device = parse(data);
return device ? toView(id, device) : null;
if (device) return { device: toView(id, device), changed: true };
const after = await readDevice(id);
return after ? { device: toView(id, after), changed: false } : null;
}
/**
* The account signed out here and took its data with it: the row has nothing
* left to look after. A wipe still waiting is answered by that, and stays on the
* record the way any confirmed wipe does. Null when the secret doesn't fit.
*/
export async function dropDevice(id: string, secret: string, now = Date.now()): Promise<{ confirmed: DeviceView | null } | null> {
const current = await readDevice(id);
if (!current) return { confirmed: null };
if (!secretMatches(current, secret)) return null;
if (current.wipe) {
const answer = await confirmDeviceWipe(id, secret, 'user', now);
return { confirmed: answer && answer !== 'no_wipe_asked' && answer.changed ? answer.device : null };
}
await collection().delete(id);
return { confirmed: null };
}
/** Null when nothing changed: no such row, a wipe already waiting, or a row that confirmed and has nothing left. */
@@ -168,7 +221,8 @@ export async function requestDeviceWipe(id: string, by: string, now = Date.now()
/** Every device of a user that hasn't wiped already, one request each. */
export async function requestUserWipe(userKey: string, by: string, now = Date.now()): Promise<DeviceView[]> {
const ids = await collection().list(`${userKey}/`);
// No more than a user can have: nothing a stray write left behind makes this unbounded.
const ids = (await collection().list(`${userKey}/`)).slice(0, MAX_DEVICES_PER_USER);
const results = await Promise.all(ids.map((id) => requestDeviceWipe(id, by, now)));
return results.filter((device): device is DeviceView => device !== null);
}