diff --git a/admin.js b/admin.js index 7aad6b5..0f55fb5 100644 --- a/admin.js +++ b/admin.js @@ -2,6 +2,7 @@ // sign them out everywhere, turn them off, or delete them and their data. Admin only (enforced by the caller). const users = require('./users'); const auth = require('./auth'); +const audit = require('./audit'); const readBody = req => new Promise((ok, no) => { const c = []; req.on('data', d => c.push(d)); req.on('end', () => ok(Buffer.concat(c).toString())); req.on('error', no); }); const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); }; @@ -15,18 +16,22 @@ async function handle(req, res, me) { if (!id && req.method === 'POST') { const b = JSON.parse(await readBody(req) || '{}'); const u = users.create({ name: b.name, role: 'user', tabs: b.tabs }); + audit.log('person added', { who: me.name, detail: u.name }, req); return json(res, 200, { user: row(u), code: auth.newInvite(u.id), minutes: auth.INVITE_MINUTES }); } const u = users.get(id); if (!u) return json(res, 404, { error: 'Person not found' }); + if (action === 'invite' && req.method === 'POST') audit.log('invite created', { who: me.name, detail: u.name }, req); if (action === 'invite' && req.method === 'POST') return json(res, 200, { code: auth.newInvite(u.id), minutes: auth.INVITE_MINUTES }); // for a new device or to recover access if (!action && req.method === 'PUT') { const b = JSON.parse(await readBody(req) || '{}'); if (u.id === me.id && b.disabled) return json(res, 400, { error: 'You cannot turn off your own account' }); + audit.log('person changed', { who: me.name, detail: u.name + (b.disabled !== undefined ? (b.disabled ? ' (turned off)' : ' (turned on)') : '') + (b.signOutEverywhere ? ' (signed out everywhere)' : '') + (b.tabs ? ' (tabs)' : '') + (b.role ? ' (role)' : '') }, req); users.update(id, { name: b.name, tabs: b.tabs, role: b.role, disabled: b.disabled, signOutEverywhere: b.signOutEverywhere }); return json(res, 200, { user: row(users.get(id)) }); } if (!action && req.method === 'DELETE') { if (u.id === me.id) return json(res, 400, { error: 'You cannot delete your own account' }); + audit.log('person removed', { who: me.name, detail: u.name }, req); users.remove(id); auth.removeCredsOf(id); return json(res, 200, { ok: true }); } diff --git a/audit.js b/audit.js new file mode 100644 index 0000000..35b8bc6 --- /dev/null +++ b/audit.js @@ -0,0 +1,26 @@ +// Audit log: who signed in, who was added or changed, what was downloaded. One JSON line per event in /data/audit.log +// (rotated at about 1 MB, keeping one older file). Contains no passwords, codes or message text. +const fs = require('fs'); +const path = require('path'); +const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'); +const FILE = path.join(DATA_DIR, 'audit.log'), MAX = 1024 * 1024; +const ipOf = req => String((req && (req.headers['x-forwarded-for'] || (req.socket && req.socket.remoteAddress))) || '').split(',')[0].trim(); + +/** log('sign-in', { who: 'Nate', ip, detail: 'iPhone' }) */ +function log(event, o = {}, req) { + try { + const line = JSON.stringify({ t: Date.now(), event, who: o.who || '', detail: o.detail || '', ip: o.ip || ipOf(req) }) + '\n'; + try { if (fs.statSync(FILE).size > MAX) fs.renameSync(FILE, FILE + '.1'); } catch {} + fs.appendFileSync(FILE, line, { mode: 0o600 }); + } catch (e) { console.error('[audit] could not write:', e.message); } +} +/** Newest first. */ +function recent(n = 200) { + const rows = []; + for (const f of [FILE, FILE + '.1']) { + let text = ''; try { text = fs.readFileSync(f, 'utf8'); } catch { continue; } + for (const l of text.split('\n').reverse()) { if (!l) continue; try { rows.push(JSON.parse(l)); } catch {} if (rows.length >= n) return rows; } + } + return rows; +} +module.exports = { log, recent }; diff --git a/auth.js b/auth.js index 30ac49f..d6b796e 100644 --- a/auth.js +++ b/auth.js @@ -10,6 +10,7 @@ const { generateAuthenticationOptions, verifyAuthenticationResponse, } = require('@simplewebauthn/server'); const users = require('./users'); +const audit = require('./audit'); const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'); fs.mkdirSync(DATA_DIR, { recursive: true }); @@ -106,6 +107,22 @@ function newInvite(uid) { const normInvite = c => { const t = String(c || '').toUpperCase().replace(/[^A-Z0-9]/g, ''); return t.length === 8 ? t.slice(0, 4) + '-' + t.slice(4) : ''; }; const inviteFor = c => { const k = normInvite(c), v = k && invites.get(k); return v && v.exp > Date.now() && users.get(v.uid) && !users.get(v.uid).disabled ? { code: k, uid: v.uid } : null; }; +// Recovery codes: 8 one-time codes per person, made in Settings. Each can enrol a new passkey (like an invite) if every device is lost. +// Only keyed hashes are stored, so the file does not reveal them. +const recovery = readJson('recovery.json', {}); // uid -> [hash] +const saveRecovery = () => writeJson('recovery.json', recovery); +const recHash = code => mac('r' + normInvite(code)); +function newRecoveryCodes(uid) { + const a = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789', codes = []; + for (let i = 0; i < 8; i++) { let c = ''; for (const b of crypto.randomBytes(8)) c += a[b % a.length]; codes.push(c.slice(0, 4) + '-' + c.slice(4)); } + recovery[uid] = codes.map(recHash); saveRecovery(); + return codes; +} +const recoveryFor = c => { const k = normInvite(c); if (!k) return null; const h = recHash(k); const uid = Object.keys(recovery).find(u => recovery[u].includes(h)); const u = uid && users.get(uid); return u && !u.disabled ? { code: k, uid, recovery: true } : null; }; +const useRecovery = c => { const h = recHash(c), uid = Object.keys(recovery).find(u => recovery[u].includes(h)); if (uid) { recovery[uid] = recovery[uid].filter(x => x !== h); saveRecovery(); } }; +/** An invite or a recovery code, whichever this is. */ +const claimFor = c => inviteFor(c) || recoveryFor(c); + const RATE_LIMIT = Math.max(1, parseInt(process.env.AUTH_RATE_LIMIT, 10) || 10); // attempts per minute per address; raised only by the tests const hits = new Map(); // crude per-IP throttle on anything that checks a secret const throttled = req => { @@ -137,6 +154,7 @@ async function handle(req, res) { const me = currentUser(req); if (p === '/api/auth/state') return send(res, 200, { registered: store.creds.length > 0, authed: !!me }); if (p === '/api/auth/logout' && req.method === 'POST') { + if (me) audit.log('sign-out', { who: me.name }, req); addCookie(res, 'hub_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'); return send(res, 200, { ok: true }); } @@ -148,7 +166,7 @@ async function handle(req, res) { if (me) { kind = 'reg-authed'; uid = me.id; name = me.name; handle = me.handle; } else { if (throttled(req)) return send(res, 429, { error: 'Too many attempts. Wait a minute.' }); - const inv = body.inviteCode && inviteFor(body.inviteCode); + const inv = body.inviteCode && claimFor(body.inviteCode); if (!users.all().length && equalStr(body.setupCode || '', SETUP_CODE)) { kind = 'reg-setup'; name = 'Admin'; handle = crypto.randomBytes(32).toString('base64url'); } else if (inv) { kind = 'reg-invite'; uid = inv.uid; const u = users.get(uid); name = u.name; handle = u.handle; } else return send(res, 403, { error: body.inviteCode ? 'That invite code is wrong or has expired' : 'Wrong setup code' }); @@ -159,7 +177,7 @@ async function handle(req, res) { excludeCredentials: (uid ? credsOf(uid) : []).map(c => ({ id: c.id, transports: c.transports })), authenticatorSelection: { residentKey: 'required', userVerification: 'preferred' }, }); - return send(res, 200, { cid: newChallenge(options.challenge, kind, { uid, handle, code: body.inviteCode && inviteFor(body.inviteCode) ? inviteFor(body.inviteCode).code : undefined }), options }); + return send(res, 200, { cid: newChallenge(options.challenge, kind, { uid, handle, code: body.inviteCode && claimFor(body.inviteCode) ? claimFor(body.inviteCode).code : undefined }), options }); } if (p === '/api/auth/register/verify' && req.method === 'POST') { if (!me && throttled(req)) return send(res, 429, { error: 'Too many attempts. Wait a minute.' }); @@ -168,7 +186,7 @@ async function handle(req, res) { const ex = rec.extra || {}; const okKind = rec.kind === 'reg-authed' ? !!me && me.id === ex.uid : rec.kind === 'reg-setup' ? !users.all().length - : rec.kind === 'reg-invite' ? !!inviteFor(ex.code) && !!users.get(ex.uid) : false; + : rec.kind === 'reg-invite' ? !!claimFor(ex.code) && !!users.get(ex.uid) : false; if (!okKind) return send(res, 403, { error: 'Not allowed' }); const v = await verifyRegistrationResponse({ response: body.response, expectedChallenge: rec.challenge, expectedOrigin: origin, expectedRPID: rpID }); if (!v.verified) return send(res, 400, { error: 'Could not verify passkey' }); @@ -178,7 +196,9 @@ async function handle(req, res) { store.creds.push({ id: c.id, publicKey: Buffer.from(c.publicKey).toString('base64url'), counter: c.counter, transports: c.transports || [], name: String(body.name || 'Passkey').slice(0, 40), created: Date.now(), uid: user.id }); save(); - if (rec.kind === 'reg-invite') invites.delete(ex.code); // single use + if (rec.kind === 'reg-invite') { const wasRecovery = !inviteFor(ex.code); invites.delete(ex.code); useRecovery(ex.code); audit.log(wasRecovery ? 'recovery code used' : 'passkey added with invite', { who: user.name, detail: String(body.name || '').slice(0, 40) }, req); } // single use + else if (rec.kind === 'reg-setup') audit.log('first admin created', { who: user.name }, req); + else audit.log('passkey added', { who: user.name, detail: String(body.name || '').slice(0, 40) }, req); if (!me) startSession(req, res, user); return send(res, 200, { ok: true }); } @@ -192,27 +212,32 @@ async function handle(req, res) { const rec = takeRecord(body.cid); const cred = store.creds.find(c => c.id === body.response?.id); const user = cred && users.get(cred.uid); - if (!rec || rec.kind !== 'login' || !cred || !user) return send(res, 400, { error: 'Passkey not recognised' }); + if (!rec || rec.kind !== 'login' || !cred || !user) { audit.log('sign-in failed', { detail: 'passkey not recognised' }, req); return send(res, 400, { error: 'Passkey not recognised' }); } if (user.disabled) return send(res, 403, { error: 'This account has been turned off' }); const v = await verifyAuthenticationResponse({ response: body.response, expectedChallenge: rec.challenge, expectedOrigin: origin, expectedRPID: rpID, credential: { id: cred.id, publicKey: Buffer.from(cred.publicKey, 'base64url'), counter: cred.counter, transports: cred.transports }, }); - if (!v.verified) return send(res, 401, { error: 'Sign-in failed' }); + if (!v.verified) { audit.log('sign-in failed', { who: user.name, detail: 'verification failed' }, req); return send(res, 401, { error: 'Sign-in failed' }); } cred.counter = v.authenticationInfo.newCounter; cred.lastUsed = Date.now(); save(); startSession(req, res, user); + audit.log('sign-in', { who: user.name, detail: cred.name }, req); return send(res, 200, { ok: true }); } // Everything below needs a signed-in session. if (!me) return send(res, 401, { error: 'Not signed in' }); if (p === '/api/auth/invite' && req.method === 'POST') { // an invite for one of my own devices + audit.log('invite created for a device', { who: me.name }, req); return send(res, 200, { code: newInvite(me.id), minutes: INVITE_MINUTES }); } + if (p === '/api/auth/recovery' && req.method === 'GET') return send(res, 200, { remaining: (recovery[me.id] || []).length }); + if (p === '/api/auth/recovery' && req.method === 'POST') { audit.log('recovery codes created', { who: me.name }, req); return send(res, 200, { codes: newRecoveryCodes(me.id) }); } if (p === '/api/auth/passkeys' && req.method === 'GET') { return send(res, 200, credsOf(me.id).map(c => ({ id: c.id, name: c.name, created: c.created, lastUsed: c.lastUsed || null }))); } if (p === '/api/auth/passkeys' && req.method === 'DELETE') { const mine = credsOf(me.id); if (!mine.some(c => c.id === body.id)) return send(res, 404, { error: 'Passkey not found' }); + audit.log('passkey removed', { who: me.name, detail: (mine.find(c => c.id === body.id) || {}).name }, req); store.creds = store.creds.filter(c => c.id !== body.id); save(); return send(res, 200, { ok: true, remaining: credsOf(me.id).length }); } @@ -221,6 +246,6 @@ async function handle(req, res) { } const passkeyCount = uid => credsOf(uid).length; -function removeCredsOf(uid) { store.creds = store.creds.filter(c => c.uid !== uid); save(); } +function removeCredsOf(uid) { store.creds = store.creds.filter(c => c.uid !== uid); save(); delete recovery[uid]; saveRecovery(); } module.exports = { handle, currentUser, isAuthed, refresh, addCookie, secret, SESSION_DAYS, newInvite, INVITE_MINUTES, passkeyCount, removeCredsOf }; diff --git a/backup.js b/backup.js index 4cef0f4..67a5053 100644 --- a/backup.js +++ b/backup.js @@ -2,8 +2,9 @@ // Treat the file like a password: it contains the session secret and encrypted sign-in tokens. const { spawn } = require('child_process'); const path = require('path'); +const audit = require('./audit'); const DATA_DIR = path.resolve(process.env.DATA_DIR || path.join(__dirname, 'data')); -function handle(req, res) { +function handle(req, res, who) { if (req.method !== 'GET') { res.writeHead(405); return res.end(); } const name = 'nehub-backup-' + new Date().toISOString().slice(0, 10) + '.tar.gz'; const tar = spawn('tar', ['-czf', '-', '--exclude=./lyrics', '--exclude=*.tmp', '-C', DATA_DIR, '.'], { stdio: ['ignore', 'pipe', 'pipe'] }); @@ -12,6 +13,6 @@ function handle(req, res) { res.writeHead(200, { 'content-type': 'application/gzip', 'content-disposition': `attachment; filename="${name}"`, 'cache-control': 'no-store' }); tar.stdout.pipe(res); res.on('close', () => tar.kill()); - console.log('[backup] downloaded'); + audit.log('backup downloaded', { who: who || '' }, req); } module.exports = { handle }; diff --git a/public/app.js b/public/app.js index e60c502..5dd7d0d 100644 --- a/public/app.js +++ b/public/app.js @@ -1659,6 +1659,8 @@ function viewSettings() {
A .tar.gz of everything in /data (accounts, passkeys, tasks, events, settings, Signal history). Keep it private: it holds the session secret and encrypted sign-in tokens. Restore by unpacking it into the data volume.
` : ''}New device? Use an invite code
'); + $('#ui').insertAdjacentHTML('beforeend', 'New device or lost your devices? Use an invite or recovery code
'); $('#inv').onclick = e => { e.preventDefault(); $('#err').textContent = ''; $('#sub').textContent = 'Add this device'; - $('#ui').innerHTML = ``; $('#f').onsubmit = async ev => { diff --git a/server.js b/server.js index d9ccb52..828facb 100644 --- a/server.js +++ b/server.js @@ -502,7 +502,7 @@ async function handleRequest(req, res) { return users.runAs(user, async () => { const rule = ACCESS.find(([re]) => re.test(pathname)); if (rule && !rule[1].some(t => users.can(user, t))) return forbidden(res); - if ((pathname === '/api/system' || pathname.startsWith('/api/users')) && user.role !== 'admin') return forbidden(res, 'Only an admin can do this'); + if ((pathname === '/api/system' || pathname === '/api/audit' || pathname.startsWith('/api/users')) && user.role !== 'admin') return forbidden(res, 'Only an admin can do this'); if (pathname === '/api/me') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ ...users.publicView(user), passkeys: auth.passkeyCount(user.id), people: user.role === 'admin' ? users.all().length : undefined })); } if (pathname.startsWith('/api/users')) return admin.handle(req, res, user); if (req.url === '/api/config') { // only reveal the addresses of things this person may use @@ -512,8 +512,9 @@ async function handleRequest(req, res) { podcast: c('podcast') ? PODCAST_URL : '', signal: c('signal') && signal.enabled() ? '1' : '', lyrics: c('music') && lyrics.enabled() ? '1' : '', webmail: c('mail') ? (WEBMAIL_URL || (WEBMAIL_BACKEND_URL ? '/webmail/' : '')) : '', editor: (c('office') || c('files')) && OO_OK, jwt: !!JWT_SECRET, ooReady: OO_OK, build: BUILD })); } if (req.method === 'POST' && req.url === '/api/oo/config') return OO_OK ? ooConfig(req, res) : forbidden(res, 'In-app editing needs ONLYOFFICE_JWT_SECRET to be set on the server'); - if (pathname === '/api/backup') return user.role === 'admin' ? backup.handle(req, res) : forbidden(res, 'Only an admin can do this'); + if (pathname === '/api/backup') return user.role === 'admin' ? backup.handle(req, res, user.name) : forbidden(res, 'Only an admin can do this'); if (pathname === '/api/ddg') return ddgAnswer(req, res); + if (pathname === '/api/audit') { res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(JSON.stringify({ events: require('./audit').recent(200) })); } if (pathname === '/api/system') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify(storageInfo())); } if (pathname === '/api/settings') return settings.handle(req, res); if (pathname === '/api/search') return search.handle(req, res); diff --git a/tools/users-test.js b/tools/users-test.js index ce1d162..18652e9 100644 --- a/tools/users-test.js +++ b/tools/users-test.js @@ -80,6 +80,19 @@ const T = (title, extra) => ({ title, notes: '', due: '', done: false, ...extra r = await admin.call('/api/users', { name: ' ', tabs: [] }); ok('a name is required', r.status === 400, r); r = await register(sam, dS, { inviteCode: samCode }, 'Sam phone', base); ok('Sam enrols with the code and is signed in as Sam', r.status === 200 && sam.cookie.startsWith('hub_session=' + samUser.id + '.'), [r.status, sam.cookie.slice(0, 24)]); r = await register(client(base), device(), { inviteCode: samCode }, 'again', base); ok('the code works only once', r.status === 403, r); + // ---- recovery codes and the audit log + r = await sam.call('/api/auth/recovery'); ok('a person starts with no recovery codes', r.status === 200 && r.json.remaining === 0, r.json); + r = await sam.call('/api/auth/recovery', {}); const recCodes = r.json.codes; ok('making recovery codes gives 8 one-time codes', r.status === 200 && recCodes.length === 8 && recCodes.every(c => /^[A-Z2-9]{4}-[A-Z2-9]{4}$/.test(c)), r.json); + ok('and they are stored only as hashes', !fs.readFileSync(path.join(dir, 'recovery.json'), 'utf8').includes(recCodes[0]), 'plain code found on disk'); + const lost = client(base); + r = await register(lost, device(), { inviteCode: recCodes[0] }, 'Sam new phone', base); ok('a recovery code enrols a new passkey and signs Sam in', r.status === 200 && lost.cookie.startsWith('hub_session=' + samUser.id + '.'), [r.status, lost.cookie.slice(0, 24)]); + r = await register(client(base), device(), { inviteCode: recCodes[0] }, 'again', base); ok('the same recovery code cannot be used twice', r.status === 403, r.status); + { const pk = (await lost.call('/api/auth/passkeys')).json.find(p => p.name === 'Sam new phone'); await lost.call('/api/auth/passkeys', { id: pk.id }, 'DELETE'); } // tidy up so later checks see Sam's original passkeys + r = await sam.call('/api/auth/recovery'); ok('seven codes remain', r.json.remaining === 7, r.json); + r = await sam.call('/api/audit'); ok('an ordinary person cannot read the audit log', r.status === 403, r.status); + r = await admin.call('/api/audit'); const evs = (r.json.events || []).map(e => e.event); + ok('the admin sees the audit log: invite use, recovery codes and recovery use are recorded', ['recovery codes created', 'recovery code used', 'passkey added with invite'].every(e => evs.includes(e)), evs); + ok('and it never contains a code', !JSON.stringify(r.json).includes(recCodes[0]) && !JSON.stringify(r.json).includes(samCode), 'code leaked'); r = await sam.call('/api/me'); ok('Sam is a normal user with only Home, Settings and the tabs chosen', r.json.role === 'user' && r.json.tabs.sort().join() === 'calendar,home,search,settings,tasks', r.json); // ---- private data