Files
redefined-designs/backend/src/passwordHashing.ts
T
bermudalamb d7dacffa11
Linting / lint (pull_request) Successful in 2m10s
SonarQube Analysis / sonarqube (pull_request) Successful in 19m15s
test(perf): stop hashing test passwords at production cost (#242)
The integration suite registers around thirty-five customers and asserts nothing about any of their hashes, yet paid bcrypt cost 12 for every one. bcryptjs is a pure-JS implementation, so it pays that cost several times over compared with a native build, and hashing was most of the suite's wall clock. On a contended runner it pushed adminInventory.integration.test.ts past its twenty-second timeout, which then surfaced as a foreign key violation somewhere else entirely — the test timed out, jest moved on, beforeEach truncated, and the still-in-flight registration wrote a token for a customer that had just been deleted.

Measured rather than asserted, warm run against warm run with only the constant changed: 34.5s at cost 12, 9.8s at cost 4. Three and a half times faster, about twenty-five seconds off every integration run, with all 263 tests passing either way.

The first attempt at that measurement was wrong and worth recording. Comparing a cold run at cost 4 against a warm run at cost 12 made the change look like a 36% regression-shaped improvement of the wrong size; the difference was ts-jest and Postgres warming up, not the cost factor. Both numbers above are second runs, and the cost-12 figure was taken twice — 34.3s and 34.5s — before being believed.

Deliberately not configurable. An environment variable here would be a way to weaken password hashing in production by misconfiguration, and nothing needs to tune it. The only route to the cheap cost is NODE_ENV=test, which a deployed container would announce anyway by refusing to serve the built frontend, since app.ts gates static serving on the same value. A setting that quietly degrades a security property should be unreachable rather than warned about, which is the reasoning that already made DEMO_MODE strict.

`hashRoundsFor` is pure and separately tested because the failure it guards against is silent: only the exact string 'test' earns the cheap cost, and an unset NODE_ENV gets the strong one, so the dangerous direction has to be asked for explicitly. Both constants are pinned by assertions too — without that the branch tests pass while the numbers drift to something useless.

Closes #242
2026-08-30 12:29:19 -05:00

50 lines
2.3 KiB
TypeScript

/**
* How expensive a password hash is, and why that differs under test.
*
* bcrypt's cost is exponential: each step doubles the work. Twelve is the right
* number for real passwords and the wrong one for a test suite that registers
* around thirty-five customers and asserts nothing about any of their hashes.
* `bcryptjs` is a pure-JS implementation, so it pays that cost several times
* over compared with a native build, and the integration suite spent most of
* its wall clock there. On a loaded runner that pushed
* adminInventory.integration.test.ts past its twenty-second timeout, which read
* as a foreign key violation somewhere else entirely — see #242.
*
* Deliberately not configurable.
* ------------------------------
* An environment variable here would be a way to weaken password hashing in
* production by misconfiguration, and nothing needs to tune this. The only way
* to reach the cheap cost is NODE_ENV=test, which a deployed container would
* also announce loudly by refusing to serve the built frontend — app.ts gates
* static file serving on the same value. A setting that quietly degrades a
* security property should be unreachable rather than merely warned about,
* which is the same reasoning that made DEMO_MODE strict.
*/
/** What real passwords are hashed with, everywhere that is not a test run. */
export const PRODUCTION_ROUNDS = 12;
/**
* What tests hash with. 2^8 = 256 times less work than production.
*
* Four is bcrypt's own floor, so this is as cheap as the algorithm allows. It
* is a fine number for a suite whose passwords are fixtures; it would be a
* serious defect anywhere a real one is stored.
*/
export const TEST_ROUNDS = 4;
/**
* The cost for an environment, from NODE_ENV.
*
* Pure and exported for its test: this is the whole of the policy, and the
* failure it guards against is silent. Only the exact string 'test' earns the
* cheap cost — an unset NODE_ENV, or anything else, gets the strong one, so the
* dangerous direction requires saying so explicitly.
*/
export function hashRoundsFor(nodeEnv: string | undefined): number {
return nodeEnv === 'test' ? TEST_ROUNDS : PRODUCTION_ROUNDS;
}
/** Resolved once at import: NODE_ENV does not change while the process runs. */
export const PASSWORD_HASH_ROUNDS = hashRoundsFor(process.env.NODE_ENV);