A hosted account is its Ed25519 storage key, and that stays so: the key is what owns and decrypts the data. A Theseus ID is added as a way to look at the account (usage, sign-up, sign-out) from a browser that does not hold the key, which is the account-level login DESIGN-theseus-id.md §11.3 asks for. The owner links an ID with a normal session plus a Theseus ID sign-in for project "pithos" (POST /hosted/v1/theseus-id). The ID then signs in to POST /hosted/v1/console/session and gets a "console" token, which reaches /hosted/v1/me, its own sign-out and the link status, and is refused everything else: files, rules, moves and the forwarded owner API. Sessions stored before scopes existed read as owner sessions. A move to a new ID in Theseus carries the account along. The verifier is TheseusID/lib's message.mjs and verify.mjs, copied verbatim into hosted/theseus-id/ with libauth doing the curve maths, so the gateway adds no dependency; a test fails if the copy drifts.
73 lines
3 KiB
JavaScript
73 lines
3 KiB
JavaScript
// 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);
|
|
},
|
|
};
|
|
}
|