theseus/dev/theseus-id.test.cjs

154 lines
8.1 KiB
JavaScript
Raw Normal View History

Theseus ID in Theseus: window.theseusId.signIn and Settings › Theseus ID Pages of Silent Mode projects can now sign the user in with their Theseus ID instead of a wallet phrase typed into the page. Theseus writes the sign-in message itself, takes the origin from the committed top frame, and signs as a project only on an origin that project's list includes, so a phishing page cannot get another project's signature and no page can use the ID key to sign anything else. - lib/theseus-id.cjs: the policy (first sign-in always asks and lets the user pick a private or One ID; Silent Mode projects are silent after that while the vault is open; per-site "always"; 10 silent signatures per minute per origin), the per-project record encrypted under a key derived from the vault, origin-list fetching with a 1 h cache and a 7-day stale fallback, and ID moves that send a proof signed by both keys and only finish once the project confirms. - A locked vault is unlocked only for a page the user just clicked or typed in: navigator.userActivation alone is true on load for pages opened with loadURL, which would let a page pop the vault prompt by itself. - Settings › Theseus ID: default mode, One ID, automatic sign-in toggle, signed-in projects (always, change ID, new ID, revoke) and a recovery key behind a fresh PIN / password check. - TheseusID/registry/projects.json is the first-party list (Hephaestus, Sirius, Pithos); it and TheseusID/lib ship as extraResources. - Token-aware cashaddrs (BNS owners) now decode for owner-signed lists. Verified on a scratch profile against a local test project whose server checks signatures with TheseusID/lib/verify.mjs: locked vault on load gives "locked" with no prompt, first sign-in prompt, silent second sign-in, a claimed foreign project refused without a prompt, an ID move that keeps the project's account, and the recovery key behind the confirm prompt.
2026-10-04 20:48:07 +02:00
// node --test TheseusNavigator/dev/theseus-id.test.cjs
// lib/theseus-id.cjs with a fake vault, fake prompts and fake fetchers.
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { pathToFileURL } = require("node:url");
const { createTheseusIdHost, SILENT_PER_MIN } = require("../lib/theseus-id.cjs");
const LIB = pathToFileURL(path.join(__dirname, "..", "..", "TheseusID", "lib", "index.mjs")).href;
async function loadLib() {
const lib = await import(LIB);
const { secp256k1 } = await import("@noble/curves/secp256k1.js");
const { sha256 } = await import("@noble/hashes/sha2.js");
const { ripemd160 } = await import("@noble/hashes/legacy.js");
const { hkdf } = await import("@noble/hashes/hkdf.js");
return { lib, crypto: lib.createCrypto({ secp256k1, sha256, ripemd160, hkdf }) };
}
const REGISTRY = {
v: 1, seq: 1,
projects: [
{ project: "hephaestus", name: "Hephaestus", origins: ["https://code.silentmode.st"], provider: { issuer: "https://accounts.silentmode.st", client_id: "hephaestus" }, supports: ["move"] },
{ project: "sirius", name: "Sirius", origins: ["https://sirius.x"] },
],
};
function setup({ unlocked = true, confirm, docs = {} } = {}) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tid-"));
const vault = { root: unlocked ? new Uint8Array(32).fill(5) : null };
const prompts = [];
const host = createTheseusIdHost({
file: path.join(dir, "theseus-id.json"),
loadLib,
getPurposeRoot: () => vault.root,
hasVault: () => true,
bundledRegistry: REGISTRY,
fetchOriginDoc: async (authority) => { if (!docs[authority]) throw new Error("404"); return docs[authority]; },
ui: {
unlock: async () => { vault.root = new Uint8Array(32).fill(5); return true; },
confirm: async (req) => { prompts.push(req); return confirm ? confirm(req) : { ok: true }; },
},
});
return { host, vault, prompts, dir };
}
const req = (over = {}) => ({ projectId: "hephaestus", nonce: "nonce-0123456789abcdef", origin: "https://code.silentmode.st", uri: "https://code.silentmode.st/login", gesture: true, ...over });
test("first sign-in prompts, later first-party sign-ins are silent", async () => {
const { host, prompts } = setup();
const a = await host.signIn(req());
assert.equal(prompts.length, 1);
assert.equal(prompts[0].first, true);
assert.match(a.account, /^tid:/);
const { lib, crypto } = await loadLib();
assert.ok(crypto.verifyAddress(a.message, a.signature, a.account));
assert.equal(lib.parseMessage(a.message).origin, "https://code.silentmode.st");
const b = await host.signIn(req({ nonce: "nonce-0123456789abcdeg" }));
assert.equal(prompts.length, 1, "no second prompt");
assert.equal(b.account, a.account, "same ID every time");
});
test("origins outside the list are refused without any prompt", async () => {
const { host, prompts } = setup();
await assert.rejects(host.signIn(req({ origin: "https://evil.example" })), { code: "origin-not-listed" });
await assert.rejects(host.signIn(req({ origin: "https://navigate.st" })), { code: "origin-not-listed" });
await assert.rejects(host.signIn(req({ projectId: "unknown" })), { code: "origin-not-listed" });
assert.equal(prompts.length, 0);
// The listed provider may ask for Hephaestus's signature.
const viaProvider = await host.signIn(req({ origin: "https://accounts.silentmode.st" }));
assert.match(viaProvider.message, /Origin: https:\/\/accounts\.silentmode\.st/);
});
test("per-project IDs differ; One ID is shared", async () => {
const { host } = setup({ confirm: (r) => ({ ok: true, mode: r.projectId === "sirius" ? "one" : "project" }) });
const h = await host.signIn(req());
const s = await host.signIn(req({ projectId: "sirius", origin: "https://sirius.x", uri: "https://sirius.x/" }));
assert.notEqual(h.account, s.account);
const ov = await host.overview();
assert.equal(ov.oneId, s.account, "sirius chose One ID");
assert.deepEqual(ov.projects.map((p) => p.mode).sort(), ["one", "project"]);
});
test("locked vault: no gesture, no prompt; with a gesture, unlock first", async () => {
const { host, vault } = setup({ unlocked: false });
await assert.rejects(host.signIn(req({ gesture: false })), { code: "locked" });
assert.equal(vault.root, null);
const r = await host.signIn(req());
assert.match(r.account, /^tid:/);
});
test("declining, busy and the silent-rate limit", async () => {
const { host, prompts } = setup({ confirm: () => ({ ok: false }) });
await assert.rejects(host.signIn(req()), { code: "denied" });
const s2 = setup();
await s2.host.signIn(req());
const both = await Promise.allSettled([s2.host.signIn(req({ nonce: "nonce-aaaaaaaaaaaaaaaa" })), s2.host.signIn(req({ nonce: "nonce-bbbbbbbbbbbbbbbb" }))]);
assert.ok(both.some((x) => x.status === "rejected" && x.reason.code === "busy"));
for (let i = 0; i < SILENT_PER_MIN + 2; i++) await s2.host.signIn(req({ nonce: "nonce-cccccccccccccc" + String(i).padStart(2, "0") }));
assert.ok(s2.prompts.length >= 2, "beyond the limit, sign-ins prompt again");
});
test("a mode switch never changes the ID silently: move proof, then finish", async () => {
const { host } = setup();
const first = await host.signIn(req());
await host.setDefaultMode("one");
const still = await host.signIn(req({ nonce: "nonce-dddddddddddddddd" }));
assert.equal(still.account, first.account, "default mode change leaves existing projects alone");
await host.requestMove("hephaestus", { mode: "one" });
const moving = await host.signIn(req({ nonce: "nonce-eeeeeeeeeeeeeeee" }));
assert.notEqual(moving.account, first.account);
assert.equal(moving.move.from, first.account);
const { lib, crypto } = await loadLib();
const v = lib.createVerifier({ crypto, projectId: "hephaestus", origins: ["https://code.silentmode.st"], consumeNonce: () => true });
const ok = await v.verifySignIn({ message: moving.message, signature: moving.signature, move: moving.move });
assert.equal(ok.movedFrom, first.account);
await host.moved({ projectId: "hephaestus", origin: "https://code.silentmode.st", account: moving.account });
const after = await host.signIn(req({ nonce: "nonce-ffffffffffffffff" }));
assert.equal(after.account, moving.account);
assert.equal(after.move, undefined);
// Sirius does not list "move": refused.
await host.signIn(req({ projectId: "sirius", origin: "https://sirius.x" }));
await assert.rejects(host.requestMove("sirius", { mode: "one" }), { code: "move-unsupported" });
});
test("state is encrypted, survives a restart, and is unreadable with another vault", async () => {
const s = setup();
const a = await s.host.signIn(req());
const raw = fs.readFileSync(path.join(s.dir, "theseus-id.json"), "utf8");
assert.ok(!raw.includes("hephaestus") && !raw.includes(a.account), "no plain project names or IDs on disk");
s.host.forget();
const ov = await s.host.overview();
assert.equal(ov.projects[0].account, a.account);
s.vault.root = new Uint8Array(32).fill(9); s.host.forget();
assert.deepEqual((await s.host.overview()).projects, []);
});
test("name-scoped projects fetch their list; BNS lists must be owner-signed", async () => {
const { lib, crypto } = await loadLib();
const ownerKey = new Uint8Array(32).fill(3);
const owner = crypto.account(ownerKey, "bitcoincash");
const body = { v: 1, project: "game.x", origins: ["https://game.x"] };
const signed = { ...body, sig: crypto.sign(ownerKey, lib.canonicalJson(body)) };
const { host } = setup({ docs: {
"game.x": { doc: signed, bns: true, owner },
"blog.example.org": { doc: { v: 1, project: "sirius-press:blog.example.org", origins: ["https://blog.example.org"] }, bns: false },
"bad.x": { doc: body, bns: true, owner },
} });
assert.match((await host.signIn(req({ projectId: "game.x", origin: "https://game.x" }))).account, /^tid:/);
assert.match((await host.signIn(req({ projectId: "sirius-press:blog.example.org", origin: "https://blog.example.org" }))).account, /^tid:/);
await assert.rejects(host.signIn(req({ projectId: "bad.x", origin: "https://bad.x" })), { code: "origin-list-unavailable" });
});