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%
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