chore: add rollback-safe password rollout window
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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\$/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user