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_LOCK_RECOVERY_MS=14400000
|
||||||
SESSION_ABSOLUTE_TIMEOUT_MS=43200000
|
SESSION_ABSOLUTE_TIMEOUT_MS=43200000
|
||||||
SESSION_RECENT_AUTH_MS=1800000
|
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_ARCHIVE_ENABLED=true
|
||||||
OPERATION_LOG_RETENTION_DAYS=180
|
OPERATION_LOG_RETENTION_DAYS=180
|
||||||
OPERATION_LOG_ARCHIVE_BATCH_SIZE=1000
|
OPERATION_LOG_ARCHIVE_BATCH_SIZE=1000
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import { hashPassword, isLegacySha256, legacyHashPassword, passwordNeedsRehash, verifyPassword } from './password-hasher';
|
import { hashPassword, isLegacySha256, legacyHashPassword, passwordNeedsRehash, verifyPassword } from './password-hasher';
|
||||||
|
|
||||||
describe('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 () => {
|
it('stores new passwords using versioned salted scrypt hashes', async () => {
|
||||||
const first = await hashPassword('correct horse battery staple');
|
const first = await hashPassword('correct horse battery staple');
|
||||||
const second = 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 () => {
|
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);
|
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_R = 8;
|
||||||
const DEFAULT_P = 1;
|
const DEFAULT_P = 1;
|
||||||
const MAX_MEMORY = 64 * 1024 * 1024;
|
const MAX_MEMORY = 64 * 1024 * 1024;
|
||||||
|
const MAX_LEGACY_WRITE_WINDOW_MS = 2 * 60 * 60 * 1000;
|
||||||
const LEGACY_SHA256 = /^[a-f0-9]{64}$/i;
|
const LEGACY_SHA256 = /^[a-f0-9]{64}$/i;
|
||||||
|
|
||||||
export async function hashPassword(password: string) {
|
export async function hashPassword(password: string) {
|
||||||
|
if (legacyTransitionWriteEnabled()) return legacyHashPassword(password);
|
||||||
const salt = randomBytes(16);
|
const salt = randomBytes(16);
|
||||||
const derived = await deriveScrypt(password, salt, KEY_LENGTH, {
|
const derived = await deriveScrypt(password, salt, KEY_LENGTH, {
|
||||||
N: DEFAULT_N,
|
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')}`;
|
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) {
|
export async function verifyPassword(password: string, encoded: string) {
|
||||||
if (isLegacySha256(encoded)) {
|
if (isLegacySha256(encoded)) {
|
||||||
const candidate = Buffer.from(legacyHashPassword(password), 'hex');
|
const candidate = Buffer.from(legacyHashPassword(password), 'hex');
|
||||||
|
|||||||
Reference in New Issue
Block a user