fix(auth): POST /login 500'd on any malformed password hash — found by the journey on tick one #5475
4 changed files+54−7
Modifiedsrc/__tests__/auth.test.ts+12−0View fileUnifiedSplit
@@ -17,6 +17,18 @@ describe("auth utilities", () => {
1717 expect(await verifyPassword("wrongpassword", hash)).toBe(false);
1818 });
1919
20 it("returns false — never throws — on a malformed stored hash", async () => {
21 // Bun.password.verify throws on a hash it cannot parse. Uncaught, that
22 // turned POST /login into a 500 for any row with a non-bcrypt
23 // password_hash — found in production by the spine's login-form
24 // journey on its first tick (2026-08-09). A hash that cannot be parsed
25 // cannot match any password, and a 500 would leak that the account
26 // exists and is odd.
27 expect(await verifyPassword("anything", "!not-a-real-hash!")).toBe(false);
28 expect(await verifyPassword("anything", "")).toBe(false);
29 expect(await verifyPassword("anything", "plaintext-legacy-value")).toBe(false);
30 });
31
2032 it("should generate unique session tokens", () => {
2133 const token1 = generateSessionToken();
2234 const token2 = generateSessionToken();
Modifiedsrc/__tests__/synthetic-journeys.test.ts+7−2View fileUnifiedSplit
@@ -96,8 +96,13 @@ describe("wiring", () => {
9696 expect(fn).toContain("delete(sessions)");
9797 });
9898
99 it("the probe user's password hash can never verify", async () => {
99 it("the probe user's hash is VALID bcrypt of a discarded secret — never a malformed marker", async () => {
100 // The first version stored a deliberately malformed hash, and
101 // Bun.password.verify THROWS on unparseable hashes — so the login-form
102 // journey's own wrong-password POST 500'd in production on tick one.
100103 const src = await Bun.file("src/lib/synthetic-journeys.ts").text();
101 expect(src).toContain("!unloginable!");
104 expect(src).toContain("Bun.password.hash(discarded");
105 // The healing path for rows created by the first version.
106 expect(src).toContain('startsWith("!unloginable!")');
102107 });
103108});
Modifiedsrc/lib/auth.ts+13−1View fileUnifiedSplit
@@ -13,7 +13,19 @@ export async function verifyPassword(
1313 password: string,
1414 hash: string
1515): Promise<boolean> {
16 return await Bun.password.verify(password, hash);
16 // Bun.password.verify THROWS on a hash it cannot parse (rather than
17 // returning false), and no call site caught it — so any row whose
18 // password_hash wasn't a valid bcrypt/argon2 string turned POST /login
19 // into a 500. Found in production by the spine's login-form journey on
20 // its first tick (2026-08-09): its probe user carried a deliberately
21 // invalid hash, and the "wrong password" probe hit a server error
22 // instead of a rejection. A hash that cannot be parsed cannot match any
23 // password — and a 500 here leaks that the account exists and is odd.
24 try {
25 return await Bun.password.verify(password, hash);
26 } catch {
27 return false;
28 }
1729}
1830
1931export function generateSessionToken(): string {
Modifiedsrc/lib/synthetic-journeys.ts+22−4View fileUnifiedSplit
@@ -104,28 +104,46 @@ async function loginFormJourney(
104104 * first version of this function, on the gate's first enforced PR).
105105 */
106106async function ensureProbeUser(): Promise<string> {
107 const unloginable = Buffer.from(
107 // A VALID bcrypt hash of 48 random bytes that are discarded immediately —
108 // unloginable in practice (nobody knows the password), but structurally
109 // sound. The first version used a deliberately malformed hash marker,
110 // which turned the login-form journey's wrong-password POST into a 500:
111 // Bun.password.verify throws on unparseable hashes, and the login route
112 // didn't catch it. The journey caught that in production on its first
113 // tick; verifyPassword() now also catches (defence in depth), but the
114 // probe row should be well-formed regardless.
115 const discarded = Buffer.from(
108116 crypto.getRandomValues(new Uint8Array(48))
109117 ).toString("base64");
118 const validUnknownHash = await Bun.password.hash(discarded, {
119 algorithm: "bcrypt",
120 cost: 10,
121 });
110122 const [inserted] = await db
111123 .insert(users)
112124 .values({
113125 username: PROBE_USERNAME,
114126 email: normalizeEmail(PROBE_EMAIL),
115127 displayName: "Spine Probe (synthetic)",
116 // Random bytes, never bcrypt — no password can ever hash to this.
117 passwordHash: `!unloginable!${unloginable}`,
128 passwordHash: validUnknownHash,
118129 emailVerifiedAt: new Date(),
119130 })
120131 .onConflictDoNothing({ target: [users.username] })
121132 .returning({ id: users.id });
122133 if (inserted) return inserted.id;
123134 const [existing] = await db
124 .select({ id: users.id })
135 .select({ id: users.id, passwordHash: users.passwordHash })
125136 .from(users)
126137 .where(eq(users.username, PROBE_USERNAME))
127138 .limit(1);
128139 if (!existing) throw new Error("probe user neither inserted nor found");
140 // Heal a probe row created by the first (malformed-hash) version.
141 if (existing.passwordHash.startsWith("!unloginable!")) {
142 await db
143 .update(users)
144 .set({ passwordHash: validUnknownHash })
145 .where(eq(users.id, existing.id));
146 }
129147 return existing.id;
130148}
131149
132150
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts