mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-27 07:52:03 +00:00
feat(api): port the HTTP API surface to TypeScript
Closes the open "does the api get a TypeScript port at all" decision by
porting the subset that is not built on unported subsystems: entries (list,
search, read, create, modify, archive, links, document import/export), an
explicit secret route, config, profiles, TOTP, vault lock/unlock/export/import,
change-password, relays, nostr pubkey, stats, notifications, derivation check,
and shutdown.
Built on node:http with no 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 parent seeds would be the largest supply-chain
change in the project.
This surface binds a TCP port while holding an unlocked seed, which is
strictly more exposed than the agent's 0600 unix socket, so it is shaped by
what it refuses:
- Loopback only. A non-loopback bind requires --allow-remote; an accidental
--host 0.0.0.0 on a seed-holding process is not a mistake that should be
recoverable after the fact.
- Bearer token on every route, printed once, never written to disk,
compared in constant time.
- Routes that produce plaintext additionally require the master password in
a header, so a token lifted from a script or a process list cannot read
the vault on its own.
- Path parameters resolve inside the profile directory or 400.
- Body size capped while the request is still arriving.
- Failed unlocks get their own much tighter budget, and exhausting it
returns 429 even for the correct password, so the port is not a password
oracle and lockout is not distinguishable from a wrong guess.
- No CORS headers unless the origin was explicitly allowlisted.
- password_hash / pin_hash are never returned and cannot be set.
Every handler goes through the CLI's own vault plumbing rather than
reimplementing it. That is the point: an API deriving secrets its own way
would be a second implementation to keep in parity, and the first place the
two would drift is which secret a given entry produces. Verified end to end
against a real running server: Python, the TS CLI and the TS API all derive
g'xH9E?3n3VS8EOmX8 for the same entry.
Routes belonging to deliberately unported subsystems (high-risk partitions,
agent job profiles, agent recovery split, semantic index) answer 501 naming
the feature rather than 404 — a 404 reads as a typo and would send an
integrator hunting for a spelling mistake that is not there.
One bug the tests caught before it shipped: the 2FA export returned
materializeSecret's value for deterministic TOTP entries, which is the current
six-digit CODE, not the base32 secret. An authenticator cannot be seeded from
a code, so the export would have looked correct and been useless. It now
re-derives the secret the way the TUI's export does, and returns the otpauth
URI alongside it.
28 API tests, most of them about what is refused rather than what works.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
This commit is contained in:
1 parent
4b64d26273
commit
348245e14a
7 files changed
+1904
-1
No files matched your search
@@ -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<boolean>;
|
||||
/** Unlock and populate `mnemonic`; returns seconds taken. */
|
||||
unlock: (password: string) => Promise<number>;
|
||||
/** 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<void> {
|
||||
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<T>(
|
||||
ctx: ApiContext,
|
||||
fn: (index: VaultIndex) => T | Promise<T>,
|
||||
): Promise<T> {
|
||||
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<string, unknown> {
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown>;
|
||||
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<typeof modifyEntry>[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: "<redacted>" } };
|
||||
}
|
||||
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<string, unknown>)["kind"] ?? "") === "totp")
|
||||
.map(([id, e]) => {
|
||||
const entry = e as unknown as Record<string, unknown>;
|
||||
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<string, unknown>)["kind"] ?? "") === "totp")
|
||||
.map(([, e]) => {
|
||||
const entry = e as unknown as Record<string, unknown>;
|
||||
// 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<string, unknown>, {
|
||||
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<string, number> = {};
|
||||
let archived = 0;
|
||||
for (const entry of Object.values(vault.index.entries)) {
|
||||
const e = entry as unknown as Record<string, unknown>;
|
||||
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.`,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> => {
|
||||
// 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<void>((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();
|
||||
});
|
||||
}
|
||||
@@ -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<string, string | undefined>;
|
||||
/** 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<string, string>;
|
||||
}
|
||||
|
||||
export interface ApiResponse {
|
||||
status?: number;
|
||||
/** JSON-serializable body. Mutually exclusive with `bytes`. */
|
||||
json?: unknown;
|
||||
/** Raw bytes, for downloads. */
|
||||
bytes?: Uint8Array;
|
||||
contentType?: string;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
export type RouteHandler = (req: ApiRequest) => Promise<ApiResponse> | 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<string, number[]>();
|
||||
|
||||
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<string>;
|
||||
|
||||
/** 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<string, string> } | 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<string, string> = {};
|
||||
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<Uint8Array> {
|
||||
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<void> {
|
||||
const headers: Record<string, string | undefined> = {};
|
||||
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<string, string> = {};
|
||||
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<string, string> = {
|
||||
// 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<void>((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<void> {
|
||||
if (!this.server) return;
|
||||
await new Promise<void>((resolve) => this.server!.close(() => resolve()));
|
||||
this.server = null;
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,19 @@ export function capabilities(): Record<string, unknown> {
|
||||
"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<string, unknown> {
|
||||
"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)",
|
||||
],
|
||||
|
||||
@@ -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";
|
||||
@@ -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 <host>", "bind address", "127.0.0.1")
|
||||
.option("--port <port>", "bind port", "8765")
|
||||
.option(
|
||||
"--allow-remote",
|
||||
"permit a non-loopback bind (requires a TLS-terminating proxy in front)",
|
||||
)
|
||||
.option(
|
||||
"--cors-origin <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
|
||||
|
||||
@@ -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<typeof buildContext>;
|
||||
|
||||
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<string, string>;
|
||||
} = {},
|
||||
): Promise<Res> {
|
||||
const headers: Record<string, string> = { ...(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("<redacted>");
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user