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