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
+43
View File
@@ -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');
}