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,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()));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user