// Sign-in: a person is an Ed25519 key (from their Theseus vault, or a key file // on hosts without one). The gateway hands out a nonce, the client signs it, // and gets a session token back. Only token hashes are stored. // // Two scopes. "owner" comes from the Ed25519 key and can do everything the // account can. "console" comes from a linked Theseus ID: it can see the // account (usage, sign-up, sessions) but never reach files, rules or the // owner's API, because the ID never held the key that encrypts the data. import crypto from 'node:crypto'; import path from 'node:path'; import { readJson, writeJson } from './store.js'; const SPKI_ED25519 = Buffer.from('302a300506032b6570032100', 'hex'); const SESSION_DAYS = 90; const CONSOLE_DAYS = 7; export const accountId = (pubkeyHex) => crypto.createHash('sha256').update(Buffer.from(pubkeyHex, 'hex')).digest('hex').slice(0, 32); export function challengeText(publicUrl, nonce) { return `pithos-hosted-v1\n${publicUrl}\n${nonce}`; } export function verifyEd25519(pubkeyHex, message, signatureB64) { if (!/^[0-9a-f]{64}$/i.test(pubkeyHex || '')) return false; try { const key = crypto.createPublicKey({ key: Buffer.concat([SPKI_ED25519, Buffer.from(pubkeyHex, 'hex')]), format: 'der', type: 'spki' }); return crypto.verify(null, Buffer.from(message, 'utf8'), key, Buffer.from(String(signatureB64 || ''), 'base64')); } catch { return false; } } export function createAuth(root) { const file = path.join(root, 'sessions.json'); const nonces = new Map(); // nonce -> expiry const sessions = readJson(file, {}); const hash = (t) => crypto.createHash('sha256').update(t).digest('hex'); return { challenge() { const now = Date.now(); for (const [n, exp] of nonces) if (exp < now) nonces.delete(n); if (nonces.size > 10_000) throw Object.assign(new Error('too many sign-ins in progress, try again shortly'), { status: 503 }); const nonce = crypto.randomBytes(24).toString('base64url'); nonces.set(nonce, now + 5 * 60_000); return nonce; }, // Consumes the nonce whether or not the signature holds. takeNonce(nonce) { const exp = nonces.get(nonce); nonces.delete(nonce); return !!exp && exp > Date.now(); }, issue(id, scope = 'owner') { const token = crypto.randomBytes(32).toString('base64url'); const now = Date.now(); for (const [h, s] of Object.entries(sessions)) if (s.exp < now) delete sessions[h]; const days = scope === 'console' ? CONSOLE_DAYS : SESSION_DAYS; sessions[hash(token)] = { id, exp: now + days * 86_400_000, ...(scope === 'owner' ? {} : { scope }) }; writeJson(file, sessions); return token; }, // Sessions written before scopes existed are owner sessions. lookup(token) { const s = token && sessions[hash(token)]; return s && s.exp > Date.now() ? { id: s.id, scope: s.scope || 'owner' } : null; }, revoke(token) { delete sessions[hash(token)]; writeJson(file, sessions); }, }; }