mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 00:14:18 +00:00
fix: log a device wipe only when its row changes, and keep rows from piling up
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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' } });
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user