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.
153 lines
8.1 KiB
JavaScript
153 lines
8.1 KiB
JavaScript
// 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" });
|
|
});
|