diff --git a/src/gateway/public-gateway.mjs b/src/gateway/public-gateway.mjs
index e0f3838..23817d9 100644
--- a/src/gateway/public-gateway.mjs
+++ b/src/gateway/public-gateway.mjs
@@ -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) => `
NXDOMAIN
${h} is not registered on the Bitcoin Cash chain.
`;
// 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); }
}
diff --git a/src/lib/password-vault.js b/src/lib/password-vault.js
new file mode 100644
index 0000000..bc48998
--- /dev/null
+++ b/src/lib/password-vault.js
@@ -0,0 +1,195 @@
+// Local password vault + deterministic password derivation.
+//
+// The vault is a single encrypted file at /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",
+ }));
+}
diff --git a/src/lib/record-picker.js b/src/lib/record-picker.js
new file mode 100644
index 0000000..e2f6260
--- /dev/null
+++ b/src/lib/record-picker.js
@@ -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 `