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 `