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:
@@ -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');
|
||||
}
|
||||
Reference in New Issue
Block a user