feat(intake): generate and hash upload link tokens (#222)

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
This commit is contained in:
2026-08-31 15:42:25 -05:00
parent 1a5a8b837b
commit 2b2cbe119e
2 changed files with 74 additions and 0 deletions
+31
View File
@@ -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()));
});
});