pithos-hosted: link a Theseus ID; a console session that cannot read data
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.
This commit is contained in:
parent
df2afcf5df
commit
1673e8f018
6 changed files with 532 additions and 9 deletions
|
|
@ -1,6 +1,11 @@
|
|||
// 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';
|
||||
|
|
@ -8,6 +13,7 @@ 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);
|
||||
|
||||
|
|
@ -45,17 +51,19 @@ export function createAuth(root) {
|
|||
nonces.delete(nonce);
|
||||
return !!exp && exp > Date.now();
|
||||
},
|
||||
issue(id) {
|
||||
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];
|
||||
sessions[hash(token)] = { id, exp: now + SESSION_DAYS * 86_400_000 };
|
||||
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() ? s.id : null;
|
||||
return s && s.exp > Date.now() ? { id: s.id, scope: s.scope || 'owner' } : null;
|
||||
},
|
||||
revoke(token) {
|
||||
delete sessions[hash(token)];
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
// One HTTP listener (behind nginx/TLS) serves four kinds of traffic:
|
||||
//
|
||||
// /hosted/v1/... sign-in, sign-up gate, access rules, moving a data
|
||||
// folder in and out (Bearer session token)
|
||||
// folder in and out (Bearer session token); Theseus ID
|
||||
// linking and the read-only account console
|
||||
// /api/... the owner's own Pithos API, forwarded to their
|
||||
// per-account control server (Bearer session token)
|
||||
// /p/<id>/<drive>/.. public files, /l/<token> share links (no auth)
|
||||
|
|
@ -26,6 +27,7 @@ import { parseAuth, verify, SigError } from './sigv4.js';
|
|||
import { validateRule, classify, decideGrant, publicRuleFor, live, forOwner } from './rules.js';
|
||||
import { readJson, writeJson } from './store.js';
|
||||
import { isTransferPath, transferTarget, hashFile } from '../core/hosted.js';
|
||||
import { createPithosVerifier, PROJECT as TID_PROJECT } from './theseus-id/index.js';
|
||||
|
||||
// Owner API calls the gateway forwards. Everything that would act on the
|
||||
// server itself (binary, s3d.yml, Sia login, account switching) is the
|
||||
|
|
@ -50,6 +52,10 @@ export async function createHosted(cfg, log = console.log) {
|
|||
const grants = readJson(grantsFile, {}); // accessKeyId -> { id, rule }
|
||||
const links = readJson(linksFile, {}); // token -> { id, rule }
|
||||
const gate = createGate(cfg.root, cfg.gate || {});
|
||||
// Theseus ID -> account id, for the console sign-in (DESIGN-theseus-id.md §11.3).
|
||||
const tidFile = path.join(cfg.root, 'tid-links.json');
|
||||
const tidLinks = readJson(tidFile, {});
|
||||
const tidVerifier = createPithosVerifier((nonce) => auth.takeNonce(nonce));
|
||||
// Grant key ids are the gateway's: no account's s3d may route the same id.
|
||||
const sup = createSupervisor(cfg, log, { reservedKey: (akid) => /^PG/.test(akid) || Object.hasOwn(grants, akid) });
|
||||
|
||||
|
|
@ -91,9 +97,26 @@ export async function createHosted(cfg, log = console.log) {
|
|||
const clientIp = (req) => (cfg.trustProxy && String(req.headers['x-forwarded-for'] || '').split(',')[0].trim()) || req.socket.remoteAddress || '';
|
||||
function bearer(req) {
|
||||
const m = /^Bearer\s+(\S+)$/.exec(req.headers.authorization || '');
|
||||
const id = m && auth.lookup(m[1]);
|
||||
if (!id) throw httpError(401, 'sign in first');
|
||||
return { id, token: m[1] };
|
||||
const s = m && auth.lookup(m[1]);
|
||||
if (!s) throw httpError(401, 'sign in first');
|
||||
return { id: s.id, scope: s.scope, token: m[1] };
|
||||
}
|
||||
|
||||
// A Theseus ID sign-in for project "pithos", checked like every other
|
||||
// Theseus ID verifier (§4.3) against nonces this gateway handed out.
|
||||
async function theseusIdSignIn(body) {
|
||||
try {
|
||||
return await tidVerifier.verifySignIn({ message: String(body.message || ''), signature: String(body.signature || ''), move: body.move });
|
||||
} catch (e) {
|
||||
throw httpError(401, e.code === 'bad-nonce' ? 'the sign-in request expired; try again' : `Theseus ID: ${e.code || e.message}`);
|
||||
}
|
||||
}
|
||||
function linkTheseusId(id, account) {
|
||||
const a = sup.account(id);
|
||||
if (a.theseusId && a.theseusId.account !== account) delete tidLinks[a.theseusId.account];
|
||||
tidLinks[account] = id;
|
||||
writeJson(tidFile, tidLinks);
|
||||
sup.saveAccount({ ...a, theseusId: { account, linkedAt: new Date().toISOString() } });
|
||||
}
|
||||
|
||||
async function forwardApi(req, res, url, id) {
|
||||
|
|
@ -503,8 +526,28 @@ export async function createHosted(cfg, log = console.log) {
|
|||
return json(res, 200, { token: auth.issue(id), id, state: a.state });
|
||||
}
|
||||
|
||||
if (p === '/hosted/v1/theseus-id/challenge' && req.method === 'GET') {
|
||||
return json(res, 200, { projectId: TID_PROJECT, nonce: auth.challenge() });
|
||||
}
|
||||
// The console: a linked Theseus ID signs in to see the account, never its files.
|
||||
if (p === '/hosted/v1/console/session' && req.method === 'POST') {
|
||||
const v = await theseusIdSignIn(await jsonBody(req));
|
||||
let id = tidLinks[v.account];
|
||||
if (!id && v.movedFrom && tidLinks[v.movedFrom]) {
|
||||
// The person moved to a new ID in Theseus (§6.3); the account follows.
|
||||
id = tidLinks[v.movedFrom];
|
||||
delete tidLinks[v.movedFrom];
|
||||
linkTheseusId(id, v.account);
|
||||
}
|
||||
if (!id || !sup.account(id)) throw httpError(404, 'no hosted account is linked to this Theseus ID; sign in to Pithos in Theseus once to link it');
|
||||
return json(res, 200, { token: auth.issue(id, 'console'), id, scope: 'console', moved: !!v.movedFrom });
|
||||
}
|
||||
|
||||
if (p.startsWith('/hosted/v1/') || p.startsWith('/api/')) {
|
||||
const { id, token } = bearer(req);
|
||||
const { id, scope, token } = bearer(req);
|
||||
// Console sessions see the account and nothing that touches its data.
|
||||
const CONSOLE_OK = ['GET /hosted/v1/me', 'DELETE /hosted/v1/session', 'GET /hosted/v1/theseus-id'];
|
||||
if (scope !== 'owner' && !CONSOLE_OK.includes(`${req.method} ${p}`)) throw httpError(403, 'sign in with your Pithos key for that; a Theseus ID console session cannot reach files or settings');
|
||||
if (p.startsWith('/api/')) {
|
||||
if (!FORWARD.some(([m, re]) => m === req.method && re.test(p))) throw httpError(403, 'not available on a hosted account');
|
||||
return forwardApi(req, res, url, id);
|
||||
|
|
@ -512,8 +555,25 @@ export async function createHosted(cfg, log = console.log) {
|
|||
const a = sup.account(id);
|
||||
const q = url.searchParams;
|
||||
switch (`${req.method} ${p}`) {
|
||||
case 'GET /hosted/v1/theseus-id':
|
||||
return json(res, 200, { account: a.theseusId?.account || null, linkedAt: a.theseusId?.linkedAt || null });
|
||||
case 'POST /hosted/v1/theseus-id': {
|
||||
// Link: the owner session proves the Ed25519 key, the sign-in proves the ID.
|
||||
const v = await theseusIdSignIn(await jsonBody(req));
|
||||
if (tidLinks[v.account] && tidLinks[v.account] !== id) throw httpError(409, 'this Theseus ID is already linked to another hosted account');
|
||||
linkTheseusId(id, v.account);
|
||||
return json(res, 200, { account: v.account });
|
||||
}
|
||||
case 'DELETE /hosted/v1/theseus-id':
|
||||
if (a.theseusId) {
|
||||
delete tidLinks[a.theseusId.account];
|
||||
writeJson(tidFile, tidLinks);
|
||||
const { theseusId: _gone, ...rest } = a;
|
||||
sup.saveAccount(rest);
|
||||
}
|
||||
return json(res, 200, { ok: true });
|
||||
case 'GET /hosted/v1/me':
|
||||
return json(res, 200, { id, state: a.state, gate: { type: a.gate?.type, name: a.gate?.name || null }, createdAt: a.createdAt, usage: usageOf(id), downloadCapBytes: cfg.downloadCapBytes || null, publicUrl: cfg.publicUrl });
|
||||
return json(res, 200, { id, scope, theseusId: a.theseusId?.account || null, state: a.state, gate: { type: a.gate?.type, name: a.gate?.name || null }, createdAt: a.createdAt, usage: usageOf(id), downloadCapBytes: cfg.downloadCapBytes || null, publicUrl: cfg.publicUrl });
|
||||
case 'DELETE /hosted/v1/session':
|
||||
auth.revoke(token);
|
||||
return json(res, 200, { ok: true });
|
||||
|
|
|
|||
134
hosted/test/theseus-id.test.js
Normal file
134
hosted/test/theseus-id.test.js
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
// 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);
|
||||
});
|
||||
58
hosted/theseus-id/index.js
Normal file
58
hosted/theseus-id/index.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
// Theseus ID for the gateway: checks sign-ins for project "pithos"
|
||||
// (DESIGN-theseus-id.md §4.3, §11.3).
|
||||
//
|
||||
// message.mjs and verify.mjs are verbatim copies of TheseusID/lib, so the
|
||||
// gateway agrees with Theseus and the login service byte for byte; a test
|
||||
// fails if they drift. The curve maths is libauth's, which the gateway
|
||||
// already carries for the sign-up gate, instead of the noble packages the
|
||||
// reference library is usually given.
|
||||
|
||||
import { sha256, hash160, secp256k1, utf8ToBin, base64ToBin, decodeCashAddress, binToHex } from '@bitauth/libauth';
|
||||
import { createVerifier } from './verify.mjs';
|
||||
|
||||
export const PROJECT = 'pithos';
|
||||
|
||||
// The registry entry's origins (TheseusID/registry/projects.json) plus the
|
||||
// login service. Theseus fills Origin from what it saw, so a sign-in made
|
||||
// anywhere else does not verify here.
|
||||
export const ORIGINS = ['https://pithos.sia', 'https://s3.silentmode.st', 'theseus-addon://pithos'];
|
||||
export const PROVIDER_ORIGIN = 'https://id.theseus.x';
|
||||
|
||||
function varInt(n) {
|
||||
if (n < 0xfd) return [n];
|
||||
if (n <= 0xffff) return [0xfd, n & 0xff, n >> 8];
|
||||
return [0xfe, n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff, (n >>> 24) & 0xff];
|
||||
}
|
||||
|
||||
// BIP-137 over the real varint (sign-in messages run past 252 bytes), then
|
||||
// the recovered key's hash compared with the claimed address. Recovery alone
|
||||
// proves nothing: any signature recovers to some key. Theseus ID keys are
|
||||
// compressed, so only headers 31-34 are accepted.
|
||||
export function verifyAddress(message, signatureB64, address) {
|
||||
let sig;
|
||||
try { sig = base64ToBin(String(signatureB64 || '')); } catch { return false; }
|
||||
if (!(sig instanceof Uint8Array) || sig.length !== 65 || sig[0] < 31 || sig[0] > 34) return false;
|
||||
const magic = utf8ToBin('Bitcoin Signed Message:\n');
|
||||
const msg = utf8ToBin(String(message));
|
||||
const digest = sha256.hash(sha256.hash(Uint8Array.from([...varInt(magic.length), ...magic, ...varInt(msg.length), ...msg])));
|
||||
const pub = secp256k1.recoverPublicKeyCompressed(sig.slice(1), (sig[0] - 31) & 3, digest);
|
||||
if (typeof pub === 'string') return false;
|
||||
const want = decodeCashAddress(String(address || ''));
|
||||
if (typeof want === 'string' || want.type !== 'p2pkh') return false;
|
||||
return binToHex(want.payload) === binToHex(hash160(pub));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {(nonce: string) => boolean} consumeNonce true once for a nonce this
|
||||
* gateway issued; called last, so a bad request cannot burn a good nonce.
|
||||
*/
|
||||
export function createPithosVerifier(consumeNonce, now = () => Date.now()) {
|
||||
return createVerifier({
|
||||
crypto: { verifyAddress },
|
||||
projectId: PROJECT,
|
||||
origins: ORIGINS,
|
||||
providerOrigin: PROVIDER_ORIGIN,
|
||||
consumeNonce,
|
||||
now,
|
||||
});
|
||||
}
|
||||
207
hosted/theseus-id/message.mjs
Normal file
207
hosted/theseus-id/message.mjs
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
// Theseus ID — the sign-in message (DESIGN-theseus-id.md §4), project IDs and
|
||||
// origins (§3). Pure: no crypto, no I/O, so the same file runs in Theseus,
|
||||
// the provider, browsers and tests.
|
||||
//
|
||||
// The message is built from fields and parsed back with an exact grammar.
|
||||
// Signers never sign text a page wrote; verifiers never accept a message
|
||||
// that does not round-trip through build(parse(text)) byte for byte.
|
||||
|
||||
export const MESSAGE_VERSION = "1";
|
||||
export const ID_CHAIN = "tid:1";
|
||||
export const WALLET_CHAINS = ["bch:mainnet", "bch:chipnet", "bch:regtest"];
|
||||
export const MAX_WINDOW_MS = 10 * 60 * 1000;
|
||||
export const BACK_SKEW_MS = 60 * 1000;
|
||||
|
||||
const LABEL_RE = /^[a-z][a-z0-9-]{0,31}$/;
|
||||
const HOST_RE = /^(?=.{1,253}$)(?!-)[a-z0-9-]{1,63}(?:\.(?!-)[a-z0-9-]{1,63})+$/;
|
||||
const NONCE_RE = /^[A-Za-z0-9_-]{16,128}$/;
|
||||
const REQID_RE = /^[A-Za-z0-9._-]{1,64}$/;
|
||||
const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
||||
const ACCOUNT_RE = /^(tid|bitcoincash|bchtest|bchreg):[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{42}$/;
|
||||
|
||||
// project-id = label / hostname / label ":" hostname (§3.1)
|
||||
export function parseProjectId(id) {
|
||||
const s = String(id ?? "");
|
||||
if (s !== s.toLowerCase()) return null;
|
||||
if (LABEL_RE.test(s)) return { id: s, kind: "first-party", authority: null };
|
||||
if (HOST_RE.test(s)) return { id: s, kind: "name", authority: s };
|
||||
const i = s.indexOf(":");
|
||||
if (i > 0 && LABEL_RE.test(s.slice(0, i)) && HOST_RE.test(s.slice(i + 1))) {
|
||||
return { id: s, kind: "family", family: s.slice(0, i), authority: s.slice(i + 1) };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
export const isProjectId = (id) => parseProjectId(id) !== null;
|
||||
|
||||
// Exact origins only: scheme://host[:port], https, or http on a loopback host
|
||||
// (development). theseus-addon://<id> is the pseudo-origin of a built-in add-on.
|
||||
export function normOrigin(o) {
|
||||
const s = String(o ?? "");
|
||||
if (/^theseus-addon:\/\/[a-z0-9][a-z0-9-]{0,63}$/.test(s)) return s;
|
||||
let u;
|
||||
try { u = new URL(s); } catch { return null; }
|
||||
if (u.username || u.password || u.search || u.hash || (u.pathname && u.pathname !== "/")) return null;
|
||||
if (s.endsWith("/")) return null; // written without a trailing slash, as an origin
|
||||
const host = u.hostname.toLowerCase();
|
||||
if (u.protocol === "https:") return u.origin;
|
||||
if (u.protocol === "http:" && (host === "localhost" || host === "127.0.0.1" || host === "[::1]")) return u.origin;
|
||||
return null;
|
||||
}
|
||||
|
||||
function domainOf(origin) {
|
||||
if (origin.startsWith("theseus-addon://")) return origin;
|
||||
return new URL(origin).host; // host[:port], port only when not the default
|
||||
}
|
||||
|
||||
function oneLine(s, max) {
|
||||
const t = String(s ?? "");
|
||||
if (t.length > max || [...t].some((c) => { const n = c.codePointAt(0); return n < 0x20 || n === 0x7f || n === 0x2028 || n === 0x2029; })) return null;
|
||||
return t;
|
||||
}
|
||||
|
||||
const HEADLINES = {
|
||||
signin: "wants you to sign in with your Theseus ID:",
|
||||
link: "wants you to link a wallet to your Theseus ID:",
|
||||
};
|
||||
|
||||
// fields: { kind: "signin" | "link", origin, account, statement?, uri, chainId,
|
||||
// projectId, nonce, issuedAt, expirationTime, requestId?, resources?,
|
||||
// wallet?, walletChainId? (link only) }
|
||||
export function validateFields(f) {
|
||||
const errs = [];
|
||||
const kind = f.kind || "signin";
|
||||
if (!HEADLINES[kind]) errs.push("kind");
|
||||
const origin = normOrigin(f.origin);
|
||||
if (!origin) errs.push("origin");
|
||||
if (!ACCOUNT_RE.test(String(f.account || ""))) errs.push("account");
|
||||
if (!isProjectId(f.projectId)) errs.push("projectId");
|
||||
if (!NONCE_RE.test(String(f.nonce || ""))) errs.push("nonce");
|
||||
if (!ISO_RE.test(String(f.issuedAt || "")) || isNaN(Date.parse(f.issuedAt))) errs.push("issuedAt");
|
||||
if (!ISO_RE.test(String(f.expirationTime || "")) || isNaN(Date.parse(f.expirationTime))) errs.push("expirationTime");
|
||||
else if (!errs.includes("issuedAt")) {
|
||||
const span = Date.parse(f.expirationTime) - Date.parse(f.issuedAt);
|
||||
if (span <= 0 || span > MAX_WINDOW_MS) errs.push("expirationTime");
|
||||
}
|
||||
// A statement that looks like a field line would parse back differently.
|
||||
if (f.statement != null && f.statement !== "" &&
|
||||
(oneLine(f.statement, 200) == null || /^(URI|Wallet): /.test(f.statement))) errs.push("statement");
|
||||
if (oneLine(f.uri, 2048) == null || !f.uri) errs.push("uri");
|
||||
if (f.requestId != null && f.requestId !== "" && !REQID_RE.test(String(f.requestId))) errs.push("requestId");
|
||||
const res = f.resources || [];
|
||||
if (!Array.isArray(res) || res.length > 8 || res.some((r) => oneLine(r, 2048) == null || !r)) errs.push("resources");
|
||||
const account = String(f.account || "");
|
||||
if (kind === "signin") {
|
||||
const isId = account.startsWith("tid:");
|
||||
if (isId ? f.chainId !== ID_CHAIN : !WALLET_CHAINS.includes(f.chainId)) errs.push("chainId");
|
||||
} else if (kind === "link") {
|
||||
if (!account.startsWith("tid:") || f.chainId !== ID_CHAIN) errs.push("chainId");
|
||||
if (!/^(bitcoincash|bchtest|bchreg):/.test(String(f.wallet || "")) || !ACCOUNT_RE.test(String(f.wallet))) errs.push("wallet");
|
||||
if (!WALLET_CHAINS.includes(f.walletChainId)) errs.push("walletChainId");
|
||||
}
|
||||
return errs;
|
||||
}
|
||||
|
||||
export function buildMessage(f) {
|
||||
const errs = validateFields(f);
|
||||
if (errs.length) throw Object.assign(new Error("bad message fields: " + errs.join(", ")), { code: "bad-request", fields: errs });
|
||||
const kind = f.kind || "signin";
|
||||
const origin = normOrigin(f.origin);
|
||||
const lines = [`${domainOf(origin)} ${HEADLINES[kind]}`, f.account, ""];
|
||||
if (f.statement) lines.push(f.statement, "");
|
||||
if (kind === "link") lines.push(`Wallet: ${f.wallet}`, `Wallet Chain ID: ${f.walletChainId}`);
|
||||
lines.push(
|
||||
`URI: ${f.uri}`,
|
||||
`Version: ${MESSAGE_VERSION}`,
|
||||
`Chain ID: ${f.chainId}`,
|
||||
`Project: ${f.projectId}`,
|
||||
`Origin: ${origin}`,
|
||||
`Nonce: ${f.nonce}`,
|
||||
`Issued At: ${f.issuedAt}`,
|
||||
`Expiration Time: ${f.expirationTime}`,
|
||||
);
|
||||
if (f.requestId) lines.push(`Request ID: ${f.requestId}`);
|
||||
if (f.resources && f.resources.length) lines.push("Resources:", ...f.resources.map((r) => `- ${r}`));
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// Strict parse: returns the fields, or throws. Accepts only what buildMessage
|
||||
// produces for those fields.
|
||||
export function parseMessage(text) {
|
||||
const t = String(text ?? "");
|
||||
const lines = t.split("\n");
|
||||
const fail = (why) => { throw Object.assign(new Error("malformed message: " + why), { code: "malformed" }); };
|
||||
if (t.includes("\r")) fail("CR");
|
||||
let i = 0;
|
||||
const head = lines[i++] || "";
|
||||
let kind = null;
|
||||
for (const [k, h] of Object.entries(HEADLINES)) if (head.endsWith(" " + h)) kind = k;
|
||||
if (!kind) fail("headline");
|
||||
const account = lines[i++];
|
||||
if (lines[i++] !== "") fail("blank after account");
|
||||
let statement;
|
||||
// A statement is any line that is not a field line, followed by a blank.
|
||||
const fieldStart = kind === "link" ? "Wallet: " : "URI: ";
|
||||
if (lines[i] !== undefined && !lines[i].startsWith(fieldStart)) {
|
||||
statement = lines[i++];
|
||||
if (lines[i++] !== "") fail("blank after statement");
|
||||
}
|
||||
const take = (label) => {
|
||||
const l = lines[i];
|
||||
if (l === undefined || !l.startsWith(label + ": ")) fail("expected " + label);
|
||||
i++;
|
||||
return l.slice(label.length + 2);
|
||||
};
|
||||
const f = { kind, account, statement };
|
||||
if (kind === "link") { f.wallet = take("Wallet"); f.walletChainId = take("Wallet Chain ID"); }
|
||||
f.uri = take("URI");
|
||||
if (take("Version") !== MESSAGE_VERSION) fail("version");
|
||||
f.chainId = take("Chain ID");
|
||||
f.projectId = take("Project");
|
||||
f.origin = take("Origin");
|
||||
f.nonce = take("Nonce");
|
||||
f.issuedAt = take("Issued At");
|
||||
f.expirationTime = take("Expiration Time");
|
||||
if (lines[i] !== undefined && lines[i].startsWith("Request ID: ")) f.requestId = take("Request ID");
|
||||
if (lines[i] === "Resources:") {
|
||||
i++;
|
||||
f.resources = [];
|
||||
while (i < lines.length && lines[i].startsWith("- ")) f.resources.push(lines[i++].slice(2));
|
||||
}
|
||||
if (i !== lines.length) fail("trailing lines");
|
||||
let rebuilt;
|
||||
try { rebuilt = buildMessage(f); } catch (e) { fail(e.message); }
|
||||
if (rebuilt !== t) fail("not canonical");
|
||||
return f;
|
||||
}
|
||||
|
||||
// Move statement (§6.3): signed by both the old and the new key.
|
||||
export function buildMoveStatement({ projectId, from, to, origin, nonce, issuedAt }) {
|
||||
if (!isProjectId(projectId) || !normOrigin(origin) || !NONCE_RE.test(String(nonce || "")) || !ISO_RE.test(String(issuedAt || ""))) {
|
||||
throw Object.assign(new Error("bad move fields"), { code: "bad-request" });
|
||||
}
|
||||
if (!/^tid:/.test(from) || !/^tid:/.test(to) || !ACCOUNT_RE.test(from) || !ACCOUNT_RE.test(to) || from === to) {
|
||||
throw Object.assign(new Error("bad move accounts"), { code: "bad-request" });
|
||||
}
|
||||
return ["Theseus ID move", `Project: ${projectId}`, `From: ${from}`, `To: ${to}`,
|
||||
`Origin: ${normOrigin(origin)}`, `Nonce: ${nonce}`, `Issued At: ${issuedAt}`].join("\n");
|
||||
}
|
||||
|
||||
// Time check for a parsed message (§4.3 step 5).
|
||||
export function checkTime(f, now = Date.now()) {
|
||||
const iat = Date.parse(f.issuedAt), exp = Date.parse(f.expirationTime);
|
||||
if (!(now >= iat - BACK_SKEW_MS)) return "issued-in-future";
|
||||
if (!(now <= exp)) return "expired";
|
||||
return null;
|
||||
}
|
||||
|
||||
// Canonical JSON (sorted keys, no whitespace, nulls dropped) for signed
|
||||
// origin lists and the registry.
|
||||
export function canonicalJson(v) {
|
||||
if (v === null || v === undefined) return undefined;
|
||||
if (Array.isArray(v)) return "[" + v.map((x) => canonicalJson(x) ?? "null").join(",") + "]";
|
||||
if (typeof v === "object") {
|
||||
const keys = Object.keys(v).filter((k) => v[k] !== null && v[k] !== undefined).sort();
|
||||
return "{" + keys.map((k) => JSON.stringify(k) + ":" + canonicalJson(v[k])).join(",") + "}";
|
||||
}
|
||||
return JSON.stringify(v);
|
||||
}
|
||||
56
hosted/theseus-id/verify.mjs
Normal file
56
hosted/theseus-id/verify.mjs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
// Theseus ID — what a project's server checks (DESIGN §4.3, §6.3, §7).
|
||||
//
|
||||
// const v = createVerifier({ crypto, projectId: "hephaestus",
|
||||
// origins: ["https://code.silentmode.st"], providerOrigin: "https://accounts.silentmode.st",
|
||||
// consumeNonce: async (nonce) => true_if_issued_by_me_and_unused });
|
||||
// const { account, origin } = await v.verifySignIn({ message, signature });
|
||||
|
||||
import { parseMessage, checkTime, buildMoveStatement, normOrigin, ID_CHAIN, WALLET_CHAINS } from "./message.mjs";
|
||||
|
||||
const bad = (code, msg) => Object.assign(new Error(msg || code), { code });
|
||||
|
||||
export function createVerifier({ crypto, projectId, origins = [], providerOrigin = null, consumeNonce, acceptWallets = false, now = () => Date.now() }) {
|
||||
if (!crypto || !projectId || typeof consumeNonce !== "function") throw new Error("createVerifier: crypto, projectId and consumeNonce are required");
|
||||
const allowed = new Set(origins.map(normOrigin).filter(Boolean));
|
||||
if (providerOrigin) allowed.add(normOrigin(providerOrigin));
|
||||
|
||||
async function check(message, signature, wantKind) {
|
||||
let f;
|
||||
try { f = parseMessage(message); } catch (e) { throw bad("malformed", e.message); }
|
||||
if (f.kind !== wantKind) throw bad("malformed", "wrong message kind");
|
||||
if (f.projectId !== projectId) throw bad("wrong-project", `signed for ${f.projectId}`);
|
||||
if (!allowed.has(f.origin)) throw bad("wrong-origin", `origin ${f.origin} is not ${projectId}'s`);
|
||||
const late = checkTime(f, now());
|
||||
if (late) throw bad(late);
|
||||
const isId = f.account.startsWith("tid:");
|
||||
if (wantKind === "signin") {
|
||||
if (isId ? f.chainId !== ID_CHAIN : !(acceptWallets && WALLET_CHAINS.includes(f.chainId))) throw bad("wrong-chain");
|
||||
}
|
||||
if (!crypto.verifyAddress(message, signature, f.account)) throw bad("bad-signature");
|
||||
// Last, so a bad request cannot burn a good nonce.
|
||||
if (!(await consumeNonce(f.nonce, f))) throw bad("bad-nonce", "nonce unknown or already used");
|
||||
return f;
|
||||
}
|
||||
|
||||
return {
|
||||
async verifySignIn({ message, signature, move }) {
|
||||
const f = await check(message, signature, "signin");
|
||||
const out = { account: f.account, projectId: f.projectId, origin: f.origin, kind: f.account.startsWith("tid:") ? "tid" : "wallet" };
|
||||
if (move) {
|
||||
// §6.3: the new key signs in; a statement signed by old AND new moves the account.
|
||||
const text = buildMoveStatement({ projectId, from: move.from, to: f.account, origin: f.origin, nonce: f.nonce, issuedAt: move.issuedAt });
|
||||
if (text !== move.message) throw bad("bad-move", "move statement does not match the sign-in");
|
||||
if (!crypto.verifyAddress(text, move.signatures?.old, move.from)) throw bad("bad-move", "old key did not sign the move");
|
||||
if (!crypto.verifyAddress(text, move.signatures?.new, f.account)) throw bad("bad-move", "new key did not sign the move");
|
||||
out.movedFrom = move.from;
|
||||
}
|
||||
return out;
|
||||
},
|
||||
// §7: { message, signatures: { id, wallet } } — both keys sign the same text.
|
||||
async verifyLink({ message, signatures }) {
|
||||
const f = await check(message, signatures?.id, "link");
|
||||
if (!crypto.verifyAddress(message, signatures?.wallet, f.wallet)) throw bad("bad-signature", "wallet did not sign the link");
|
||||
return { account: f.account, wallet: f.wallet, walletChainId: f.walletChainId, projectId: f.projectId, origin: f.origin };
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue