Files
SeedPass/js/packages/cli/test/api.test.ts
T
thePR0M3TH3ANandClaude Opus 5 e2e9aa851e Fix what three platforms found: two shipped bugs, four unportable tests
The platform workflow's first run produced seventeen failures on Windows and
macOS, where the JavaScript suite had never executed. Two were real defects
in shipped code.

A CONTAINMENT CHECK THAT REFUSED EVERYTHING ON WINDOWS

`resolveWithinProfile` compared `target.startsWith(root + "/")` with a
hardcoded forward slash. On Windows `resolve()` returns
`C:\profile\file.txt` and the comparison asked for `C:\profile/`, so it
matched nothing: document export and import refused every path, including
legitimate ones. It failed CLOSED, so nothing leaked — the feature simply did
not work. Now uses `relative()`, which needs no separator of our choosing.

A COMMAND PARSER THAT ATE WINDOWS PATHS

`parseCommandSpec` treated backslash as a POSIX escape unconditionally, so
`--exec "C:\Program Files\tool.exe"` was spawned as the command `C:Program`
with an argument `Filestool.exe`. Every Windows path handed to --exec or
--stdin-to was mangled before the spawn. This is the SECOND instance of this
exact bug today — the Python TUI palette had it too — which is what a whole
class looks like rather than a one-off.

`windows` is an explicit parameter, not a `process.platform` read, so both
branches are tested from either platform. That parameter is the actual fix
for the class: the reason these survived is that the Windows branch could
only ever run on Windows.

THE REST WERE TESTS, NOT CODE

- `/bin/true` does not exist on macOS (it is /usr/bin/true), so a spawn failed
  with ENOENT and the refusal looked like the daemon's crash-safety fix having
  regressed. Now uses process.execPath, which exists everywhere by definition.
- The oversized-request test treated its own EPIPE as fatal. The daemon caps
  the line, answers and hangs up; on macOS that hangup lands while the client
  is still pushing the remaining megabyte. Correct server behaviour was
  failing the test on one platform and passing on the other.
- Three sink tests wrote `#!/bin/sh` files. Shebangs and /bin/sh do not exist
  on Windows. Now Node scripts run by the interpreter already running the
  tests.
- Eight `0600` assertions and two clipboard tests are guarded by documented
  constants in test/helpers/platform.ts. Windows has no POSIX mode bits and
  the runner has no clipboard.

The skips state a gap rather than closing it, and TODO.md now records that the
Windows permissions question grew from two Python tests to ten across both
languages — including the vault index and the semantic index, which hold
secrets. With TypeScript becoming the only implementation that is a shipping
question, not a test-suite one.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
2026-08-20 14:39:52 -04:00

1550 lines
66 KiB
TypeScript

/**
* 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 { NO_POSIX_PERMISSIONS } from "./helpers/platform.js";
import { mkdtemp, mkdir, readFile, writeFile, stat } from "node:fs/promises";
import { existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { mnemonics } from "@seedpass/test-vectors";
import {
generateFingerprint,
deriveIndexKeyBytes,
encryptV3,
utf8,
addPasswordEntry,
addKeyValueEntry,
addTotpDeterministic,
addSshKeyEntry,
partitionStub,
type VaultIndex,
} from "@seedpass/core";
import {
setFactor,
tagForFactor,
writePartition,
factorConfigured,
} from "../src/highRisk.js";
import {
UNPORTED_PREFIXES,
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";
// The fixture's entries, so tests can assert against what was actually seeded
// instead of a hand-counted literal that has to be chased every time the
// fixture grows.
const FIXTURE_TOTP_LABELS = ["blank-secret-2fa", "email-2fa"];
const FIXTURE_LABELS = ["api-token", "bank.example", ...FIXTURE_TOTP_LABELS].sort();
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);
// A deterministic entry that also carries a BLANK `secret` field. Python
// and older indexes can produce this shape, and the export has to read it
// as "no stored secret, derive one" rather than exporting the empty string
// as if it were the key. Built here because the API refuses to create it.
addTotpDeterministic(index, "blank-secret-2fa", MNEMONIC);
for (const entry of Object.values(index.entries) as Record<string, unknown>[]) {
if (entry["label"] === "blank-secret-2fa") entry["secret"] = "";
}
await writeFile(
join(dir, INDEX_FILENAME),
await encryptV3(deriveIndexKeyBytes(MNEMONIC), utf8(JSON.stringify(index))),
);
// Generous on purpose. The default budget is 100 requests a minute, and
// this file passed it as it grew — which made unrelated tests fail with
// 429 depending on how many requests ran before them. Rate limiting has
// its own tests below, each on a server with a budget it sets explicitly,
// so the shared fixture should not be enforcing one at all.
server = new ApiServer({ host: "127.0.0.1", port: 0, token: TOKEN, rateLimit: 100_000 });
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);
// Every fixture entry is listed — asserted against the fixture rather
// than a literal, so adding one does not silently weaken the check below
// into "no secrets in an empty list".
expect(res.json.length).toBeGreaterThan(0);
expect(res.json.map((r: { label: string }) => r.label).sort()).toEqual(
FIXTURE_LABELS,
);
// 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");
}
// The POSITIVE half. The guard is `target !== root && !target.startsWith(
// root + "/")` — turn that `&&` into `||` and every path except the
// profile root itself is refused, which the traversal cases above cannot
// see because they expect a refusal either way. A check that refuses
// everything is not a working check.
const inside = await call("POST", "/api/v1/entry/0/document/export", {
password: PASSWORD,
// Flat, not nested: atomicWrite does not create parent directories,
// and a nested path answers 500 rather than a clear refusal — recorded
// in TODO.md rather than fixed inside a test for a different property.
body: { path: "exported-secret.txt" },
});
expect(inside.status).toBe(200);
expect(inside.json.path).toBe(
join(app.profileDir(FINGERPRINT), "exported-secret.txt"),
);
expect(existsSync(inside.json.path)).toBe(true);
});
it("imports a document from a path, and says 404 when there is no file", async () => {
// Without the existence check, readFile's ENOENT surfaces as 500 — "the
// server broke" for a filename the caller simply got wrong. Both halves
// are asserted so the test cannot pass by the route being broken.
const missing = await call("POST", "/api/v1/entry/document/import", {
password: PASSWORD,
body: { label: "nope", path: "no-such-file.txt" },
});
expect(missing.status).toBe(404);
expect(missing.json.detail).toContain("file not found");
const path = join(app.profileDir(FINGERPRINT), "importable.txt");
await writeFile(path, "document contents");
const imported = await call("POST", "/api/v1/entry/document/import", {
password: PASSWORD,
body: { label: "imported-doc", path: "importable.txt" },
});
expect(imported.status).toBe(201);
// And the traversal guard applies to the import side too, not only export.
expect(
(await call("POST", "/api/v1/entry/document/import", {
password: PASSWORD,
body: { label: "escape", path: "../../../etc/passwd" },
})).status,
).toBe(400);
});
it("validates optional integers at every edge, not just the obvious one", async () => {
// optInt guards `length`, `period`, `digits`, `index`, `delay` and the
// history `target`. Each of its three clauses was unasserted.
for (const delay of [1.5, -1, "soon"]) {
const res = await call("POST", "/api/v1/secret-mode", {
password: PASSWORD,
body: { enabled: true, delay },
});
expect(res.status).toBe(400);
expect(res.json.detail).toContain("delay");
}
// Zero is NOT negative, and optInt says non-negative. A delay of 0 is a
// real choice, so `n < 0` must not quietly become `n <= 0`.
const zero = await call("POST", "/api/v1/secret-mode", {
password: PASSWORD,
body: { enabled: true, delay: 0 },
});
expect(zero.status).toBe(200);
expect((await call("GET", "/api/v1/config/clipboard_clear_delay")).json.value).toBe(0);
});
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. 403, not
// 400: the key is real and the refusal is about authority, which is
// also the status Python answers.
const set = await call("PUT", `/api/v1/config/${key}`, { body: { value: "x" } });
expect(set.status).toBe(403);
}
});
it("refuses a body that is not a JSON object", async () => {
// typeof [] is "object", so the array check is doing real work rather
// than restating the typeof. Without it an array body reaches the
// handlers, where `body["kind"]` is undefined and the caller is told a
// field is missing — sending them looking for the wrong problem.
for (const raw of ["[1,2,3]", '"a string"', "42", "null"]) {
const res = await call("POST", "/api/v1/entry", {
password: PASSWORD,
raw,
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(400);
expect(res.json.detail).toContain("JSON object");
}
// An object still works, so this is rejecting the shape and not the route.
expect(
(await call("POST", "/api/v1/entry", {
password: PASSWORD,
body: { kind: "password", label: "shape-check" },
})).status,
).toBe(201);
});
it("refuses a config key it does not know, instead of storing it forever", async () => {
// This route used to take the key from the URL and the value from the
// body with no validation at all, so `inactivity_timout` was accepted,
// written into the encrypted config permanently, and reported as ok —
// while the setting it was meant to change never moved. Nothing reads an
// unknown key, so the failure could only ever be silent.
const typo = await call("PUT", "/api/v1/config/inactivity_timout", {
body: { value: 60 },
});
expect(typo.status).toBe(400);
expect(typo.json.detail).toContain("Unknown key");
expect((await call("GET", "/api/v1/config/inactivity_timout")).json.value).toBeNull();
// A body with no `value` at all. The boolean keys are where this bites:
// Boolean(undefined) is false, so without the check `PUT
// /api/v1/config/offline_mode` with an empty body would quietly SET
// offline_mode to false rather than refuse.
for (const key of ["offline_mode", "secret_mode_enabled", "relays"]) {
const res = await call("PUT", `/api/v1/config/${key}`, {
password: PASSWORD,
body: {},
});
expect(res.status).toBe(400);
expect(res.json.detail).toContain("value is required");
}
// Unchanged by the refusals above.
expect((await call("GET", "/api/v1/config/offline_mode")).json.value).toBe(false);
await call("PUT", "/api/v1/config/offline_mode", { body: { value: true } });
expect((await call("GET", "/api/v1/config/offline_mode")).json.value).toBe(true);
expect(
(await call("PUT", "/api/v1/config/offline_mode", { body: {} })).status,
).toBe(400);
// Still true — the empty body did not reset it.
expect((await call("GET", "/api/v1/config/offline_mode")).json.value).toBe(true);
await call("PUT", "/api/v1/config/offline_mode", { body: { value: false } });
// Nor a key that only LOOKS like an object property.
expect(
(await call("PUT", "/api/v1/config/__proto__", { body: { value: {} } })).status,
).toBe(400);
expect(
(await call("PUT", "/api/v1/config/constructor", { body: { value: 1 } })).status,
).toBe(400);
});
it("refuses a value of the wrong type for a key it does know", async () => {
// The allowlist alone is not enough: `inactivity_timeout: "soon"` used to
// be stored verbatim and turn every later comparison into NaN, which
// compares false — so the vault would simply never time out.
for (const [key, value] of [
["inactivity_timeout", "soon"],
["inactivity_timeout", 0],
["clipboard_clear_delay", -1],
["relays", "wss://one.example"],
["relays", []],
["min_uppercase", "lots"],
// Values that LOOK numeric to Number() and are not numbers. Number(true)
// is 1, Number(null) and Number("") are both 0, so without an explicit
// check `inactivity_timeout: true` becomes a one-second timeout and
// `clipboard_clear_delay: null` becomes zero — a setting silently
// changed to something the caller never asked for.
["inactivity_timeout", true],
["clipboard_clear_delay", null],
["min_uppercase", ""],
["nostr_retry_delay", false],
// Zero KDF iterations is not a slow KDF, it is no KDF — and it would be
// written into the config of a vault whose master password protects
// everything else.
["kdf_iterations", 0],
["kdf_iterations", -1],
] as const) {
const res = await call("PUT", `/api/v1/config/${key}`, { body: { value } });
expect(res.status).toBe(400);
}
// Zero is not negative, and the two must not be conflated: a
// clipboard delay of 0 is a real choice (clear on the next tick), while
// a negative one is nonsense.
expect(
(await call("PUT", "/api/v1/config/clipboard_clear_delay", { body: { value: 0 } }))
.status,
).toBe(200);
expect((await call("GET", "/api/v1/config/clipboard_clear_delay")).json.value).toBe(0);
// One iteration is pointless but it is a number the caller may legitimately
// choose; the floor is where nonsense starts, not where good advice does.
expect(
(await call("PUT", "/api/v1/config/kdf_iterations", { body: { value: 1 } })).status,
).toBe(200);
await call("PUT", "/api/v1/config/kdf_iterations", { body: { value: 200000 } });
// And the well-typed versions still go through, so the check is
// rejecting the value rather than the key.
expect(
(await call("PUT", "/api/v1/config/inactivity_timeout", { body: { value: 120 } }))
.status,
).toBe(200);
expect((await call("GET", "/api/v1/config/inactivity_timeout")).json.value).toBe(120);
expect(
(await call("PUT", "/api/v1/config/relays", {
body: { value: ["wss://one.example"] },
})).status,
).toBe(200);
});
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);
// The BOUNDARY, which nothing checked: a limit of N bytes has to mean N
// bytes are allowed. `>` accepts exactly N and `>=` rejects it, and
// sending 500 bytes at a 64-byte cap cannot tell the two apart — so the
// cap could quietly become off-by-one and only show up as a request
// that fails at a size the operator configured as fine.
const url = `http://${bound.host}:${bound.port}/api/v1/entry`;
const send = (body: string) =>
fetch(url, {
method: "POST",
headers: { authorization: `Bearer ${TOKEN}`, "content-type": "application/json" },
body,
});
// Exactly 64 bytes of valid JSON: accepted (whatever the route then
// makes of it — the point is that it is not refused for its size).
const padded = `{"kind":"password","label":"${"x".repeat(64 - 30)}"}`;
expect(padded.length).toBe(64);
expect((await send(padded)).status).not.toBe(413);
// One byte more: refused.
const overBy1 = `{"kind":"password","label":"${"x".repeat(64 - 29)}"}`;
expect(overBy1.length).toBe(65);
expect((await send(overBy1)).status).toBe(413);
} finally {
await small.close();
}
});
it("actually enforces the ordinary rate limit", async () => {
// Mutation testing found this: disabling the rate-limit check entirely
// left every test passing. A limiter nothing exercises is a limiter that
// can be removed by accident.
const limited = new ApiServer({
host: "127.0.0.1",
port: 0,
token: TOKEN,
rateLimit: 3,
rateWindowSeconds: 60,
});
registerRoutes(limited, buildContext({ app, fingerprint: FINGERPRINT, mnemonic: MNEMONIC }));
const bound = await limited.listen();
const url = `http://${bound.host}:${bound.port}/api/v1/entry`;
try {
const hit = () => fetch(url, { headers: { authorization: `Bearer ${TOKEN}` } });
expect((await hit()).status).toBe(200);
expect((await hit()).status).toBe(200);
expect((await hit()).status).toBe(200);
// Fourth request in the window is refused.
const blocked = await hit();
expect(blocked.status).toBe(429);
expect((await blocked.json()).detail).toContain("Rate limit");
} finally {
await limited.close();
}
});
it("rate-limits before authenticating, so a flood cannot hammer the token check", async () => {
const limited = new ApiServer({
host: "127.0.0.1",
port: 0,
token: TOKEN,
rateLimit: 2,
rateWindowSeconds: 60,
});
registerRoutes(limited, buildContext({ app, fingerprint: FINGERPRINT, mnemonic: MNEMONIC }));
const bound = await limited.listen();
const url = `http://${bound.host}:${bound.port}/api/v1/entry`;
try {
// Unauthenticated requests consume the budget too — otherwise the limit
// is trivially bypassed by simply not sending a token.
await fetch(url);
await fetch(url);
expect((await fetch(url)).status).toBe(429);
} finally {
await limited.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("answers 404 on EVERY entry-scoped route for an id that is not there", async () => {
// Four of these (PUT, archive, unarchive, GET links) had no test at all,
// so their existence checks could be removed and the underlying op would
// run against a missing entry — answering 400 or 500 instead of 404, and
// telling a client to fix its request or retry when neither would help.
//
// Enumerated rather than listed by hand so a NEW entry-scoped route is
// covered the day it is added, which is when the check is most likely to
// be forgotten.
const source = await readFile(
fileURLToPath(new URL("../src/api/routes.ts", import.meta.url)),
"utf8",
);
const routes = [
...source.matchAll(/server\.route\(\s*"(\w+)",\s*\n?\s*"(\/api\/v1\/entry\/:entry_id[^"]*)"/g),
].map(([, method, pattern]) => {
let path = pattern!.replace(":entry_id", "9999");
// DELETE links validates its `target` before looking the entry up, and
// that ordering is fine — request validation before state. Supply a
// valid one so a 400 here could only mean the entry check is missing.
if (method === "DELETE" && path.endsWith("/links")) path += "?target=0";
return [method!, path] as const;
});
// Every one of them, so the enumeration is doing something.
expect(routes.length).toBeGreaterThanOrEqual(6);
for (const [method, path] of routes) {
const res = await call(method, path, {
password: PASSWORD,
// Bodies valid enough that a 400 could only come from the entry being
// missing, never from the body being wrong. GET and DELETE take none.
...(method === "GET" || method === "DELETE"
? {}
: { body: { target: "0", path: "unused.txt", label: "x" } }),
});
expect({ method, path, status: res.status }).toEqual({
method,
path,
status: 404,
});
}
});
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);
// The SECRET route has its own existence check, and only this one was
// covered. Without it the missing entry reaches the partition hydrator
// and answers 500 — "the server broke" for an id that is simply not
// there. Both statuses matter to a client deciding whether to retry.
const secret = await call("GET", "/api/v1/entry/9999/secret", { password: PASSWORD });
expect(secret.status).toBe(404);
expect(
(await call("GET", "/api/v1/entry/not-a-number/secret", { password: PASSWORD })).status,
).toBe(400);
});
});
describe("profiles and vault state", () => {
// Everything here was reachable and unasserted: mutation testing disabled
// the delete guard, inverted the "current" flag and inverted the locked
// flag, and the suite stayed green through all three.
const OTHER = generateFingerprint(mnemonics["legal12"]!);
it("marks exactly the served profile as current", async () => {
await app.mutateFingerprints((data) => {
if (!data.fingerprints.includes(OTHER)) data.fingerprints.push(OTHER);
data.names[OTHER] = "other";
});
const res = await call("GET", "/api/v1/fingerprint");
expect(res.status).toBe(200);
const current = res.json.filter((f: { current: boolean }) => f.current);
// Assert the PAIRING, not that some entry is current: inverted, every
// profile except the served one is flagged, and "at least one is current"
// still holds. This is what a profile switcher shows the user.
expect(current).toHaveLength(1);
expect(current[0].fingerprint).toBe(FINGERPRINT);
expect(
res.json.find((f: { fingerprint: string }) => f.fingerprint === OTHER).current,
).toBe(false);
});
it("refuses to delete the profile it is serving, but deletes another", async () => {
// Destructive and unrecoverable: without the guard the server removes the
// vault it is holding open, and keeps answering requests about it. Paired
// with a successful delete so the test cannot pass by refusing everything.
const refused = await call("DELETE", `/api/v1/fingerprint/${FINGERPRINT}`, {
password: PASSWORD,
});
expect(refused.status).toBe(400);
expect(existsSync(app.profileDir(FINGERPRINT))).toBe(true);
await mkdir(app.profileDir(OTHER), { recursive: true });
const deleted = await call("DELETE", `/api/v1/fingerprint/${OTHER}`, {
password: PASSWORD,
});
expect(deleted.status).toBe(200);
expect(existsSync(app.profileDir(OTHER))).toBe(false);
});
it("refuses to select a profile that does not exist", async () => {
// Without this the caller's string becomes ctx.fingerprint, which every
// later request joins onto a path — so the refusal is what keeps
// "../../etc" out of the profile directory. Asserted on the shared
// server precisely because a refusal must leave its state untouched.
const before = ctx.fingerprint;
for (const bogus of [generateFingerprint(mnemonics["zoo24"]!), "../../etc"]) {
const res = await call("POST", "/api/v1/fingerprint/select", {
body: { fingerprint: bogus },
});
// 404 specifically. AppDir.switchProfile repeats both checks, so
// dropping this one still refuses — as a raw 500 from an unhandled
// error, telling a caller the server broke when it simply named a
// profile that is not there.
expect(res.status).toBe(404);
expect(ctx.fingerprint).toBe(before);
}
});
it("selects a real profile, dropping the seed on the way", async () => {
// The positive half, on its own context so the shared server keeps
// serving the profile the rest of this file expects.
const second = generateFingerprint(mnemonics["zoo24"]!);
await app.mutateFingerprints((data) => {
if (!data.fingerprints.includes(second)) data.fingerprints.push(second);
});
await mkdir(app.profileDir(second), { recursive: true });
const srv = new ApiServer({ host: "127.0.0.1", port: 0, token: TOKEN });
const ctx2 = buildContext({ app, fingerprint: FINGERPRINT, mnemonic: MNEMONIC });
registerRoutes(srv, ctx2);
const bound = await srv.listen();
try {
const res = await fetch(
`http://${bound.host}:${bound.port}/api/v1/fingerprint/select`,
{
method: "POST",
headers: {
authorization: `Bearer ${TOKEN}`,
"content-type": "application/json",
},
body: JSON.stringify({ fingerprint: second }),
},
);
expect(res.status).toBe(200);
expect(ctx2.fingerprint).toBe(second);
// The held seed belongs to the profile being left; carrying it across
// would read one vault while the caller believed they selected another.
expect(ctx2.mnemonic).toBeNull();
} finally {
await srv.close();
}
});
it("reports lock state that follows the actual seed", async () => {
expect((await call("GET", "/api/v1/vault/status")).json.locked).toBe(false);
expect((await call("POST", "/api/v1/vault/lock")).status).toBe(200);
expect((await call("GET", "/api/v1/vault/status")).json.locked).toBe(true);
// And the flag is not decorative — a locked vault refuses secrets.
expect((await call("GET", "/api/v1/entry/1/secret", { password: PASSWORD })).status)
.not.toBe(200);
const unlocked = await call("POST", "/api/v1/vault/unlock", { password: PASSWORD });
expect(unlocked.status).toBe(200);
expect((await call("GET", "/api/v1/vault/status")).json.locked).toBe(false);
});
});
describe("the high-risk status the API reports", () => {
// Nothing asserted this route, so mutation testing walked straight through
// it: `>` to `>=`, `&&` to `||`, and the expires_at null check inverted all
// survived. It is the only thing telling a caller whether the second factor
// is currently open, so every one of those is a lie about the vault's
// state. Built on its own context because it needs a clock it controls.
const AT_SECONDS = 1_800_000_000;
async function frozen(): Promise<{
ctx2: ReturnType<typeof buildContext>;
status: () => Promise<any>;
close: () => Promise<void>;
setClock: (seconds: number) => void;
}> {
let ms = AT_SECONDS * 1000;
const srv = new ApiServer({ host: "127.0.0.1", port: 0, token: TOKEN });
const ctx2 = buildContext({
app,
fingerprint: FINGERPRINT,
mnemonic: MNEMONIC,
now: () => ms,
});
registerRoutes(srv, ctx2);
const bound = await srv.listen();
const url = `http://${bound.host}:${bound.port}/api/v1/high-risk/status`;
return {
ctx2,
setClock: (seconds: number) => {
ms = seconds * 1000;
},
status: async () =>
(await fetch(url, { headers: { authorization: `Bearer ${TOKEN}` } })).json(),
close: () => srv.close(),
};
}
it("reports unlocked only while a tag is held AND unexpired", async () => {
const { ctx2, status, setClock, close } = await frozen();
try {
// No tag at all.
expect((await status()).unlocked).toBe(false);
expect((await status()).expires_at).toBeNull();
ctx2.highRiskTag = "a".repeat(64);
ctx2.highRiskExpiresAt = AT_SECONDS + 60;
expect(await status()).toMatchObject({
unlocked: true,
expires_at: AT_SECONDS + 60,
});
// One second short of expiry: still open.
setClock(AT_SECONDS + 59);
expect((await status()).unlocked).toBe(true);
// EXACTLY at expiry: closed. `>=` would report one more second of a
// second factor that has lapsed.
setClock(AT_SECONDS + 60);
expect((await status()).unlocked).toBe(false);
// A future expiry with no tag is not an unlock — that is the `&&`.
// Without it, an expiry left behind by a previous session reads as open.
setClock(AT_SECONDS);
ctx2.highRiskTag = null;
ctx2.highRiskExpiresAt = AT_SECONDS + 600;
expect(await status()).toMatchObject({ unlocked: false, expires_at: null });
} finally {
await close();
}
});
});
describe("totp, config, relays, stats", () => {
it("requires the master password for live TOTP codes", async () => {
// A live code authenticates, so a leaked bearer token must not produce
// one. Python gates this the same way; this port did not until the
// route-table invariant test caught it.
const noPassword = await call("GET", "/api/v1/totp");
expect(noPassword.status).toBe(401);
expect(noPassword.text).not.toMatch(/\d{6}/);
});
it("returns live TOTP codes with the time remaining", async () => {
const res = await call("GET", "/api/v1/totp", { password: PASSWORD });
expect(res.status).toBe(200);
// One per TOTP entry in the fixture, and every one a real code.
expect(res.json.codes).toHaveLength(FIXTURE_TOTP_LABELS.length);
expect(res.json.codes.map((c: { label: string }) => c.label).sort()).toEqual(
FIXTURE_TOTP_LABELS,
);
for (const c of res.json.codes) {
expect(c.code).toMatch(/^\d{6}$/);
expect(c.seconds_remaining).toBeGreaterThan(0);
}
});
it("derives past a blank stored secret rather than exporting the blank", async () => {
// `typeof secret === "string" && secret` — the second half is the part
// that matters. Loosened to `||`, an entry whose `secret` is present but
// empty exports as an empty secret: an authenticator entry that silently
// produces nothing, from an export that looked like it worked.
const exported = await call("GET", "/api/v1/totp/export", { password: PASSWORD });
const entries = exported.json.entries as { label: string; secret: string }[];
const blank = entries.find((e) => e.label === "blank-secret-2fa");
expect(blank).toBeDefined();
expect(blank!.secret).toMatch(/^[A-Z2-7]{16,}$/);
});
it("exports an imported secret as imported, not re-derived from the seed", async () => {
// The export picks per entry: stored secret if it has one, derived
// otherwise. Invert that choice and every exported secret is still
// well-formed base32 — so shape assertions pass while the codes the user
// loads into their authenticator are for a different account entirely.
const IMPORTED = "JBSWY3DPEHPK3PXP";
const created = await call("POST", "/api/v1/entry", {
password: PASSWORD,
body: { kind: "totp", label: "imported-2fa", secret: IMPORTED },
});
expect(created.status).toBe(201);
const exported = await call("GET", "/api/v1/totp/export", { password: PASSWORD });
const entries = exported.json.entries as { label: string; secret: string }[];
const mine = entries.find((e) => e.label === "imported-2fa");
expect(mine?.secret).toBe(IMPORTED);
// And the derived entries in the same export are NOT the imported one:
// the branch has to be taken per entry, not once for the whole export.
for (const other of entries.filter((e) => e.label !== "imported-2fa")) {
expect(other.secret).not.toBe(IMPORTED);
}
});
it("derives a TOTP secret when none is supplied, rather than importing nothing", async () => {
// `secret.length > 0` decides between importing the caller's secret and
// deriving one from the seed. Relaxed to `>= 0`, an omitted-but-present
// empty secret would be IMPORTED -- an entry that looks like a second
// factor and is keyed on nothing. Nothing tested the empty case, so the
// relaxation went unnoticed.
const created = await call("POST", "/api/v1/entry", {
password: PASSWORD,
body: { kind: "totp", label: "derived-2fa", secret: "" },
});
expect(created.status).toBe(201);
const exported = await call("GET", "/api/v1/totp/export", { password: PASSWORD });
const entry = exported.json.entries.find(
(e: { label: string }) => e.label === "derived-2fa",
);
expect(entry).toBeDefined();
// A derived secret is real base32 of the usual length; an imported empty
// one could only be empty.
expect(entry.secret).toMatch(/^[A-Z2-7]{16,}$/);
});
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("deletes the relay at the index named, and no other", async () => {
// DELETE /relays/:idx had no test at all. An off-by-one here does not
// fail — it removes the WRONG relay, so a user dropping one they distrust
// keeps it and loses one they wanted. Asserting the exact resulting list
// is the only way to see that; a length check would pass either way.
const three = ["wss://a.example", "wss://b.example", "wss://c.example"];
await call("PUT", "/api/v1/config/relays", {
password: PASSWORD,
body: { value: three },
});
// Index 0 is a valid index, not a missing one.
const first = await call("DELETE", "/api/v1/relays/0");
expect(first.status).toBe(200);
expect(first.json.relays).toEqual(["wss://b.example", "wss://c.example"]);
// The LAST index is in range; one past it is not. `>=` versus `>` is
// exactly this boundary.
expect((await call("DELETE", "/api/v1/relays/2")).status).toBe(404);
const last = await call("DELETE", "/api/v1/relays/1");
expect(last.status).toBe(200);
expect(last.json.relays).toEqual(["wss://b.example"]);
// And an index that is not a non-negative integer is a 400, not a 404:
// the caller's request is malformed, not pointing at something absent.
for (const idx of ["-1", "1.5", "abc"]) {
const res = await call("DELETE", `/api/v1/relays/${idx}`);
expect(res.status).toBe(400);
expect(res.json.detail).toContain("non-negative integer");
}
await call("POST", "/api/v1/relays/reset");
});
it("revokes a job profile only if there was one to revoke", async () => {
// The whole job-profile surface had no API test. The one that matters is
// the DELETE: without its check it answers 200 "revoked" for a job that
// never existed, so an operator revoking access is TOLD it worked while
// nothing happened — the worst possible answer for a revocation.
expect((await call("DELETE", "/api/v1/agent/job-profiles/never-existed")).status)
.toBe(404);
const created = await call("POST", "/api/v1/agent/job-profiles", {
body: { id: "nightly-backup", query: "label:bank" },
});
expect(created.status).toBe(201);
expect(
(await call("GET", "/api/v1/agent/job-profiles")).text,
).toContain("nightly-backup");
const revoked = await call("DELETE", "/api/v1/agent/job-profiles/nightly-backup");
expect(revoked.status).toBe(200);
expect(revoked.json.revoked).toBe("nightly-backup");
// Revoking it a second time must NOT report success again.
expect((await call("DELETE", "/api/v1/agent/job-profiles/nightly-backup")).status)
.toBe(404);
});
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");
// An empty body is a caller mistake, not a server fault. Without the
// explicit check, parseBackupWrapper's JSON error escapes as 500.
const empty = await call("POST", "/api/v1/vault/import", {
password: PASSWORD,
raw: "",
headers: { "content-type": "application/json" },
});
expect(empty.status).toBe(400);
expect(empty.json.detail).toContain("backup body is required");
// As is a body that is not a backup at all.
const garbage = await call("POST", "/api/v1/vault/import", {
password: PASSWORD,
raw: "{not a backup}",
headers: { "content-type": "application/json" },
});
expect(garbage.status).toBeGreaterThanOrEqual(400);
expect(garbage.status).toBeLessThan(500);
});
});
describe("semantic index", () => {
it("builds, reports status, and ranks by metadata", async () => {
const built = await call("POST", "/api/v1/semantic/build");
expect(built.status).toBe(200);
expect(built.json.records).toBeGreaterThan(0);
const status = await call("GET", "/api/v1/semantic/status");
expect(status.json.built).toBe(true);
expect(status.json.model_id).toBe("seedpass-token-overlap-v2");
const hits = await call("POST", "/api/v1/semantic/search", { body: { query: "bank" } });
expect(hits.json.results.length).toBeGreaterThan(0);
expect(hits.json.results[0].ref).toMatch(/^sp:\/\/entry\//);
});
it("never writes a stored secret into the plaintext index", async () => {
await call("POST", "/api/v1/semantic/build");
// The vault holds a key_value entry whose value is this string. The
// index file sits unencrypted next to the vault, so the value must not
// reach it — nor a tokenized form of it.
const raw = await readFile(
join(app.profileDir(FINGERPRINT), "semantic_index", "records.json"),
"utf8",
);
expect(raw).not.toContain("kv-secret-value");
expect(raw.toLowerCase()).not.toContain("kv-secret-value".toLowerCase());
// But the entry is still findable by its name.
const hits = await call("POST", "/api/v1/semantic/search", { body: { query: "api-token" } });
expect(hits.json.results.map((r: any) => r.label)).toContain("api-token");
});
it.skipIf(NO_POSIX_PERMISSIONS)("writes the index at 0600, not the process umask", async () => {
await call("POST", "/api/v1/semantic/build");
const path = join(app.profileDir(FINGERPRINT), "semantic_index", "records.json");
expect((await stat(path)).mode & 0o777).toBe(0o600);
});
it("deletes an index built before secrets stopped being indexed", async () => {
// Such a file holds stored secrets in the clear, and fixing the writer
// does not rewrite what is already on disk. Leaving it and reporting a
// version string in a status field is not a remedy.
await call("POST", "/api/v1/semantic/build");
const dir = join(app.profileDir(FINGERPRINT), "semantic_index");
const manifestPath = join(dir, "manifest.json");
const recordsPath = join(dir, "records.json");
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
manifest.model_id = "seedpass-token-overlap-v1";
await writeFile(manifestPath, JSON.stringify(manifest));
await writeFile(
recordsPath,
JSON.stringify([
{ entry_id: 1, kind: "key_value", label: "x", text: "LEAKED-SECRET", tokens: ["leaked"] },
]),
);
// Touching it at all removes it, and the leaked content never comes back
// out through search.
const status = await call("GET", "/api/v1/semantic/status");
expect(status.json.built).toBe(false);
expect(existsSync(recordsPath)).toBe(false);
const hits = await call("POST", "/api/v1/semantic/search", { body: { query: "leaked" } });
expect(hits.status).toBe(409);
});
it("keeps a current index rather than deleting anything it cannot identify", async () => {
// "Cannot tell" must not mean "delete it": that would throw away a good
// index on every corrupt-manifest read.
await call("POST", "/api/v1/semantic/build");
const dir = join(app.profileDir(FINGERPRINT), "semantic_index");
await writeFile(join(dir, "manifest.json"), "{ not json");
const status = await call("GET", "/api/v1/semantic/status");
expect(status.status).toBe(200);
expect(existsSync(join(dir, "records.json"))).toBe(true);
// Put it back for any later test.
await call("POST", "/api/v1/semantic/build");
});
it("says so rather than returning nothing when no index exists", async () => {
const fresh = new ApiServer({ host: "127.0.0.1", port: 0, token: TOKEN });
const other = await mkdtemp(join(tmpdir(), "seedpass-nosem-"));
const otherApp = new AppDir(other);
await otherApp.mutateFingerprints((d) => {
d.fingerprints.push(FINGERPRINT);
d.last_used = FINGERPRINT;
});
await mkdir(otherApp.profileDir(FINGERPRINT), { recursive: true });
await writeFile(
join(otherApp.profileDir(FINGERPRINT), INDEX_FILENAME),
await encryptV3(deriveIndexKeyBytes(MNEMONIC), utf8(JSON.stringify({ schema_version: 4, entries: {} }))),
);
registerRoutes(fresh, buildContext({ app: otherApp, fingerprint: FINGERPRINT, mnemonic: MNEMONIC }));
const bound = await fresh.listen();
try {
const res = await fetch(`http://${bound.host}:${bound.port}/api/v1/semantic/search`, {
method: "POST",
headers: { authorization: `Bearer ${TOKEN}`, "content-type": "application/json" },
body: JSON.stringify({ query: "anything" }),
});
expect(res.status).toBe(409);
} finally {
await fresh.close();
}
});
});
describe("high-risk partition over the API", () => {
it("reports the factor as unconfigured before it is set", async () => {
const res = await call("GET", "/api/v1/high-risk/status");
expect(res.status).toBe(200);
expect(res.json.configured).toBe(false);
expect(res.json.unlocked).toBe(false);
// And unlocking says so, rather than blaming the factor. Without this
// check the request falls through to tagForFactor, which fails and
// reports "high_risk_factor_invalid" — telling someone who has never set
// a factor that the factor they just chose is wrong, permanently. Both
// refuse, so only the distinction is testable, and the distinction is
// the whole value.
const unconfigured = await call("POST", "/api/v1/high-risk/unlock", {
password: PASSWORD,
headers: { "x-seedpass-high-risk-factor": "anything-at-all" },
});
expect(unconfigured.status).toBe(409);
expect(unconfigured.json.detail).toContain("not_configured");
});
it("needs the master password AND the factor to unlock", async () => {
await setFactor(appDir, "api-second-factor");
// Bearer token alone: no.
expect((await call("POST", "/api/v1/high-risk/unlock")).status).toBe(401);
// Password but no factor: still no. The whole point of the partition is
// that the master password is not enough for these kinds.
const noFactor = await call("POST", "/api/v1/high-risk/unlock", { password: PASSWORD });
expect(noFactor.status).toBe(401);
expect(noFactor.json.detail).toContain("High-Risk-Factor");
// Wrong factor: one reason, so probing cannot distinguish failures.
const wrong = await call("POST", "/api/v1/high-risk/unlock", {
password: PASSWORD,
headers: { "x-seedpass-high-risk-factor": "not-it" },
});
expect(wrong.status).toBe(401);
expect(wrong.json.detail).toBe("high_risk_factor_invalid");
const ok = await call("POST", "/api/v1/high-risk/unlock", {
password: PASSWORD,
headers: { "x-seedpass-high-risk-factor": "api-second-factor" },
body: { ttl: 60 },
});
expect(ok.status).toBe(200);
expect(ok.json.status).toBe("unlocked");
});
it("never returns the partition key tag over the wire", async () => {
const tag = await tagForFactor(appDir, "api-second-factor");
// The tag IS the partition's encryption key. No response may carry it.
for (const [method, path] of [
["GET", "/api/v1/high-risk/status"],
["POST", "/api/v1/high-risk/lock"],
] as const) {
const res = await call(method, path);
expect(res.text).not.toContain(tag);
}
});
it("refuses a partition entry once the session has EXPIRED, not just when locked", async () => {
// The dangerous half of this gate. With no tag at all, dropping the check
// fails anyway — readPartition cannot decrypt with null. But an EXPIRED
// session still holds its tag, so without the check readPartition
// succeeds with the stale key and hands back the secret. That is the
// whole second factor lapsing silently, and nothing covered it: no test
// read a partition entry through the API at all.
// Self-sufficient rather than relying on an earlier test having set it.
if (!factorConfigured(appDir)) await setFactor(appDir, "api-second-factor");
const tag = await tagForFactor(appDir, "api-second-factor");
const dir = app.profileDir(FINGERPRINT);
// A stub in the index, and the real record in the partition file.
const vaultIndex = { schema_version: 4, entries: {} } as VaultIndex;
addPasswordEntry(vaultIndex, "bank.example", 16, { username: "alice", tags: ["money"] });
addKeyValueEntry(vaultIndex, "api-token", "TOKEN", "kv-secret-value");
addTotpDeterministic(vaultIndex, "email-2fa", MNEMONIC);
addTotpDeterministic(vaultIndex, "blank-secret-2fa", MNEMONIC);
for (const entry of Object.values(vaultIndex.entries) as Record<string, unknown>[]) {
if (entry["label"] === "blank-secret-2fa") entry["secret"] = "";
}
// An ssh entry, because key_value is not a high-risk kind — only ssh,
// pgp, seed, nostr and managed_account are partitioned. And the real stub
// builder rather than a hand-made shape, which would prove nothing about
// what production writes.
const stubId = addSshKeyEntry(vaultIndex, "sealed", {});
const entries = vaultIndex.entries as unknown as Record<string, Record<string, unknown>>;
entries[stubId] = partitionStub(stubId, entries[stubId]!, "ssh", Date.now() / 1000);
await writeFile(
join(dir, INDEX_FILENAME),
await encryptV3(deriveIndexKeyBytes(MNEMONIC), utf8(JSON.stringify(vaultIndex))),
);
await writePartition(dir, tag, {
[stubId]: {
type: "ssh",
kind: "ssh",
label: "sealed",
index: 0,
},
});
const srv = new ApiServer({ host: "127.0.0.1", port: 0, token: TOKEN, rateLimit: 100_000 });
const ctx2 = buildContext({ app, fingerprint: FINGERPRINT, mnemonic: MNEMONIC });
registerRoutes(srv, ctx2);
const bound = await srv.listen();
const get = () =>
fetch(`http://${bound.host}:${bound.port}/api/v1/entry/${stubId}/secret`, {
headers: {
authorization: `Bearer ${TOKEN}`,
"x-seedpass-password": PASSWORD,
},
});
try {
// Live session: the secret comes back, so the test cannot pass by the
// route being broken.
ctx2.highRiskTag = tag;
ctx2.highRiskExpiresAt = Math.floor(Date.now() / 1000) + 600;
const live = await get();
expect(live.status).toBe(200);
// High-risk kinds derive their secret rather than storing it, so the
// partition supplies the metadata the derivation needs. Capture what a
// live session returns and require the expired one not to leak it.
const secret = (await live.json()).value as string;
expect(typeof secret).toBe("string");
expect(secret.length).toBeGreaterThan(0);
// Same tag, expiry in the past. Must be 423, and must not carry the
// secret in any form.
ctx2.highRiskTag = tag;
ctx2.highRiskExpiresAt = Math.floor(Date.now() / 1000) - 1;
const expired = await get();
expect(expired.status).toBe(423);
expect(await expired.text()).not.toContain(secret);
// And the stale tag is dropped rather than left to be retried against.
expect(ctx2.highRiskTag).toBeNull();
} finally {
await srv.close();
}
});
it("refuses a partition record whose shape it cannot trust", async () => {
// The record decrypts — it is authentic, written under the right factor.
// It is just not a valid entry. Deriving from it would hand back a
// confidently wrong secret, so the route refuses instead.
if (!factorConfigured(appDir)) await setFactor(appDir, "api-second-factor");
const tag = await tagForFactor(appDir, "api-second-factor");
const dir = app.profileDir(FINGERPRINT);
const vaultIndex = { schema_version: 4, entries: {} } as VaultIndex;
const stubId = addSshKeyEntry(vaultIndex, "sealed-bad", {});
const entries = vaultIndex.entries as unknown as Record<string, Record<string, unknown>>;
entries[stubId] = partitionStub(stubId, entries[stubId]!, "ssh", Date.now() / 1000);
await writeFile(
join(dir, INDEX_FILENAME),
await encryptV3(deriveIndexKeyBytes(MNEMONIC), utf8(JSON.stringify(vaultIndex))),
);
// A seed record with no word_count — the exact shape a divergence between
// the two implementations would produce.
await writePartition(dir, tag, {
[stubId]: { type: "seed", kind: "seed", label: "sealed-bad", index: 0 },
});
const srv = new ApiServer({ host: "127.0.0.1", port: 0, token: TOKEN, rateLimit: 100_000 });
const ctx2 = buildContext({ app, fingerprint: FINGERPRINT, mnemonic: MNEMONIC });
registerRoutes(srv, ctx2);
const bound = await srv.listen();
try {
ctx2.highRiskTag = tag;
ctx2.highRiskExpiresAt = Math.floor(Date.now() / 1000) + 600;
const res = await fetch(
`http://${bound.host}:${bound.port}/api/v1/entry/${stubId}/secret`,
{ headers: { authorization: `Bearer ${TOKEN}`, "x-seedpass-password": PASSWORD } },
);
expect(res.status).toBe(409);
const body = await res.text();
expect(body).toContain("does not match any known entry shape");
// And the refusal does not echo the record back over the wire.
expect(body).not.toContain("sealed-bad");
} finally {
await srv.close();
}
});
it("locks on request", async () => {
await call("POST", "/api/v1/high-risk/unlock", {
password: PASSWORD,
headers: { "x-seedpass-high-risk-factor": "api-second-factor" },
});
expect((await call("GET", "/api/v1/high-risk/status")).json.unlocked).toBe(true);
expect((await call("POST", "/api/v1/high-risk/lock")).json.locked).toBe(true);
expect((await call("GET", "/api/v1/high-risk/status")).json.unlocked).toBe(false);
});
});
describe("path handling", () => {
it("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);
});
it("has no remaining 501 surface", async () => {
// Every Python endpoint now has an equivalent here. If a subsystem is
// ever dropped again it should answer 501 naming the feature rather than
// 404, which is why the mechanism stays.
expect(UNPORTED_PREFIXES).toEqual([]);
});
});
describe("structural invariants of the route table", () => {
/**
* Mutation testing showed the route-level `requiresPassword` flag is
* redundant: every route that sets it also calls `requirePassword` in its
* handler, so disabling the flag changes nothing. That redundancy is
* defense in depth and worth keeping — but nothing ENFORCED the pairing,
* which meant a future route could set one and forget the other, and the
* suite would be silent either way.
*
* This reads the source rather than exercising behaviour, which is unusual
* for a test and justified here: the property is about how routes are
* DECLARED, and it is exactly the property a new route would break.
*/
it("pairs the requiresPassword flag with an actual requirePassword call", async () => {
const source = await readFile(
fileURLToPath(new URL("../src/api/routes.ts", import.meta.url)),
"utf8",
);
const blocks = source
.split(/(?=\n server\.route\()/)
// Drop the file preamble: it holds the requirePassword DEFINITION, not
// a route, and would otherwise read as a route that calls it.
.filter((block) => /\n?\s*server\.route\(/.test(block));
const mismatched: string[] = [];
for (const block of blocks) {
const flagged = block.includes("requiresPassword: true");
const called = block.includes("requirePassword(ctx");
if (flagged === called) continue;
const name = /server\.route\(\s*"(\w+)",\s*"([^"]+)"/.exec(block);
mismatched.push(
`${name ? `${name[1]} ${name[2]}` : "(unparsed)"}: ` +
`flag=${flagged} call=${called}`,
);
}
expect(mismatched).toEqual([]);
});
it("refuses a flagged route whose handler forgot to check", async () => {
// The behavioural half of the pairing test above. The flag is redundant
// for every route that exists today, which is why disabling the server's
// check changed nothing — but its entire purpose is the route that does
// NOT exist yet, written by someone who set the flag and skipped the
// call. So register exactly that route and confirm the server layer
// stops it before the handler ever runs.
const srv = new ApiServer({ host: "127.0.0.1", port: 0, token: TOKEN });
let handlerRan = false;
srv.route(
"GET",
"/api/v1/test-forgot-to-check",
async () => {
handlerRan = true;
return { json: { secret: "leaked-plaintext" } };
},
{ requiresPassword: true },
);
const bound = await srv.listen();
try {
const url = `http://${bound.host}:${bound.port}/api/v1/test-forgot-to-check`;
const res = await fetch(url, { headers: { authorization: `Bearer ${TOKEN}` } });
expect(res.status).toBe(401);
expect(await res.text()).not.toContain("leaked-plaintext");
// Not merely refused after the fact — never invoked.
expect(handlerRan).toBe(false);
// With the password it goes through, so the test cannot pass by the
// route being broken.
const ok = await fetch(url, {
headers: {
authorization: `Bearer ${TOKEN}`,
"x-seedpass-password": PASSWORD,
},
});
expect(ok.status).toBe(200);
expect(handlerRan).toBe(true);
} finally {
await srv.close();
}
});
it("password-gates every route that can return plaintext", async () => {
// The failure this guards against is a NEW secret-returning route with
// neither the flag nor the call — which the pairing test above cannot
// see, because it only checks consistency, not coverage.
const source = await readFile(
fileURLToPath(new URL("../src/api/routes.ts", import.meta.url)),
"utf8",
);
const blocks = source
.split(/(?=\n server\.route\()/)
.filter((block) => /\n?\s*server\.route\(/.test(block));
const ungated: string[] = [];
for (const block of blocks) {
// A handler that materializes a secret, reads the partition, or hands
// back the parent seed must be password-gated.
const producesPlaintext =
block.includes("materializeSecret(") ||
block.includes("deriveTotpSecret(") ||
block.includes("readPartition(") ||
block.includes("requireUnlocked(ctx)") && block.includes("mnemonic + ");
if (!producesPlaintext) continue;
if (block.includes("requirePassword(ctx")) continue;
const name = /server\.route\(\s*"(\w+)",\s*"([^"]+)"/.exec(block);
ungated.push(name ? `${name[1]} ${name[2]}` : "(unparsed)");
}
expect(ungated).toEqual([]);
});
});