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