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.
134 lines
8 KiB
JavaScript
134 lines
8 KiB
JavaScript
// Theseus ID on the gateway (DESIGN-theseus-id.md §11.3): the owner links an
|
|
// ID with a normal session; the ID then signs in to a console session that can
|
|
// see the account but never its files, rules or owner API.
|
|
//
|
|
// Sign-ins are built and signed with the reference library in TheseusID/, so
|
|
// this also proves the vendored verifier and its libauth curve maths agree
|
|
// with what Theseus produces.
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import http from 'node:http';
|
|
import crypto from 'node:crypto';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
import { createHosted } from '../server.js';
|
|
import { challengeText } from '../auth.js';
|
|
import { verifyAddress } from '../theseus-id/index.js';
|
|
|
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
const tidDir = path.join(here, '..', '..', '..', 'TheseusID');
|
|
const haveRef = fs.existsSync(path.join(tidDir, 'node_modules', '@noble', 'curves'));
|
|
const ref = haveRef ? await import(pathToFileURL(path.join(tidDir, 'lib', 'index.mjs')).href) : null;
|
|
const deps = haveRef ? await import(pathToFileURL(path.join(tidDir, 'test', 'deps.mjs')).href) : null;
|
|
|
|
test('the vendored verifier is a verbatim copy of TheseusID/lib', { skip: !fs.existsSync(tidDir) && 'TheseusID/ not in this checkout' }, () => {
|
|
for (const f of ['message.mjs', 'verify.mjs']) {
|
|
assert.equal(fs.readFileSync(path.join(here, '..', 'theseus-id', f), 'utf8'), fs.readFileSync(path.join(tidDir, 'lib', f), 'utf8'), `${f} drifted from TheseusID/lib; copy it again`);
|
|
}
|
|
});
|
|
|
|
const call = (port, method, p, { body, token } = {}) => new Promise((resolve, reject) => {
|
|
const data = body === undefined ? null : JSON.stringify(body);
|
|
const req = http.request({ host: '127.0.0.1', port, path: p, method, headers: { ...(data ? { 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) } : {}), ...(token ? { authorization: `Bearer ${token}` } : {}) } }, (res) => {
|
|
let b = '';
|
|
res.on('data', (d) => { b += d; });
|
|
res.on('end', () => { let j = null; try { j = JSON.parse(b); } catch {} resolve({ status: res.statusCode, body: j }); });
|
|
});
|
|
req.on('error', reject);
|
|
if (data) req.write(data);
|
|
req.end();
|
|
});
|
|
|
|
const iso = (ms) => new Date(ms).toISOString();
|
|
function tidSignIn(priv, nonce, { origin = 'theseus-addon://pithos', projectId = 'pithos' } = {}) {
|
|
const now = Date.now();
|
|
const message = ref.buildMessage({
|
|
kind: 'signin', origin, account: deps.crypto.account(priv), uri: origin + '/', chainId: 'tid:1',
|
|
projectId, nonce, issuedAt: iso(now), expirationTime: iso(now + 5 * 60_000),
|
|
});
|
|
return { message, signature: deps.crypto.sign(priv, message) };
|
|
}
|
|
|
|
test('verifyAddress agrees with the reference library', { skip: !haveRef && 'TheseusID/node_modules missing' }, () => {
|
|
const priv = crypto.randomBytes(32);
|
|
const s = tidSignIn(priv, 'n'.repeat(20));
|
|
const me = deps.crypto.account(priv);
|
|
assert.equal(verifyAddress(s.message, s.signature, me), true);
|
|
assert.equal(verifyAddress(s.message + 'x', s.signature, me), false, 'other text');
|
|
assert.equal(verifyAddress(s.message, s.signature, deps.crypto.account(crypto.randomBytes(32))), false, 'other account');
|
|
});
|
|
|
|
test('link a Theseus ID, then a console session that cannot touch data', { skip: !haveRef && 'TheseusID/node_modules missing' }, async (t) => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pithos-tid-'));
|
|
const port = 18900 + Math.floor(Math.random() * 200);
|
|
const h = await createHosted({
|
|
listen: `127.0.0.1:${port}`, publicUrl: `http://127.0.0.1:${port}`, root, s3dBinary: null, portBase: 23200, maxRunning: 1, idleMinutes: 1,
|
|
gate: { invites: ['inv-1', 'inv-2'] },
|
|
}, () => {});
|
|
t.after(async () => { await h.close(); fs.rmSync(root, { recursive: true, force: true }); });
|
|
|
|
// An owner: an Ed25519 key, signed up with an invite.
|
|
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
|
|
const pubkey = publicKey.export({ format: 'der', type: 'spki' }).subarray(12).toString('hex');
|
|
const ownerSession = async (signup) => {
|
|
const c = (await call(port, 'GET', '/hosted/v1/challenge')).body;
|
|
const signature = crypto.sign(null, Buffer.from(challengeText(`http://127.0.0.1:${port}`, c.nonce)), privateKey).toString('base64');
|
|
return call(port, 'POST', '/hosted/v1/session', { body: { pubkey, nonce: c.nonce, signature, signup } });
|
|
};
|
|
const owner = (await ownerSession({ type: 'invite', code: 'inv-1' })).body.token;
|
|
assert.ok(owner);
|
|
|
|
const tidKey = crypto.randomBytes(32);
|
|
const tid = deps.crypto.account(tidKey);
|
|
const fresh = async (opts) => tidSignIn(tidKey, (await call(port, 'GET', '/hosted/v1/theseus-id/challenge')).body.nonce, opts);
|
|
|
|
// Before linking, the ID has nothing to sign in to.
|
|
assert.equal((await call(port, 'POST', '/hosted/v1/console/session', { body: await fresh() })).status, 404);
|
|
|
|
// Linking needs the owner session.
|
|
assert.equal((await call(port, 'POST', '/hosted/v1/theseus-id', { body: await fresh() })).status, 401);
|
|
const linked = await call(port, 'POST', '/hosted/v1/theseus-id', { body: await fresh(), token: owner });
|
|
assert.equal(linked.status, 200);
|
|
assert.equal(linked.body.account, tid);
|
|
assert.equal((await call(port, 'GET', '/hosted/v1/theseus-id', { token: owner })).body.account, tid);
|
|
|
|
// A sign-in for another project, or made on an origin outside the list, is refused.
|
|
assert.equal((await call(port, 'POST', '/hosted/v1/console/session', { body: await fresh({ projectId: 'hephaestus' }) })).status, 401);
|
|
assert.equal((await call(port, 'POST', '/hosted/v1/console/session', { body: await fresh({ origin: 'https://navigate.st' }) })).status, 401);
|
|
// A nonce is good once.
|
|
const once = await fresh();
|
|
const c1 = await call(port, 'POST', '/hosted/v1/console/session', { body: once });
|
|
assert.equal(c1.status, 200);
|
|
assert.equal(c1.body.scope, 'console');
|
|
assert.equal((await call(port, 'POST', '/hosted/v1/console/session', { body: once })).status, 401);
|
|
|
|
// The console sees the account...
|
|
const consoleTok = c1.body.token;
|
|
const me = await call(port, 'GET', '/hosted/v1/me', { token: consoleTok });
|
|
assert.equal(me.status, 200);
|
|
assert.equal(me.body.scope, 'console');
|
|
assert.equal(me.body.theseusId, tid);
|
|
// ...and nothing that touches data, rules, the owner API or the link itself.
|
|
for (const [m, p] of [['GET', '/hosted/v1/rules'], ['POST', '/hosted/v1/data/checkout'], ['GET', '/hosted/v1/data/file?path=x'], ['GET', '/api/status'], ['GET', '/api/keys'], ['DELETE', '/hosted/v1/theseus-id'], ['POST', '/hosted/v1/theseus-id']]) {
|
|
assert.equal((await call(port, m, p, { token: consoleTok, body: m === 'POST' ? {} : undefined })).status, 403, `${m} ${p}`);
|
|
}
|
|
// It can sign itself out.
|
|
assert.equal((await call(port, 'DELETE', '/hosted/v1/session', { token: consoleTok })).status, 200);
|
|
assert.equal((await call(port, 'GET', '/hosted/v1/me', { token: consoleTok })).status, 401);
|
|
|
|
// Another account cannot take the same ID.
|
|
const other = crypto.generateKeyPairSync('ed25519');
|
|
const otherPub = other.publicKey.export({ format: 'der', type: 'spki' }).subarray(12).toString('hex');
|
|
const oc = (await call(port, 'GET', '/hosted/v1/challenge')).body;
|
|
const os2 = await call(port, 'POST', '/hosted/v1/session', { body: { pubkey: otherPub, nonce: oc.nonce, signature: crypto.sign(null, Buffer.from(challengeText(`http://127.0.0.1:${port}`, oc.nonce)), other.privateKey).toString('base64'), signup: { type: 'invite', code: 'inv-2' } } });
|
|
assert.equal(os2.status, 200);
|
|
assert.equal((await call(port, 'POST', '/hosted/v1/theseus-id', { body: await fresh(), token: os2.body.token })).status, 409);
|
|
|
|
// The owner unlinks; the ID no longer signs in.
|
|
assert.equal((await call(port, 'DELETE', '/hosted/v1/theseus-id', { token: owner })).status, 200);
|
|
assert.equal((await call(port, 'POST', '/hosted/v1/console/session', { body: await fresh() })).status, 404);
|
|
// Old owner sessions (written before scopes) stay owner sessions.
|
|
assert.equal((await call(port, 'GET', '/hosted/v1/rules', { token: owner })).status, 200);
|
|
});
|