/** * ───────────────────────────────────────────── * SPB Drive — Personal Cloud Drive * ───────────────────────────────────────────── * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : src/auth/password.mjs * Purpose : argon2id password bootstrap/verify/change + brute-force lockout * License : MIT © Simon-Pierre Boucher * ───────────────────────────────────────────── */ import { randomBytes } from 'node:crypto'; import { existsSync, readFileSync, writeFileSync, chmodSync } from 'node:fs'; import readline from 'node:readline'; import argon2 from 'argon2'; import { config } from '../config.mjs'; import { getDb, logActivity } from '../db/db.mjs'; const ARGON_OPTS = { type: argon2.argon2id, memoryCost: 65536, timeCost: 3, parallelism: 2 }; /** Prompt for a password on the local TTY with echo disabled. */ function promptPassword(question) { return new Promise((resolve) => { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); const orig = rl._writeToOutput.bind(rl); rl.question(question, (answer) => { rl._writeToOutput = orig; process.stdout.write('\n'); rl.close(); resolve(answer); }); rl._writeToOutput = (s) => { if (s.includes(question)) orig(question); }; }); } /** * First-boot seeding: create data/auth.json (argon2id hash) and * data/keys.json (session + share signing keys), both chmod 600. * The bootstrap env var is consumed once and ignored forever after. */ export async function ensureAuthBootstrap() { if (!existsSync(config.keysFile)) { writeFileSync( config.keysFile, JSON.stringify( { sessionSecret: randomBytes(32).toString('hex'), shareKey: randomBytes(32).toString('hex'), created: new Date().toISOString(), }, null, 2, ), { mode: 0o600 }, ); chmodSync(config.keysFile, 0o600); } if (existsSync(config.authFile)) return; let password = config.bootstrapPassword; if (!password) { if (!process.stdin.isTTY) { throw new Error( 'First boot: set SPBDRIVE_BOOTSTRAP_PASSWORD or run interactively to seed the login password.', ); } password = await promptPassword('SPB Drive first boot — choose the drive password: '); } if (!password || password.length < 6) { throw new Error('Bootstrap password must be at least 6 characters.'); } const hash = await argon2.hash(password, ARGON_OPTS); writeFileSync( config.authFile, JSON.stringify({ passwordHash: hash, updated: new Date().toISOString() }, null, 2), { mode: 0o600 }, ); chmodSync(config.authFile, 0o600); } /** Load signing keys (session secret, share key). */ export function getKeys() { return JSON.parse(readFileSync(config.keysFile, 'utf8')); } /** * Verify the drive password. * @returns {Promise} */ export async function verifyPassword(password) { const { passwordHash } = JSON.parse(readFileSync(config.authFile, 'utf8')); try { return await argon2.verify(passwordHash, password ?? ''); } catch { return false; } } /** * Change the drive password (requires the current one). * @returns {Promise} false if current password was wrong */ export async function changePassword(current, next) { if (!(await verifyPassword(current))) return false; if (!next || next.length < 6) throw new Error('New password must be at least 6 characters.'); const hash = await argon2.hash(next, ARGON_OPTS); writeFileSync( config.authFile, JSON.stringify({ passwordHash: hash, updated: new Date().toISOString() }, null, 2), { mode: 0o600 }, ); logActivity('auth.password_changed'); return true; } /** Overwrite the password without knowing the old one (local CLI only). */ export async function resetPassword(next) { const hash = await argon2.hash(next, ARGON_OPTS); writeFileSync( config.authFile, JSON.stringify({ passwordHash: hash, updated: new Date().toISOString() }, null, 2), { mode: 0o600 }, ); chmodSync(config.authFile, 0o600); } // ── Brute-force protection ────────────────────────────────────────────── // Per-IP: 5 failures → 15-minute lockout. Global: exponential backoff that // slows every attempt after repeated failures from anywhere. /** * @param {string} ip * @returns {{locked: boolean, retryAfterMs?: number}} */ export function checkLockout(ip) { const db = getDb(); const now = Date.now(); const row = db.prepare('SELECT * FROM login_attempts WHERE ip = ?').get(ip); if (row?.locked_until && row.locked_until > now) { return { locked: true, retryAfterMs: row.locked_until - now }; } const global = db.prepare("SELECT value FROM meta WHERE key = 'global_login_backoff'").get(); if (global) { const { count, last } = JSON.parse(global.value); if (count >= config.loginMaxFailures) { const waitMs = Math.min(2 ** (count - config.loginMaxFailures) * 1000, 60_000); if (now - last < waitMs) return { locked: true, retryAfterMs: waitMs - (now - last) }; } } return { locked: false }; } /** Record a failed login for an IP; trip the lockout at the threshold. */ export function recordLoginFailure(ip) { const db = getDb(); const now = Date.now(); const row = db.prepare('SELECT * FROM login_attempts WHERE ip = ?').get(ip); const failCount = (row?.fail_count ?? 0) + 1; const lockedUntil = failCount >= config.loginMaxFailures ? now + config.loginLockoutMs : null; db.prepare( `INSERT INTO login_attempts (ip, fail_count, last_fail, locked_until) VALUES (?, ?, ?, ?) ON CONFLICT(ip) DO UPDATE SET fail_count = ?, last_fail = ?, locked_until = ?`, ).run(ip, failCount, now, lockedUntil, failCount, now, lockedUntil); const global = db.prepare("SELECT value FROM meta WHERE key = 'global_login_backoff'").get(); const g = global ? JSON.parse(global.value) : { count: 0, last: 0 }; db.prepare( `INSERT INTO meta (key, value) VALUES ('global_login_backoff', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, ).run(JSON.stringify({ count: g.count + 1, last: now })); logActivity('auth.login_failed', { ip }); } /** Clear failure counters after a successful login. */ export function clearLoginFailures(ip) { const db = getDb(); db.prepare('DELETE FROM login_attempts WHERE ip = ?').run(ip); db.prepare("DELETE FROM meta WHERE key = 'global_login_backoff'").run(); }