pithos/hosted/test/theseus-id.test.js

156 lines
9.4 KiB
JavaScript
Raw Normal View History

// 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);
});