pithos/hosted/theseus-id/message.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

207 lines
9.4 KiB
JavaScript

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