Spreadsheet-UX op een echte relationele engine

main

Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

2.8 kB Ruw bestand
import { randomBytes, scrypt as scryptCb, timingSafeEqual } from 'node:crypto';
import { promisify } from 'node:util';

const scrypt = promisify(scryptCb) as (
  password: string | Buffer,
  salt: string | Buffer,
  keylen: number,
  options: { N: number; r: number; p: number; maxmem: number },
) => Promise<Buffer>;

/**
 * scrypt parameters. N=2^15 costs roughly 100ms on a modern laptop, which is
 * the point: it makes an offline attack on a stolen `_wellformed_user` table
 * expensive without making login feel slow.
 */
const PARAMS = { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 };
const KEY_LENGTH = 64;
const SALT_LENGTH = 16;

export const MIN_PASSWORD_LENGTH = 10;
export const MAX_PASSWORD_LENGTH = 200;

export async function hashPassword(password: string): Promise<string> {
  const salt = randomBytes(SALT_LENGTH);
  const key = await scrypt(password.normalize('NFKC'), salt, KEY_LENGTH, PARAMS);
  return ['scrypt', PARAMS.N, PARAMS.r, PARAMS.p, salt.toString('base64'), key.toString('base64')].join('$');
}

/** Constant-time comparison; returns false rather than throwing on junk input. */
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
  const parts = stored.split('$');
  if (parts.length !== 6 || parts[0] !== 'scrypt') return false;

  const [, nRaw, rRaw, pRaw, saltRaw, keyRaw] = parts as [string, string, string, string, string, string];
  const N = Number(nRaw);
  const r = Number(rRaw);
  const p = Number(pRaw);
  if (!Number.isInteger(N) || !Number.isInteger(r) || !Number.isInteger(p)) return false;

  let expected: Buffer;
  let actual: Buffer;
  try {
    expected = Buffer.from(keyRaw, 'base64');
    actual = await scrypt(password.normalize('NFKC'), Buffer.from(saltRaw, 'base64'), expected.length, {
      N,
      r,
      p,
      maxmem: PARAMS.maxmem,
    });
  } catch {
    return false;
  }

  return expected.length === actual.length && timingSafeEqual(expected, actual);
}

export interface PasswordProblem {
  message: string;
}

export function checkPasswordStrength(password: string): PasswordProblem | null {
  if (password.length < MIN_PASSWORD_LENGTH) {
    return { message: `Passwords must be at least ${MIN_PASSWORD_LENGTH} characters.` };
  }
  if (password.length > MAX_PASSWORD_LENGTH) {
    return { message: `Passwords must be at most ${MAX_PASSWORD_LENGTH} characters.` };
  }
  if (/^\s|\s$/.test(password)) {
    return { message: 'Passwords cannot start or end with whitespace.' };
  }
  if (COMMON_PASSWORDS.has(password.toLowerCase())) {
    return { message: 'That password is too common. Choose something else.' };
  }
  return null;
}

const COMMON_PASSWORDS = new Set([
  'password',
  'password1',
  'password123',
  'passw0rd123',
  '1234567890',
  '12345678901',
  'qwertyuiop',
  'letmein123',
  'admin12345',
  'administrator',
  'welcome123',
  'iloveyou123',
  'changeme123',
]);