pithos/hosted/theseus-id/verify.mjs
Local Dev 1673e8f018 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.
2026-10-06 20:34:52 +02:00

56 lines
3.3 KiB
JavaScript

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