Files
webmail/lib/__tests__/pair-store.test.ts
T
Linus Rath 473e5c8bff fix: clean up after pairings a phone never completes
- An app password minted for a phone that never redeemed its code stayed
  valid (no expiry, full permissions, no TOTP) and used up one of the
  account's five app-password slots. It is now deleted, with its own
  credential, when its grant is discarded or expires.
- Replaced codes were kept for twelve minutes each, so a session could
  grow the store, and the cost of every request, without bound; only the
  last three of a grant are kept now.
- A session read that failed after the credential check was taken for a
  non-Stalwart server and handed the phone the account password; it now
  refuses the pairing instead.
2026-09-27 04:51:30 +02:00

290 lines
12 KiB
TypeScript

// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
CODE_TTL_MS,
GRANT_TTL_MS,
createPairingCode,
discardGrant,
getPairingStatus,
isGrantAvailable,
redeemPairingCode,
registerGrantDisposer,
resetPairingStoreForTests,
stashGrant,
type PairingGrant,
} from '@/lib/auth/pairing-store';
// The "Link mobile app" store: a step-up mints a grant (5 min), the desktop
// shows single-use codes for it (2 min each, a new one supersedes the old),
// and the phone redeems one code for the grant, exactly once. Spent records
// stay behind as tombstones without their secrets.
const OWNER = '0:alice@example.org';
const BASE = 'https://webmail.example';
const T0 = 1_800_000_000_000;
const TOMBSTONE_MS = 10 * 60 * 1000;
const GRANT: PairingGrant = {
flow: 'oauth',
serverUrl: 'https://mail.example.org',
serverId: null,
accessToken: 'access-1',
refreshToken: 'refresh-1',
expiresIn: 3600,
issuedAt: T0,
tokenEndpoint: 'https://mail.example.org/auth/token',
clientId: 'bulwark-webmail',
confidential: false,
trusted: true,
};
interface InternalStore {
grants: Map<string, { grant: PairingGrant | null; expiresAt: number; owner: string; forgetAt: number }>;
codes: Map<string, { grantId: string; state: string; expiresAt: number }>;
statusIndex: Map<string, string>;
}
/** The pinned store itself, to check what is (not) kept in memory. */
function internals(): InternalStore {
return (globalThis as unknown as { __bulwarkPairingStore: InternalStore }).__bulwarkPairingStore;
}
function newCode(grantId: string, now: number, owner = OWNER, base: string | null = BASE) {
const pairing = createPairingCode(grantId, owner, base, now);
if (!pairing) throw new Error('expected a code');
return pairing;
}
beforeEach(() => {
resetPairingStoreForTests();
});
describe('pairing store lifecycle', () => {
it('issues a 256-bit code and a separate status id, and redeems the grant once', () => {
const grantId = stashGrant(GRANT, OWNER, T0);
expect(isGrantAvailable(grantId, OWNER, T0)).toBe(true);
const pairing = newCode(grantId, T0);
expect(pairing.code).toMatch(/^[0-9a-f]{64}$/);
expect(pairing.statusId).toMatch(/^[0-9a-f]{32}$/);
expect(pairing.statusId).not.toBe(pairing.code.slice(0, 32));
expect(pairing.expiresIn).toBe(CODE_TTL_MS / 1000);
expect(getPairingStatus(pairing.statusId, T0 + 1)).toBe('pending');
const redeemed = redeemPairingCode(pairing.code, T0 + 1000);
expect(redeemed).toEqual({ ok: true, grant: GRANT, webmailBase: BASE });
expect(getPairingStatus(pairing.statusId, T0 + 1001)).toBe('redeemed');
// Single use.
expect(redeemPairingCode(pairing.code, T0 + 2000)).toEqual({ ok: false, reason: 'used' });
// ...and the grant is consumed: no new code for it, even within its window.
expect(isGrantAvailable(grantId, OWNER, T0 + 2000)).toBe(false);
expect(createPairingCode(grantId, OWNER, BASE, T0 + 2000)).toBeNull();
});
it('forgets the grant secrets once redeemed', () => {
const grantId = stashGrant(GRANT, OWNER, T0);
const pairing = newCode(grantId, T0);
redeemPairingCode(pairing.code, T0 + 1);
expect(internals().grants.get(grantId)?.grant).toBeNull();
});
it('issues a different code every time', () => {
const grantId = stashGrant(GRANT, OWNER, T0);
const codes = new Set<string>();
for (let i = 0; i < 20; i++) codes.add(newCode(grantId, T0 + i).code);
expect(codes.size).toBe(20);
});
it('answers invalid for a code nobody issued, and unknown for a status id nobody issued', () => {
stashGrant(GRANT, OWNER, T0);
expect(redeemPairingCode('0'.repeat(64), T0)).toEqual({ ok: false, reason: 'invalid' });
expect(redeemPairingCode('', T0)).toEqual({ ok: false, reason: 'invalid' });
expect(getPairingStatus('0'.repeat(32), T0)).toBe('unknown');
});
it('treats a code redeemed after its two minutes as expired', () => {
const grantId = stashGrant(GRANT, OWNER, T0);
const pairing = newCode(grantId, T0);
expect(getPairingStatus(pairing.statusId, T0 + CODE_TTL_MS - 1)).toBe('pending');
expect(getPairingStatus(pairing.statusId, T0 + CODE_TTL_MS)).toBe('expired');
expect(redeemPairingCode(pairing.code, T0 + CODE_TTL_MS)).toEqual({ ok: false, reason: 'expired' });
// The grant itself is still there for a new QR within its own window.
expect(isGrantAvailable(grantId, OWNER, T0 + CODE_TTL_MS)).toBe(true);
const next = newCode(grantId, T0 + CODE_TTL_MS);
expect(redeemPairingCode(next.code, T0 + CODE_TTL_MS + 1)).toMatchObject({ ok: true });
});
it('supersedes the previous code of the same grant when a new one is shown', () => {
const grantId = stashGrant(GRANT, OWNER, T0);
const first = newCode(grantId, T0);
const second = newCode(grantId, T0 + 1000);
expect(getPairingStatus(first.statusId, T0 + 1001)).toBe('expired');
expect(getPairingStatus(second.statusId, T0 + 1001)).toBe('pending');
expect(redeemPairingCode(first.code, T0 + 1001)).toEqual({ ok: false, reason: 'expired' });
// Only the QR on screen can be redeemed.
expect(redeemPairingCode(second.code, T0 + 2000)).toMatchObject({ ok: true });
});
it('marks every other code of a redeemed grant used, and its status redeemed', () => {
const grantId = stashGrant(GRANT, OWNER, T0);
const first = newCode(grantId, T0);
const second = newCode(grantId, T0 + 1000);
expect(redeemPairingCode(second.code, T0 + 2000)).toMatchObject({ ok: true });
expect(getPairingStatus(second.statusId, T0 + 2001)).toBe('redeemed');
expect(getPairingStatus(first.statusId, T0 + 2001)).toBe('expired');
// A late scan of the older QR is told it was already used, not expired.
expect(redeemPairingCode(first.code, T0 + 2002)).toEqual({ ok: false, reason: 'used' });
// ...even after its own two minutes ran out.
expect(redeemPairingCode(first.code, T0 + CODE_TTL_MS + 5000)).toEqual({ ok: false, reason: 'used' });
});
it('keeps grants of different owners apart', () => {
const grantId = stashGrant(GRANT, OWNER, T0);
expect(isGrantAvailable(grantId, '1:alice@example.org', T0)).toBe(false);
expect(isGrantAvailable(grantId, '0:mallory@example.org', T0)).toBe(false);
expect(createPairingCode(grantId, '1:alice@example.org', BASE, T0)).toBeNull();
expect(createPairingCode(grantId, '0:mallory@example.org', BASE, T0)).toBeNull();
expect(createPairingCode('f'.repeat(32), OWNER, BASE, T0)).toBeNull();
// The rightful owner still can.
expect(createPairingCode(grantId, OWNER, BASE, T0)).not.toBeNull();
});
it('wipes the grant secrets when the grant expires, keeping a tombstone', () => {
const grantId = stashGrant(GRANT, OWNER, T0);
expect(isGrantAvailable(grantId, OWNER, T0 + GRANT_TTL_MS - 1)).toBe(true);
expect(isGrantAvailable(grantId, OWNER, T0 + GRANT_TTL_MS)).toBe(false);
expect(createPairingCode(grantId, OWNER, BASE, T0 + GRANT_TTL_MS)).toBeNull();
const record = internals().grants.get(grantId);
expect(record).toBeDefined();
expect(record!.grant).toBeNull();
expect(JSON.stringify(record)).not.toContain('access-1');
expect(JSON.stringify(record)).not.toContain('refresh-1');
// The tombstone goes too, eventually.
stashGrant(GRANT, OWNER, T0 + GRANT_TTL_MS + TOMBSTONE_MS);
expect(internals().grants.has(grantId)).toBe(false);
});
it('never lets a code outlive its grant', () => {
const grantId = stashGrant(GRANT, OWNER, T0);
const late = T0 + GRANT_TTL_MS - 30_000;
const pairing = newCode(grantId, late);
expect(pairing.expiresIn).toBe(30);
expect(redeemPairingCode(pairing.code, T0 + GRANT_TTL_MS - 1)).toMatchObject({ ok: true });
const other = stashGrant(GRANT, OWNER, T0);
const code = newCode(other, late);
expect(getPairingStatus(code.statusId, T0 + GRANT_TTL_MS)).toBe('expired');
expect(redeemPairingCode(code.code, T0 + GRANT_TTL_MS)).toEqual({ ok: false, reason: 'expired' });
});
it('stops the codes of a discarded grant', () => {
const grantId = stashGrant(GRANT, OWNER, T0);
const pairing = newCode(grantId, T0);
discardGrant(grantId, T0 + 1);
expect(isGrantAvailable(grantId, OWNER, T0 + 2)).toBe(false);
expect(createPairingCode(grantId, OWNER, BASE, T0 + 2)).toBeNull();
expect(redeemPairingCode(pairing.code, T0 + 2)).toEqual({ ok: false, reason: 'expired' });
expect(internals().grants.get(grantId)?.grant).toBeNull();
});
it('remembers spent codes for status polls and late scans, then forgets them', () => {
const grantId = stashGrant(GRANT, OWNER, T0);
const pairing = newCode(grantId, T0);
redeemPairingCode(pairing.code, T0 + 1);
const forgetAt = T0 + CODE_TTL_MS + TOMBSTONE_MS;
expect(getPairingStatus(pairing.statusId, forgetAt - 1)).toBe('redeemed');
expect(redeemPairingCode(pairing.code, forgetAt - 1)).toEqual({ ok: false, reason: 'used' });
expect(getPairingStatus(pairing.statusId, forgetAt)).toBe('unknown');
expect(redeemPairingCode(pairing.code, forgetAt)).toEqual({ ok: false, reason: 'invalid' });
expect(internals().codes.size).toBe(0);
expect(internals().statusIndex.size).toBe(0);
});
it('hands out the webmail base the code was issued for', () => {
const grantId = stashGrant(GRANT, OWNER, T0);
const pairing = newCode(grantId, T0, OWNER, 'https://webmail.example/mail');
expect(redeemPairingCode(pairing.code, T0 + 1)).toMatchObject({ ok: true, webmailBase: 'https://webmail.example/mail' });
});
it('forgets everything on reset', () => {
const grantId = stashGrant(GRANT, OWNER, T0);
const pairing = newCode(grantId, T0);
resetPairingStoreForTests();
expect(getPairingStatus(pairing.statusId, T0 + 1)).toBe('unknown');
expect(redeemPairingCode(pairing.code, T0 + 1)).toEqual({ ok: false, reason: 'invalid' });
});
});
describe('pairing store instance', () => {
it('is shared by every evaluation of the module (globalThis pinning)', async () => {
const first = await import('@/lib/auth/pairing-store');
const grantId = first.stashGrant(GRANT, OWNER, T0);
const pairing = first.createPairingCode(grantId, OWNER, BASE, T0)!;
vi.resetModules();
const second = await import('@/lib/auth/pairing-store');
expect(second).not.toBe(first);
expect(second.isGrantAvailable(grantId, OWNER, T0 + 1)).toBe(true);
expect(second.getPairingStatus(pairing.statusId, T0 + 1)).toBe('pending');
expect(second.redeemPairingCode(pairing.code, T0 + 2)).toMatchObject({ ok: true, grant: GRANT });
// The first instance sees the redemption made through the second.
expect(first.getPairingStatus(pairing.statusId, T0 + 3)).toBe('redeemed');
expect(first.redeemPairingCode(pairing.code, T0 + 3)).toEqual({ ok: false, reason: 'used' });
});
});
describe('pairing store after the security review', () => {
const APP_GRANT: PairingGrant = {
flow: 'password',
serverUrl: 'https://mail.example.org',
username: 'alice@example.org',
password: 'app_secret',
credential: 'app-password',
appPasswordId: 'ap-1',
};
it('hands a grant that dies unredeemed to the disposer, once', () => {
const disposed: PairingGrant[] = [];
registerGrantDisposer((g) => disposed.push(g));
const discarded = stashGrant(APP_GRANT, OWNER, T0);
discardGrant(discarded, T0 + 1);
discardGrant(discarded, T0 + 2);
const expired = stashGrant(APP_GRANT, OWNER, T0);
isGrantAvailable(expired, OWNER, T0 + GRANT_TTL_MS + 1); // sweeps
expect(disposed).toEqual([APP_GRANT, APP_GRANT]);
});
it('does not dispose of a redeemed grant', () => {
const disposed: PairingGrant[] = [];
registerGrantDisposer((g) => disposed.push(g));
const id = stashGrant(APP_GRANT, OWNER, T0);
expect(redeemPairingCode(newCode(id, T0).code, T0 + 1).ok).toBe(true);
discardGrant(id, T0 + 2);
isGrantAvailable(id, OWNER, T0 + GRANT_TTL_MS + 1);
expect(disposed).toEqual([]);
});
it('keeps only the last few replaced codes of a grant', () => {
const id = stashGrant(GRANT, OWNER, T0);
const all = Array.from({ length: 50 }, (_, i) => newCode(id, T0 + i));
expect(internals().codes.size).toBe(4);
expect(internals().statusIndex.size).toBe(4);
expect(getPairingStatus(all[49].statusId, T0 + 60)).toBe('pending');
expect(getPairingStatus(all[48].statusId, T0 + 60)).toBe('expired');
expect(getPairingStatus(all[0].statusId, T0 + 60)).toBe('unknown');
expect(redeemPairingCode(all[0].code, T0 + 60)).toEqual({ ok: false, reason: 'invalid' });
});
});