openvibers/openvibe.network Commits

Add pilot authenticator sign-in and recovery codes (#764)

OP OpenVibers committed 3 days ago · 02b1952d22 · 17 files, +490 −5

* Add pilot authenticator sign-in with one-use recovery and remembered browsers

* Include authenticator migration and security module in inventory checks

* Use salted slow hashes for authenticator recovery codes

Changes

ddiff --git a/docs/security/authenticator.md b/docs/security/authenticator.md
nnew file mode 100644
iindex 00000000..c9a44925
---- /dev/null
++++ b/docs/security/authenticator.md
@@@ -0,0 +1,17 @@
++# Authenticator rollout (Part 1)
++
++`mfa_enrollment` is an owner-only Console setting: `off` (default), `pilot` (the configured owner and `ovprobe_merge_keep`), or `all`. The deployment-only `NETWORK_MFA_PILOT=1` also enables just those two pilot accounts while the Console remains off for everyone else. Remove that environment flag to end the pilot. These flags control enrollment and display, never enforcement of an already enrolled factor. Missing encryption configuration fails closed for enrolled users; existing sessions continue unchanged. No site data is cleared.
++
++Deploy prerequisite: `NETWORK_MFA_KEY`, 32 random bytes encoded as exactly 64 hexadecimal characters, in the Network service environment. Generate once on the host using a cryptographic random generator, append directly to `/etc/openvibe/network.env` with owner-only permissions and without terminal output. Do not replace an existing key. Back it up with the service's encrypted secrets; losing it loses authenticator secrets. Restart through the normal deploy. AES-256-GCM binds each encrypted secret to its account id; HKDF separates encryption and remembered-cookie signing keys. Never store this key in Console, git, logs or a command argument.
++
++Enrollment requires the current password. A server-created 160-bit secret is rendered as a local QR (qrcode 1.5.4, MIT), plus a manual key, and expires after ten minutes until confirmed. OTPAuth 9.6.0 (MIT) verifies six digits, SHA-1/30 seconds, one adjacent step of clock tolerance. A locked account row serializes confirmation, TOTP counters and recovery use. Ten 80-bit recovery codes appear once and only their individually salted bcrypt cost-10 hashes persist. Pending sign-ins are random 256-bit HttpOnly, Secure, host-only cookies, hashed in storage, expire in five minutes, and are bound to the factor generation and password credential. They cannot be exchanged as sessions. Five failures across pending states block the account's factor for five minutes.
++
++Remembered-browser cookies are HMAC signed with a separate derived key, random, HttpOnly, Secure, host-only and expire in 30 days. The database holds only their hashes and public ids. The security page can revoke each browser. Password reset, token revocation and factor removal revoke remembered browsers and pending sign-ins. Existing access/refresh sessions retain their behavior. Factor disabling requires a current unused code or recovery code.
++
++Account export covers metadata for all four tables and excludes secret ciphertext, credential hashes, code hashes, token hashes and generation values. ADR-033 erases all four tables. Errors are generic and no codes or secrets are logged. API calls use JSON, rate limits and account-bound authorization; the cross-origin account hub carries its existing Bearer token.
++
++Validation: `ov test mfa account-data security-session login-auto-continue`; full suite in CI. Production pilot: enroll the probe, complete a new password sign-in with a fresh authenticator step, consume one recovery code, start another password sign-in and confirm the same recovery code is refused. Disable the probe factor using another unused recovery code after checking, so unattended probe work is not locked out. Do not enroll the owner's account without their own authenticator.
++
++Research: [OWASP MFA guidance](https://cheatsheetseries.owasp.org/cheatsheets/Multifactor_Authentication_Cheat_Sheet.html), [OTPAuth implementation](https://github.com/hectorm/otpauth), [RFC 6238](https://www.rfc-editor.org/rfc/rfc6238). A passkey (Part 2) will authenticate on Network's own origin when sign-in starts on another site. Step-up claims, stronger session management and factor-change notifications follow in Part 3; phone verification follows in Part 4.
++
++For Fable: account security enrollment/confirmation/recovery presentation, sign-in challenge with remember checkbox, remembered-browser list and revoke controls. All new markup is plain; no added CSS.
ddiff --git a/migrations/0066_authenticator.sql b/migrations/0066_authenticator.sql
nnew file mode 100644
iindex 00000000..a1370b43
---- /dev/null
++++ b/migrations/0066_authenticator.sql
@@@ -0,0 +1,36 @@
++-- phase: expand
++CREATE TABLE IF NOT EXISTS user_mfa (
++ user_id bigint PRIMARY KEY REFERENCES users(id),
++ secret_ciphertext text,
++ pending_secret_ciphertext text,
++ pending_until bigint NOT NULL DEFAULT 0,
++ last_step bigint NOT NULL DEFAULT -1,
++ failures integer NOT NULL DEFAULT 0,
++ locked_until bigint NOT NULL DEFAULT 0,
++ enabled_at bigint,
++ generation text NOT NULL
++);
++CREATE TABLE IF NOT EXISTS mfa_recovery_codes (
++ user_id bigint NOT NULL REFERENCES users(id),
++ code_hash text NOT NULL,
++ used_at bigint,
++ PRIMARY KEY(user_id, code_hash)
++);
++CREATE TABLE IF NOT EXISTS mfa_challenges (
++ token_hash text PRIMARY KEY,
++ user_id bigint NOT NULL REFERENCES users(id),
++ credential_hash text NOT NULL,
++ generation text NOT NULL,
++ expires_at bigint NOT NULL
++);
++CREATE INDEX IF NOT EXISTS mfa_challenges_user ON mfa_challenges(user_id);
++CREATE TABLE IF NOT EXISTS mfa_devices (
++ id text PRIMARY KEY,
++ user_id bigint NOT NULL REFERENCES users(id),
++ token_hash text NOT NULL,
++ generation text NOT NULL,
++ created_at bigint NOT NULL,
++ expires_at bigint NOT NULL
++);
++CREATE INDEX IF NOT EXISTS mfa_devices_user ON mfa_devices(user_id);
++INSERT INTO site_settings (key, value, type) VALUES ('mfa_enrollment', 'off', 'string') ON CONFLICT (key) DO NOTHING;
ddiff --git a/package-lock.json b/package-lock.json
iindex ef6bf188..c808d82d 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -23,6 +23,7 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.391.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.187.0",
++ "otpauth": "9.6.0",
"pg": "^8.23.0",
"qrcode": "1.5.4",
"uuid": "^11.1.0",
@@@ -663,6 +664,18 @@
"url": "https://opencollective.com/express"
}
},
++ "node_modules/@noble/hashes": {
++ "version": "2.4.0",
++ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz",
++ "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 20.19.0"
++ },
++ "funding": {
++ "url": "https://paulmillr.com/funding/"
++ }
++ },
"node_modules/@sapphire/async-queue": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz",
@@@ -2359,6 +2372,18 @@
"openvibe-build-assets": "scripts/build-assets.js"
}
},
++ "node_modules/otpauth": {
++ "version": "9.6.0",
++ "resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.6.0.tgz",
++ "integrity": "sha512-qQXHTxcXohDhQjF1ZEbUVEpTIssFL2rd+2kHfw/McIVCbTLD6qAsbTCQxpXRLyeyUw6Ipjpykw4dWZL/zfIVqA==",
++ "license": "MIT",
++ "dependencies": {
++ "@noble/hashes": "2.4.0"
++ },
++ "funding": {
++ "url": "https://github.com/hectorm/otpauth?sponsor=1"
++ }
++ },
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
ddiff --git a/package.json b/package.json
iindex bf990b91..74779b71 100644
---- a/package.json
++++ b/package.json
@@@ -27,6 +27,7 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.391.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.187.0",
++ "otpauth": "9.6.0",
"pg": "^8.23.0",
"qrcode": "1.5.4",
"uuid": "^11.1.0",
ddiff --git a/public/js/login.js b/public/js/login.js
iindex 9348e61e..d373ce7c 100644
---- a/public/js/login.js
++++ b/public/js/login.js
@@@ -780,6 +780,12 @@ async function handleLogin(e) {
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Login failed');
++ if (data.mfa_required) {
++ e.target.reset();
++ showMfaChallenge();
++ return;
++ }
++
// Store token & save account
localStorage.setItem('ov_token', data.token);
setAuthCookie(data.token);
@@@ -1103,3 +1109,31 @@ async function continueAnonymous() {
prefillUsername(params.get('login_hint'));
}
})();
++
++// Pending sign-in lives only in the server's short-lived HttpOnly cookie.
++function showMfaChallenge() {
++ const previous = document.getElementById('mfa-login');
++ if (previous) previous.remove();
++ const form = document.createElement('form');
++ form.id = 'mfa-login';
++ form.innerHTML = '<label>Authenticator or recovery code <input name="code" autocomplete="one-time-code" required maxlength="30"></label><label><input name="remember" type="checkbox"> Remember this browser for 30 days</label><button class="ovk-btn ovk-btn--primary" type="submit">Continue</button><p role="status"></p>';
++ document.getElementById('login-form').hidden = true;
++ document.getElementById('login-form').after(form);
++ form.elements.code.focus();
++ form.addEventListener('submit', async e => {
++ e.preventDefault();
++ const button = form.querySelector('button');
++ button.disabled = true;
++ try {
++ const res = await fetch(API_BASE + '/api/auth/mfa/verify', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code: form.elements.code.value, remember: form.elements.remember.checked }) });
++ const data = await res.json();
++ form.elements.code.value = '';
++ if (!res.ok) throw new Error(data.error || 'Sign-in failed');
++ localStorage.setItem('ov_token', data.token);
++ setAuthCookie(data.token);
++ saveAccount(data.user, data.token);
++ await guardScheduledDeletion(data.token, () => finishSignIn(data));
++ } catch (err) { form.querySelector('[role="status"]').textContent = err.message; }
++ finally { button.disabled = false; }
++ });
++}
ddiff --git a/public/js/my/mfa.js b/public/js/my/mfa.js
nnew file mode 100644
iindex 00000000..49ac4fa8
---- /dev/null
++++ b/public/js/my/mfa.js
@@@ -0,0 +1,57 @@
++/* Plain account security controls; presentation belongs to the account-hub design. */
++async function loadMfaSettings() {
++ const root = document.getElementById('mfa-settings');
++ if (!root || isAnonSession()) return;
++ try {
++ const state = await apiFetch('/api/auth/mfa');
++ if (!state.available && !state.enabled) return;
++ root.hidden = false;
++ root.innerHTML = '<h3>Authenticator</h3><p role="status"></p><div data-controls></div><div data-devices></div>';
++ const status = root.querySelector('[role="status"]');
++ const controls = root.querySelector('[data-controls]');
++ const call = (path, body) => apiFetch('/api/auth/mfa/' + path, { method: 'POST', body: JSON.stringify(body) });
++ const form = (markup, action) => {
++ controls.innerHTML = '<form>' + markup + '<button type="submit" class="ovk-btn ovk-btn--primary">Continue</button></form>';
++ controls.firstChild.addEventListener('submit', async e => {
++ e.preventDefault();
++ const button = e.target.querySelector('button');
++ button.disabled = true;
++ try { await action(new FormData(e.target)); } catch (err) { status.textContent = err.message; }
++ finally { button.disabled = false; }
++ });
++ };
++ if (state.enabled) {
++ status.textContent = 'Authenticator is enabled.';
++ form('<label>Current authenticator or recovery code <input name="code" autocomplete="one-time-code" required></label><p>Continue to turn off the authenticator.</p>', async data => {
++ await call('disable', { code: data.get('code') });
++ await loadMfaSettings();
++ });
++ } else {
++ form('<label>Current password <input name="password" type="password" autocomplete="current-password" required></label>', async data => {
++ const setup = await call('enroll', { password: data.get('password') });
++ form('<p>Scan this QR in your authenticator, or enter the key.</p><img alt="Authenticator setup QR"><p><code></code></p><label>Authenticator code <input name="code" inputmode="numeric" autocomplete="one-time-code" required></label>', async data => {
++ const result = await call('confirm', { code: data.get('code') });
++ controls.textContent = '';
++ status.textContent = 'Authenticator enabled. Save these recovery codes now; each works once and they will not be shown again.';
++ const codes = document.createElement('pre');
++ codes.textContent = result.recovery_codes.join('\n');
++ controls.append(codes);
++ const done = document.createElement('button');
++ done.className = 'ovk-btn ovk-btn--quiet'; done.textContent = 'I saved my codes';
++ done.addEventListener('click', loadMfaSettings); controls.append(done);
++ });
++ controls.querySelector('img').src = setup.qr;
++ controls.querySelector('code').textContent = setup.secret;
++ });
++ }
++ for (const device of state.devices) {
++ const row = document.createElement('p');
++ row.textContent = 'Remembered browser added ' + new Date(Number(device.created_at)).toLocaleDateString() + ' ';
++ const button = document.createElement('button'); button.className = 'ovk-btn ovk-btn--quiet'; button.textContent = 'Forget';
++ button.addEventListener('click', async () => {
++ try { await call('devices/revoke', { id: device.id }); row.remove(); } catch (err) { status.textContent = err.message; }
++ });
++ row.append(button); root.querySelector('[data-devices]').append(row);
++ }
++ } catch { /* Unavailable enrollment does not interrupt the account hub. */ }
++}
ddiff --git a/public/js/my/profile.js b/public/js/my/profile.js
iindex bfb94524..70105b9f 100644
---- a/public/js/my/profile.js
++++ b/public/js/my/profile.js
@@@ -138,6 +138,7 @@ async function loadUser() {
loadNotifPrefs(); loadQuietHours();
loadLinked();
loadSessions();
++ loadMfaSettings();
loadApps();
loadBlocks();
loadAnonInfo();
ddiff --git a/public/my.html b/public/my.html
iindex 4a74f1d7..628fb27f 100644
---- a/public/my.html
++++ b/public/my.html
@@@ -508,6 +508,7 @@
</div>
<div class="section" id="sec-security">
++ <div id="mfa-settings" hidden></div>
<div class="card qr-entry" id="qr-signin-card" hidden>
<h3><span class="icon"><i class="fa-solid fa-mobile-screen-button"></i></span> Sign in on another device</h3>
<p>Show a code on this device, scan it with the device you want to sign in, then confirm the number it shows.</p>
@@@ -647,6 +648,7 @@
<script src="/js/my/consent.js" defer></script>
<script src="/js/my/labels.js" defer></script>
<script src="/js/my/security.js" defer></script>
++<script src="/js/my/mfa.js" defer></script>
<script src="/js/my/approvals.js" defer></script>
<script src="/js/my/apps.js" defer></script>
<script src="/js/my/receipts.js" defer></script>
ddiff --git a/server/auth/mfa.js b/server/auth/mfa.js
nnew file mode 100644
iindex 00000000..1219fed6
---- /dev/null
++++ b/server/auth/mfa.js
@@@ -0,0 +1,191 @@
++'use strict';
++const crypto = require('crypto');
++const OTPAuth = require('otpauth');
++const express = require('express');
++const rateLimit = require('express-rate-limit');
++const bcrypt = require('bcryptjs');
++const QRCode = require('qrcode');
++const MINUTE = 60000;
++const DEVICE_AGE = 30 * 24 * 60 * MINUTE;
++const PENDING_COOKIE = '__Host-ov_mfa_pending';
++const DEVICE_COOKIE = '__Host-ov_mfa_device';
++const cookie = { httpOnly: true, secure: true, sameSite: 'strict', path: '/' };
++const hash = s => crypto.createHash('sha256').update(String(s)).digest('hex');
++const random = () => crypto.randomBytes(32).toString('base64url');
++function keys() {
++ const raw = process.env.NETWORK_MFA_KEY || '';
++ if (!/^[a-f0-9]{64}$/i.test(raw)) throw new Error('MFA unavailable');
++ const derive = label => Buffer.from(crypto.hkdfSync('sha256', Buffer.from(raw, 'hex'), Buffer.alloc(0), `network-mfa-${label}-v1`, 32));
++ return { enc: derive('encryption'), sign: derive('device') };
++}
++function seal(secret, userId) {
++ const iv = crypto.randomBytes(12);
++ const c = crypto.createCipheriv('aes-256-gcm', keys().enc, iv);
++ c.setAAD(Buffer.from(`totp:${userId}`));
++ const data = Buffer.concat([c.update(secret), c.final()]);
++ return Buffer.concat([iv, c.getAuthTag(), data]).toString('base64');
++}
++function open(ciphertext, userId) {
++ const b = Buffer.from(ciphertext, 'base64');
++ const d = crypto.createDecipheriv('aes-256-gcm', keys().enc, b.subarray(0, 12));
++ d.setAAD(Buffer.from(`totp:${userId}`)); d.setAuthTag(b.subarray(12, 28));
++ return Buffer.concat([d.update(b.subarray(28)), d.final()]).toString();
++}
++function totp(secret) { return new OTPAuth.TOTP({ issuer: 'OpenVibe.Network', label: 'OpenVibe', algorithm: 'SHA1', digits: 6, period: 30, secret }); }
++function validStep(secret, code, now) {
++ if (!/^\d{6}$/.test(code)) return null;
++ const delta = totp(secret).validate({ token: code, timestamp: now, window: 1 });
++ return delta === null ? null : Math.floor(now / 30000) + delta;
++}
++async function allowed(db, user) {
++ try { keys(); } catch { return false; }
++ const flag = await db.getSetting('mfa_enrollment');
++ return flag === 'all' || ((flag === 'pilot' || process.env.NETWORK_MFA_PILOT === '1') && ['ovprobe_merge_keep', (process.env.OWNER_USERNAME || 'goosely').toLowerCase()].includes(user.username.toLowerCase()));
++}
++async function factor(db, userId, lock = false) {
++ return db.prepare(`SELECT * FROM user_mfa WHERE user_id = ?${lock ? ' FOR UPDATE' : ''}`).get(userId);
++}
++function deviceSignature(token) { return crypto.createHmac('sha256', keys().sign).update(token).digest('base64url'); }
++async function remembered(db, req, user, f, now) {
++ const value = req.cookies?.[DEVICE_COOKIE];
++ if (typeof value !== 'string' || value.length > 200) return false;
++ const [token, sig] = value.split('.');
++ const expect = deviceSignature(token);
++ if (!sig || sig.length !== expect.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expect))) return false;
++ return !!await db.prepare('SELECT id FROM mfa_devices WHERE user_id = ? AND token_hash = ? AND generation = ? AND expires_at > ?').get(user.id, hash(token), f.generation, now);
++}
++// Returns true only when the password route must stop before issuing any session.
++async function challengeLogin(db, req, res, user) {
++ const f = await factor(db, user.id);
++ if (!f?.secret_ciphertext) return false;
++ keys(); // Fail closed even when enrollment is disabled or encryption configuration is missing.
++ const now = Date.now();
++ if (await remembered(db, req, user, f, now)) return false;
++ if (Number(f.locked_until) > now) { res.status(429).json({ error: 'Please wait before trying another code.' }); return true; }
++ const token = random();
++ await db.tx(async () => {
++ // Keep at most one pending sign-in per account; failures survive new passwords/challenges.
++ await factor(db, user.id, true);
++ await db.prepare('DELETE FROM mfa_challenges WHERE user_id = ? OR expires_at <= ?').run(user.id, now);
++ await db.prepare('INSERT INTO mfa_challenges (token_hash, user_id, credential_hash, generation, expires_at) VALUES (?, ?, ?, ?, ?)')
++ .run(hash(token), user.id, hash(user.password_hash), f.generation, now + 5 * MINUTE);
++ });
++ res.cookie(PENDING_COOKIE, token, { ...cookie, maxAge: 5 * MINUTE });
++ res.status(202).json({ mfa_required: true });
++ return true;
++}
++// Called only with the person's factor row locked. Failure counters commit even on rejection.
++async function consume(db, f, code, now, enrollment = false) {
++ if (Number(f.locked_until) > now) return false;
++ const ciphertext = enrollment ? f.pending_secret_ciphertext : f.secret_ciphertext;
++ if (!ciphertext || (enrollment && Number(f.pending_until) <= now)) return false;
++ let ok = false;
++ const step = validStep(open(ciphertext, f.user_id), code, now);
++ if (step !== null && step > Number(f.last_step)) {
++ await db.prepare('UPDATE user_mfa SET last_step = ? WHERE user_id = ?').run(step, f.user_id);
++ ok = true;
++ } else if (!enrollment && /^[A-F0-9]{20}$/.test(code)) {
++ const candidates = await db.prepare('SELECT code_hash FROM mfa_recovery_codes WHERE user_id = ? AND used_at IS NULL').all(f.user_id);
++ for (const candidate of candidates) {
++ if (await bcrypt.compare(code, candidate.code_hash)) {
++ ok = (await db.prepare('UPDATE mfa_recovery_codes SET used_at = ? WHERE user_id = ? AND code_hash = ? AND used_at IS NULL').run(now, f.user_id, candidate.code_hash)).changes === 1;
++ break;
++ }
++ }
++ }
++ const failures = ok ? 0 : (Number(f.locked_until) > 0 ? 1 : f.failures + 1);
++ await db.prepare('UPDATE user_mfa SET failures = ?, locked_until = ? WHERE user_id = ?').run(failures, failures >= 5 ? now + 5 * MINUTE : 0, f.user_id);
++ return ok;
++}
++const cleanCode = value => typeof value === 'string' ? value.replace(/[-\s]/g, '').toUpperCase().slice(0, 80) : '';
++function router({ requireAuth, signToken, sanitizeUser }) {
++ const r = express.Router();
++ r.use(rateLimit({ windowMs: MINUTE, max: 30, standardHeaders: true, legacyHeaders: false }));
++ r.use((req, res, next) => {
++ res.set('Cache-Control', 'no-store');
++ // No HTML form submissions, including cross-site login CSRF. Account hub uses Bearer + JSON.
++ if (req.method !== 'GET' && !req.is('application/json')) return res.status(415).json({ error: 'JSON required' });
++ if (req.headers['sec-fetch-site'] === 'cross-site' && !req.headers.authorization) return res.status(403).json({ error: 'Same-site request required' });
++ next();
++ });
++ const route = fn => async (req, res) => { try { await fn(req, res, req.app.locals.db); } catch { res.status(503).json({ error: 'Authenticator service unavailable' }); } };
++ r.post('/verify', route(async (req, res, db) => {
++ const pending = req.cookies?.[PENDING_COOKIE];
++ if (typeof pending !== 'string' || pending.length > 100) return res.status(401).json({ error: 'Sign in again' });
++ const now = Date.now();
++ let device;
++ const user = await db.tx(async () => {
++ const c = await db.prepare('SELECT * FROM mfa_challenges WHERE token_hash = ?').get(hash(pending));
++ if (!c || Number(c.expires_at) <= now) return null;
++ const f = await factor(db, c.user_id, true);
++ const u = await db.prepare('SELECT * FROM users WHERE id = ?').get(c.user_id);
++ if (!f?.secret_ciphertext || f.generation !== c.generation || !u || u.is_banned || u.deleted_at || u.merged_into || hash(u.password_hash) !== c.credential_hash) return null;
++ if (!await db.prepare('SELECT token_hash FROM mfa_challenges WHERE token_hash = ?').get(hash(pending))) return null;
++ if (!await consume(db, f, cleanCode(req.body.code), now)) return null;
++ await db.prepare('DELETE FROM mfa_challenges WHERE token_hash = ?').run(hash(pending));
++ if (req.body.remember === true) {
++ device = random();
++ await db.prepare('DELETE FROM mfa_devices WHERE user_id = ? AND expires_at <= ?').run(u.id, now);
++ await db.prepare('INSERT INTO mfa_devices (id, user_id, token_hash, generation, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)')
++ .run(crypto.randomUUID(), u.id, hash(device), f.generation, now, now + DEVICE_AGE);
++ }
++ return u;
++ });
++ if (!user) return res.status(401).json({ error: 'Code or sign-in state invalid. Try again or sign in again after five minutes.' });
++ if (device) res.cookie(DEVICE_COOKIE, `${device}.${deviceSignature(device)}`, { ...cookie, maxAge: DEVICE_AGE });
++ res.clearCookie(PENDING_COOKIE, cookie);
++ const token = signToken(user, req.app.locals.privateKey, req.app.locals.config);
++ require('./session').setSessionCookies(res, token);
++ res.json({ token, user: sanitizeUser(user) });
++ }));
++ r.use(requireAuth);
++ r.get('/', route(async (req, res, db) => {
++ const f = await factor(db, req.user.id);
++ res.json({ available: await allowed(db, req.user), enabled: !!f?.secret_ciphertext,
++ devices: await db.prepare('SELECT id, created_at, expires_at FROM mfa_devices WHERE user_id = ? AND expires_at > ?').all(req.user.id, Date.now()) });
++ }));
++ r.post('/enroll', route(async (req, res, db) => {
++ if (!await allowed(db, req.user)) return res.status(404).json({ error: 'Not available' });
++ if (typeof req.body.password !== 'string' || !bcrypt.compareSync(req.body.password, req.user.password_hash)) return res.status(401).json({ error: 'Current password required' });
++ const secret = new OTPAuth.Secret({ size: 20 }).base32;
++ const result = await db.tx(async () => {
++ await db.prepare('INSERT INTO user_mfa (user_id, generation) VALUES (?, ?) ON CONFLICT (user_id) DO NOTHING').run(req.user.id, random());
++ const f = await factor(db, req.user.id, true);
++ if (f.secret_ciphertext || Number(f.locked_until) > Date.now()) return false;
++ await db.prepare('UPDATE user_mfa SET pending_secret_ciphertext = ?, pending_until = ? WHERE user_id = ?').run(seal(secret, req.user.id), Date.now() + 10 * MINUTE, req.user.id);
++ return true;
++ });
++ if (!result) return res.status(409).json({ error: 'Already enabled or temporarily locked' });
++ res.json({ secret, qr: await QRCode.toDataURL(totp(secret).toString()) });
++ }));
++ r.post('/confirm', route(async (req, res, db) => {
++ if (!await allowed(db, req.user)) return res.status(404).json({ error: 'Not available' });
++ const codes = await db.tx(async () => {
++ const f = await factor(db, req.user.id, true);
++ if (!f || f.secret_ciphertext || !await consume(db, f, cleanCode(req.body.code), Date.now(), true)) return null;
++ const codes = Array.from({ length: 10 }, () => crypto.randomBytes(10).toString('hex').toUpperCase());
++ await db.prepare('DELETE FROM mfa_recovery_codes WHERE user_id = ?').run(req.user.id);
++ for (const code of codes) await db.prepare('INSERT INTO mfa_recovery_codes (user_id, code_hash) VALUES (?, ?)').run(req.user.id, await bcrypt.hash(code, 10));
++ await db.prepare('UPDATE user_mfa SET secret_ciphertext = pending_secret_ciphertext, pending_secret_ciphertext = NULL, pending_until = 0, enabled_at = ? WHERE user_id = ?').run(Date.now(), req.user.id);
++ return codes;
++ });
++ if (!codes) return res.status(401).json({ error: 'Code or enrollment invalid' });
++ res.json({ recovery_codes: codes });
++ }));
++ r.post('/disable', route(async (req, res, db) => {
++ const ok = await db.tx(async () => {
++ const f = await factor(db, req.user.id, true);
++ if (!f || !await consume(db, f, cleanCode(req.body.code), Date.now())) return false;
++ for (const table of ['mfa_recovery_codes', 'mfa_challenges', 'mfa_devices', 'user_mfa']) await db.prepare(`DELETE FROM ${table} WHERE user_id = ?`).run(req.user.id);
++ return true;
++ });
++ res.status(ok ? 200 : 401).json(ok ? { ok: true } : { error: 'Code invalid' });
++ }));
++ r.post('/devices/revoke', route(async (req, res, db) => {
++ const id = typeof req.body.id === 'string' ? req.body.id : '';
++ await db.prepare('DELETE FROM mfa_devices WHERE user_id = ? AND id = ?').run(req.user.id, id);
++ res.json({ ok: true });
++ }));
++ return r;
++}
++module.exports = { router, challengeLogin, validStep, totp, hash };
ddiff --git a/server/auth/revocation.js b/server/auth/revocation.js
iindex 251ae014..fc456954 100644
---- a/server/auth/revocation.js
++++ b/server/auth/revocation.js
@@@ -52,6 +52,8 @@ async function revokeTokens(db, userId, { reason, actor, ctx, strict = false } =
if (!user) throw new Error(`revocation: no user ${userId}`);
await db.prepare(strict ? "UPDATE users SET token_valid_after = datetime(ov_now(), '+1 second') WHERE id = ?" : 'UPDATE users SET token_valid_after = ov_now() WHERE id = ?').run(user.id);
await db.prepare('UPDATE user_sessions SET is_active = 0 WHERE user_id = ?').run(user.id);
++ await db.prepare('DELETE FROM mfa_devices WHERE user_id = ?').run(user.id);
++ await db.prepare('DELETE FROM mfa_challenges WHERE user_id = ?').run(user.id);
const validAfter = iso((await db.prepare('SELECT token_valid_after AS t FROM users WHERE id = ?').get(user.id)).t);
let event = null;
if (ids.isSubjectId('user', user.subject_id)) {
ddiff --git a/server/auth/routes.js b/server/auth/routes.js
iindex c900f78e..dc778d8c 100644
---- a/server/auth/routes.js
++++ b/server/auth/routes.js
@@@ -109,6 +109,8 @@ const { makeRequireAuth, GRACE_MS: SESSION_GRACE_MS } = require('./session');
// Sliding session guard: expired-but-renewable tokens are accepted and renewed (cookie + X-OV-Token).
const requireAuth = makeRequireAuth(async (req) => ({ db: await getDb(req), publicKey: req.app.locals.publicKey, config: getConfig(req) }), signToken);
++router.use('/mfa', require('./mfa').router({ requireAuth, signToken, sanitizeUser }));
++
// ── Register ─────────────────────────────────────────────────
router.post('/register', async (req, res) => {
const db = await getDb(req);
@@@ -224,6 +226,10 @@ router.post('/login', async (req, res) => {
const signedIn = await require('../identity/account-merge').effectiveUser(db, user);
if (signedIn.is_banned) return res.status(403).json({ error: 'Account banned', ban_reason: signedIn.ban_reason });
++ try {
++ if (await require('./mfa').challengeLogin(db, req, res, signedIn)) return;
++ } catch { return res.status(503).json({ error: 'Authenticator service unavailable' }); }
++
await db.prepare('UPDATE users SET last_seen = ov_now() WHERE id = ?').run(signedIn.id);
const token = signToken(signedIn, req.app.locals.privateKey, config);
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex a02d4bed..4b8d7a97 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -175,6 +175,9 @@ async function networkPart(db, userId) {
const byUser = async (name, table, opts) => { const r = await rowsOf(db, table, 'user_id = ?', [u.id], opts); add(name, r.rows, r.truncated); };
await byUser('sign_in_providers.json', 'linked_accounts');
await byUser('sessions.json', 'user_sessions');
++ for (const table of ['user_mfa', 'mfa_recovery_codes', 'mfa_challenges', 'mfa_devices']) {
++ await byUser(`${table}.json`, table, { omit: ['generation'] });
++ }
await byUser('oauth_grants.json', 'oauth_tokens');
await byUser('notifications.json', 'notifications');
const prefs = (await rowsOf(db, 'user_preferences', 'user_id = ?', [u.id])).rows[0] || null;
@@@ -359,7 +362,7 @@ async function erase(db, deletion, { now = Date.now() } = {}) {
const inS = `(${subjects.map(() => '?').join(',')})`;
const del = async (table, where, params, key = table) => { if (await tableExists(db, table)) count(key, (await db.prepare(`DELETE FROM ${table} WHERE ${where}`).run(...params)).changes); };
-- for (const t of ['linked_accounts', 'user_sessions', 'oauth_tokens', 'oauth_codes', 'email_verification_tokens', 'password_reset_tokens', 'notifications',
++ for (const t of ['mfa_recovery_codes', 'mfa_challenges', 'mfa_devices', 'user_mfa', 'linked_accounts', 'user_sessions', 'oauth_tokens', 'oauth_codes', 'email_verification_tokens', 'password_reset_tokens', 'notifications',
'notification_preferences', 'notification_quiet_hours', 'notification_digest_runs', 'user_preferences', 'user_effects', 'push_subscriptions', 'user_history', 'username_history', 'user_profile_changes',
'ip_log', 'email_delivery_log', 'user_theme_history', 'user_theme_site_overrides']) await del(t, `user_id IN ${inU}`, userIds);
await del('experience_events', `user_id IN ${inU}`, userIds);
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex 34829a6b..75d364d4 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -229,6 +229,10 @@ const server = http.createServer(app);
await require('../server/levels/store').award(db, 'quest', { subject: dana.subject_id, reason: 'quest.completed', amount: 25, idempotency_key: 'deletion-level' });
const experiencePart = (await accountData.networkPart(db, dana.id)).files.find(f => f.name === 'experience.json');
assert.equal(experiencePart.content.events.length, 1, 'experience exported');
++ await db.prepare('INSERT INTO user_mfa (user_id, secret_ciphertext, generation) VALUES (?, ?, ?)').run(dana.id, 'encrypted-test-fixture', 'test-generation');
++ await db.prepare('INSERT INTO mfa_recovery_codes (user_id, code_hash) VALUES (?, ?)').run(dana.id, 'fixture-hash');
++ await db.prepare('INSERT INTO mfa_challenges (user_id, token_hash, credential_hash, generation, expires_at) VALUES (?, ?, ?, ?, ?)').run(dana.id, 'fixture-token-hash', 'fixture-password-hash', 'test-generation', due + 100000);
++ await db.prepare('INSERT INTO mfa_devices (id, user_id, token_hash, generation, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)').run('fixture-device', dana.id, 'fixture-device-hash', 'test-generation', due, due + 100000);
const oldToken = tok(dana);
assert.strictEqual((await accountData.sweep(db, { dir: exportDir, now: due + 1000 })).deleted, 1);
const nodeEvents = await events('network.link.revoked');
@@@ -248,7 +252,7 @@ const server = http.createServer(app);
assert.match(gone.username, /^deleted-/);
assert.deepStrictEqual([gone.email, gone.display_name, gone.password_hash, !!gone.deleted_at], [null, null, '!deleted', true]);
assert.match((await db.prepare('SELECT username FROM users WHERE id = ?').get(old.id)).username, /^deleted-/, 'the merged-in account too');
-- for (const [t, col] of [['linked_accounts', 'user_id'], ['user_sessions', 'user_id'], ['oauth_tokens', 'user_id'], ['user_preferences', 'user_id'], ['experience_events', 'user_id'], ['person_levels', 'user_id']]) {
++ for (const [t, col] of [['user_mfa', 'user_id'], ['mfa_recovery_codes', 'user_id'], ['mfa_challenges', 'user_id'], ['mfa_devices', 'user_id'], ['linked_accounts', 'user_id'], ['user_sessions', 'user_id'], ['oauth_tokens', 'user_id'], ['user_preferences', 'user_id'], ['experience_events', 'user_id'], ['person_levels', 'user_id']]) {
assert.strictEqual((await db.prepare(`SELECT COUNT(*) AS n FROM ${t} WHERE ${col} = ?`).get(dana.id)).n, 0, `${t} erased`);
}
assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM user_modules WHERE subject_id = ?').get(dana.subject_id)).n, 0);
ddiff --git a/test/agent-schema.test.js b/test/agent-schema.test.js
iindex feb4c3ab..c4cf216d 100644
---- a/test/agent-schema.test.js
++++ b/test/agent-schema.test.js
@@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand']]);
++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand']]);
assert.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
ddiff --git a/test/mfa.test.js b/test/mfa.test.js
nnew file mode 100644
iindex 00000000..efda4320
---- /dev/null
++++ b/test/mfa.test.js
@@@ -0,0 +1,106 @@
++'use strict';
++const assert = require('assert');
++const crypto = require('crypto');
++const express = require('express');
++const bcrypt = require('bcryptjs');
++const cookieParser = require('cookie-parser');
++const { getDb } = require('../server/db/database');
++const auth = require('../server/auth/routes');
++const mfa = require('../server/auth/mfa');
++const { verifySession } = require('../server/auth/session');
++const accountData = require('../server/identity/account-data');
++(async () => {
++ process.env.NETWORK_MFA_KEY = crypto.randomBytes(32).toString('hex');
++ const realNow = Date.now; let now = realNow(); Date.now = () => now;
++ const db = getDb();
++ const pw = 'test-only-password';
++ await db.prepare('INSERT INTO users (id, username, password_hash) VALUES (?, ?, ?), (?, ?, ?)').run(71, 'mfa_alice', bcrypt.hashSync(pw, 4), 72, 'mfa_bob', bcrypt.hashSync(pw, 4));
++ const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
++ const config = { jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };
++ const app = express(); app.use(express.json()); app.use(cookieParser());
++ Object.assign(app.locals, { db, config, ...keys }); app.use('/api/auth', auth);
++ const server = app.listen(0, '127.0.0.1'); await new Promise(r => server.on('listening', r));
++ const base = `http://127.0.0.1:${server.address().port}/api/auth`;
++ const alice = await db.prepare('SELECT * FROM users WHERE id = 71').get();
++ const bob = await db.prepare('SELECT * FROM users WHERE id = 72').get();
++ const at = auth.signToken(alice, keys.privateKey, config); const bt = auth.signToken(bob, keys.privateKey, config);
++ const call = async (path, body, token, cookies) => {
++ const res = await fetch(base + path, { method: body === undefined ? 'GET' : 'POST', headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}), ...(cookies ? { cookie: cookies } : {}) }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
++ return { status: res.status, body: await res.json(), cookies: res.headers.getSetCookie(), cache: res.headers.get('cache-control') };
++ };
++ const cookieOf = (r, name) => r.cookies.find(s => s.startsWith(name + '='))?.split(';')[0];
++ const login = () => call('/login', { username: alice.username, password: pw });
++ const code = secret => mfa.totp(secret).generate({ timestamp: now });
++ try {
++ let r = await login(); assert.equal(r.status, 200); assert.ok(r.body.token, 'flag-off login unchanged');
++ r = await call('/mfa/enroll', { password: pw }, at); assert.equal(r.status, 404);
++ await db.prepare("UPDATE site_settings SET value = 'all' WHERE key = 'mfa_enrollment'").run();
++ r = await call('/mfa/enroll', { password: 'wrong' }, at); assert.equal(r.status, 401);
++ r = await call('/mfa/enroll', { password: pw }, at); assert.equal(r.status, 200); assert.equal(r.cache, 'no-store');
++ const secret = r.body.secret; assert.match(r.body.qr, /^data:image\/png;base64,/);
++ let f = await db.prepare('SELECT * FROM user_mfa WHERE user_id = 71').get();
++ assert.ok(!f.secret_ciphertext); assert.ok(!f.pending_secret_ciphertext.includes(secret));
++ assert.equal((await login()).status, 200, 'unconfirmed enrollment does not affect login');
++ assert.equal((await call('/mfa/confirm', { code: code(secret), user_id: 71 }, bt)).status, 401, 'other person cannot confirm');
++ assert.equal((await call('/mfa/confirm', { code: 'wrong' }, at)).status, 401);
++ await db.prepare('UPDATE user_mfa SET pending_until = ? WHERE user_id = 71').run(now - 1);
++ assert.equal((await call('/mfa/confirm', { code: code(secret) }, at)).status, 401, 'expired enrollment');
++ await db.prepare('UPDATE user_mfa SET pending_until = ? WHERE user_id = 71').run(now + 600000);
++ r = await call('/mfa/confirm', { code: code(secret) }, at); assert.equal(r.status, 200); const recovery = r.body.recovery_codes;
++ assert.equal(recovery.length, 10); assert.equal(new Set(recovery).size, 10);
++ const storedCodes = await db.prepare('SELECT code_hash FROM mfa_recovery_codes WHERE user_id = 71').all();
++ assert.ok(storedCodes.every(row => row.code_hash.startsWith('$2a$10$')));
++ assert.ok(await bcrypt.compare(recovery[0], storedCodes[0].code_hash) || await Promise.all(storedCodes.map(row => bcrypt.compare(recovery[0], row.code_hash))).then(matches => matches.some(Boolean)));
++ assert.equal((await call('/mfa/confirm', { code: code(secret) }, at)).status, 401, 'cannot confirm twice');
++ assert.ok(!(await verifySession(at, { db, publicKey: keys.publicKey, config })).error, 'existing session untouched');
++ r = await login(); assert.equal(r.status, 202); assert.ok(!r.body.token); let pending = cookieOf(r, '__Host-ov_mfa_pending');
++ assert.ok(pending); assert.ok(r.cookies.every(c => !c.startsWith('ov_token=')));
++ assert.match(r.cookies[0], /HttpOnly/); assert.match(r.cookies[0], /Secure/);
++ assert.ok((await verifySession(pending.split('=')[1], { db, publicKey: keys.publicKey, config })).error);
++ assert.equal((await call('/mfa/verify', { code: code(secret) }, null, pending)).status, 401, 'enrollment step cannot be replayed');
++ now += 61000;
++ r = await call('/mfa/verify', { code: code(secret), remember: true }, null, pending); assert.equal(r.status, 200);
++ const remembered = cookieOf(r, '__Host-ov_mfa_device'); assert.ok(remembered);
++ assert.equal((await call('/mfa/verify', { code: recovery[0] }, null, pending)).status, 401, 'consumed pending state');
++ r = await call('/login', { username: alice.username, password: pw }, null, remembered); assert.equal(r.status, 200, 'remembered browser');
++ r = await call('/login', { username: alice.username, password: pw }, null, remembered + 'bad'); assert.equal(r.status, 202, 'bad device signature');
++ r = await call('/mfa', undefined, at); assert.equal(r.body.devices.length, 1); const deviceId = r.body.devices[0].id;
++ await call('/mfa/devices/revoke', { id: deviceId }, bt);
++ assert.equal((await call('/login', { username: alice.username, password: pw }, null, remembered)).status, 200, 'other person cannot revoke device');
++ await call('/mfa/devices/revoke', { id: deviceId }, at);
++ assert.equal((await call('/login', { username: alice.username, password: pw }, null, remembered)).status, 202, 'revocation enforced');
++ now += 61000;
++ r = await login(); pending = cookieOf(r, '__Host-ov_mfa_pending');
++ const concurrent = await Promise.all([call('/mfa/verify', { code: recovery[0] }, null, pending), call('/mfa/verify', { code: recovery[0] }, null, pending)]);
++ assert.deepEqual(concurrent.map(x => x.status).sort(), [200, 401], 'recovery and state are atomic');
++ pending = cookieOf(await login(), '__Host-ov_mfa_pending');
++ assert.equal((await call('/mfa/verify', { code: recovery[0] }, null, pending)).status, 401, 'used recovery code refused');
++ await db.prepare('UPDATE mfa_challenges SET expires_at = ? WHERE user_id = 71').run(now - 1);
++ assert.equal((await call('/mfa/verify', { code: recovery[1] }, null, pending)).status, 401, 'expired pending state');
++ // Five failures per account, even across newly issued pending states.
++ await db.prepare('UPDATE user_mfa SET failures = 0, locked_until = 0 WHERE user_id = 71').run();
++ for (let i = 0; i < 5; i++) {
++ pending = cookieOf(await login(), '__Host-ov_mfa_pending');
++ assert.equal((await call('/mfa/verify', { code: 'wrong' }, null, pending)).status, 401);
++ }
++ assert.equal((await login()).status, 429);
++ assert.equal((await call('/mfa/verify', { code: recovery[1] }, null, pending)).status, 401, 'correct code also waits');
++ now += 301000;
++ pending = cookieOf(await login(), '__Host-ov_mfa_pending');
++ assert.equal((await call('/mfa/verify', { code: recovery[1] }, null, pending)).status, 200, 'works after wait');
++ const part = await accountData.networkPart(db, alice.id);
++ const exported = JSON.stringify(part);
++ assert.ok(!exported.includes(secret)); assert.ok(!exported.includes(recovery[2]));
++ for (const name of ['user_mfa', 'mfa_challenges', 'mfa_recovery_codes', 'mfa_devices']) assert.ok(part.files.some(f => f.name === name + '.json'));
++ await db.prepare("UPDATE site_settings SET value = 'off' WHERE key = 'mfa_enrollment'").run();
++ assert.equal((await login()).status, 202, 'enrollment switch never bypasses existing MFA');
++ const key = process.env.NETWORK_MFA_KEY; delete process.env.NETWORK_MFA_KEY;
++ assert.equal((await login()).status, 503, 'missing key fails closed'); process.env.NETWORK_MFA_KEY = key;
++ assert.equal((await call('/mfa/disable', { code: recovery[2], user_id: 71 }, bt)).status, 401, 'other account');
++ assert.equal((await call('/mfa/disable', { code: recovery[0] }, at)).status, 401, 'used code cannot disable');
++ assert.equal((await call('/mfa/disable', { code: recovery[2] }, at)).status, 200);
++ assert.equal((await login()).status, 200);
++ for (const table of ['user_mfa', 'mfa_challenges', 'mfa_recovery_codes', 'mfa_devices']) assert.equal((await db.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE user_id = 71`).get()).n, 0);
++ console.log('MFA: enrollment, expiry, replay, five-attempt lockout, recovery race, remembered browser, account isolation, export, flag-off and fail-closed checks passed');
++ } finally { Date.now = realNow; server.close(); await globalThis.__ovNetworkTestDbClose?.(); }
++})().catch(err => { console.error('MFA tests failed (assertion details redacted)'); console.error(String(err.stack).split('\n').filter(line => /^\s+at /.test(line)).slice(0, 4).join('\n')); process.exitCode = 1; });
ddiff --git a/test/my-modules.test.js b/test/my-modules.test.js
iindex 859e2d77..d113a19b 100644
---- a/test/my-modules.test.js
++++ b/test/my-modules.test.js
@@@ -12,7 +12,7 @@ for (const [, body] of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)) as
const files = fs.readdirSync(path.join(pub, 'js', 'my')).filter((f) => f.endsWith('.js')).sort();
// The module map, in load order (public/js/my/…).
--const modules = ['core.js', 'tools.js', 'today.js', 'machines.js', 'themes.js', 'profile.js', 'emails.js', 'consent.js', 'labels.js', 'security.js', 'approvals.js', 'apps.js', 'receipts.js', 'boot.js'];
++const modules = ['core.js', 'tools.js', 'today.js', 'machines.js', 'themes.js', 'profile.js', 'emails.js', 'consent.js', 'labels.js', 'security.js', 'mfa.js', 'approvals.js', 'apps.js', 'receipts.js', 'boot.js'];
assert.deepStrictEqual(files, [...modules].sort(), 'each module file exists and there is no other');
const tags = [...html.matchAll(/<script\b([^>]*)\bsrc="\/js\/my\/([^"]+)"([^>]*)><\/script>/g)];
const loaded = tags.map((m) => m[2]);
ddiff --git a/test/user-owned-trust.test.js b/test/user-owned-trust.test.js
iindex 72b7edaf..9c55271c 100644
---- a/test/user-owned-trust.test.js
++++ b/test/user-owned-trust.test.js
@@@ -56,7 +56,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand']]);
++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand']]);
assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice
// 0020–0061 keep every row; 0062 moves the principal ids to lnk_ in place and references follow.