From d1227bfac6402a31f8bf8a50898066c90b43d9f2 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:11:44 +0100 Subject: [PATCH] fix: log a device wipe only when its row changes, and keep rows from piling up --- app/api/admin/devices/__tests__/auth.test.ts | 91 ++++++++++++++ app/api/devices/__tests__/route.test.ts | 123 ++++++++++++++++++- app/api/devices/route.ts | 19 ++- app/api/devices/wiped/route.ts | 33 +++-- lib/devices/__tests__/registry.test.ts | 58 +++++++++ lib/devices/registry.ts | 82 ++++++++++--- 6 files changed, 378 insertions(+), 28 deletions(-) create mode 100644 app/api/admin/devices/__tests__/auth.test.ts diff --git a/app/api/admin/devices/__tests__/auth.test.ts b/app/api/admin/devices/__tests__/auth.test.ts new file mode 100644 index 000000000..77cb78375 --- /dev/null +++ b/app/api/admin/devices/__tests__/auth.test.ts @@ -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()); +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): 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(); + }); +}); diff --git a/app/api/devices/__tests__/route.test.ts b/app/api/devices/__tests__/route.test.ts index 58e141401..3d26acf90 100644 --- a/app/api/devices/__tests__/route.test.ts +++ b/app/api/devices/__tests__/route.test.ts @@ -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(), 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 () => { diff --git a/app/api/devices/route.ts b/app/api/devices/route.ts index eea279c95..40748e1fe 100644 --- a/app/api/devices/route.ts +++ b/app/api/devices/route.ts @@ -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' } }); } diff --git a/app/api/devices/wiped/route.ts b/app/api/devices/wiped/route.ts index a8258108d..31c10e2b3 100644 --- a/app/api/devices/wiped/route.ts +++ b/app/api/devices/wiped/route.ts @@ -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)) { diff --git a/lib/devices/__tests__/registry.test.ts b/lib/devices/__tests__/registry.test.ts index 5c2afe014..88d2b92ae 100644 --- a/lib/devices/__tests__/registry.test.ts +++ b/lib/devices/__tests__/registry.test.ts @@ -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); diff --git a/lib/devices/registry.ts b/lib/devices/registry.ts index b7cea30b9..c69d95813 100644 --- a/lib/devices/registry.ts +++ b/lib/devices/registry.ts @@ -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> { return found; } -async function makeRoomFor(userKey: string): Promise { - 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 { + 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 { + 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 { +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 { 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 { - 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); }