diff --git a/js/packages/cli/src/api/routes.ts b/js/packages/cli/src/api/routes.ts new file mode 100644 index 00000000..8dc2efef --- /dev/null +++ b/js/packages/cli/src/api/routes.ts @@ -0,0 +1,835 @@ +/** + * API route handlers — parity with src/seedpass/api.py. + * + * Every handler reuses the CLI's own vault plumbing (openVault, mutateVault, + * materializeSecret, loadConfig) rather than reimplementing it. That is the + * point: an API that derived secrets its own way would be a second + * implementation to keep in parity, and the first place the two would drift + * is the part nobody notices — which secret a given entry produces. + */ + +import { readFile, stat } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { basename, dirname, join, resolve as resolvePath } from "node:path"; +import { + addDocumentEntry, + addKeyValueEntry, + addManagedAccountEntry, + addNostrKeyEntry, + addPasswordEntry, + addPgpKeyEntry, + addSeedEntry, + addSshKeyEntry, + addTotpDeterministic, + addTotpImported, + addLink, + archiveEntry, + deriveNostrKeys, + deriveTotpSecret, + exportBackup, + findDerivationCollisions, + generateFingerprint, + generatePassword, + getLinks, + importBackup, + modifyEntry, + parseBackupWrapper, + parseVaultIndex, + passwordPolicyFromRecord, + removeLink, + restoreEntry, + totpCodeAt, + Bip85, + type Entry, + type PasswordPolicy, + type VaultIndex, +} from "@seedpass/core"; +import { AppDir, INDEX_FILENAME, DEFAULT_PBKDF2_ITERATIONS } from "../appDir.js"; +import { loadConfig, mutateConfig, passwordPolicyFromConfig, DEFAULT_RELAYS } from "../configFile.js"; +import { openVault, saveVault, saveVaultHoldingLock, withVaultLock, atomicWrite } from "../vaultFile.js"; +import { entryMetadata, refFor, resolveEntry } from "../refs.js"; +import { materializeSecret } from "../secrets.js"; +import { createIndexBackup } from "../backups.js"; +import { HttpError, type ApiRequest, type ApiResponse, type ApiServer } from "./server.js"; + +/** + * Features that exist in the Python API but are deliberately not ported. + * + * These hang off subsystems the TypeScript port does not implement at all + * (see capabilities().not_yet_ported). They answer 501 with the reason rather + * than 404, because a 404 reads as "you typed the path wrong" and would send + * an integrator hunting for a spelling mistake that is not there. + */ +export const UNPORTED_PREFIXES: Array<{ prefix: string; feature: string }> = [ + { prefix: "/api/v1/high-risk", feature: "high-risk partitions" }, + { prefix: "/api/v1/agent/job-profiles", feature: "agent job profiles" }, + { prefix: "/api/v1/agent/recovery", feature: "agent recovery split" }, + { prefix: "/api/v1/semantic", feature: "the semantic (vector search) index" }, +]; + +export interface ApiContext { + appDir: AppDir; + /** Fingerprint of the profile the server was started against. */ + fingerprint: string; + /** + * The unlocked parent seed, or null when locked. + * + * Held in this object rather than captured, so `vault lock` can genuinely + * drop it: every handler reads it through `requireUnlocked` at call time. + */ + mnemonic: string | null; + /** Verify a master password without unlocking. */ + verifyPassword: (password: string) => Promise; + /** Unlock and populate `mnemonic`; returns seconds taken. */ + unlock: (password: string) => Promise; + /** Queued notifications, drained by GET /notifications. */ + notifications: Array<{ level: string; message: string }>; + /** Requests the process to exit; wired by the CLI command. */ + requestShutdown: () => void; + now: () => number; +} + +function requireUnlocked(ctx: ApiContext): string { + if (!ctx.mnemonic) throw new HttpError(423, "Vault is locked"); + return ctx.mnemonic; +} + +async function requirePassword(ctx: ApiContext, req: ApiRequest): Promise { + const password = req.headers["x-seedpass-password"]; + if (!password || !(await ctx.verifyPassword(password))) { + throw new HttpError(401, "Invalid password"); + } +} + +function profileDir(ctx: ApiContext): string { + return ctx.appDir.profileDir(ctx.fingerprint); +} + +function indexPath(ctx: ApiContext): string { + return join(profileDir(ctx), INDEX_FILENAME); +} + +async function readVault(ctx: ApiContext) { + return openVault(indexPath(ctx), requireUnlocked(ctx)); +} + +/** Read-modify-write the vault under one lock, then snapshot it. */ +async function mutate( + ctx: ApiContext, + fn: (index: VaultIndex) => T | Promise, +): Promise { + const path = indexPath(ctx); + const mnemonic = requireUnlocked(ctx); + const result = await withVaultLock(path, async () => { + const vault = await openVault(path, mnemonic); + const out = await fn(vault.index); + await saveVaultHoldingLock(vault); + return out; + }); + try { + const config = await loadConfig(profileDir(ctx), mnemonic); + await createIndexBackup({ indexPath: path, config }); + } catch { + // Best-effort, exactly as the CLI and TUI treat it: a failed snapshot + // must not make a committed write look like it failed. + } + return result; +} + +function entryIdOf(req: ApiRequest): string { + const raw = req.params["entry_id"] ?? ""; + if (!/^\d+$/.test(raw)) throw new HttpError(400, "entry id must be a non-negative integer"); + return raw; +} + +function bodyObject(req: ApiRequest): Record { + if (req.body === undefined) return {}; + if (typeof req.body !== "object" || req.body === null || Array.isArray(req.body)) { + throw new HttpError(400, "expected a JSON object body"); + } + return req.body as Record; +} + +function str(value: unknown, field: string): string { + if (typeof value !== "string" || value.length === 0) { + throw new HttpError(400, `${field} is required`); + } + return value; +} + +function optInt(value: unknown, field: string): number | undefined { + if (value === undefined || value === null) return undefined; + const n = Number(value); + if (!Number.isInteger(n) || n < 0) throw new HttpError(400, `${field} must be a non-negative integer`); + return n; +} + +/** + * Resolve a caller-supplied path, refusing anything outside the profile. + * + * Parity with Python's `_validate_encryption_path`. A path traversal here + * would let an authenticated caller read or write arbitrary files as the + * user running a process that holds unlocked seeds. + */ +function resolveWithinProfile(ctx: ApiContext, raw: string): string { + const root = resolvePath(profileDir(ctx)); + const target = resolvePath(root, raw); + if (target !== root && !target.startsWith(root + "/")) { + throw new HttpError(400, "path must stay inside the profile directory"); + } + return target; +} + +/** Register every ported route on `server`. */ +export function registerRoutes(server: ApiServer, ctx: ApiContext): void { + // ---------------------------------------------------------------- entries + + server.route("GET", "/api/v1/entry", async (req) => { + const vault = await readVault(ctx); + const query = (req.query.get("query") ?? "").trim().toLowerCase(); + const kindFilter = req.query.get("kind"); + const includeArchived = ["1", "true", "yes"].includes( + (req.query.get("archived") ?? "").toLowerCase(), + ); + const rows = Object.entries(vault.index.entries) + .map(([id, entry]) => ({ id, entry: entry as Entry })) + .filter(({ entry }) => { + const e = entry as unknown as Record; + if (!includeArchived && e["archived"] === true) return false; + if (kindFilter && String(e["kind"] ?? e["type"] ?? "") !== kindFilter) return false; + if (!query) return true; + const haystack = [e["label"], e["username"], e["url"], e["notes"], ...(Array.isArray(e["tags"]) ? e["tags"] : [])] + .map((v) => String(v ?? "").toLowerCase()) + .join(" "); + return haystack.includes(query); + }) + // Reference-first, like every other surface: a listing carries metadata + // and never a secret value. + .map(({ id, entry }) => entryMetadata(id, entry)); + return { json: rows }; + }); + + server.route( + "GET", + "/api/v1/entry/:entry_id", + async (req) => { + await requirePassword(ctx, req); + const vault = await readVault(ctx); + const id = entryIdOf(req); + const entry = vault.index.entries[id]; + if (!entry) throw new HttpError(404, "Not found"); + return { json: { id, ref: refFor(id), ...(entry as object) } }; + }, + { requiresPassword: true }, + ); + + server.route( + "GET", + "/api/v1/entry/:entry_id/secret", + async (req) => { + // Not in the Python surface: there, GET /entry/{id} returns stored + // secrets directly because Python stores some of them. Derived secrets + // need an explicit route, and making it explicit is the better shape — + // "give me the entry" and "give me the plaintext" are different asks. + await requirePassword(ctx, req); + const mnemonic = requireUnlocked(ctx); + const vault = await readVault(ctx); + const id = entryIdOf(req); + const entry = vault.index.entries[id]; + if (!entry) throw new HttpError(404, "Not found"); + const timestamp = optInt(req.query.get("at") ?? undefined, "at"); + const config = await loadConfig(profileDir(ctx), mnemonic); + const secret = materializeSecret(vault.index, id, entry as Entry, mnemonic, { + ...(timestamp !== undefined && { timestamp }), + basePolicy: passwordPolicyFromConfig(config), + }); + return { json: { id, descriptor: secret.descriptor, value: secret.value } }; + }, + { requiresPassword: true }, + ); + + server.route("POST", "/api/v1/entry", async (req) => { + const mnemonic = requireUnlocked(ctx); + const body = bodyObject(req); + const kind = str(body["kind"] ?? body["type"], "kind"); + const label = str(body["label"], "label"); + // Built by omission rather than by assigning undefined: the entry + // constructors distinguish "not supplied" from "supplied as undefined". + const common: { username?: string; url?: string; notes?: string; tags?: string[] } = { + ...(typeof body["username"] === "string" && { username: body["username"] }), + ...(typeof body["url"] === "string" && { url: body["url"] }), + ...(typeof body["notes"] === "string" && { notes: body["notes"] }), + ...(Array.isArray(body["tags"]) && { tags: body["tags"] as string[] }), + }; + const created = await mutate(ctx, (index) => { + switch (kind) { + case "password": + return addPasswordEntry(index, label, optInt(body["length"], "length") ?? 16, common); + case "totp": { + const secret = body["secret"]; + if (typeof secret === "string" && secret.length > 0) { + return addTotpImported(index, label, secret, { + period: optInt(body["period"], "period") ?? 30, + digits: optInt(body["digits"], "digits") ?? 6, + ...common, + }); + } + return addTotpDeterministic(index, label, mnemonic, { + period: optInt(body["period"], "period") ?? 30, + digits: optInt(body["digits"], "digits") ?? 6, + ...common, + }); + } + case "key_value": + return addKeyValueEntry( + index, + label, + str(body["key"], "key"), + str(body["value"], "value"), + common, + ); + case "document": + return addDocumentEntry(index, label, str(body["content"], "content"), common); + case "ssh": + return addSshKeyEntry(index, label, common); + case "pgp": + return addPgpKeyEntry(index, label, common); + case "nostr": + return addNostrKeyEntry(index, label, common); + case "seed": { + const wordCount = optInt(body["word_count"], "word_count") ?? 12; + if (wordCount !== 12 && wordCount !== 18 && wordCount !== 24) { + throw new HttpError(400, "word_count must be 12, 18 or 24"); + } + return addSeedEntry(index, label, { ...common, wordCount }); + } + case "managed_account": + return addManagedAccountEntry(index, label, mnemonic, common); + default: + throw new HttpError(400, `unsupported entry kind '${kind}'`); + } + }); + // Provision-blind, matching every other surface: creating an entry hands + // back a reference, never the secret it will derive. + return { status: 201, json: { id: created, ref: refFor(String(created)) } }; + }); + + server.route("PUT", "/api/v1/entry/:entry_id", async (req) => { + const id = entryIdOf(req); + const body = bodyObject(req); + await mutate(ctx, (index) => { + if (!index.entries[id]) throw new HttpError(404, "Not found"); + try { + modifyEntry(index, id, body as Parameters[2]); + } catch (e) { + throw new HttpError(400, (e as Error).message); + } + }); + return { json: { status: "ok" } }; + }); + + server.route("POST", "/api/v1/entry/:entry_id/archive", async (req) => { + const id = entryIdOf(req); + await mutate(ctx, (index) => { + if (!index.entries[id]) throw new HttpError(404, "Not found"); + archiveEntry(index, id); + }); + return { json: { status: "archived" } }; + }); + + server.route("POST", "/api/v1/entry/:entry_id/unarchive", async (req) => { + const id = entryIdOf(req); + await mutate(ctx, (index) => { + if (!index.entries[id]) throw new HttpError(404, "Not found"); + restoreEntry(index, id); + }); + return { json: { status: "active" } }; + }); + + server.route("GET", "/api/v1/entry/:entry_id/links", async (req) => { + const vault = await readVault(ctx); + const id = entryIdOf(req); + if (!vault.index.entries[id]) throw new HttpError(404, "Not found"); + return { json: { entry_id: id, links: getLinks(vault.index, id) } }; + }); + + server.route("POST", "/api/v1/entry/:entry_id/links", async (req) => { + const id = entryIdOf(req); + const body = bodyObject(req); + const targetRaw = body["target"] ?? body["to"]; + const target = optInt(targetRaw, "target"); + if (target === undefined) throw new HttpError(400, "target is required"); + const links = await mutate(ctx, (index) => { + if (!index.entries[id]) throw new HttpError(404, "Not found"); + try { + addLink(index, id, target, { + ...(typeof body["relation"] === "string" && { relation: body["relation"] }), + }); + } catch (e) { + throw new HttpError(400, (e as Error).message); + } + return getLinks(index, id); + }); + return { json: { entry_id: id, links } }; + }); + + server.route("DELETE", "/api/v1/entry/:entry_id/links", async (req) => { + const id = entryIdOf(req); + const targetRaw = req.query.get("target") ?? bodyObject(req)["target"]; + const target = optInt(targetRaw, "target"); + if (target === undefined) throw new HttpError(400, "target is required"); + const links = await mutate(ctx, (index) => { + if (!index.entries[id]) throw new HttpError(404, "Not found"); + removeLink(index, id, target); + return getLinks(index, id); + }); + return { json: { entry_id: id, links } }; + }); + + server.route("POST", "/api/v1/entry/document/import", async (req) => { + const body = bodyObject(req); + const label = str(body["label"], "label"); + let content: string; + if (typeof body["content"] === "string") { + content = body["content"]; + } else if (typeof body["path"] === "string") { + const path = resolveWithinProfile(ctx, body["path"]); + if (!existsSync(path)) throw new HttpError(404, "file not found"); + content = await readFile(path, "utf8"); + } else { + throw new HttpError(400, "content or path is required"); + } + const id = await mutate(ctx, (index) => addDocumentEntry(index, label, content, {})); + return { status: 201, json: { id, ref: refFor(String(id)) } }; + }); + + server.route( + "POST", + "/api/v1/entry/:entry_id/document/export", + async (req) => { + await requirePassword(ctx, req); + const mnemonic = requireUnlocked(ctx); + const id = entryIdOf(req); + const vault = await readVault(ctx); + const entry = vault.index.entries[id]; + if (!entry) throw new HttpError(404, "Not found"); + const body = bodyObject(req); + const dest = resolveWithinProfile(ctx, str(body["path"], "path")); + const config = await loadConfig(profileDir(ctx), mnemonic); + const secret = materializeSecret(vault.index, id, entry as Entry, mnemonic, { + basePolicy: passwordPolicyFromConfig(config), + }); + // 0600 via a fresh file, the way every other export path here does it — + // writeFile's mode argument applies only at creation and would leave an + // existing world-readable file exactly as it found it. + await atomicWrite(dest, new TextEncoder().encode(secret.value)); + return { json: { path: dest } }; + }, + { requiresPassword: true }, + ); + + // ----------------------------------------------------------------- config + + server.route("GET", "/api/v1/config/:key", async (req) => { + const mnemonic = requireUnlocked(ctx); + const key = req.params["key"]!; + const config = await loadConfig(profileDir(ctx), mnemonic); + if (key === "password_hash" || key === "pin_hash") { + // Never hand back a credential verifier over the wire, even to an + // authenticated caller: it is offline-crackable and nothing legitimate + // needs it. + return { json: { key, value: "" } }; + } + return { json: { key, value: config[key] ?? null } }; + }); + + server.route("PUT", "/api/v1/config/:key", async (req) => { + const mnemonic = requireUnlocked(ctx); + const key = req.params["key"]!; + if (key === "password_hash" || key === "pin_hash") { + throw new HttpError(400, `${key} is not settable through the API`); + } + const body = bodyObject(req); + if (!("value" in body)) throw new HttpError(400, "value is required"); + await mutateConfig(profileDir(ctx), mnemonic, (config) => { + config[key] = body["value"]; + }); + return { json: { status: "ok" } }; + }); + + server.route("POST", "/api/v1/secret-mode", async (req) => { + const mnemonic = requireUnlocked(ctx); + const body = bodyObject(req); + const enabled = Boolean(body["enabled"]); + const delay = optInt(body["delay"], "delay"); + await mutateConfig(profileDir(ctx), mnemonic, (config) => { + config["secret_mode_enabled"] = enabled; + if (delay !== undefined) config["clipboard_clear_delay"] = delay; + }); + return { json: { status: "ok" } }; + }); + + // ------------------------------------------------------------ fingerprints + + server.route("GET", "/api/v1/fingerprint", async () => { + const data = await ctx.appDir.readFingerprints(); + return { + json: data.fingerprints.map((fp) => ({ + fingerprint: fp, + name: data.names[fp] ?? null, + current: fp === ctx.fingerprint, + })), + }; + }); + + server.route("POST", "/api/v1/fingerprint/select", async (req) => { + const body = bodyObject(req); + const fingerprint = str(body["fingerprint"], "fingerprint"); + const data = await ctx.appDir.readFingerprints(); + if (!data.fingerprints.includes(fingerprint)) throw new HttpError(404, "no such profile"); + await ctx.appDir.switchProfile(fingerprint); + // Switching profiles drops the held seed: the seed in memory belongs to + // the profile we are leaving, and carrying it across would let a caller + // read one profile's vault while believing they had selected another. + ctx.mnemonic = null; + ctx.fingerprint = fingerprint; + return { json: { status: "ok", fingerprint, locked: true } }; + }); + + server.route("DELETE", "/api/v1/fingerprint/:fingerprint", async (req) => { + await requirePassword(ctx, req); + const fingerprint = req.params["fingerprint"]!; + if (fingerprint === ctx.fingerprint) { + throw new HttpError(400, "refusing to delete the profile this server is serving"); + } + await ctx.appDir.removeProfile(fingerprint); + return { json: { status: "deleted" } }; + }, { requiresPassword: true }); + + // ------------------------------------------------------------------- totp + + server.route("GET", "/api/v1/totp", async () => { + const mnemonic = requireUnlocked(ctx); + const vault = await readVault(ctx); + const now = Math.floor(ctx.now() / 1000); + const codes = Object.entries(vault.index.entries) + .filter(([, e]) => String((e as unknown as Record)["kind"] ?? "") === "totp") + .map(([id, e]) => { + const entry = e as unknown as Record; + const secret = materializeSecret(vault.index, id, e as Entry, mnemonic, { + timestamp: now, + }); + const period = Number(entry["period"] ?? 30); + return { + id, + label: String(entry["label"] ?? ""), + code: secret.value, + seconds_remaining: period - (now % period), + }; + }); + return { json: { codes } }; + }); + + server.route( + "GET", + "/api/v1/totp/export", + async (req) => { + await requirePassword(ctx, req); + const mnemonic = requireUnlocked(ctx); + const vault = await readVault(ctx); + // Every TOTP secret in the vault, in plaintext. Password-gated for + // that reason alone. + const entries = Object.entries(vault.index.entries) + .filter(([, e]) => String((e as unknown as Record)["kind"] ?? "") === "totp") + .map(([, e]) => { + const entry = e as unknown as Record; + // The SECRET, not a code. materializeSecret returns the current + // 6-digit code for a totp entry, which would make this export look + // correct and be useless — an authenticator cannot be seeded from + // a code. Imported entries store their secret; deterministic ones + // re-derive it, exactly as the TUI's 2FA export does. + const secret = + typeof entry["secret"] === "string" && entry["secret"] + ? (entry["secret"] as string) + : deriveTotpSecret(mnemonic, Number(entry["index"] ?? 0)); + const label = String(entry["label"] ?? ""); + const period = Number(entry["period"] ?? 30); + const digits = Number(entry["digits"] ?? 6); + return { + label, + secret, + period, + digits, + // The form an authenticator can actually consume. + uri: + `otpauth://totp/${encodeURIComponent(label)}?secret=${secret}` + + `&issuer=SeedPass&period=${period}&digits=${digits}`, + }; + }); + return { json: { entries } }; + }, + { requiresPassword: true }, + ); + + // ------------------------------------------------------------- vault state + + server.route("POST", "/api/v1/vault/unlock", async (req) => { + const password = req.headers["x-seedpass-password"]; + if (!password) throw new HttpError(401, "master password header required"); + try { + const duration = await ctx.unlock(password); + return { json: { status: "unlocked", duration } }; + } catch { + server.recordUnlockFailure(req); + throw new HttpError(401, "Invalid password"); + } + }); + + server.route("POST", "/api/v1/vault/lock", async () => { + ctx.mnemonic = null; + return { json: { status: "locked" } }; + }); + + server.route("GET", "/api/v1/vault/status", async () => { + return { + json: { + fingerprint: ctx.fingerprint, + locked: ctx.mnemonic === null, + }, + }; + }); + + server.route("POST", "/api/v1/change-password", async (req) => { + const body = bodyObject(req); + const current = str(body["current_password"] ?? body["old_password"], "current_password"); + const next = str(body["new_password"], "new_password"); + const mnemonic = requireUnlocked(ctx); + const config = await loadConfig(profileDir(ctx), mnemonic); + const iterations = Number(config["kdf_iterations"] ?? DEFAULT_PBKDF2_ITERATIONS); + try { + await ctx.appDir.changePassword(ctx.fingerprint, current, next, iterations); + } catch (e) { + throw new HttpError(401, (e as Error).message); + } + return { json: { status: "ok" } }; + }); + + // ------------------------------------------------------------------ vault + + server.route( + "POST", + "/api/v1/vault/export", + async (req) => { + await requirePassword(ctx, req); + const mnemonic = requireUnlocked(ctx); + const vault = await readVault(ctx); + const body = bodyObject(req); + const encrypt = body["plaintext"] !== true; + const wrapper = await exportBackup(vault.index as unknown as Record, { + mnemonic, + fingerprint: generateFingerprint(mnemonic), + encrypt, + }); + return { + bytes: new TextEncoder().encode(JSON.stringify(wrapper, null, 2)), + contentType: "application/octet-stream", + headers: { + "content-disposition": `attachment; filename="seedpass-${generateFingerprint(mnemonic)}.seedpass"`, + }, + }; + }, + { requiresPassword: true }, + ); + + server.route( + "POST", + "/api/v1/vault/import", + async (req) => { + await requirePassword(ctx, req); + const mnemonic = requireUnlocked(ctx); + if (req.rawBody.length === 0) throw new HttpError(400, "a backup body is required"); + const wrapper = parseBackupWrapper(req.rawBody); + const target = generateFingerprint(mnemonic); + const allowMismatch = (req.query.get("allow_fingerprint_mismatch") ?? "") === "true"; + if (wrapper.fingerprint !== target && !allowMismatch) { + throw new HttpError( + 409, + `backup belongs to profile ${wrapper.fingerprint}, but this server ` + + `serves ${target}. Importing it would re-derive every secret from ` + + `this profile's seed, silently producing different passwords than ` + + `the backup holds. Retry with ?allow_fingerprint_mismatch=true if ` + + `that is really intended.`, + ); + } + const parsed = await importBackup(req.rawBody, { mnemonic }); + const index = parseVaultIndex(parsed); + await saveVault({ index, mnemonic, path: indexPath(ctx) }); + const collisions = findDerivationCollisions(index); + return { + json: { + status: "ok", + entry_count: Object.keys(index.entries).length, + ...(collisions.length > 0 && { + derivation_collisions: collisions.map((c) => ({ + index: c.index, + severity: c.severity, + entries: c.entries, + })), + }), + }, + }; + }, + { requiresPassword: true }, + ); + + server.route( + "POST", + "/api/v1/vault/backup-parent-seed", + async (req) => { + await requirePassword(ctx, req); + const mnemonic = requireUnlocked(ctx); + const body = bodyObject(req); + const dest = resolveWithinProfile(ctx, str(body["path"], "path")); + await atomicWrite(dest, new TextEncoder().encode(mnemonic + "\n")); + return { json: { status: "saved", path: dest } }; + }, + { requiresPassword: true }, + ); + + // ------------------------------------------------------------------ nostr + + server.route("GET", "/api/v1/nostr/pubkey", async () => { + const mnemonic = requireUnlocked(ctx); + return { json: { npub: deriveNostrKeys(Bip85.fromMnemonic(mnemonic), 0).npub } }; + }); + + server.route("GET", "/api/v1/relays", async () => { + const mnemonic = requireUnlocked(ctx); + const config = await loadConfig(profileDir(ctx), mnemonic); + return { json: { relays: config["relays"] ?? [] } }; + }); + + server.route("POST", "/api/v1/relays", async (req) => { + const mnemonic = requireUnlocked(ctx); + const url = str(bodyObject(req)["url"], "url"); + if (!/^wss?:\/\//.test(url)) throw new HttpError(400, "relay must be a ws:// or wss:// URL"); + const relays = await mutateConfig(profileDir(ctx), mnemonic, (config) => { + const list = Array.isArray(config["relays"]) ? (config["relays"] as string[]) : []; + if (!list.includes(url)) list.push(url); + config["relays"] = list; + return list; + }); + return { json: { status: "ok", relays } }; + }); + + server.route("DELETE", "/api/v1/relays/:idx", async (req) => { + const mnemonic = requireUnlocked(ctx); + const idx = Number(req.params["idx"]); + if (!Number.isInteger(idx) || idx < 0) throw new HttpError(400, "idx must be a non-negative integer"); + const relays = await mutateConfig(profileDir(ctx), mnemonic, (config) => { + const list = Array.isArray(config["relays"]) ? [...(config["relays"] as string[])] : []; + if (idx >= list.length) throw new HttpError(404, "no relay at that index"); + list.splice(idx, 1); + config["relays"] = list; + return list; + }); + return { json: { status: "ok", relays } }; + }); + + server.route("POST", "/api/v1/relays/reset", async () => { + const mnemonic = requireUnlocked(ctx); + const relays = await mutateConfig(profileDir(ctx), mnemonic, (config) => { + config["relays"] = [...DEFAULT_RELAYS]; + return config["relays"] as string[]; + }); + return { json: { status: "ok", relays } }; + }); + + // -------------------------------------------------------------- utilities + + server.route("POST", "/api/v1/password", async (req) => { + const mnemonic = requireUnlocked(ctx); + const body = bodyObject(req); + const length = optInt(body["length"], "length") ?? 16; + const config = await loadConfig(profileDir(ctx), mnemonic); + const policy: PasswordPolicy = { + ...passwordPolicyFromConfig(config), + ...passwordPolicyFromRecord(body), + }; + try { + const password = generatePassword(Bip85.fromMnemonic(mnemonic), { + length, + index: optInt(body["index"], "index") ?? 0, + genVersion: optInt(body["gen_version"], "gen_version") ?? 2, + policy, + }); + return { json: { password } }; + } catch (e) { + throw new HttpError(400, (e as Error).message); + } + }); + + server.route("GET", "/api/v1/stats", async () => { + const vault = await readVault(ctx); + const counts: Record = {}; + let archived = 0; + for (const entry of Object.values(vault.index.entries)) { + const e = entry as unknown as Record; + const kind = String(e["kind"] ?? e["type"] ?? "unknown"); + counts[kind] = (counts[kind] ?? 0) + 1; + if (e["archived"] === true) archived++; + } + return { + json: { + fingerprint: ctx.fingerprint, + schema_version: vault.index.schema_version, + total_entries: Object.keys(vault.index.entries).length, + archived_entries: archived, + by_kind: counts, + }, + }; + }); + + server.route("GET", "/api/v1/notifications", async () => { + // Drain: reading them clears them, matching Python's queue semantics. + const notes = ctx.notifications.splice(0, ctx.notifications.length); + return { json: notes }; + }); + + server.route("GET", "/api/v1/check-derivation", async () => { + const vault = await readVault(ctx); + const collisions = findDerivationCollisions(vault.index); + return { + json: { + checked: Object.keys(vault.index.entries).length, + collisions: collisions.map((c) => ({ + index: c.index, + severity: c.severity, + entries: c.entries, + message: c.message, + })), + }, + }; + }); + + server.route("POST", "/api/v1/shutdown", async () => { + ctx.requestShutdown(); + return { json: { status: "shutting down" } }; + }); + + // ----------------------------------------------------- deliberate 501s + + for (const { prefix, feature } of UNPORTED_PREFIXES) { + for (const method of ["GET", "POST", "PUT", "DELETE"]) { + for (const pattern of [prefix, `${prefix}/:rest`, `${prefix}/:rest/:more`]) { + server.route(method, pattern, () => { + throw new HttpError( + 501, + `${feature} is not implemented in the TypeScript port. This is a ` + + `deliberate omission, not a missing path — see capabilities().not_yet_ported.`, + ); + }); + } + } + } +} diff --git a/js/packages/cli/src/api/serve.ts b/js/packages/cli/src/api/serve.ts new file mode 100644 index 00000000..28a40ab6 --- /dev/null +++ b/js/packages/cli/src/api/serve.ts @@ -0,0 +1,129 @@ +/** + * Wire the API server to a profile and run it until interrupted. + * + * Kept separate from the route handlers so tests can build a server against a + * context they control, without a process that installs signal handlers and + * never returns. + */ + +import { AppDir } from "../appDir.js"; +import type { ProgramIo } from "../program.js"; +import { ApiServer } from "./server.js"; +import { registerRoutes, type ApiContext } from "./routes.js"; + +export interface ServeApiOptions { + app: AppDir; + fingerprint: string; + /** Seed to start with, or null to start locked. */ + mnemonic: string | null; + host: string; + port: number; + allowRemote?: boolean; + corsOrigins?: string[]; + io: ProgramIo; +} + +/** + * Build the context that routes operate through. + * + * `unlock` and `verifyPassword` both go through AppDir's parent-seed + * decryption, which is the only thing that can actually answer "is this the + * master password" — there is no separate password verifier to disagree with. + */ +export function buildContext(options: { + app: AppDir; + fingerprint: string; + mnemonic: string | null; + requestShutdown?: () => void; + now?: () => number; +}): ApiContext { + const ctx: ApiContext = { + appDir: options.app, + fingerprint: options.fingerprint, + mnemonic: options.mnemonic, + notifications: [], + now: options.now ?? (() => Date.now()), + requestShutdown: options.requestShutdown ?? (() => {}), + verifyPassword: async (password: string) => { + try { + await options.app.decryptParentSeed(ctx.fingerprint, password); + return true; + } catch { + return false; + } + }, + unlock: async (password: string) => { + const started = Date.now(); + // Throws on a wrong password; the caller turns that into a 401 and + // records the attempt against the tighter unlock budget. + ctx.mnemonic = await options.app.decryptParentSeed(ctx.fingerprint, password); + return (Date.now() - started) / 1000; + }, + }; + return ctx; +} + +export async function serveApi(options: ServeApiOptions): Promise { + let shuttingDown = false; + const server = new ApiServer({ + host: options.host, + port: options.port, + ...(options.allowRemote !== undefined && { allowRemote: options.allowRemote }), + ...(options.corsOrigins !== undefined && { corsOrigins: options.corsOrigins }), + }); + + const ctx = buildContext({ + app: options.app, + fingerprint: options.fingerprint, + mnemonic: options.mnemonic, + requestShutdown: () => { + if (shuttingDown) return; + shuttingDown = true; + // Let the response for POST /shutdown finish before the socket closes. + setTimeout(() => { + void stop(); + }, 50).unref(); + }, + }); + + registerRoutes(server, ctx); + const bound = await server.listen(); + + const stop = async (): Promise => { + // Drop the seed before releasing the port, not after: the window between + // the two is a window where the process is still holding an unlocked seed + // with no way to say so. + ctx.mnemonic = null; + await server.close(); + }; + + // The token goes to stdout exactly once and is never written to disk. An + // operator who loses it restarts the server. + options.io.out( + JSON.stringify({ + listening: `http://${bound.host}:${bound.port}`, + fingerprint: options.fingerprint, + locked: ctx.mnemonic === null, + token: server.token, + }), + ); + options.io.err( + "This process holds an unlocked parent seed and is listening on a port. " + + "The token above is shown once. Stop it with Ctrl-C or POST /api/v1/shutdown.", + ); + + await new Promise((resolve) => { + const onSignal = (): void => { + void stop().then(resolve); + }; + process.once("SIGINT", onSignal); + process.once("SIGTERM", onSignal); + const poll = setInterval(() => { + if (shuttingDown) { + clearInterval(poll); + resolve(); + } + }, 100); + poll.unref(); + }); +} diff --git a/js/packages/cli/src/api/server.ts b/js/packages/cli/src/api/server.ts new file mode 100644 index 00000000..88160f66 --- /dev/null +++ b/js/packages/cli/src/api/server.ts @@ -0,0 +1,421 @@ +/** + * HTTP API server — parity with src/seedpass/api.py. + * + * This process holds an unlocked parent seed and binds a TCP port, which is a + * strictly larger attack surface than the session agent's 0600 unix socket. + * Everything here is shaped by that: + * + * - Loopback only unless the operator explicitly says otherwise, and even + * then it refuses to bind a non-loopback address without a token they + * supplied themselves (see `resolveBind`). + * - Every route requires a bearer token. The token is printed once, at + * startup, and never written to disk. + * - Routes that produce plaintext secrets additionally require the master + * password in a header, so a leaked bearer token alone does not read the + * vault. This mirrors Python's `_require_password`. + * - Rate limits on everything, and a separate, much tighter limit on failed + * unlock attempts, so the port is not an offline-speed password oracle. + * + * Implemented on `node:http` rather than a framework. The CLI ships as a + * single audited bundle with an empty production dependency list, and adding + * a web framework to a process that holds unlocked seeds would be the largest + * supply-chain change in the project. + */ + +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; +import { isIP } from "node:net"; + +/** Default cap on a request body, matching Python's SEEDPASS_MAX_IMPORT_BYTES. */ +export const DEFAULT_MAX_BODY_BYTES = 10 * 1024 * 1024; + +export const DEFAULT_RATE_LIMIT = 100; +export const DEFAULT_RATE_WINDOW_SECONDS = 60; +export const DEFAULT_UNLOCK_ATTEMPT_LIMIT = 5; +export const DEFAULT_UNLOCK_ATTEMPT_WINDOW_SECONDS = 300; + +export class HttpError extends Error { + constructor( + readonly status: number, + message: string, + ) { + super(message); + this.name = "HttpError"; + } +} + +export interface ApiRequest { + method: string; + /** Path with no query string. */ + path: string; + query: URLSearchParams; + headers: Record; + /** Parsed JSON body, or undefined when there was none. */ + body: unknown; + /** Raw body bytes, for routes that take a file. */ + rawBody: Uint8Array; + /** Path parameters captured by the route pattern. */ + params: Record; +} + +export interface ApiResponse { + status?: number; + /** JSON-serializable body. Mutually exclusive with `bytes`. */ + json?: unknown; + /** Raw bytes, for downloads. */ + bytes?: Uint8Array; + contentType?: string; + headers?: Record; +} + +export type RouteHandler = (req: ApiRequest) => Promise | ApiResponse; + +interface CompiledRoute { + method: string; + /** Segments; a leading ':' marks a parameter. */ + segments: string[]; + handler: RouteHandler; + /** Whether this route needs the master-password header. */ + requiresPassword: boolean; +} + +export interface ApiServerOptions { + host?: string; + port?: number; + /** + * Explicit acknowledgement that a non-loopback bind is intended. Without it + * the server refuses to listen anywhere but localhost — an accidental + * `--host 0.0.0.0` on a process holding unlocked seeds is not a mistake + * that should be recoverable after the fact. + */ + allowRemote?: boolean; + /** Supply a token instead of generating one (tests, or an operator's own). */ + token?: string; + maxBodyBytes?: number; + rateLimit?: number; + rateWindowSeconds?: number; + unlockAttemptLimit?: number; + unlockAttemptWindowSeconds?: number; + /** Origins allowed for CORS. Empty (the default) sends no CORS headers. */ + corsOrigins?: string[]; + /** Injectable clock, in milliseconds. */ + now?: () => number; +} + +function loopback(host: string): boolean { + if (host === "localhost") return true; + if (isIP(host) === 4) return host.startsWith("127."); + if (isIP(host) === 6) return host === "::1" || host === "::ffff:127.0.0.1"; + return false; +} + +/** + * Where to bind, refusing anything exposed unless it was asked for outright. + */ +export function resolveBind(options: ApiServerOptions): { host: string; port: number } { + const host = options.host ?? "127.0.0.1"; + const port = options.port ?? 8765; + if (!loopback(host) && !options.allowRemote) { + throw new Error( + `refusing to bind ${host}: this process holds unlocked parent seeds, so ` + + `it listens on loopback only. Pass --allow-remote if you genuinely ` + + `intend to expose it, and put a TLS-terminating proxy in front of it.`, + ); + } + return { host, port }; +} + +/** Sliding-window counter, keyed per client+token. */ +class SlidingWindow { + private readonly buckets = new Map(); + + constructor( + private readonly limit: number, + private readonly windowMs: number, + ) {} + + /** Drops expired hits and reports whether `key` is already at the limit. */ + atLimit(key: string, now: number): boolean { + return this.prune(key, now).length >= this.limit; + } + + record(key: string, now: number): void { + this.prune(key, now).push(now); + } + + private prune(key: string, now: number): number[] { + const bucket = this.buckets.get(key) ?? []; + const cutoff = now - this.windowMs; + let i = 0; + while (i < bucket.length && bucket[i]! <= cutoff) i++; + const live = i > 0 ? bucket.slice(i) : bucket; + this.buckets.set(key, live); + return live; + } +} + +export class ApiServer { + private readonly routes: CompiledRoute[] = []; + private server: Server | null = null; + private readonly tokenHash: Buffer; + private readonly rate: SlidingWindow; + private readonly unlockAttempts: SlidingWindow; + private readonly now: () => number; + private readonly maxBodyBytes: number; + private readonly corsOrigins: Set; + + /** The bearer token. Printed once by the caller; never persisted. */ + readonly token: string; + + constructor(private readonly options: ApiServerOptions = {}) { + // A 32-byte random token is not a password: it is not guessable, not + // reused, and not chosen by a human, so a slow KDF over it buys nothing + // that a hash does not. (Python uses bcrypt here; matching that would + // mean adding a native dependency to a seed-holding process for no gain.) + // What does matter is comparing in constant time, which is why this + // hashes both sides to a fixed length before comparing. + this.token = options.token ?? randomBytes(32).toString("base64url"); + this.tokenHash = createHash("sha256").update(this.token).digest(); + this.now = options.now ?? (() => Date.now()); + this.maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES; + this.rate = new SlidingWindow( + options.rateLimit ?? DEFAULT_RATE_LIMIT, + (options.rateWindowSeconds ?? DEFAULT_RATE_WINDOW_SECONDS) * 1000, + ); + this.unlockAttempts = new SlidingWindow( + options.unlockAttemptLimit ?? DEFAULT_UNLOCK_ATTEMPT_LIMIT, + (options.unlockAttemptWindowSeconds ?? DEFAULT_UNLOCK_ATTEMPT_WINDOW_SECONDS) * 1000, + ); + this.corsOrigins = new Set(options.corsOrigins ?? []); + } + + /** + * Register a route. + * + * `requiresPassword` marks routes that hand back plaintext secrets. Those + * need the master password in addition to the bearer token, so that a + * token captured from a script cannot read the vault on its own. + */ + route( + method: string, + pattern: string, + handler: RouteHandler, + opts: { requiresPassword?: boolean } = {}, + ): this { + this.routes.push({ + method: method.toUpperCase(), + segments: pattern.split("/").filter((s) => s.length > 0), + handler, + requiresPassword: opts.requiresPassword ?? false, + }); + return this; + } + + /** Verify a bearer token in constant time. */ + private tokenValid(authorization: string | undefined): boolean { + if (!authorization || !authorization.startsWith("Bearer ")) return false; + const presented = createHash("sha256") + .update(authorization.slice("Bearer ".length)) + .digest(); + return timingSafeEqual(presented, this.tokenHash); + } + + /** + * Rate-limit key. + * + * Includes the client address so one misbehaving caller cannot exhaust + * another's budget, and a short digest of the token rather than the token, + * so the limiter's own state never holds the credential. + */ + private rateKey(req: IncomingMessage, authorization: string | undefined): string { + const client = req.socket.remoteAddress ?? "unknown"; + const digest = createHash("sha256") + .update(authorization ?? "") + .digest("hex") + .slice(0, 16); + return `${client}:${digest}`; + } + + /** Note a failed unlock, tightening the dedicated unlock limiter. */ + recordUnlockFailure(req: ApiRequest): void { + this.unlockAttempts.record(String(req.headers["x-seedpass-rate-key"] ?? ""), this.now()); + } + + private match(method: string, path: string): { route: CompiledRoute; params: Record } | null { + const parts = path.split("/").filter((s) => s.length > 0); + // A path that matches some route but not this method should say so, + // rather than reporting the path as missing. + let pathMatched = false; + for (const route of this.routes) { + if (route.segments.length !== parts.length) continue; + const params: Record = {}; + let ok = true; + for (let i = 0; i < route.segments.length; i++) { + const seg = route.segments[i]!; + if (seg.startsWith(":")) { + params[seg.slice(1)] = decodeURIComponent(parts[i]!); + } else if (seg !== parts[i]) { + ok = false; + break; + } + } + if (!ok) continue; + pathMatched = true; + if (route.method === method) return { route, params }; + } + if (pathMatched) throw new HttpError(405, "Method Not Allowed"); + return null; + } + + private async readBody(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + let total = 0; + for await (const chunk of req) { + const buf = chunk as Buffer; + total += buf.length; + // Stop while it is still arriving rather than after it is all in + // memory: this process holds seeds and must not be trivially OOM-able. + if (total > this.maxBodyBytes) { + throw new HttpError(413, `request body exceeds ${this.maxBodyBytes} bytes`); + } + chunks.push(buf); + } + return new Uint8Array(Buffer.concat(chunks)); + } + + private async handle(req: IncomingMessage, res: ServerResponse): Promise { + const headers: Record = {}; + for (const [k, v] of Object.entries(req.headers)) { + headers[k.toLowerCase()] = Array.isArray(v) ? v.join(", ") : v; + } + const origin = headers["origin"]; + const corsHeaders: Record = {}; + if (origin && this.corsOrigins.has(origin)) { + corsHeaders["access-control-allow-origin"] = origin; + corsHeaders["vary"] = "Origin"; + corsHeaders["access-control-allow-headers"] = + "authorization, content-type, x-seedpass-password"; + corsHeaders["access-control-allow-methods"] = "GET, POST, PUT, DELETE, OPTIONS"; + } + + try { + if (req.method === "OPTIONS") { + this.send(res, { status: 204, headers: corsHeaders }); + return; + } + + const url = new URL(req.url ?? "/", "http://localhost"); + const authorization = headers["authorization"]; + const rateKey = this.rateKey(req, authorization); + const now = this.now(); + + // Rate limit before authentication, so an unauthenticated flood cannot + // be used to hammer the token comparison either. + if (this.rate.atLimit(rateKey, now)) { + throw new HttpError(429, "Rate limit exceeded"); + } + this.rate.record(rateKey, now); + + if (!this.tokenValid(authorization)) throw new HttpError(401, "Unauthorized"); + + const matched = this.match(req.method ?? "GET", url.pathname); + if (!matched) throw new HttpError(404, "Not Found"); + + const rawBody = await this.readBody(req); + let body: unknown; + if (rawBody.length > 0 && (headers["content-type"] ?? "").includes("json")) { + try { + body = JSON.parse(new TextDecoder().decode(rawBody)); + } catch { + throw new HttpError(400, "invalid JSON body"); + } + } + + const apiReq: ApiRequest = { + method: req.method ?? "GET", + path: url.pathname, + query: url.searchParams, + headers: { ...headers, "x-seedpass-rate-key": rateKey }, + body, + rawBody, + params: matched.params, + }; + + if (matched.route.requiresPassword && !headers["x-seedpass-password"]) { + // Said plainly: this is not a missing token, it is a route that will + // not hand over plaintext on a bearer token alone. + throw new HttpError( + 401, + "this route returns plaintext secrets and requires the master " + + "password in the X-SeedPass-Password header", + ); + } + + // The unlock route is the one place a wrong password is expected, so it + // gets its own much tighter budget — otherwise the port is a password + // oracle at network speed. + if (url.pathname.endsWith("/vault/unlock")) { + if (this.unlockAttempts.atLimit(rateKey, now)) { + throw new HttpError(429, "Too many failed unlock attempts; wait and retry"); + } + } + + const result = await matched.route.handler(apiReq); + this.send(res, { ...result, headers: { ...corsHeaders, ...(result.headers ?? {}) } }); + } catch (e) { + const status = e instanceof HttpError ? e.status : 500; + // Never let an internal message escape: stack traces and filesystem + // paths from this process describe a machine holding unlocked seeds. + const detail = + e instanceof HttpError ? e.message : "internal error"; + if (!(e instanceof HttpError)) { + process.stderr.write(`api: unhandled error: ${String(e)}\n`); + } + this.send(res, { status, json: { detail }, headers: corsHeaders }); + } + } + + private send(res: ServerResponse, response: ApiResponse): void { + const status = response.status ?? 200; + const headers: Record = { + // This is a JSON API for local automation; nothing here should be + // framed, sniffed, or cached. + "cache-control": "no-store", + "x-content-type-options": "nosniff", + ...(response.headers ?? {}), + }; + let payload: Uint8Array; + if (response.bytes) { + payload = response.bytes; + headers["content-type"] = response.contentType ?? "application/octet-stream"; + } else if (response.json !== undefined) { + payload = new TextEncoder().encode(JSON.stringify(response.json)); + headers["content-type"] = response.contentType ?? "application/json"; + } else { + payload = new Uint8Array(0); + } + headers["content-length"] = String(payload.length); + res.writeHead(status, headers); + res.end(Buffer.from(payload)); + } + + async listen(): Promise<{ host: string; port: number }> { + const { host, port } = resolveBind(this.options); + this.server = createServer((req, res) => { + void this.handle(req, res); + }); + await new Promise((resolve, reject) => { + this.server!.once("error", reject); + this.server!.listen(port, host, () => resolve()); + }); + const address = this.server.address(); + const actualPort = typeof address === "object" && address ? address.port : port; + return { host, port: actualPort }; + } + + async close(): Promise { + if (!this.server) return; + await new Promise((resolve) => this.server!.close(() => resolve())); + this.server = null; + } +} diff --git a/js/packages/cli/src/capabilities.ts b/js/packages/cli/src/capabilities.ts index a440c8af..49d059ea 100644 --- a/js/packages/cli/src/capabilities.ts +++ b/js/packages/cli/src/capabilities.ts @@ -75,6 +75,19 @@ export function capabilities(): Record { "boundary: a token holder that can read the owner capability file can " + "still escalate. See docs/agent_security_model.md.", }, + api: { + command: "seedpass-js api", + base_path: "/api/v1", + // Loopback by default because this process holds unlocked seeds; a + // non-loopback bind needs --allow-remote and should sit behind a + // TLS-terminating proxy. + binds: "127.0.0.1 unless --allow-remote", + auth: "bearer token, printed once at startup, never written to disk", + // A leaked bearer token alone must not read the vault. + plaintext_routes_require: "X-SeedPass-Password header in addition to the token", + locked_status: 423, + unported_status: 501, + }, audit: { commands: ["agent audit-verify", "agent audit-tail"], chain: "HMAC-SHA256(prev_sig + canonical_payload), keyed by KEY_INDEX", @@ -86,7 +99,7 @@ export function capabilities(): Record { "pgp RSA keys (ed25519 is supported; RSA generation is not reproducible)", "index0/atlas", "approval gates and high-risk partitions", - "semantic (vector search) and api (FastAPI server) command groups", + "semantic (vector search) command group", "Python's v2/v3 TUIs (interactive mode follows the legacy v1 menus)", "QR code display in the TUI (no QR encoder in this build)", ], diff --git a/js/packages/cli/src/index.ts b/js/packages/cli/src/index.ts index d9131703..837fba32 100644 --- a/js/packages/cli/src/index.ts +++ b/js/packages/cli/src/index.ts @@ -7,3 +7,6 @@ export { capabilities } from "./capabilities.js"; export { AppDir, resolveAppDir, INDEX_FILENAME, CONFIG_FILENAME } from "./appDir.js"; export { loadConfig, saveConfig, defaultConfig, DEFAULT_RELAYS } from "./configFile.js"; export { AgentDaemon, AgentClient, agentSocketPath } from "./agent.js"; +export { ApiServer, HttpError, resolveBind } from "./api/server.js"; +export { registerRoutes, UNPORTED_PREFIXES, type ApiContext } from "./api/routes.js"; +export { buildContext, serveApi } from "./api/serve.js"; diff --git a/js/packages/cli/src/program.ts b/js/packages/cli/src/program.ts index dacd9f1b..9506b253 100644 --- a/js/packages/cli/src/program.ts +++ b/js/packages/cli/src/program.ts @@ -1523,6 +1523,52 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { io.out(JSON.stringify({ agent: "stopped" })); }); + program + .command("api") + .description( + "serve the HTTP API on loopback (holds an unlocked seed; prints a " + + "bearer token once)", + ) + .option("--host ", "bind address", "127.0.0.1") + .option("--port ", "bind port", "8765") + .option( + "--allow-remote", + "permit a non-loopback bind (requires a TLS-terminating proxy in front)", + ) + .option( + "--cors-origin ", + "allow these browser origins (repeatable; none by default)", + ) + .option("--locked", "start locked; unlock via POST /api/v1/vault/unlock") + .action( + async (o: { + host: string; + port: string; + allowRemote?: boolean; + corsOrigin?: string[]; + locked?: boolean; + }) => { + const opts = program.opts() as GlobalOpts; + const app = new AppDir(resolveAppDir(opts.appDir)); + const fingerprint = await currentFingerprint(app, opts); + // Starting unlocked is the common case for a local automation + // server, but --locked lets an operator hand the port to something + // before the password exists anywhere. + const mnemonic = o.locked ? null : await resolveMnemonic(app, opts); + const { serveApi } = await import("./api/serve.js"); + await serveApi({ + app, + fingerprint, + mnemonic, + host: o.host, + port: parseIntOption(o.port, "--port", { min: 1, max: 65535 }), + ...(o.allowRemote !== undefined && { allowRemote: o.allowRemote }), + ...(o.corsOrigin !== undefined && { corsOrigins: o.corsOrigin }), + io, + }); + }, + ); + const util = program.command("util").description("utility commands"); util diff --git a/js/packages/cli/test/api.test.ts b/js/packages/cli/test/api.test.ts new file mode 100644 index 00000000..25d07251 --- /dev/null +++ b/js/packages/cli/test/api.test.ts @@ -0,0 +1,456 @@ +/** + * The HTTP API. + * + * This process holds an unlocked parent seed and binds a TCP port, which is a + * strictly larger attack surface than the agent's 0600 unix socket. Most of + * this file is therefore about what the API REFUSES: unauthenticated calls, + * secret reads on a bearer token alone, path traversal, non-loopback binds, + * and unbounded unlock guessing. The happy paths matter too, but they are not + * what makes opening a port defensible. + */ + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import process from "node:process"; +import { mnemonics } from "@seedpass/test-vectors"; +import { + generateFingerprint, + deriveIndexKeyBytes, + encryptV3, + utf8, + addPasswordEntry, + addKeyValueEntry, + addTotpDeterministic, + type VaultIndex, +} from "@seedpass/core"; +import { + ApiServer, + registerRoutes, + buildContext, + resolveBind, + AppDir, + INDEX_FILENAME, +} from "../src/index.js"; + +const MNEMONIC = mnemonics["abandon12"]!; +const FINGERPRINT = generateFingerprint(MNEMONIC); +const PASSWORD = "api-test-password"; +const TOKEN = "test-token-not-random-on-purpose"; + +let appDir: string; +let app: AppDir; +let server: ApiServer; +let base: string; +let ctx: ReturnType; + +interface Res { + status: number; + json: any; + text: string; + headers: Headers; +} + +async function call( + method: string, + path: string, + opts: { + token?: string | null; + password?: string; + body?: unknown; + raw?: string; + headers?: Record; + } = {}, +): Promise { + const headers: Record = { ...(opts.headers ?? {}) }; + const token = opts.token === undefined ? TOKEN : opts.token; + if (token !== null) headers["authorization"] = `Bearer ${token}`; + if (opts.password !== undefined) headers["x-seedpass-password"] = opts.password; + let body: string | undefined; + if (opts.raw !== undefined) { + body = opts.raw; + } else if (opts.body !== undefined) { + body = JSON.stringify(opts.body); + headers["content-type"] = "application/json"; + } + const res = await fetch(`${base}${path}`, { method, headers, ...(body !== undefined && { body }) }); + const text = await res.text(); + let json: any; + try { + json = JSON.parse(text); + } catch { + json = undefined; + } + return { status: res.status, json, text, headers: res.headers }; +} + +beforeAll(async () => { + appDir = await mkdtemp(join(tmpdir(), "seedpass-api-")); + app = new AppDir(appDir); + await app.mutateFingerprints((data) => { + data.fingerprints.push(FINGERPRINT); + data.names[FINGERPRINT] = "api"; + data.last_used = FINGERPRINT; + }); + const dir = app.profileDir(FINGERPRINT); + await mkdir(dir, { recursive: true }); + await app.writeParentSeed(FINGERPRINT, MNEMONIC, PASSWORD); + + const index = { schema_version: 4, entries: {} } as VaultIndex; + addPasswordEntry(index, "bank.example", 16, { username: "alice", tags: ["money"] }); + addKeyValueEntry(index, "api-token", "TOKEN", "kv-secret-value"); + addTotpDeterministic(index, "email-2fa", MNEMONIC); + await writeFile( + join(dir, INDEX_FILENAME), + await encryptV3(deriveIndexKeyBytes(MNEMONIC), utf8(JSON.stringify(index))), + ); + + server = new ApiServer({ host: "127.0.0.1", port: 0, token: TOKEN }); + ctx = buildContext({ app, fingerprint: FINGERPRINT, mnemonic: MNEMONIC }); + registerRoutes(server, ctx); + const bound = await server.listen(); + base = `http://${bound.host}:${bound.port}`; +}); + +afterAll(async () => { + await server.close(); +}); + +describe("what the API refuses", () => { + it("rejects every request without a valid bearer token", async () => { + for (const [method, path] of [ + ["GET", "/api/v1/entry"], + ["GET", "/api/v1/stats"], + ["POST", "/api/v1/entry"], + ["GET", "/api/v1/nostr/pubkey"], + ] as const) { + expect((await call(method, path, { token: null })).status).toBe(401); + expect((await call(method, path, { token: "wrong-token" })).status).toBe(401); + } + }); + + it("does not hand over plaintext on a bearer token alone", async () => { + // The whole point of the second factor: a token lifted from a script or a + // process list must not be enough to read the vault. + const noPassword = await call("GET", "/api/v1/entry/1/secret"); + expect(noPassword.status).toBe(401); + expect(noPassword.text).not.toContain("kv-secret-value"); + + const wrongPassword = await call("GET", "/api/v1/entry/1/secret", { + password: "not-the-password", + }); + expect(wrongPassword.status).toBe(401); + expect(wrongPassword.text).not.toContain("kv-secret-value"); + + const right = await call("GET", "/api/v1/entry/1/secret", { password: PASSWORD }); + expect(right.status).toBe(200); + expect(right.json.value).toBe("kv-secret-value"); + }); + + it("keeps secret values out of listings", async () => { + const res = await call("GET", "/api/v1/entry"); + expect(res.status).toBe(200); + expect(res.json).toHaveLength(3); + // Reference-first, like every other surface. + expect(res.text).not.toContain("kv-secret-value"); + for (const row of res.json) expect(row.ref).toMatch(/^sp:\/\/entry\//); + }); + + it("refuses to read or write outside the profile directory", async () => { + for (const path of ["../../../etc/passwd", "/etc/passwd", "a/../../../outside"]) { + const res = await call("POST", "/api/v1/entry/0/document/export", { + password: PASSWORD, + body: { path }, + }); + expect(res.status).toBe(400); + expect(res.json.detail).toContain("inside the profile directory"); + } + }); + + it("refuses a non-loopback bind unless explicitly allowed", () => { + expect(() => resolveBind({ host: "0.0.0.0" })).toThrow(/refusing to bind/); + expect(() => resolveBind({ host: "192.168.1.10" })).toThrow(/refusing to bind/); + // Explicit acknowledgement is the only way through. + expect(resolveBind({ host: "0.0.0.0", allowRemote: true }).host).toBe("0.0.0.0"); + // Loopback in its various spellings needs no flag. + for (const host of ["127.0.0.1", "localhost", "::1"]) { + expect(resolveBind({ host }).host).toBe(host); + } + }); + + it("never returns the password or pin verifier through config", async () => { + for (const key of ["password_hash", "pin_hash"]) { + const got = await call("GET", `/api/v1/config/${key}`); + expect(got.json.value).toBe(""); + // And it cannot be set either — nothing legitimate needs to. + const set = await call("PUT", `/api/v1/config/${key}`, { body: { value: "x" } }); + expect(set.status).toBe(400); + } + }); + + it("caps the request body rather than buffering whatever arrives", async () => { + const small = new ApiServer({ host: "127.0.0.1", port: 0, token: TOKEN, maxBodyBytes: 64 }); + const smallCtx = buildContext({ app, fingerprint: FINGERPRINT, mnemonic: MNEMONIC }); + registerRoutes(small, smallCtx); + const bound = await small.listen(); + try { + const res = await fetch(`http://${bound.host}:${bound.port}/api/v1/entry`, { + method: "POST", + headers: { authorization: `Bearer ${TOKEN}`, "content-type": "application/json" }, + body: JSON.stringify({ kind: "password", label: "x".repeat(500) }), + }); + expect(res.status).toBe(413); + } finally { + await small.close(); + } + }); + + it("limits failed unlock attempts far more tightly than ordinary requests", async () => { + const locked = new ApiServer({ + host: "127.0.0.1", + port: 0, + token: TOKEN, + unlockAttemptLimit: 3, + }); + const lockedCtx = buildContext({ app, fingerprint: FINGERPRINT, mnemonic: null }); + registerRoutes(locked, lockedCtx); + const bound = await locked.listen(); + const url = `http://${bound.host}:${bound.port}/api/v1/vault/unlock`; + try { + const attempt = (password: string) => + fetch(url, { + method: "POST", + headers: { authorization: `Bearer ${TOKEN}`, "x-seedpass-password": password }, + }); + // Wrong passwords are counted... + expect((await attempt("wrong-1")).status).toBe(401); + expect((await attempt("wrong-2")).status).toBe(401); + expect((await attempt("wrong-3")).status).toBe(401); + // ...and the budget is then spent, even for the RIGHT password. An + // attacker must not be able to distinguish "locked out" from "wrong". + expect((await attempt(PASSWORD)).status).toBe(429); + } finally { + await locked.close(); + } + }); + + it("reports a locked vault as 423 rather than failing obscurely", async () => { + const locked = new ApiServer({ host: "127.0.0.1", port: 0, token: TOKEN }); + const lockedCtx = buildContext({ app, fingerprint: FINGERPRINT, mnemonic: null }); + registerRoutes(locked, lockedCtx); + const bound = await locked.listen(); + try { + const res = await fetch(`http://${bound.host}:${bound.port}/api/v1/entry`, { + headers: { authorization: `Bearer ${TOKEN}` }, + }); + expect(res.status).toBe(423); + + // And unlocking with the right password opens it. + const unlocked = await fetch(`http://${bound.host}:${bound.port}/api/v1/vault/unlock`, { + method: "POST", + headers: { authorization: `Bearer ${TOKEN}`, "x-seedpass-password": PASSWORD }, + }); + expect(unlocked.status).toBe(200); + expect((await unlocked.json()).status).toBe("unlocked"); + expect(lockedCtx.mnemonic).toBe(MNEMONIC); + } finally { + await locked.close(); + } + }); + + it("sends no CORS headers unless the origin was allowlisted", async () => { + const res = await call("GET", "/api/v1/entry", { headers: { origin: "https://evil.example" } }); + expect(res.headers.get("access-control-allow-origin")).toBeNull(); + }); +}); + +describe("entries", () => { + it("creates an entry and returns a reference, never the secret", async () => { + const res = await call("POST", "/api/v1/entry", { + body: { kind: "password", label: "created.example", length: 20 }, + }); + expect(res.status).toBe(201); + expect(res.json.ref).toMatch(/^sp:\/\/entry\//); + // Provision-blind: creating an entry must not disclose what it derives. + expect(res.text).not.toMatch(/"(password|value|secret)"\s*:/); + + const id = String(res.json.id); + const fetched = await call("GET", `/api/v1/entry/${id}`, { password: PASSWORD }); + expect(fetched.json.label).toBe("created.example"); + expect(fetched.json.length).toBe(20); + }); + + it("derives the same password the CLI would, honouring the config policy", async () => { + // The API must not become a second derivation implementation. This pins + // it to the shared one: a non-default config policy has to reach it. + await call("PUT", "/api/v1/config/min_uppercase", { body: { value: 5 } }); + await call("PUT", "/api/v1/config/exclude_ambiguous", { body: { value: true } }); + const res = await call("GET", "/api/v1/entry/0/secret", { password: PASSWORD }); + expect(res.status).toBe(200); + expect(res.json.value).not.toMatch(/[O0Il1]/); + const uppers = (res.json.value.match(/[A-Z]/g) ?? []).length; + expect(uppers).toBeGreaterThanOrEqual(5); + // Put it back so later assertions are not reading a tuned vault. + await call("PUT", "/api/v1/config/min_uppercase", { body: { value: 2 } }); + await call("PUT", "/api/v1/config/exclude_ambiguous", { body: { value: false } }); + }); + + it("modifies, archives and unarchives", async () => { + expect((await call("PUT", "/api/v1/entry/0", { body: { notes: "edited" } })).status).toBe(200); + const after = await call("GET", "/api/v1/entry/0", { password: PASSWORD }); + expect(after.json.notes).toBe("edited"); + + expect((await call("POST", "/api/v1/entry/0/archive")).json.status).toBe("archived"); + const hidden = await call("GET", "/api/v1/entry"); + expect(hidden.json.map((r: any) => r.id)).not.toContain("0"); + const shown = await call("GET", "/api/v1/entry?archived=true"); + expect(shown.json.map((r: any) => r.id)).toContain("0"); + + expect((await call("POST", "/api/v1/entry/0/unarchive")).json.status).toBe("active"); + }); + + it("searches by label, username, tag and kind", async () => { + expect((await call("GET", "/api/v1/entry?query=bank")).json).toHaveLength(1); + expect((await call("GET", "/api/v1/entry?query=alice")).json).toHaveLength(1); + expect((await call("GET", "/api/v1/entry?query=money")).json).toHaveLength(1); + const kv = await call("GET", "/api/v1/entry?kind=key_value"); + expect(kv.json.every((r: any) => r.kind === "key_value")).toBe(true); + }); + + it("manages typed links between entries", async () => { + const added = await call("POST", "/api/v1/entry/0/links", { + body: { target: 1, relation: "depends_on" }, + }); + expect(added.status).toBe(200); + expect(added.json.links).toHaveLength(1); + expect(added.json.links[0].relation).toBe("depends_on"); + + expect((await call("GET", "/api/v1/entry/0/links")).json.links).toHaveLength(1); + const removed = await call("DELETE", "/api/v1/entry/0/links?target=1"); + expect(removed.json.links).toHaveLength(0); + }); + + it("returns 404 for an entry that does not exist and 400 for a nonsense id", async () => { + expect((await call("GET", "/api/v1/entry/9999", { password: PASSWORD })).status).toBe(404); + expect((await call("GET", "/api/v1/entry/not-a-number", { password: PASSWORD })).status).toBe(400); + }); +}); + +describe("totp, config, relays, stats", () => { + it("returns live TOTP codes with the time remaining", async () => { + const res = await call("GET", "/api/v1/totp"); + expect(res.status).toBe(200); + expect(res.json.codes).toHaveLength(1); + expect(res.json.codes[0].code).toMatch(/^\d{6}$/); + expect(res.json.codes[0].seconds_remaining).toBeGreaterThan(0); + }); + + it("gates the full 2FA export behind the master password", async () => { + // This is every TOTP secret in the vault, in plaintext. + expect((await call("GET", "/api/v1/totp/export")).status).toBe(401); + const res = await call("GET", "/api/v1/totp/export", { password: PASSWORD }); + expect(res.status).toBe(200); + // The base32 SECRET, not the current code — an authenticator cannot be + // seeded from a 6-digit code, and an export that returned one would look + // correct while being useless. + expect(res.json.entries[0].secret).toMatch(/^[A-Z2-7]+$/); + expect(res.json.entries[0].secret).not.toMatch(/^\d{6}$/); + expect(res.json.entries[0].uri).toContain("otpauth://totp/"); + }); + + it("reads and writes config", async () => { + expect((await call("PUT", "/api/v1/config/inactivity_timeout", { body: { value: 42 } })).status).toBe(200); + expect((await call("GET", "/api/v1/config/inactivity_timeout")).json.value).toBe(42); + }); + + it("manages the relay list", async () => { + const added = await call("POST", "/api/v1/relays", { body: { url: "wss://relay.example" } }); + expect(added.json.relays).toContain("wss://relay.example"); + expect((await call("POST", "/api/v1/relays", { body: { url: "http://nope" } })).status).toBe(400); + const reset = await call("POST", "/api/v1/relays/reset"); + expect(reset.json.relays).not.toContain("wss://relay.example"); + }); + + it("reports profile statistics", async () => { + const res = await call("GET", "/api/v1/stats"); + expect(res.json.fingerprint).toBe(FINGERPRINT); + expect(res.json.total_entries).toBeGreaterThanOrEqual(3); + expect(res.json.by_kind.password).toBeGreaterThanOrEqual(1); + }); + + it("exposes the sync identity's npub", async () => { + const res = await call("GET", "/api/v1/nostr/pubkey"); + expect(res.json.npub).toMatch(/^npub1/); + }); + + it("drains notifications once", async () => { + ctx.notifications.push({ level: "warning", message: "something happened" }); + expect((await call("GET", "/api/v1/notifications")).json).toHaveLength(1); + expect((await call("GET", "/api/v1/notifications")).json).toHaveLength(0); + }); + + it("reports derivation collisions on the served vault", async () => { + const res = await call("GET", "/api/v1/check-derivation"); + expect(res.status).toBe(200); + expect(res.json.collisions).toEqual([]); + }); +}); + +describe("vault export and import", () => { + it("exports a backup and refuses to import one from another seed", async () => { + const exported = await call("POST", "/api/v1/vault/export", { + password: PASSWORD, + body: { plaintext: true }, + }); + expect(exported.status).toBe(200); + expect(exported.headers.get("content-disposition")).toContain(".seedpass"); + + const wrapper = JSON.parse(exported.text); + expect(wrapper.fingerprint).toBe(FINGERPRINT); + + // Re-importing our own backup is fine. + const ok = await call("POST", "/api/v1/vault/import", { + password: PASSWORD, + raw: exported.text, + headers: { "content-type": "application/json" }, + }); + expect(ok.status).toBe(200); + + // One from a different seed is refused, because importing it would + // re-derive every secret from THIS seed and silently change them. + const foreign = { ...wrapper, fingerprint: "0123456789ABCDEF" }; + const refused = await call("POST", "/api/v1/vault/import", { + password: PASSWORD, + raw: JSON.stringify(foreign), + headers: { "content-type": "application/json" }, + }); + expect(refused.status).toBe(409); + expect(refused.json.detail).toContain("belongs to profile"); + }); +}); + +describe("the unported surface answers honestly", () => { + it("returns 501 with the reason, not 404", async () => { + for (const [method, path, feature] of [ + ["GET", "/api/v1/high-risk/status", "high-risk partitions"], + ["GET", "/api/v1/agent/job-profiles", "agent job profiles"], + ["POST", "/api/v1/agent/recovery/split", "agent recovery split"], + ["POST", "/api/v1/semantic/search", "semantic"], + ] as const) { + const res = await call(method, path); + // 404 would read as "you typed the path wrong" and send an integrator + // hunting for a spelling mistake that is not there. + expect(res.status).toBe(501); + expect(res.json.detail).toContain(feature); + } + }); + + it("still returns 404 for a genuinely unknown path", async () => { + expect((await call("GET", "/api/v1/no-such-thing")).status).toBe(404); + }); + + it("distinguishes a wrong method from a wrong path", async () => { + expect((await call("DELETE", "/api/v1/stats")).status).toBe(405); + }); +});