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.
56 lines
3.3 KiB
JavaScript
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 };
|
|
},
|
|
};
|
|
}
|