From d85ff8599955e8d217b923dd314690f4b18a4dd2 Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Fri, 28 Aug 2026 11:47:08 +0800 Subject: [PATCH] chore: add rollback-safe password rollout window --- .env.example | 4 ++++ api/src/auth/password-hasher.spec.ts | 19 +++++++++++++++++++ api/src/auth/password-hasher.ts | 9 +++++++++ 3 files changed, 32 insertions(+) diff --git a/.env.example b/.env.example index a43ae23..fabfa99 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,10 @@ CLIENT_SESSION_IDLE_TIMEOUT_MS=7200000 SESSION_LOCK_RECOVERY_MS=14400000 SESSION_ABSOLUTE_TIMEOUT_MS=43200000 SESSION_RECENT_AUTH_MS=1800000 +# First-rollout safety only: both values must be set and the deadline must be within two hours. +# Omit them during normal operation so all password writes use scrypt. +PASSWORD_HASH_LEGACY_TRANSITION=false +PASSWORD_HASH_LEGACY_WRITE_UNTIL= OPERATION_LOG_ARCHIVE_ENABLED=true OPERATION_LOG_RETENTION_DAYS=180 OPERATION_LOG_ARCHIVE_BATCH_SIZE=1000 diff --git a/api/src/auth/password-hasher.spec.ts b/api/src/auth/password-hasher.spec.ts index 6902300..6fd8bd5 100644 --- a/api/src/auth/password-hasher.spec.ts +++ b/api/src/auth/password-hasher.spec.ts @@ -1,6 +1,16 @@ import { hashPassword, isLegacySha256, legacyHashPassword, passwordNeedsRehash, verifyPassword } from './password-hasher'; describe('password hasher', () => { + const originalTransition = process.env.PASSWORD_HASH_LEGACY_TRANSITION; + const originalUntil = process.env.PASSWORD_HASH_LEGACY_WRITE_UNTIL; + + afterEach(() => { + if (originalTransition === undefined) delete process.env.PASSWORD_HASH_LEGACY_TRANSITION; + else process.env.PASSWORD_HASH_LEGACY_TRANSITION = originalTransition; + if (originalUntil === undefined) delete process.env.PASSWORD_HASH_LEGACY_WRITE_UNTIL; + else process.env.PASSWORD_HASH_LEGACY_WRITE_UNTIL = originalUntil; + }); + it('stores new passwords using versioned salted scrypt hashes', async () => { const first = await hashPassword('correct horse battery staple'); const second = await hashPassword('correct horse battery staple'); @@ -24,4 +34,13 @@ describe('password hasher', () => { it('rejects malformed or excessive scrypt parameters', async () => { await expect(verifyPassword('password', '$scrypt$v=1$N=1048576,r=8,p=1$YWJjZGVmZ2hpamtsbW5vcA$YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXowMTIzNDU')).resolves.toBe(false); }); + + it('supports only a bounded legacy-write window for the first compatibility rollout', async () => { + process.env.PASSWORD_HASH_LEGACY_TRANSITION = 'true'; + process.env.PASSWORD_HASH_LEGACY_WRITE_UNTIL = new Date(Date.now() + 30 * 60 * 1000).toISOString(); + await expect(hashPassword('transition-password')).resolves.toBe(legacyHashPassword('transition-password')); + + process.env.PASSWORD_HASH_LEGACY_WRITE_UNTIL = new Date(Date.now() + 3 * 60 * 60 * 1000).toISOString(); + await expect(hashPassword('strong-password')).resolves.toMatch(/^\$scrypt\$/); + }); }); diff --git a/api/src/auth/password-hasher.ts b/api/src/auth/password-hasher.ts index 0c29c9d..4baa3a9 100644 --- a/api/src/auth/password-hasher.ts +++ b/api/src/auth/password-hasher.ts @@ -5,9 +5,11 @@ const DEFAULT_N = 32768; const DEFAULT_R = 8; const DEFAULT_P = 1; const MAX_MEMORY = 64 * 1024 * 1024; +const MAX_LEGACY_WRITE_WINDOW_MS = 2 * 60 * 60 * 1000; const LEGACY_SHA256 = /^[a-f0-9]{64}$/i; export async function hashPassword(password: string) { + if (legacyTransitionWriteEnabled()) return legacyHashPassword(password); const salt = randomBytes(16); const derived = await deriveScrypt(password, salt, KEY_LENGTH, { N: DEFAULT_N, @@ -18,6 +20,13 @@ export async function hashPassword(password: string) { return `$scrypt$v=${VERSION}$N=${DEFAULT_N},r=${DEFAULT_R},p=${DEFAULT_P}$${salt.toString('base64url')}$${derived.toString('base64url')}`; } +function legacyTransitionWriteEnabled() { + if (process.env.PASSWORD_HASH_LEGACY_TRANSITION !== 'true') return false; + const until = Date.parse(process.env.PASSWORD_HASH_LEGACY_WRITE_UNTIL ?? ''); + const remaining = until - Date.now(); + return Number.isFinite(until) && remaining > 0 && remaining <= MAX_LEGACY_WRITE_WINDOW_MS; +} + export async function verifyPassword(password: string, encoded: string) { if (isLegacySha256(encoded)) { const candidate = Buffer.from(legacyHashPassword(password), 'hex');