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.
207 lines
9.4 KiB
JavaScript
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);
|
|
}
|