In Theseus, Pithos now links the person's Theseus ID to their hosted account by itself, once, right after signing in to Silent Mode (or on the first look at an account already signed in): one consent prompt, then Pithos is listed under Settings › Theseus ID. On a Theseus without the add-on API it does nothing, so the add-on can ship before the Theseus release that carries it. The gateway serves /console on its own origin: sign in with the Theseus ID and see the account's state, sign-up, usage and Theseus ID, with a console session that cannot reach files. Three fixed files under a CSP that allows only this origin. The bundled copy in TheseusNavigator/bundled-addons/pithos is rebuilt from Pithos/ with no version bump; the release that ships it bumps the version.
155 lines
9.4 KiB
JavaScript
155 lines
9.4 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);
|
|
});
|
|
|
|
test('the console page: three fixed files with a strict CSP', async (t) => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pithos-console-'));
|
|
const port = 19500 + 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: 23600, maxRunning: 1, idleMinutes: 1, gate: { invites: [] },
|
|
}, () => {});
|
|
t.after(async () => { await h.close(); fs.rmSync(root, { recursive: true, force: true }); });
|
|
const get = (p) => new Promise((resolve, reject) => http.get({ host: '127.0.0.1', port, path: p }, (res) => {
|
|
let b = ''; res.on('data', (d) => { b += d; }); res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: b }));
|
|
}).on('error', reject));
|
|
const page = await get('/console');
|
|
assert.equal(page.status, 200);
|
|
assert.match(page.headers['content-type'], /text\/html/);
|
|
assert.match(page.headers['content-security-policy'], /default-src 'none'; script-src 'self'/);
|
|
assert.match(page.headers['content-security-policy'], /frame-ancestors 'none'/);
|
|
assert.match(page.body, /\/console\/console\.js/);
|
|
assert.equal((await get('/console/console.js')).status, 200);
|
|
assert.equal((await get('/console/console.css')).status, 200);
|
|
for (const p of ['/console/', '/console/index.html', '/console/../server.js', '/console/%2e%2e/server.js']) assert.equal((await get(p)).status, 404, p);
|
|
});
|