Files
SeedPass/js/packages/cli/src/capabilities.ts
T
thePR0M3TH3ANandClaude Opus 5 348245e14a 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
2026-08-19 09:23:32 -04:00

108 lines
4.6 KiB
TypeScript

/**
* Machine-readable capability map (`seedpass-js capabilities --format json`).
* Mirrors the intent of the Python `seedpass capabilities` surface: automation
* discovers what this build can do instead of guessing from the version.
*/
export function capabilities(): Record<string, unknown> {
return {
name: "seedpass-js",
port: "typescript",
protocol: {
password_gen_versions: [1, 2],
vault_payload_formats: ["V3", "V2", "fernet"],
entry_schema_version: 4,
portable_backup_format: 1,
nostr_event_kinds: { manifest: 30070, snapshot_chunk: 30071, delta: 30072 },
},
interactive: {
command: "seedpass-js (no subcommand, on a terminal)",
model: "numbered menus mirroring Python's legacy (v1) TUI; blank input goes back",
secrets: "metadata only; 'S' shows and 'C' copies, and Secret Mode routes both to the clipboard",
non_tty: "prints help",
},
agent_surface: {
reference_scheme: "sp://entry/<id>",
reference_first_output: true,
plaintext_egress_commands: [
"entry reveal",
"entry totp-codes",
"util generate-password",
],
sinks: ["clipboard", "exec-env", "stdin-to"],
exec_env_var: "SEEDPASS_SECRET",
session_agent: {
transport: "unix-socket",
commands: ["agent start/status/stop", "vault unlock/lock"],
seed_resolution_order: ["SEEDPASS_MNEMONIC env", "session agent"],
},
},
profiles: {
layout: "python-compatible ~/.seedpass",
commands: [
"fingerprint list/create/add/switch/remove",
"config get/set",
],
seed_generation: {
command: "fingerprint create",
word_counts: [12, 24],
entropy: "32 bytes from the OS CSPRNG, via BIP-85 app 39 index 0",
egress:
"the new phrase goes to --out <0600 file>, --show, or an interactive " +
"terminal; with none of those the command refuses rather than writing " +
"the seed into a pipe",
},
},
tokens: {
commands: ["agent token-issue/token-list/token-revoke"],
scopes: ["read", "use", "reveal"],
constraints: ["kinds", "label_regex", "ttl", "uses", "exec_allowlist"],
// Automation issuing tokens has to know this to scope them correctly:
// label_regex is a SEARCH, not a full match, in both implementations
// (Python uses re.search). `prod` therefore also matches `not-prod-db`.
// Anchor it yourself -- `^prod$` -- to get an exact match.
label_regex_semantics: "search (substring); anchor with ^...$ for exact match",
// Automation that issues `use` tokens needs this to pick allowlist
// entries safely: the check covers the command word, not its arguments.
exec_allowlist_semantics:
"command word only; the token holder supplies all arguments, so an " +
"allowlisted binary that can write a file or open a socket can " +
"return the secret to its caller",
token_env_var: "SEEDPASS_TOKEN",
enforcement: "session agent (secrets materialized agent-side in token mode)",
escalation:
"token mode refuses owner operations, but this is a same-uid process " +
"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",
},
// Automation branches on this list, so it has to describe this build
// rather than an earlier one. SSH and ed25519 PGP entries are at
// byte-for-byte parity with Python and were wrongly listed here.
not_yet_ported: [
"pgp RSA keys (ed25519 is supported; RSA generation is not reproducible)",
"index0/atlas",
"approval gates and high-risk partitions",
"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)",
],
};
}