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:
parent
2c307249a1
commit
b4a550ef41
6 changed files with 599 additions and 24 deletions
|
|
@ -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
195
src/lib/password-vault.js
Normal 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
55
src/lib/record-picker.js
Normal 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
46
src/lib/resolver-web.d.ts
vendored
Normal 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[];
|
||||
121
test/password-vault.test.mjs
Normal file
121
test/password-vault.test.mjs
Normal 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
133
test/record-picker.test.mjs
Normal 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");
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue