Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus

Several concurrent workstreams committed together as a checkpoint:

- Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic,
  AndroidManifest + build.ps1
- Theseus password manager — settings.html/chrome.html/settings-preload.js UI +
  main.js wiring + package.json resource; Argus password-vault.js, record-picker.js
  (+ tests) and resolver-web.d.ts
- Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and
  restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored)
- Argus public-gateway.mjs updates
- Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant),
  site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup,
  Decentralized Storage map, coordination notes
- .gitignore — exclude /.keys/ and Hephaestus/.env
This commit is contained in:
Local Dev 2026-08-14 23:17:18 +02:00
parent 2c307249a1
commit b4a550ef41
6 changed files with 599 additions and 24 deletions

View file

@ -13,7 +13,11 @@
import http from "http";
import { AwsClient } from "aws4fetch";
import WebSocket from "ws";
import { resolveName, buildIndex, normalizeName } from "./resolver-web.js";
// resolver-web.js lives at ../lib/, not alongside this file — the previous
// `./resolver-web.js` import was broken (file was moved into lib/ but this
// import wasn't updated). Fix while touching the file.
import { resolveName, buildIndex, normalizeName } from "../lib/resolver-web.js";
import { pickRecord, isSubdomainOf } from "../lib/record-picker.js";
// Full-index cache (rebuilt every 30s) for fast availability lookups.
let _idx = { at: 0, index: null };
@ -58,36 +62,57 @@ function injectBase(html, href) {
const nx = (h) => `<div style="font:16px system-ui;padding:2rem;color:#333"><h2>NXDOMAIN</h2><p><code>${h}</code> is not registered on the Bitcoin Cash chain.</p></div>`;
// Serve a resolved name. baseHref = null for subdomain form (own origin).
//
// SUBDOMAIN INHERITANCE — read before touching the record-priority ordering.
// A subdomain like `checkers.game.x` shares its parent's on-chain entry
// (`normalizeName` collapses it to `game.x`), so we serve it from the parent's
// records. But `s3` and `ip` mean *different things* about that inheritance:
// - `ip` = "this server handles the whole namespace with Host routing" —
// applies to the apex AND every subdomain the same way. Sending
// `Host: checkers.game.x` lets the parent's nginx serve the subdomain.
// - `s3` = "this Sia key is this exact name" — inheritance requires a
// name-mangling convention (subFolder + name) that only works when the
// parent chose to organise their bucket that way.
// So for subdomains, `ip` is the unambiguous intent and takes priority. For the
// apex, current priority stands (h → s3 → ip → u). This does not break
// Sia-per-subdomain owners: if they omit `ip` from the parent, the fallback
// still tries `s3` first as before.
async function serve(res, name, reqPath, baseHref) {
let entry;
try { entry = await resolve(name); } catch (e) { res.writeHead(502); return res.end("resolve error: " + e.message); }
if (!entry) { res.writeHead(404, { "content-type": "text/html" }); return res.end(nx(name)); }
const r = entry.records;
// `pickRecord` is the single source of truth for record priority; keep this
// call and the inline copy in TheseusNavigator/main.js:serveBns in step.
const pick = pickRecord(r, { isSubdomain: isSubdomainOf(name, entry.name) });
try {
if (r.h) {
if (reqPath !== "/") { res.writeHead(404); return res.end("not found"); }
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
return res.end(injectBase(r.h, baseHref));
switch (pick) {
case "h":
if (reqPath !== "/") { res.writeHead(404); return res.end("not found"); }
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
return res.end(injectBase(r.h, baseHref));
case "s3": {
const sub = subFolder(name);
let base = r.s3; if (base.endsWith("/") && sub) base += sub + "/";
const key = base.endsWith("/") ? base + (reqPath === "/" ? "index.html" : reqPath.slice(1)) : base;
const up = await aws.fetch(`${S3_ENDPOINT}/${key}`);
const buf = Buffer.from(await up.arrayBuffer());
let ct = up.headers.get("content-type"); if (!ct || ct === "application/octet-stream") ct = guess(key);
if (ct.includes("text/html")) { res.writeHead(up.status, { "content-type": ct }); return res.end(injectBase(buf.toString("utf8"), baseHref)); }
res.writeHead(up.status, { "content-type": ct }); return res.end(buf);
}
case "ip": {
const up = await fetch(`http://${r.ip}${reqPath}`, { headers: { host: name } });
const buf = Buffer.from(await up.arrayBuffer());
let ct = up.headers.get("content-type") || guess(reqPath);
if (ct.includes("text/html")) { res.writeHead(up.status, { "content-type": ct }); return res.end(injectBase(buf.toString("utf8"), baseHref)); }
res.writeHead(up.status, { "content-type": ct }); return res.end(buf);
}
case "u":
res.writeHead(302, { location: r.u }); return res.end();
default:
res.writeHead(200, { "content-type": "application/json" }); return res.end(JSON.stringify(entry, null, 2));
}
if (r.s3) {
const sub = subFolder(name);
let base = r.s3; if (base.endsWith("/") && sub) base += sub + "/";
const key = base.endsWith("/") ? base + (reqPath === "/" ? "index.html" : reqPath.slice(1)) : base;
const up = await aws.fetch(`${S3_ENDPOINT}/${key}`);
const buf = Buffer.from(await up.arrayBuffer());
let ct = up.headers.get("content-type"); if (!ct || ct === "application/octet-stream") ct = guess(key);
if (ct.includes("text/html")) { res.writeHead(up.status, { "content-type": ct }); return res.end(injectBase(buf.toString("utf8"), baseHref)); }
res.writeHead(up.status, { "content-type": ct }); return res.end(buf);
}
if (r.ip) {
const up = await fetch(`http://${r.ip}${reqPath}`, { headers: { host: name } });
const buf = Buffer.from(await up.arrayBuffer());
let ct = up.headers.get("content-type") || guess(reqPath);
if (ct.includes("text/html")) { res.writeHead(up.status, { "content-type": ct }); return res.end(injectBase(buf.toString("utf8"), baseHref)); }
res.writeHead(up.status, { "content-type": ct }); return res.end(buf);
}
if (r.u) { res.writeHead(302, { location: r.u }); return res.end(); }
res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify(entry, null, 2));
} catch (e) { res.writeHead(502); res.end("gateway error: " + e.message); }
}

195
src/lib/password-vault.js Normal file
View file

@ -0,0 +1,195 @@
// Local password vault + deterministic password derivation.
//
// The vault is a single encrypted file at <userData>/passwords.vault. Its
// header (KDF params + salt + IV) is plaintext; contents are opaque
// AES-256-GCM ciphertext. Metadata about which sites the user has passwords
// for is INSIDE the ciphertext — a stolen laptop reveals only "this user
// enabled the password manager", nothing else.
//
// Two entry shapes coexist in one vault:
// { id, domain, username, generated: { version, rules } } — password is
// re-derived from the seed on demand via derivePassword(); nothing
// else is stored, so the same seed on any device produces the same
// password.
// { id, domain, username, literal } — a pasted /
// legacy password, encrypted with the vault key (which is already
// protecting the wrapping ciphertext, so `literal` is stored plain
// inside the plaintext JSON, not double-encrypted).
//
// See ../../TheseusNavigator/DESIGN-password-manager.md for the threat
// model and the "why" behind every choice.
import { webcrypto as wc } from "node:crypto";
import { readFileSync, writeFileSync, existsSync, renameSync } from "node:fs";
const enc = new TextEncoder(); const dec = new TextDecoder();
const PBKDF2_ITERS = 200_000;
const VAULT_VERSION = 1;
// Purpose byte in the HKDF `info` string. Keep in lockstep with the design
// doc — changing this value invalidates every derived password.
const PW_INFO_PREFIX = "silentmode-passwords-v1";
// -- byte helpers -------------------------------------------------------------
export function bytesToHex(b) { return Array.from(b).map((x) => x.toString(16).padStart(2, "0")).join(""); }
export function hexToBytes(h) {
const b = new Uint8Array(h.length / 2);
for (let i = 0; i < b.length; i++) b[i] = parseInt(h.substr(i * 2, 2), 16);
return b;
}
// -- key derivation -----------------------------------------------------------
async function importPbkdf2Key(masterPw) {
return wc.subtle.importKey("raw", enc.encode(masterPw), "PBKDF2", false, ["deriveKey"]);
}
async function deriveVaultKey(masterPw, salt, iters) {
const km = await importPbkdf2Key(masterPw);
return wc.subtle.deriveKey(
{ name: "PBKDF2", salt, iterations: iters, hash: "SHA-256" },
km, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
}
// BIP39 mnemonic → 64-byte seed. Standard: PBKDF2-SHA512(mnemonic, "mnemonic"
// + passphrase, 2048). No wordlist validation — a user pasting garbage gets
// a valid-shaped but unrecoverable seed, which is their problem.
export async function bip39ToSeed(mnemonic, passphrase = "") {
const km = await wc.subtle.importKey("raw", enc.encode(String(mnemonic).normalize("NFKD").trim()),
"PBKDF2", false, ["deriveBits"]);
const salt = enc.encode(("mnemonic" + String(passphrase || "")).normalize("NFKD"));
return new Uint8Array(await wc.subtle.deriveBits(
{ name: "PBKDF2", salt, iterations: 2048, hash: "SHA-512" }, km, 512));
}
// Derive the password-purpose root key (32 bytes) from a 64-byte BIP39 seed.
// Hardened-derivation-style separation: HKDF with a purpose-specific info
// keeps this subtree opaque to any other purpose derived from the same seed.
export async function seedToPurposeRoot(seedBytes, purpose = "passwords/0") {
const key = await wc.subtle.importKey("raw", seedBytes, "HKDF", false, ["deriveBits"]);
const info = enc.encode(`silentmode/${purpose}`);
return new Uint8Array(await wc.subtle.deriveBits(
{ name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info }, key, 256));
}
// -- deterministic password derivation ---------------------------------------
const CHARSET_LOW = "abcdefghijkmnopqrstuvwxyz"; // no `l`
const CHARSET_UPP = "ABCDEFGHIJKLMNPQRSTUVWXYZ"; // no `O` — visually clashes with 0
const CHARSET_DIG = "23456789"; // no 0/1 — visually clash
const CHARSET_SYM = "!@#$%^&*()-_=+[]{},.?";
const DEFAULT_RULES = Object.freeze({ length: 20, upper: true, lower: true, digits: true, symbols: true });
export async function derivePassword(purposeRootHex, { domain, username = "", version = 1, rules = {} } = {}) {
const r = { ...DEFAULT_RULES, ...rules };
if (!domain) throw new Error("derivePassword: domain required");
const key = await wc.subtle.importKey("raw", hexToBytes(purposeRootHex), "HKDF", false, ["deriveBits"]);
const info = enc.encode(`${PW_INFO_PREFIX}|${String(domain).toLowerCase()}|${String(username).toLowerCase()}|v${version}`);
// 64 bytes gives enough headroom for a length-32 password + class-anchor bytes.
const bits = new Uint8Array(await wc.subtle.deriveBits(
{ name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info }, key, 512));
return mapBytesToPassword(bits, r);
}
function mapBytesToPassword(bytes, rules) {
const classes = [];
if (rules.lower) classes.push(CHARSET_LOW);
if (rules.upper) classes.push(CHARSET_UPP);
if (rules.digits) classes.push(CHARSET_DIG);
if (rules.symbols) classes.push(CHARSET_SYM);
if (!classes.length) classes.push(CHARSET_LOW);
const len = Math.max(4, Math.min(64, rules.length || 20));
// Anchor: the first `classes.length` chars each come from one class in
// order, guaranteeing at least one of each enabled class. Fill the rest
// by picking from ALL classes' concatenation.
const out = [];
let i = 0;
for (const cls of classes) {
if (i >= len) break;
out.push(cls[bytes[i] % cls.length]); i++;
}
const all = classes.join("");
while (i < len) { out.push(all[bytes[i] % all.length]); i++; }
return out.join("");
}
// -- vault lifecycle ----------------------------------------------------------
export function vaultExists(path) { return existsSync(path); }
function writeVaultFile(path, { salt, iv, ct, iters }) {
const raw = {
version: VAULT_VERSION,
kdf: { name: "PBKDF2", iters, salt: bytesToHex(salt) },
iv: bytesToHex(iv),
ciphertext: bytesToHex(ct),
};
writeFileSync(path + ".tmp", JSON.stringify(raw), "utf8");
renameSync(path + ".tmp", path);
}
export async function createVault(path, masterPw, purposeRootHex) {
const salt = wc.getRandomValues(new Uint8Array(16));
const iv = wc.getRandomValues(new Uint8Array(12));
const key = await deriveVaultKey(masterPw, salt, PBKDF2_ITERS);
const plaintext = { purposeRoot: purposeRootHex, entries: [] };
const ct = new Uint8Array(await wc.subtle.encrypt(
{ name: "AES-GCM", iv }, key, enc.encode(JSON.stringify(plaintext))));
writeVaultFile(path, { salt, iv, ct, iters: PBKDF2_ITERS });
return { key, purposeRoot: purposeRootHex, entries: [], _salt: salt, _iters: PBKDF2_ITERS };
}
export async function unlockVault(path, masterPw) {
const raw = JSON.parse(readFileSync(path, "utf8"));
if (raw.version !== VAULT_VERSION) throw new Error(`vault version ${raw.version} not supported`);
const salt = hexToBytes(raw.kdf.salt);
const iv = hexToBytes(raw.iv);
const ct = hexToBytes(raw.ciphertext);
const key = await deriveVaultKey(masterPw, salt, raw.kdf.iters);
let pt;
try { pt = new Uint8Array(await wc.subtle.decrypt({ name: "AES-GCM", iv }, key, ct)); }
catch { throw new Error("bad master password"); }
const plaintext = JSON.parse(dec.decode(pt));
return { key, purposeRoot: plaintext.purposeRoot, entries: plaintext.entries || [], _salt: salt, _iters: raw.kdf.iters };
}
export async function saveVault(path, state) {
const iv = wc.getRandomValues(new Uint8Array(12));
const plaintext = { purposeRoot: state.purposeRoot, entries: state.entries };
const ct = new Uint8Array(await wc.subtle.encrypt(
{ name: "AES-GCM", iv }, state.key, enc.encode(JSON.stringify(plaintext))));
writeVaultFile(path, { salt: state._salt, iv, ct, iters: state._iters });
}
// -- entry mutations (operate on an unlocked state object) --------------------
export function newEntry({ domain, username = "", literal, generated } = {}) {
if (!domain) throw new Error("entry: domain required");
const id = wc.getRandomValues(new Uint8Array(16));
const hex = bytesToHex(id);
// UUIDv4-like formatting (not RFC-strict — we don't need cross-tool interop).
const idStr = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
const base = { id: idStr, domain: String(domain).toLowerCase(), username: String(username), addedAt: dateStamp() };
if (literal) return { ...base, literal: String(literal) };
return { ...base, generated: generated || { version: 1, rules: { ...DEFAULT_RULES } } };
}
// Date without Date.now() — this file may be imported into contexts where
// Date is unavailable. Callers supply the timestamp OR we ISO-8601 it via
// the runtime env's default toISOString.
function dateStamp() { try { return new Date().toISOString(); } catch { return ""; } }
// Retrieve the password plaintext for an entry (looks up in the vault OR
// re-derives it from purposeRoot for `generated` entries).
export async function resolvePassword(state, id) {
const entry = (state.entries || []).find((e) => e.id === id);
if (!entry) throw new Error("no such entry");
if (entry.literal) return entry.literal;
const g = entry.generated || {};
return derivePassword(state.purposeRoot, {
domain: entry.domain, username: entry.username,
version: g.version || 1, rules: g.rules || {},
});
}
// Convenience metadata list — never leaks passwords, safe for renderers.
export function listMetadata(state) {
return (state.entries || []).map((e) => ({
id: e.id, domain: e.domain, username: e.username, addedAt: e.addedAt,
kind: e.literal ? "literal" : "generated",
}));
}

55
src/lib/record-picker.js Normal file
View file

@ -0,0 +1,55 @@
// Which BNS record should the loader use for a request?
//
// Extracted because getting this rule wrong ships broken subdomains (2026-08-13
// bug: bare subdomains inheriting from a parent that had BOTH `s3` and `ip`
// were routed through Sia — where they don't exist — instead of through the
// parent's server, which was already configured to serve them by Host header).
//
// TWO SEMANTICS THAT LOOK ALIKE, AREN'T:
// `ip` — "this IP handles the whole namespace; route by Host header."
// Applies uniformly to the apex and every subdomain.
// `s3` — "this bucket key is this exact name." Inheritance to a subdomain
// requires a name-mangling convention that only works if the owner
// chose to organise their bucket that way.
//
// For a subdomain (a query that resolves to the parent via `normalizeName`),
// `ip` is the unambiguous parent intent, so it wins over `s3`. For the apex
// (query name === registered name) the previous priority stands: h > s3 > ip > u.
//
// This does NOT break Sia-per-subdomain owners: if they don't publish `ip`, the
// fallback still tries `s3` first.
//
// KEEP IN STEP with the inline copy in TheseusNavigator/main.js `serveBns()`.
// Theseus is CommonJS and dual-package wrappering for five lines is not worth
// the complexity — but a divergence between them is a real product bug, so both
// sides carry the same test invariants (see test/record-picker.test.mjs).
/**
* Decide which record wins.
* @param {object} records `entry.records` from the resolver
* @param {object} opts
* @param {boolean} opts.isSubdomain true iff the requested name has labels
* above the registered `<label>.<tld>` (so we're serving via inheritance).
* @returns {'ip'|'h'|'s3'|'u'|null} key of the winning record, or null if
* nothing serviceable is present.
*/
export function pickRecord(records, { isSubdomain } = { isSubdomain: false }) {
if (!records) return null;
if (isSubdomain && records.ip) return "ip";
if (records.h) return "h";
if (records.s3) return "s3";
if (records.ip) return "ip";
if (records.u) return "u";
return null;
}
/**
* Are we serving `<sub>…<label>.<tld>` via the entry for `<label>.<tld>`?
* The resolver's `normalizeName` collapses subdomains to the registered name,
* so a mismatch between the requested hostname and the entry's canonical name
* means "this is a subdomain inheritance case."
*/
export function isSubdomainOf(requestedHost, entryName) {
if (!entryName) return false;
return String(requestedHost).toLowerCase() !== String(entryName).toLowerCase();
}

46
src/lib/resolver-web.d.ts vendored Normal file
View file

@ -0,0 +1,46 @@
export function normalizeName(input: any): string;
export function buildIndex({ electrum, WebSocket: WS, client, directIP }?: {
electrum?: {
url: string;
ip: string;
}[] | undefined;
directIP?: boolean | undefined;
}): Promise<Map<any, any>>;
export function resolveName(input: any, opts?: {}): Promise<any>;
export function parseElectrumRecord(records: any): ({
url: string;
ip: string;
} | {
url: string;
ip?: undefined;
})[];
export function getElectrumServers(index: any): ({
url: string;
ip: string;
} | {
url: string;
ip?: undefined;
})[];
export function fetchElectrumServers(opts?: {}): Promise<({
url: string;
ip: string;
} | {
url: string;
ip?: undefined;
})[]>;
export function parseTldsRecord(records: any): string[];
export function getBcnrTlds(index: any): string[];
export function fetchBcnrTlds(opts?: {}): Promise<string[]>;
export function isBcnrNativeTld(tldOrList: any, tld: any): boolean;
export const BEACON: "bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl";
export const BEACON_SCRIPTHASH: "e1cdbba719436f49acb4bf245b44df875c6d6b405eda46a9d3e736d138102f3b";
export const PREFIX: "BNS1";
export const TLD: ".bch";
export const DEFAULT_TLD: "bch";
export const CHIPNET_ELECTRUM: {
url: string;
ip: string;
}[];
export const ELECTRUM_LIST_NAME: "electrum.silentmode.bch";
export const ROOT_TLD_NAME: "tlds.bch";
export const BOOTSTRAP_TLDS: string[];

View file

@ -0,0 +1,121 @@
// Wire-format + determinism tests for the password vault.
// Runs offline — no chain, no IPC, no filesystem beyond a tmp path.
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
bip39ToSeed, seedToPurposeRoot, derivePassword,
createVault, unlockVault, saveVault, vaultExists,
newEntry, resolvePassword, listMetadata,
bytesToHex,
} from "../src/lib/password-vault.js";
const tmp = mkdtempSync(join(tmpdir(), "sm-pv-"));
const vaultPath = join(tmp, "passwords.vault");
// A fixed test seed (32 random bytes hex) — real vaults use HKDF from a
// BIP39 seed; this is just deterministic input for the derivePassword tests.
const TEST_ROOT = "9a3f6d21b4e0c8127f4d5a6789bcdef012345678901234567890abcdef123456";
test("derivePassword — same inputs → same output", async () => {
const a = await derivePassword(TEST_ROOT, { domain: "example.com", username: "alice", version: 1 });
const b = await derivePassword(TEST_ROOT, { domain: "example.com", username: "alice", version: 1 });
assert.equal(a, b);
});
test("derivePassword — different domain → different password", async () => {
const a = await derivePassword(TEST_ROOT, { domain: "example.com", username: "alice", version: 1 });
const b = await derivePassword(TEST_ROOT, { domain: "other.com", username: "alice", version: 1 });
assert.notEqual(a, b);
});
test("derivePassword — different version → different password (rotation)", async () => {
const a = await derivePassword(TEST_ROOT, { domain: "example.com", username: "alice", version: 1 });
const b = await derivePassword(TEST_ROOT, { domain: "example.com", username: "alice", version: 2 });
assert.notEqual(a, b);
});
test("derivePassword — different root → different password", async () => {
const OTHER = "1111111111111111111111111111111111111111111111111111111111111111";
const a = await derivePassword(TEST_ROOT, { domain: "example.com" });
const b = await derivePassword(OTHER, { domain: "example.com" });
assert.notEqual(a, b);
});
test("derivePassword — length + charset rules honored", async () => {
const p = await derivePassword(TEST_ROOT, { domain: "e.com", rules: { length: 24, upper: true, lower: true, digits: true, symbols: true } });
assert.equal(p.length, 24);
assert.match(p, /[a-z]/);
assert.match(p, /[A-Z]/);
assert.match(p, /[0-9]/);
assert.match(p, /[^A-Za-z0-9]/);
});
test("derivePassword — digits-only rules produce digits-only password", async () => {
const p = await derivePassword(TEST_ROOT, { domain: "e.com", rules: { length: 12, digits: true, upper: false, lower: false, symbols: false } });
assert.equal(p.length, 12);
assert.match(p, /^[0-9]+$/);
});
test("bip39ToSeed → matches BIP39 test vector", async () => {
// BIP39 official vector — mnemonic + empty passphrase.
// https://github.com/trezor/python-mnemonic/blob/master/vectors.json
const mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
const seed = await bip39ToSeed(mnemonic, "");
const expected = "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4";
assert.equal(bytesToHex(seed), expected);
});
test("seedToPurposeRoot — deterministic, different purpose → different root", async () => {
const seed = new Uint8Array(64).fill(0x42);
const r1 = await seedToPurposeRoot(seed, "passwords/0");
const r2 = await seedToPurposeRoot(seed, "passwords/0");
const rMsg = await seedToPurposeRoot(seed, "messenger/0");
assert.deepEqual(r1, r2);
assert.notDeepEqual(r1, rMsg);
});
test("createVault + unlockVault round-trip with the right master password", async () => {
const path = join(tmp, "roundtrip.vault");
const rootHex = TEST_ROOT;
await createVault(path, "correct-horse-battery-staple", rootHex);
assert.ok(vaultExists(path));
const st = await unlockVault(path, "correct-horse-battery-staple");
assert.equal(st.purposeRoot, rootHex);
assert.deepEqual(st.entries, []);
});
test("unlockVault — wrong master password → throws", async () => {
const path = join(tmp, "wrongpw.vault");
await createVault(path, "right", TEST_ROOT);
await assert.rejects(unlockVault(path, "wrong"), /bad master password/);
});
test("newEntry + saveVault + unlockVault preserves entries; resolvePassword works", async () => {
const path = join(tmp, "entries.vault");
const st = await createVault(path, "pw", TEST_ROOT);
const e1 = newEntry({ domain: "example.com", username: "alice" });
const e2 = newEntry({ domain: "bank.com", username: "bob", literal: "hunter2!!" });
st.entries.push(e1, e2);
await saveVault(path, st);
const st2 = await unlockVault(path, "pw");
assert.equal(st2.entries.length, 2);
assert.equal(await resolvePassword(st2, e1.id), await derivePassword(TEST_ROOT, { domain: "example.com", username: "alice", version: 1 }));
assert.equal(await resolvePassword(st2, e2.id), "hunter2!!");
});
test("listMetadata — never leaks passwords", () => {
const st = { entries: [
{ id: "1", domain: "a.com", username: "a", literal: "secret1", addedAt: "" },
{ id: "2", domain: "b.com", username: "b", generated: { version: 1, rules: {} }, addedAt: "" },
]};
const meta = listMetadata(st);
for (const m of meta) {
assert.equal(m.literal, undefined);
assert.equal(m.generated, undefined);
assert.ok(m.kind === "literal" || m.kind === "generated");
}
});

133
test/record-picker.test.mjs Normal file
View file

@ -0,0 +1,133 @@
// The record-picker rule. Extracted from the 2026-08-13 subdomain-Sia bug:
// `checkers.game.x` inherits the parent `game.x`'s records (which include BOTH
// `s3` and `ip`); the gateway and Theseus were preferring `s3`, constructing a
// Sia key from the subdomain name, and serving a NoSuchKey XML error to every
// visitor. The parent's `ip` — configured on the VPS to Host-route the
// subdomain — was ignored.
//
// These tests assert the rule that fixes it, not the code that implements it.
// They will catch any regression that reintroduces s3-first-for-subdomains, and
// they will fail on any future change to `pickRecord` that violates a stated
// invariant even if the change looks locally reasonable.
import test from "node:test";
import assert from "node:assert/strict";
import { pickRecord, isSubdomainOf } from "../src/lib/record-picker.js";
// A convenience — the exact bug case, in one place, so a search for it lands here.
const gameXRecords = {
ip: "130.12.242.84",
tls: "6af5f7cb3715e3b81bbabb51d0947cc8611a3e66ad7bc7940a447c2f66deec20",
s3: "bns/game/",
};
// ---------- isSubdomainOf ----------
test("isSubdomainOf: apex is not a subdomain of itself", () => {
assert.equal(isSubdomainOf("game.x", "game.x"), false);
assert.equal(isSubdomainOf("silentmode.bch", "silentmode.bch"), false);
});
test("isSubdomainOf: any label above the registered name counts as a subdomain", () => {
assert.equal(isSubdomainOf("checkers.game.x", "game.x"), true);
assert.equal(isSubdomainOf("blog.alice.p2p", "alice.p2p"), true);
assert.equal(isSubdomainOf("a.b.c.alice.p2p", "alice.p2p"), true);
});
test("isSubdomainOf: case-insensitive, so a badly-cased Host header does not fool it", () => {
assert.equal(isSubdomainOf("Game.X", "game.x"), false);
assert.equal(isSubdomainOf("Checkers.Game.X", "game.x"), true);
});
test("isSubdomainOf: a missing entry name means no inheritance decision to make", () => {
assert.equal(isSubdomainOf("anything.x", ""), false);
assert.equal(isSubdomainOf("anything.x", undefined), false);
});
// ---------- pickRecord: the regression from 2026-08-13 ----------
test("SUBDOMAIN with parent's {s3, ip}: picks ip — this is the 2026-08-13 fix", () => {
// The exact wrong behaviour was picking s3 here.
assert.equal(pickRecord(gameXRecords, { isSubdomain: true }), "ip");
});
test("APEX with the same {s3, ip}: picks s3 — the previous behaviour is unchanged", () => {
// If this ever flips, the apex Sia landing page (game.x itself) breaks.
assert.equal(pickRecord(gameXRecords, { isSubdomain: false }), "s3");
});
// ---------- pickRecord: full priority ordering ----------
test("apex priority is h → s3 → ip → u — matches the pre-2026-08-13 behaviour", () => {
const isSubdomain = false;
assert.equal(pickRecord({ h: "<b>hi</b>", s3: "bns/x/", ip: "1.2.3.4", u: "https://x" }, { isSubdomain }), "h");
assert.equal(pickRecord({ s3: "bns/x/", ip: "1.2.3.4", u: "https://x" }, { isSubdomain }), "s3");
assert.equal(pickRecord({ ip: "1.2.3.4", u: "https://x" }, { isSubdomain }), "ip");
assert.equal(pickRecord({ u: "https://x" }, { isSubdomain }), "u");
});
test("subdomain: ip wins over s3 AND over h — inheritance via Host, not per-name content", () => {
// Parent's `h` would render the same tiny HTML for every subdomain. That's
// the same class of wrong-behaviour as inherited `s3`. So subdomain-with-ip
// wins over `h` too.
const isSubdomain = true;
assert.equal(pickRecord({ h: "<b>hi</b>", ip: "1.2.3.4" }, { isSubdomain }), "ip");
assert.equal(pickRecord({ s3: "bns/x/", ip: "1.2.3.4" }, { isSubdomain }), "ip");
assert.equal(pickRecord({ h: "<b>hi</b>", s3: "bns/x/", ip: "1.2.3.4" }, { isSubdomain }), "ip");
});
// ---------- pickRecord: does NOT break Sia-per-subdomain owners ----------
test("subdomain, parent has ONLY s3 (no ip): still picks s3 — Sia-per-subdomain works", () => {
// An owner who wants every *.foo.p2p served from Sia should either register
// each subdomain with its own s3, or simply not publish `ip` on the parent.
// This asserts the second case still routes to Sia — the fix is targeted at
// the s3+ip case, not a broad "never inherit s3" rule.
const isSubdomain = true;
assert.equal(pickRecord({ s3: "bns/foo/" }, { isSubdomain }), "s3");
assert.equal(pickRecord({ h: "<b>hi</b>" }, { isSubdomain }), "h");
assert.equal(pickRecord({ u: "https://x" }, { isSubdomain }), "u");
});
// ---------- pickRecord: edge cases ----------
test("empty records → null (caller renders the entry as JSON, or 404s)", () => {
assert.equal(pickRecord({}, { isSubdomain: false }), null);
assert.equal(pickRecord({}, { isSubdomain: true }), null);
});
test("null/undefined records → null (do not throw)", () => {
assert.equal(pickRecord(null, { isSubdomain: false }), null);
assert.equal(pickRecord(undefined, { isSubdomain: true }), null);
});
test("missing options default to apex behaviour — safe default is not the fix path", () => {
// If a caller forgets to pass isSubdomain, we must not silently apply the
// subdomain rule; that could hide bugs where isSubdomain was miscomputed.
assert.equal(pickRecord(gameXRecords), "s3");
});
test("unrelated fields (tls, r, arbitrary) do not affect the pick", () => {
const records = { tls: "abc123", r: "phishing", extra: "ignored", ip: "1.2.3.4" };
assert.equal(pickRecord(records, { isSubdomain: false }), "ip");
assert.equal(pickRecord(records, { isSubdomain: true }), "ip");
});
// ---------- The invariant that must hold across any future change ----------
test("INVARIANT: for any records with both s3 and ip, the pick differs between apex and subdomain", () => {
// This is the core property of the fix. If some future edit makes them the
// same, the 2026-08-13 bug has been reintroduced regardless of what the
// ordering table now says.
const cases = [
{ s3: "bns/a/", ip: "1.1.1.1" },
{ s3: "bns/a/", ip: "1.1.1.1", u: "https://x" },
{ s3: "bns/a/", ip: "1.1.1.1", tls: "abc" },
];
for (const r of cases) {
const apex = pickRecord(r, { isSubdomain: false });
const sub = pickRecord(r, { isSubdomain: true });
assert.notEqual(apex, sub, `apex ${apex} === subdomain ${sub} for ${JSON.stringify(r)}`);
assert.equal(sub, "ip", "the subdomain path must pick ip when it is available");
}
});