From e392bf2eb7ee5d3c29e097afb3462278e62d3481 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Wed, 17 Jun 2026 20:34:58 -0400 Subject: [PATCH] fix(19): IN-02 add lockstep test pinning inlined scrypt params against canonical verifyPassword --- apps/api/tests/auth/localCredentials.test.ts | 37 ++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/apps/api/tests/auth/localCredentials.test.ts b/apps/api/tests/auth/localCredentials.test.ts index 6c3a591..55b03e8 100644 --- a/apps/api/tests/auth/localCredentials.test.ts +++ b/apps/api/tests/auth/localCredentials.test.ts @@ -16,6 +16,7 @@ */ import { describe, it, expect } from 'vitest'; +import { scryptSync, randomBytes } from 'node:crypto'; import { hashPassword, verifyPassword } from '../../src/auth/localCredentials.js'; describe('hashPassword / verifyPassword', () => { @@ -43,6 +44,42 @@ describe('hashPassword / verifyPassword', () => { await expect(verifyPassword('scrypt$bad$data', 'x')).resolves.toBe(false); }); + it('Test 6 (IN-02): a hash produced by the INLINED scrypt parameters round-trips against the canonical verifyPassword', async () => { + // IN-02: the PHC scrypt hash is copy-pasted in three places — the canonical module + // (src/auth/localCredentials.ts), the break-glass CLI (scripts/reset-admin.ts), and the + // CI seed step (.gitea/workflows/ci.yml). If the parameters ever drift, those inlined + // copies would produce hashes the canonical verifyPassword cannot validate, silently + // breaking login for seeded/reset accounts. This test pins the inlined parameter set: + // if anyone changes N/r/p/KEY_LEN in the canonical module without updating the inlined + // copies (or vice versa), this round-trip fails loudly in CI. + // + // These constants MUST match scripts/reset-admin.ts and .gitea/workflows/ci.yml exactly. + const INLINE_N = 16384; + const INLINE_R = 8; + const INLINE_P = 1; + const INLINE_KEY_LEN = 32; + + const password = 'inline-roundtrip-pw'; + const salt = randomBytes(16); + const hash = scryptSync(password, salt, INLINE_KEY_LEN, { + N: INLINE_N, + r: INLINE_R, + p: INLINE_P, + }); + const inlineEncoded = [ + 'scrypt', + INLINE_N, + INLINE_R, + INLINE_P, + salt.toString('base64url'), + hash.toString('base64url'), + ].join('$'); + + // The canonical verifyPassword must validate a hash produced by the inlined params. + expect(await verifyPassword(inlineEncoded, password)).toBe(true); + expect(await verifyPassword(inlineEncoded, 'wrong')).toBe(false); + }); + it('Test 5: encoded string has scrypt$N$r$p$salt$hash shape (6 $-delimited segments)', async () => { const encoded = await hashPassword('testpassword'); const segments = encoded.split('$');