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
44 lines
1.8 KiB
TypeScript
44 lines
1.8 KiB
TypeScript
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');
|
|
}
|