chore: add rollback-safe password rollout window

This commit is contained in:
hectorzhao
2026-08-28 11:47:08 +08:00
parent 2744690f9f
commit d85ff85999
3 changed files with 32 additions and 0 deletions
+19
View File
@@ -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\$/);
});
});
+9
View File
@@ -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');