SPB Git

spb/drive Public

SPB Drive — self-hosted personal cloud drive (files, previews, sharing) on the MacLustr cluster.

JavaScript 82.7% CSS 10.6% Nunjucks 3.6% Shell 1.8% SQL 1.3%
6.7 KB · 186 lines javascript
Raw Blame History
1/**2 * ─────────────────────────────────────────────3 *  SPB Drive — Personal Cloud Drive4 * ─────────────────────────────────────────────5 *  Author  : Simon-Pierre Boucher6 *  Contact : contact@spboucher.ai7 *  File    : src/auth/password.mjs8 *  Purpose : argon2id password bootstrap/verify/change + brute-force lockout9 *  License : MIT © Simon-Pierre Boucher10 * ─────────────────────────────────────────────11 */1213import { randomBytes } from 'node:crypto';14import { existsSync, readFileSync, writeFileSync, chmodSync } from 'node:fs';15import readline from 'node:readline';16import argon2 from 'argon2';17import { config } from '../config.mjs';18import { getDb, logActivity } from '../db/db.mjs';1920const ARGON_OPTS = { type: argon2.argon2id, memoryCost: 65536, timeCost: 3, parallelism: 2 };2122/** Prompt for a password on the local TTY with echo disabled. */23function promptPassword(question) {24  return new Promise((resolve) => {25    const rl = readline.createInterface({ input: process.stdin, output: process.stdout });26    const orig = rl._writeToOutput.bind(rl);27    rl.question(question, (answer) => {28      rl._writeToOutput = orig;29      process.stdout.write('\n');30      rl.close();31      resolve(answer);32    });33    rl._writeToOutput = (s) => {34      if (s.includes(question)) orig(question);35    };36  });37}3839/**40 * First-boot seeding: create data/auth.json (argon2id hash) and41 * data/keys.json (session + share signing keys), both chmod 600.42 * The bootstrap env var is consumed once and ignored forever after.43 */44export async function ensureAuthBootstrap() {45  if (!existsSync(config.keysFile)) {46    writeFileSync(47      config.keysFile,48      JSON.stringify(49        {50          sessionSecret: randomBytes(32).toString('hex'),51          shareKey: randomBytes(32).toString('hex'),52          created: new Date().toISOString(),53        },54        null,55        2,56      ),57      { mode: 0o600 },58    );59    chmodSync(config.keysFile, 0o600);60  }6162  if (existsSync(config.authFile)) return;6364  let password = config.bootstrapPassword;65  if (!password) {66    if (!process.stdin.isTTY) {67      throw new Error(68        'First boot: set SPBDRIVE_BOOTSTRAP_PASSWORD or run interactively to seed the login password.',69      );70    }71    password = await promptPassword('SPB Drive first boot — choose the drive password: ');72  }73  if (!password || password.length < 6) {74    throw new Error('Bootstrap password must be at least 6 characters.');75  }7677  const hash = await argon2.hash(password, ARGON_OPTS);78  writeFileSync(79    config.authFile,80    JSON.stringify({ passwordHash: hash, updated: new Date().toISOString() }, null, 2),81    { mode: 0o600 },82  );83  chmodSync(config.authFile, 0o600);84}8586/** Load signing keys (session secret, share key). */87export function getKeys() {88  return JSON.parse(readFileSync(config.keysFile, 'utf8'));89}9091/**92 * Verify the drive password.93 * @returns {Promise<boolean>}94 */95export async function verifyPassword(password) {96  const { passwordHash } = JSON.parse(readFileSync(config.authFile, 'utf8'));97  try {98    return await argon2.verify(passwordHash, password ?? '');99  } catch {100    return false;101  }102}103104/**105 * Change the drive password (requires the current one).106 * @returns {Promise<boolean>} false if current password was wrong107 */108export async function changePassword(current, next) {109  if (!(await verifyPassword(current))) return false;110  if (!next || next.length < 6) throw new Error('New password must be at least 6 characters.');111  const hash = await argon2.hash(next, ARGON_OPTS);112  writeFileSync(113    config.authFile,114    JSON.stringify({ passwordHash: hash, updated: new Date().toISOString() }, null, 2),115    { mode: 0o600 },116  );117  logActivity('auth.password_changed');118  return true;119}120121/** Overwrite the password without knowing the old one (local CLI only). */122export async function resetPassword(next) {123  const hash = await argon2.hash(next, ARGON_OPTS);124  writeFileSync(125    config.authFile,126    JSON.stringify({ passwordHash: hash, updated: new Date().toISOString() }, null, 2),127    { mode: 0o600 },128  );129  chmodSync(config.authFile, 0o600);130}131132// ── Brute-force protection ──────────────────────────────────────────────133// Per-IP: 5 failures → 15-minute lockout. Global: exponential backoff that134// slows every attempt after repeated failures from anywhere.135136/**137 * @param {string} ip138 * @returns {{locked: boolean, retryAfterMs?: number}}139 */140export function checkLockout(ip) {141  const db = getDb();142  const now = Date.now();143  const row = db.prepare('SELECT * FROM login_attempts WHERE ip = ?').get(ip);144  if (row?.locked_until && row.locked_until > now) {145    return { locked: true, retryAfterMs: row.locked_until - now };146  }147  const global = db.prepare("SELECT value FROM meta WHERE key = 'global_login_backoff'").get();148  if (global) {149    const { count, last } = JSON.parse(global.value);150    if (count >= config.loginMaxFailures) {151      const waitMs = Math.min(2 ** (count - config.loginMaxFailures) * 1000, 60_000);152      if (now - last < waitMs) return { locked: true, retryAfterMs: waitMs - (now - last) };153    }154  }155  return { locked: false };156}157158/** Record a failed login for an IP; trip the lockout at the threshold. */159export function recordLoginFailure(ip) {160  const db = getDb();161  const now = Date.now();162  const row = db.prepare('SELECT * FROM login_attempts WHERE ip = ?').get(ip);163  const failCount = (row?.fail_count ?? 0) + 1;164  const lockedUntil = failCount >= config.loginMaxFailures ? now + config.loginLockoutMs : null;165  db.prepare(166    `INSERT INTO login_attempts (ip, fail_count, last_fail, locked_until) VALUES (?, ?, ?, ?)167     ON CONFLICT(ip) DO UPDATE SET fail_count = ?, last_fail = ?, locked_until = ?`,168  ).run(ip, failCount, now, lockedUntil, failCount, now, lockedUntil);169170  const global = db.prepare("SELECT value FROM meta WHERE key = 'global_login_backoff'").get();171  const g = global ? JSON.parse(global.value) : { count: 0, last: 0 };172  db.prepare(173    `INSERT INTO meta (key, value) VALUES ('global_login_backoff', ?)174     ON CONFLICT(key) DO UPDATE SET value = excluded.value`,175  ).run(JSON.stringify({ count: g.count + 1, last: now }));176177  logActivity('auth.login_failed', { ip });178}179180/** Clear failure counters after a successful login. */181export function clearLoginFailures(ip) {182  const db = getDb();183  db.prepare('DELETE FROM login_attempts WHERE ip = ?').run(ip);184  db.prepare("DELETE FROM meta WHERE key = 'global_login_backoff'").run();185}186