From 2b2cbe119e53dd5104c8a915b7061959e70a6b5c Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Mon, 31 Aug 2026 15:00:36 -0500 Subject: [PATCH] feat(intake): generate and hash upload link tokens (#222) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The token is the entire access control on an endpoint the whole internet can reach, so both halves are pure and tested directly rather than through a request — the same reasoning that has uploadTypes.ts and keyByCallerAndEmail exported for their tests. 32 bytes of CSPRNG output, base64url so the value survives being pasted into a URL, a chat message or a QR code without escaping. That matters for something a person is handed rather than something a machine reads. The collision test runs a thousand generations rather than asserting the obvious, because a repeat would mean one person's link opening another's. SHA-256 rather than bcrypt, and the reasoning inverts the one that governs passwords. A password hash is slow on purpose because a human password carries little entropy and must survive an offline dictionary attack. This is 256 bits from a CSPRNG: there is no dictionary, so slowing the hash buys nothing. Meanwhile the digest is computed on every submission to an unauthenticated endpoint, where a deliberately slow hash would be a denial-of-service surface — #242 is the local proof that cost-12 hashing on a request path is enough to push it past a timeout under load. No timing-safe comparison, deliberately: the lookup is an indexed equality match on the digest rather than a byte-by-byte compare of the secret, and an attacker who could mount a timing attack against a 256-bit random value would still need the value. Backend: 307 unit tests, lint unchanged at 6 pre-existing warnings, build clean. Ref #222 --- backend/src/uploadLinks.ts | 43 ++++++++++++++++++++++++++ backend/tests/unit/uploadLinks.test.ts | 31 +++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 backend/src/uploadLinks.ts create mode 100644 backend/tests/unit/uploadLinks.test.ts 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())); + }); +});