diff --git a/backend/src/uploadLinks.ts b/backend/src/uploadLinks.ts new file mode 100644 index 0000000..6b346fc --- /dev/null +++ b/backend/src/uploadLinks.ts @@ -0,0 +1,43 @@ +import crypto from 'crypto'; + +/** + * Issuing and recognising the tokens that open the public intake endpoint. + * + * Kept apart from the routes so the rules are pure and testable directly — the + * same reasoning as `uploadTypes.ts` and `keyByCallerAndEmail`, both of which + * are exported for their tests because they are where the real decisions live. + */ + +// 32 bytes — 256 bits. base64url so the value survives being pasted into a URL, +// a chat message and a QR code without escaping, which is the whole point of a +// link somebody is handed. +const TOKEN_BYTES = 32; + +export function generateToken(): string { + return crypto.randomBytes(TOKEN_BYTES).toString('base64url'); +} + +/** + * The digest stored against a link. + * + * SHA-256 rather than bcrypt, deliberately, and the reasoning is the opposite + * of the one that governs passwords. A password hash is slow on purpose, + * because a human password carries little entropy and has to survive an + * offline dictionary attack. This is 256 bits from a CSPRNG: there is no + * dictionary to try, and guessing is not a threat that slowing the hash + * addresses. + * + * Meanwhile the digest is computed on every submission request, and the intake + * endpoint is unauthenticated. A deliberately slow hash there would be a + * denial-of-service surface rather than a protection — see #242, where cost-12 + * bcrypt in the test suite was enough to push a request past its timeout under + * load. + * + * No timing-safe comparison is needed. The lookup is an indexed equality match + * on the digest rather than a byte-by-byte compare of the secret, and an + * attacker able to mount a timing attack against a 256-bit random value would + * still need the value. + */ +export function hashToken(token: string): string { + return crypto.createHash('sha256').update(token).digest('hex'); +} diff --git a/backend/tests/unit/uploadLinks.test.ts b/backend/tests/unit/uploadLinks.test.ts new file mode 100644 index 0000000..3de57c7 --- /dev/null +++ b/backend/tests/unit/uploadLinks.test.ts @@ -0,0 +1,31 @@ +import { generateToken, hashToken } from '../../src/uploadLinks'; + +describe('generateToken', () => { + it('produces a URL-safe token with no padding', () => { + expect(generateToken()).toMatch(/^[A-Za-z0-9_-]{43}$/); + }); + + // The token is the entire access control on the intake endpoint. If two + // calls could collide, one person's link would open another's. + it('does not repeat', () => { + const seen = new Set(Array.from({ length: 1000 }, () => generateToken())); + expect(seen.size).toBe(1000); + }); +}); + +describe('hashToken', () => { + it('is a lowercase hex sha256 digest', () => { + expect(hashToken('abc')).toBe( + 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad' + ); + }); + + it('is stable across calls, so a stored digest keeps matching', () => { + const token = generateToken(); + expect(hashToken(token)).toBe(hashToken(token)); + }); + + it('gives different tokens different digests', () => { + expect(hashToken(generateToken())).not.toBe(hashToken(generateToken())); + }); +});