Make every login refusal cost the same verification

Sign-in returned the moment it found no account, or an account carrying
no password hash, before it reached the verifier. An account that did
have a password paid a full Argon2id verification at 19 MiB and t=2. The
three refusals answered with the identical 401 body and three very
different durations, so the clock told an anonymous caller whether an
address is registered and whether it carries a password.

`verifyPasswordOrDummy` verifies against a stand-in hash minted once per
process at the same cost parameters real hashes use, and returns false
whenever there is nothing to verify, so the passkey-only account takes
the same path as one with a password. The dummy arm discards its own
verdict and swallows a throw, so no outcome there can be read as a
success or answer with a different status.
This commit is contained in:
Marc-André Bombeck committed 2026-09-11 13:59:42 +02:00
1 parent 6ef139c7b2
commit 6a5fb338b1
8 files changed
+332 -17

No files matched your search

@@ -0,0 +1,93 @@
/**
* Signing in must cost the same whether or not the account exists.
*
* The login route used to return the moment it found no account, or an
* account with no password hash, before it reached the verifier. An account
* that did have a password paid a full Argon2id verification. Same status,
* same body, very different durations — so the clock answered "is this
* address registered, and does it carry a password", which is the question
* the removed discovery endpoint used to answer out loud.
*
* What this file pins is the work, not the wall clock: that the empty arms
* reach the verifier at all, that the hash they verify against was minted at
* the same cost parameters every real hash carries, and that no outcome on
* those arms can be read as a successful verification. A unit test cannot
* prove two durations are equal — it can only prove the same call is made.
*
* `@node-rs/argon2` is mocked so the assertions are about which calls happen
* with which arguments, and so the suite does not spend real Argon2 time.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
// Hoisted: `password.ts` mints the dummy hash at module load, so the mock
// has to exist before the import below runs.
const { hashMock, verifyMock } = vi.hoisted(() => ({
hashMock: vi.fn(async (input: string) => `hashed:${input}`),
verifyMock: vi.fn(async (_hash: string, _password: string) => false),
}));
vi.mock("@node-rs/argon2", () => ({ hash: hashMock, verify: verifyMock }));
import { verifyPasswordOrDummy } from "@/lib/auth/password";
import { ARGON2_HASH_OPTIONS } from "@/lib/auth/argon2-params.mjs";
beforeEach(() => {
verifyMock.mockReset();
verifyMock.mockResolvedValue(false);
});
describe("verifyPasswordOrDummy", () => {
it("mints the dummy hash under the same Argon2id parameters real hashes use", () => {
// The module mints it once at load, so the call has already happened.
expect(hashMock).toHaveBeenCalledWith(
expect.any(String),
ARGON2_HASH_OPTIONS,
);
});
it("verifies against the stored hash when the account has one", async () => {
verifyMock.mockResolvedValueOnce(true);
await expect(
verifyPasswordOrDummy("$argon2id$stored", "correct horse"),
).resolves.toBe(true);
expect(verifyMock).toHaveBeenCalledTimes(1);
expect(verifyMock).toHaveBeenCalledWith(
"$argon2id$stored",
"correct horse",
);
});
it("still verifies when there is no account at all (null hash)", async () => {
await expect(verifyPasswordOrDummy(null, "anything")).resolves.toBe(false);
expect(verifyMock).toHaveBeenCalledTimes(1);
// Against the dummy, not against nothing.
expect(verifyMock.mock.calls[0][0]).toBe(
await hashMock.mock.results[0].value,
);
});
it("still verifies for a passkey-only account (undefined hash)", async () => {
// The passkey-only case is the one that matters most: if it took the
// cheap path the channel would still answer "this account has no
// password", which is half of what the removed endpoint published.
await expect(verifyPasswordOrDummy(undefined, "anything")).resolves.toBe(
false,
);
expect(verifyMock).toHaveBeenCalledTimes(1);
});
it("never reports success from the dummy arm, whatever the verifier says", async () => {
verifyMock.mockResolvedValue(true);
await expect(verifyPasswordOrDummy(null, "anything")).resolves.toBe(false);
await expect(verifyPasswordOrDummy(undefined, "anything")).resolves.toBe(
false,
);
});
it("swallows a throw from the dummy arm rather than answering differently", async () => {
// A 500 only the unknown-account arm can produce is the same oracle in
// a different field.
verifyMock.mockRejectedValueOnce(new Error("malformed hash"));
await expect(verifyPasswordOrDummy(null, "anything")).resolves.toBe(false);
});
});
+56
View File
@@ -20,6 +20,62 @@ export async function verifyPassword(
return verify(storedHash, password);
}
/**
* A stand-in stored hash for the login arms that have nothing to verify.
*
* Sign-in used to return the moment it found no account, or an account with
* no password — one indexed SELECT — while an account that did have a
* password paid a full Argon2id verification at 19 MiB and t=2. The two
* answers were byte-identical and the two costs were not, so the clock told
* a caller which addresses are registered and which of those carry a
* password. Verifying against this hash on the empty arms makes the work
* comparable.
*
* Minted once per process from `ARGON2_HASH_OPTIONS`, the same parameters
* every real hash carries, because Argon2 encodes its cost in the hash
* string and the verifier pays whatever the string says. A dummy minted at
* cheaper settings would leave the gap it was added to close.
*
* The input is a fixed constant rather than a random string so the module
* has no hidden state, and it is never compared to anything: the arm that
* verifies against it discards the verdict and returns false. There is no
* path on which a caller can make this hash accept.
*/
const DUMMY_VERIFY_INPUT = "healthlog: not a credential, never accepted";
const DUMMY_PASSWORD_HASH: Promise<string> = hash(
DUMMY_VERIFY_INPUT,
ARGON2_HASH_OPTIONS,
);
// Keep a mint failure from surfacing as an unhandled rejection at import
// time; `verifyPasswordOrDummy` still awaits and still sees it.
void DUMMY_PASSWORD_HASH.catch(() => undefined);
/**
* Verify `password` against `storedHash`, or against the dummy hash when
* there is no stored hash to verify against.
*
* Returns false whenever `storedHash` is absent — an account without a
* password, or no account at all. The dummy verification's own verdict is
* thrown away, so a false here is never a rejected credential being
* mistaken for anything else, and a true can only come from a real stored
* hash.
*
* Callers on an anonymous surface should use this rather than branching on
* the absent hash themselves: the branch is what the clock reads.
*/
export async function verifyPasswordOrDummy(
storedHash: string | null | undefined,
password: string,
): Promise<boolean> {
if (storedHash) return verify(storedHash, password);
// Same verifier, same cost profile, verdict discarded. A throw here (a
// malformed dummy, a mint that failed) must not turn into a 500 that only
// the unknown-account arm can produce — that would be the oracle again,
// in the status code this time.
await verify(await DUMMY_PASSWORD_HASH, password).catch(() => false);
return false;
}
export interface PasswordStrength {
score: number; // 0-4
feedback: string[];