diff --git a/.github/workflows/ts-parity.yml b/.github/workflows/ts-parity.yml new file mode 100644 index 00000000..3fcea334 --- /dev/null +++ b/.github/workflows/ts-parity.yml @@ -0,0 +1,60 @@ +name: TypeScript Parity + +on: + push: + branches: ["**"] + paths: + - "js/**" + - "scripts/generate_ts_port_fixtures.py" + - ".github/workflows/ts-parity.yml" + pull_request: + branches: ["**"] + paths: + - "js/**" + - "scripts/generate_ts_port_fixtures.py" + - ".github/workflows/ts-parity.yml" + +jobs: + parity: + runs-on: ubuntu-latest + defaults: + run: + working-directory: js + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Enable pnpm via corepack + run: corepack enable + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Typecheck + run: pnpm -r typecheck + - name: Parity tests (Node + jsdom) + run: pnpm -r test + + # The fixtures are committed, so the parity job never regenerates them. + # This job proves the committed fixtures actually come from the committed + # generator: it regenerates from Python and fails on any drift. + fixture-drift: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - name: Install Poetry + run: pipx install poetry + - name: Install dependencies + run: poetry install --no-interaction --no-ansi + - name: Regenerate fixtures + run: poetry run python scripts/generate_ts_port_fixtures.py + - name: Fail on drift + # manifest.json records the generating commit, which legitimately + # differs from the checked-out commit — everything else must match. + run: > + git diff --exit-code -- + js/packages/test-vectors/fixtures + ':(exclude)js/packages/test-vectors/fixtures/manifest.json' diff --git a/docs/typescript_port_compatibility_matrix.md b/docs/typescript_port_compatibility_matrix.md index 7d662923..015f4b20 100644 --- a/docs/typescript_port_compatibility_matrix.md +++ b/docs/typescript_port_compatibility_matrix.md @@ -35,14 +35,15 @@ until a later milestone. | Deterministic conflict merge | P0 | green | `sync_merge.json` | Full `merge_index_payloads` port: ts precedence, canonical-hash tie-breaks (Python-compatible ensure_ascii canonical JSON), equal-ts field union incl. the `custom_fields: null` quirk, tombstone lifecycle, retention cap, `_sync_meta`. | | Tombstones / delta replay | P0 | green | `delta_replay.json` | Encrypted V3 deltas decrypted and merged in order with `source_tag = sha256(payload)[:16]`, final state matches Python. | | index0 content merge (`_system.index0`) | P2 | partial | — | Empty-skeleton normalization matches Python; merging populated index0 event logs throws loudly instead of silently dropping data. Full port belongs to the atlas milestone. | -| Portable backup import/export | P0 | todo | — | Milestone 3. | +| Portable backup import/export | P0 | green | `portable_backup.json` | format_version 1, seed-only and plaintext modes; TS imports the Python export, re-exports byte-identically under pinned nonce/timestamp, verifies canonical checksum, rejects tampering/unknown versions. | +| CLI (Milestone 5, agent-blind MVP) | P1 | partial | — | `packages/cli` seedpass-js: capabilities, entry list/get/search (reference-first, `sp://entry/`, secrets replaced by `has_*` flags), `entry reveal` as the only plaintext egress, `use --clipboard/--exec/--stdin-to` sinks (env-var injection, never argv/stdout), vault export/import. Tests assert secrets never appear in default output. Not yet: add/modify entries, profiles, relay sync, leases/tokens. | ## Environment coverage | Environment | Status | |---|---| -| Node 22 (vitest) | green — 117/117 | -| jsdom browser-like env | green — 117/117 | +| Node 22 (vitest) | green — 125/125 core + 11 CLI | +| jsdom browser-like env | green — 125/125 core | | Real Chromium/Firefox (vitest browser mode) | todo — lands with Milestone 6 web app CI | ## Dependency notes (to expand into docs/typescript_dependency_review.md) diff --git a/js/packages/cli/bin/seedpass-js.mjs b/js/packages/cli/bin/seedpass-js.mjs new file mode 100644 index 00000000..0d10bded --- /dev/null +++ b/js/packages/cli/bin/seedpass-js.mjs @@ -0,0 +1,12 @@ +#!/usr/bin/env node +// Thin launcher: runs the TypeScript CLI via tsx until a build step exists. +import { pathToFileURL } from "node:url"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const entry = pathToFileURL(join(here, "..", "src", "main.ts")).href; + +const { register } = await import("tsx/esm/api"); +register(); +await import(entry); diff --git a/js/packages/cli/package.json b/js/packages/cli/package.json new file mode 100644 index 00000000..7c8c879a --- /dev/null +++ b/js/packages/cli/package.json @@ -0,0 +1,27 @@ +{ + "name": "@seedpass/cli", + "version": "0.0.1", + "description": "SeedPass command-line interface (TypeScript port) — reference-first, agent-blind by default", + "type": "module", + "bin": { + "seedpass-js": "./bin/seedpass-js.mjs" + }, + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@seedpass/core": "workspace:*", + "commander": "^14.0.2" + }, + "devDependencies": { + "@seedpass/test-vectors": "workspace:*", + "@types/node": "^26.2.0", + "tsx": "^4.20.0", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + } +} diff --git a/js/packages/cli/src/capabilities.ts b/js/packages/cli/src/capabilities.ts new file mode 100644 index 00000000..11c9fe10 --- /dev/null +++ b/js/packages/cli/src/capabilities.ts @@ -0,0 +1,33 @@ +/** + * 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 { + 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 }, + }, + agent_surface: { + reference_scheme: "sp://entry/", + reference_first_output: true, + plaintext_egress_commands: ["entry reveal"], + sinks: ["clipboard", "exec-env", "stdin-to"], + exec_env_var: "SEEDPASS_SECRET", + }, + not_yet_ported: [ + "ssh/pgp key material", + "relay sync commands", + "profile management", + "index0/atlas", + "leases and scoped tokens (design: plan section 9.3)", + ], + }; +} diff --git a/js/packages/cli/src/index.ts b/js/packages/cli/src/index.ts new file mode 100644 index 00000000..41f840f4 --- /dev/null +++ b/js/packages/cli/src/index.ts @@ -0,0 +1,6 @@ +export { buildProgram, type ProgramIo } from "./program.js"; +export { openVault, saveVault, type OpenedVault } from "./vaultFile.js"; +export { resolveEntry, entryMetadata, refFor, parseRef, REF_PREFIX } from "./refs.js"; +export { materializeSecret } from "./secrets.js"; +export { execSink, stdinSink, clipboardSink, EXEC_ENV_VAR } from "./sinks.js"; +export { capabilities } from "./capabilities.js"; diff --git a/js/packages/cli/src/main.ts b/js/packages/cli/src/main.ts new file mode 100644 index 00000000..bf663e2e --- /dev/null +++ b/js/packages/cli/src/main.ts @@ -0,0 +1,16 @@ +import process from "node:process"; +import { buildProgram } from "./program.js"; + +const program = buildProgram(); +try { + await program.parseAsync(process.argv); +} catch (e) { + // commander's exitOverride throws for --help/--version too; those carry + // exitCode 0 and must not be reported as errors. + const err = e as { exitCode?: number; code?: string; message?: string }; + if (err.code === "commander.helpDisplayed" || err.code === "commander.version") { + process.exit(0); + } + process.stderr.write(`error: ${err.message ?? String(e)}\n`); + process.exit(typeof err.exitCode === "number" && err.exitCode !== 0 ? err.exitCode : 1); +} diff --git a/js/packages/cli/src/program.ts b/js/packages/cli/src/program.ts new file mode 100644 index 00000000..ff17dfd6 --- /dev/null +++ b/js/packages/cli/src/program.ts @@ -0,0 +1,202 @@ +/** + * seedpass-js command surface. + * + * Design constraint (plan section 9.3): default output is reference-first — + * entry ids, labels, and metadata, never secret values. Plaintext egress + * exists only as the distinct `entry reveal` command; `use` delivers secrets + * to sinks (clipboard, child-process env, stdin) without printing them. + */ + +import { Command } from "commander"; +import process from "node:process"; +import { entryMetadata, resolveEntry } from "./refs.js"; +import { materializeSecret } from "./secrets.js"; +import { clipboardSink, execSink, stdinSink } from "./sinks.js"; +import { capabilities } from "./capabilities.js"; +import { openVault, type OpenedVault } from "./vaultFile.js"; +import { importBackup, exportBackup, generateFingerprint } from "@seedpass/core"; +import { readFile, writeFile } from "node:fs/promises"; + +export interface ProgramIo { + out(line: string): void; + err(line: string): void; +} + +const defaultIo: ProgramIo = { + out: (line) => process.stdout.write(line + "\n"), + err: (line) => process.stderr.write(line + "\n"), +}; + +function requireMnemonic(): string { + const m = process.env["SEEDPASS_MNEMONIC"]; + if (!m) { + throw new Error( + "SEEDPASS_MNEMONIC is not set. Export the parent seed mnemonic into the " + + "environment of this process (never pass it on the command line).", + ); + } + return m; +} + +async function openFromOptions(opts: { vault?: string }): Promise { + if (!opts.vault) throw new Error("--vault is required"); + return openVault(opts.vault, requireMnemonic()); +} + +export function buildProgram(io: ProgramIo = defaultIo): Command { + const program = new Command(); + program + .name("seedpass-js") + .description("SeedPass (TypeScript port) — reference-first CLI") + .option("--vault ", "encrypted vault index file") + .configureOutput({ + writeOut: (s) => io.out(s.replace(/\n$/, "")), + writeErr: (s) => io.err(s.replace(/\n$/, "")), + }) + .exitOverride(); + + program + .command("capabilities") + .description("machine-readable capability map") + .action(() => { + io.out(JSON.stringify(capabilities(), null, 2)); + }); + + const entry = program.command("entry").description("entry operations (reference-first)"); + + entry + .command("list") + .description("list entries as references + metadata (no secrets)") + .action(async () => { + const vault = await openFromOptions(program.opts()); + const rows = Object.entries(vault.index.entries).map(([id, e]) => + entryMetadata(id, e), + ); + io.out(JSON.stringify(rows, null, 2)); + }); + + entry + .command("get ") + .description("show one entry's reference + metadata (no secrets)") + .action(async (refOrQuery: string) => { + const vault = await openFromOptions(program.opts()); + const hit = resolveEntry(vault.index, refOrQuery); + io.out(JSON.stringify(entryMetadata(hit.id, hit.entry), null, 2)); + }); + + entry + .command("search ") + .description("substring search over labels/tags/notes (no secrets)") + .action(async (text: string) => { + const vault = await openFromOptions(program.opts()); + const needle = text.toLowerCase(); + const rows = Object.entries(vault.index.entries) + .filter(([, e]) => { + const hay = [e.label, e.notes ?? "", ...(e.tags ?? [])].join("\n").toLowerCase(); + return hay.includes(needle); + }) + .map(([id, e]) => entryMetadata(id, e)); + io.out(JSON.stringify(rows, null, 2)); + }); + + entry + .command("reveal ") + .description("PLAINTEXT EGRESS: print the secret to stdout") + .option("--at ", "TOTP: unix time to compute the code at") + .action(async (refOrQuery: string, cmdOpts: { at?: string }) => { + const vault = await openFromOptions(program.opts()); + const hit = resolveEntry(vault.index, refOrQuery); + const secret = materializeSecret(vault.index, hit.id, hit.entry, vault.mnemonic, { + ...(cmdOpts.at !== undefined && { timestamp: Number(cmdOpts.at) }), + }); + io.out(secret.value); + }); + + program + .command("use ") + .description("deliver a secret to a sink without printing it") + .option("--clipboard", "copy to the system clipboard") + .option("--exec ", "run a command with SEEDPASS_SECRET in its env") + .option("--stdin-to ", "pipe the secret to a command's stdin") + .option("--at ", "TOTP: unix time to compute the code at") + .action( + async ( + refOrQuery: string, + cmdOpts: { clipboard?: boolean; exec?: string[]; stdinTo?: string[]; at?: string }, + ) => { + const chosen = [cmdOpts.clipboard, cmdOpts.exec, cmdOpts.stdinTo].filter( + (v) => v !== undefined, + ).length; + if (chosen !== 1) { + throw new Error("choose exactly one sink: --clipboard, --exec, or --stdin-to"); + } + const vault = await openFromOptions(program.opts()); + const hit = resolveEntry(vault.index, refOrQuery); + const secret = materializeSecret(vault.index, hit.id, hit.entry, vault.mnemonic, { + ...(cmdOpts.at !== undefined && { timestamp: Number(cmdOpts.at) }), + }); + + let result; + if (cmdOpts.clipboard) { + result = await clipboardSink(secret.value); + } else if (cmdOpts.exec) { + const [cmd, ...args] = cmdOpts.exec; + result = await execSink(secret.value, cmd!, args); + } else { + const [cmd, ...args] = cmdOpts.stdinTo!; + result = await stdinSink(secret.value, cmd!, args); + } + io.out( + JSON.stringify({ + delivered: secret.descriptor, + ref: hit.ref, + ...result, + }), + ); + }, + ); + + const vaultCmd = program.command("vault").description("vault import/export"); + + vaultCmd + .command("export ") + .description("write a portable backup (encrypted by default)") + .option("--plaintext", "HIGH RISK: export without encryption") + .action(async (destFile: string, cmdOpts: { plaintext?: boolean }) => { + const vault = await openFromOptions(program.opts()); + const wrapper = await exportBackup(vault.index as Record, { + mnemonic: vault.mnemonic, + fingerprint: generateFingerprint(vault.mnemonic), + encrypt: !cmdOpts.plaintext, + }); + await writeFile(destFile, JSON.stringify(wrapper, null, 2) + "\n", { mode: 0o600 }); + io.out( + JSON.stringify({ + exported: destFile, + encrypted: !cmdOpts.plaintext, + checksum: wrapper.checksum, + }), + ); + }); + + vaultCmd + .command("import ") + .description("read a portable backup and print its reference summary") + .action(async (srcFile: string) => { + const raw = await readFile(srcFile); + const mnemonic = process.env["SEEDPASS_MNEMONIC"]; + const index = await importBackup(new Uint8Array(raw), { + ...(mnemonic && { mnemonic }), + }); + const entries = (index["entries"] ?? {}) as Record; + io.out( + JSON.stringify({ + imported: srcFile, + schema_version: index["schema_version"], + entry_count: Object.keys(entries).length, + }), + ); + }); + + return program; +} diff --git a/js/packages/cli/src/refs.ts b/js/packages/cli/src/refs.ts new file mode 100644 index 00000000..1314c1b2 --- /dev/null +++ b/js/packages/cli/src/refs.ts @@ -0,0 +1,80 @@ +/** + * Entry references — the agent-safe way to address vault entries. + * + * A reference (`sp://entry/`) carries no secret material and is safe to + * store in agent context, transcripts, scripts, and job templates. All + * default CLI output speaks in references and metadata; plaintext egress is + * a separate explicit operation (plan section 9.3). + */ + +import type { Entry, VaultIndex } from "@seedpass/core"; + +export const REF_PREFIX = "sp://entry/"; + +export function refFor(id: string): string { + return REF_PREFIX + id; +} + +export function parseRef(ref: string): string | null { + if (ref.startsWith(REF_PREFIX)) { + const id = ref.slice(REF_PREFIX.length); + return id.length > 0 ? id : null; + } + return null; +} + +export interface ResolvedEntry { + id: string; + ref: string; + entry: Entry; +} + +/** Resolve a ref, bare id, or exact label to a single entry. */ +export function resolveEntry(index: VaultIndex, refOrQuery: string): ResolvedEntry { + const byId = (id: string): ResolvedEntry | null => { + const entry = index.entries[id]; + return entry ? { id, ref: refFor(id), entry } : null; + }; + + const refId = parseRef(refOrQuery); + if (refId !== null) { + const hit = byId(refId); + if (!hit) throw new Error(`no entry for reference ${refOrQuery}`); + return hit; + } + if (/^\d+$/.test(refOrQuery)) { + const hit = byId(refOrQuery); + if (hit) return hit; + } + const labelHits = Object.entries(index.entries).filter( + ([, e]) => e.label === refOrQuery, + ); + if (labelHits.length === 1) { + const [id, entry] = labelHits[0]!; + return { id, ref: refFor(id), entry }; + } + if (labelHits.length > 1) { + throw new Error( + `label "${refOrQuery}" is ambiguous (${labelHits.length} entries); use an id or sp:// reference`, + ); + } + throw new Error(`no entry matches "${refOrQuery}"`); +} + +const SECRET_FIELDS = new Set(["secret", "value", "content"]); + +/** + * Metadata view of an entry: everything an agent may see by default. + * Secret-bearing fields are replaced by presence flags. + */ +export function entryMetadata(id: string, entry: Entry): Record { + const out: Record = { id, ref: refFor(id) }; + for (const [k, v] of Object.entries(entry)) { + if (SECRET_FIELDS.has(k)) { + out[`has_${k}`] = true; + continue; + } + out[k] = v; + } + return out; +} diff --git a/js/packages/cli/src/secrets.ts b/js/packages/cli/src/secrets.ts new file mode 100644 index 00000000..f6d20cd9 --- /dev/null +++ b/js/packages/cli/src/secrets.ts @@ -0,0 +1,79 @@ +/** + * Secret materialization: derive or read the plaintext for an entry. + * + * This module is the ONLY place the CLI turns an entry into a secret value. + * Callers decide what happens to it: `reveal` prints it (explicit egress), + * `use` delivers it to a sink without letting it touch stdout. + */ + +import { + Bip85, + generatePassword, + deriveTotpSecret, + totpCodeAt, + deriveNostrKeys, + type Entry, + type VaultIndex, +} from "@seedpass/core"; + +export interface MaterializedSecret { + /** The secret itself. Handle with care; never log. */ + value: string; + /** What kind of thing it is, for sink labeling. */ + descriptor: string; +} + + +export function materializeSecret( + index: VaultIndex, + id: string, + entry: Entry, + mnemonic: string, + options: { timestamp?: number } = {}, +): MaterializedSecret { + switch (entry.kind) { + case "password": { + // Per-profile policy overrides live in the config file (not the + // index); the default policy applies until profile management is + // ported. + const bip85 = Bip85.fromMnemonic(mnemonic); + const value = generatePassword(bip85, { + length: entry.length, + index: Number(id), + genVersion: entry.gen_version ?? 1, + }); + return { value, descriptor: `password for ${entry.label}` }; + } + case "totp": { + const secret = + entry.secret ?? deriveTotpSecret(mnemonic, entry.index ?? 0); + const ts = options.timestamp ?? Math.floor(Date.now() / 1000); + const value = totpCodeAt(secret, ts, entry.period, entry.digits); + return { value, descriptor: `TOTP code for ${entry.label}` }; + } + case "key_value": + return { value: entry.value, descriptor: `value of ${entry.label}` }; + case "document": + return { value: entry.content, descriptor: `document ${entry.label}` }; + case "nostr": { + const bip85 = Bip85.fromMnemonic(mnemonic); + const keys = deriveNostrKeys(bip85, entry.index); + return { value: keys.nsec, descriptor: `nsec for ${entry.label}` }; + } + case "seed": + case "managed_account": { + const bip85 = Bip85.fromMnemonic(mnemonic); + const value = bip85.deriveMnemonic(entry.index, entry.word_count); + return { value, descriptor: `derived mnemonic for ${entry.label}` }; + } + case "ssh": + case "pgp": + throw new Error( + `${entry.kind} key material is not ported yet (see the compatibility matrix)`, + ); + default: { + const kind: string = (entry as { kind: string }).kind; + throw new Error(`unsupported entry kind: ${kind}`); + } + } +} diff --git a/js/packages/cli/src/sinks.ts b/js/packages/cli/src/sinks.ts new file mode 100644 index 00000000..1c56d847 --- /dev/null +++ b/js/packages/cli/src/sinks.ts @@ -0,0 +1,86 @@ +/** + * Secret sinks: deliver a secret to a destination without routing it + * through the CLI's stdout (plan section 9.3). The orchestrating agent sees + * only a delivery confirmation. + */ + +import { spawn } from "node:child_process"; +import process from "node:process"; + +export interface SinkResult { + sink: string; + detail: string; + exitCode?: number; +} + +/** Env var the --exec sink injects into the child process. */ +export const EXEC_ENV_VAR = "SEEDPASS_SECRET"; + +/** Run a command with the secret injected as an env var (never on argv). */ +export async function execSink( + secret: string, + command: string, + args: string[], +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + stdio: ["ignore", "inherit", "inherit"], + env: { ...process.env, [EXEC_ENV_VAR]: secret }, + }); + child.on("error", reject); + child.on("close", (code) => { + resolve({ + sink: "exec", + detail: `ran ${command} with ${EXEC_ENV_VAR} injected`, + exitCode: code ?? -1, + }); + }); + }); +} + +/** Pipe the secret to a command's stdin. */ +export async function stdinSink( + secret: string, + command: string, + args: string[], +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + stdio: ["pipe", "inherit", "inherit"], + env: process.env, + }); + child.on("error", reject); + child.stdin.write(secret); + child.stdin.end(); + child.on("close", (code) => { + resolve({ sink: "stdin", detail: `piped to ${command}`, exitCode: code ?? -1 }); + }); + }); +} + +const CLIPBOARD_COMMANDS: Array<{ cmd: string; args: string[] }> = [ + { cmd: "wl-copy", args: [] }, + { cmd: "xclip", args: ["-selection", "clipboard"] }, + { cmd: "xsel", args: ["--clipboard", "--input"] }, + { cmd: "pbcopy", args: [] }, + { cmd: "clip.exe", args: [] }, +]; + +/** Copy the secret to the system clipboard via the first available tool. */ +export async function clipboardSink(secret: string): Promise { + let lastError: unknown = new Error("no clipboard tool found"); + for (const { cmd, args } of CLIPBOARD_COMMANDS) { + try { + const result = await stdinSink(secret, cmd, args); + if (result.exitCode === 0) { + return { sink: "clipboard", detail: `copied via ${cmd}` }; + } + lastError = new Error(`${cmd} exited ${result.exitCode}`); + } catch (e) { + lastError = e; + } + } + throw new Error( + `clipboard delivery failed: ${lastError instanceof Error ? lastError.message : String(lastError)}`, + ); +} diff --git a/js/packages/cli/src/vaultFile.ts b/js/packages/cli/src/vaultFile.ts new file mode 100644 index 00000000..38814ca6 --- /dev/null +++ b/js/packages/cli/src/vaultFile.ts @@ -0,0 +1,36 @@ +/** + * Vault file access for the CLI: an encrypted index file (V3 payload, with + * or without the JSON kdf/ct wrapper) unlocked by the parent seed mnemonic. + */ + +import { readFile, writeFile } from "node:fs/promises"; +import { + decryptPayload, + parseEncryptedFile, + deriveIndexKeyBytes, + encryptV3, + parseVaultIndex, + utf8, + type VaultIndex, +} from "@seedpass/core"; + +export interface OpenedVault { + index: VaultIndex; + mnemonic: string; + path: string; +} + +export async function openVault(path: string, mnemonic: string): Promise { + const blob = new Uint8Array(await readFile(path)); + const { ciphertext } = parseEncryptedFile(blob); + const key = deriveIndexKeyBytes(mnemonic); + const plaintext = await decryptPayload(key, ciphertext); + const index = parseVaultIndex(JSON.parse(new TextDecoder().decode(plaintext))); + return { index, mnemonic, path }; +} + +export async function saveVault(vault: OpenedVault): Promise { + const key = deriveIndexKeyBytes(vault.mnemonic); + const payload = await encryptV3(key, utf8(JSON.stringify(vault.index))); + await writeFile(vault.path, payload, { mode: 0o600 }); +} diff --git a/js/packages/cli/test/cli.test.ts b/js/packages/cli/test/cli.test.ts new file mode 100644 index 00000000..23ee536b --- /dev/null +++ b/js/packages/cli/test/cli.test.ts @@ -0,0 +1,181 @@ +/** + * CLI behavior tests against a fixture vault. + * + * The load-bearing assertions are the agent-blind properties from plan + * section 9.3: default output never contains secret values; `reveal` is the + * only command that prints one; sinks deliver without printing. + */ + +import { beforeAll, describe, expect, it } from "vitest"; +import { mkdtemp, writeFile, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import process from "node:process"; +import { + entriesIndex, + passwordV2Cases, + totpCases, + mnemonics, +} from "@seedpass/test-vectors"; +import { deriveIndexKeyBytes, encryptV3, utf8 } from "@seedpass/core"; +import { buildProgram, type ProgramIo } from "../src/index.js"; + +const MNEMONIC = mnemonics["abandon12"]!; +let vaultPath: string; + +interface RunResult { + stdout: string; + stderr: string; + error?: unknown; +} + +async function run(...argv: string[]): Promise { + const out: string[] = []; + const err: string[] = []; + const io: ProgramIo = { + out: (l: string) => out.push(l), + err: (l: string) => err.push(l), + }; + const program = buildProgram(io); + let error: unknown; + try { + await program.parseAsync(["node", "seedpass-js", ...argv]); + } catch (e) { + error = e; + } + return { stdout: out.join("\n"), stderr: err.join("\n"), error }; +} + +beforeAll(async () => { + process.env["SEEDPASS_MNEMONIC"] = MNEMONIC; + const dir = await mkdtemp(join(tmpdir(), "seedpass-cli-")); + vaultPath = join(dir, "vault.enc"); + const key = deriveIndexKeyBytes(MNEMONIC); + const payload = await encryptV3(key, utf8(JSON.stringify(entriesIndex.entries))); + await writeFile(vaultPath, payload); +}); + +describe("capabilities", () => { + it("reports the agent surface", async () => { + const r = await run("capabilities"); + const caps = JSON.parse(r.stdout); + expect(caps.agent_surface.reference_first_output).toBe(true); + expect(caps.protocol.entry_schema_version).toBe(4); + }); +}); + +describe("reference-first output (agent-blind default)", () => { + it("entry list returns refs and metadata for all entries, no secret values", async () => { + const r = await run("--vault", vaultPath, "entry", "list"); + const rows = JSON.parse(r.stdout) as Record[]; + expect(rows).toHaveLength(10); + for (const row of rows) { + expect(String(row["ref"])).toMatch(/^sp:\/\/entry\/\d+$/); + expect(row).not.toHaveProperty("secret"); + expect(row).not.toHaveProperty("value"); + expect(row).not.toHaveProperty("content"); + } + // The imported TOTP secret must not appear anywhere in the output + expect(r.stdout).not.toContain("JBSWY3DPEHPK3PXP"); + // ...but its presence is flagged + const importedTotp = rows.find((row) => row["label"] === "imported-totp")!; + expect(importedTotp["has_secret"]).toBe(true); + }); + + it("entry get resolves by label, id, and ref identically", async () => { + const byLabel = await run("--vault", vaultPath, "entry", "get", "example.com"); + const byId = await run("--vault", vaultPath, "entry", "get", "0"); + const byRef = await run("--vault", vaultPath, "entry", "get", "sp://entry/0"); + expect(byLabel.stdout).toBe(byId.stdout); + expect(byId.stdout).toBe(byRef.stdout); + const row = JSON.parse(byRef.stdout); + expect(row.label).toBe("example.com"); + expect(row.kind).toBe("password"); + }); + + it("entry search matches tags and never leaks values", async () => { + const r = await run("--vault", vaultPath, "entry", "search", "api"); + const rows = JSON.parse(r.stdout) as Record[]; + expect(rows.map((x) => x["label"])).toEqual(["api-token"]); + expect(r.stdout).not.toContain("abc123"); + }); +}); + +describe("plaintext egress is explicit and correct", () => { + it("reveal derives the same password as the Python fixture", async () => { + // Vault entry 0: example.com, length 16, gen_version 2, derivation index 0 + const expected = passwordV2Cases.find( + (c) => c.policy === "default" && c.length === 16 && c.index === 0, + )!.password; + const r = await run("--vault", vaultPath, "entry", "reveal", "example.com"); + expect(r.stdout).toBe(expected); + }); + + it("reveal computes deterministic TOTP codes at a pinned time", async () => { + const fixture = totpCases.find((c) => c.mnemonic_id === "abandon12" && c.index === 0)!; + const r = await run( + "--vault", vaultPath, "entry", "reveal", "example-totp", "--at", "1700000000", + ); + expect(r.stdout).toBe(fixture.codes_at["1700000000"]); + }); + + it("reveal prints an imported key_value secret", async () => { + const r = await run("--vault", vaultPath, "entry", "reveal", "api-token"); + expect(r.stdout).toBe("abc123"); + }); +}); + +describe("sink delivery keeps secrets out of CLI output", () => { + it("use --exec injects SEEDPASS_SECRET into the child env only", async () => { + const dir = await mkdtemp(join(tmpdir(), "seedpass-sink-")); + const capture = join(dir, "captured.txt"); + const script = join(dir, "cap.sh"); + await writeFile(script, `#!/bin/sh\nprintf '%s' "$SEEDPASS_SECRET" > "${capture}"\n`, { + mode: 0o755, + }); + + const r = await run("--vault", vaultPath, "use", "api-token", "--exec", script); + const delivered = JSON.parse(r.stdout); + expect(delivered.sink).toBe("exec"); + expect(delivered.exitCode).toBe(0); + expect(delivered.ref).toBe("sp://entry/5"); + // The secret reached the child... + expect(await readFile(capture, "utf8")).toBe("abc123"); + // ...and never appeared in the CLI's own output + expect(r.stdout).not.toContain("abc123"); + expect(r.stderr).not.toContain("abc123"); + }); + + it("use --stdin-to pipes the secret to the command's stdin", async () => { + const dir = await mkdtemp(join(tmpdir(), "seedpass-sink-")); + const capture = join(dir, "captured.txt"); + const script = join(dir, "cap.sh"); + await writeFile(script, `#!/bin/sh\ncat > "${capture}"\n`, { mode: 0o755 }); + + const r = await run("--vault", vaultPath, "use", "api-token", "--stdin-to", script); + expect(JSON.parse(r.stdout).sink).toBe("stdin"); + expect(await readFile(capture, "utf8")).toBe("abc123"); + expect(r.stdout).not.toContain("abc123"); + }); + + it("use refuses ambiguous sink selection", async () => { + const r = await run( + "--vault", vaultPath, "use", "api-token", "--clipboard", "--stdin-to", "cat", + ); + expect(String((r.error as Error).message)).toContain("exactly one sink"); + }); +}); + +describe("vault export/import", () => { + it("exports an encrypted portable backup the importer accepts", async () => { + const dir = await mkdtemp(join(tmpdir(), "seedpass-export-")); + const dest = join(dir, "backup.json"); + const exportResult = await run("--vault", vaultPath, "vault", "export", dest); + expect(JSON.parse(exportResult.stdout).encrypted).toBe(true); + + const importResult = await run("vault", "import", dest); + const summary = JSON.parse(importResult.stdout); + expect(summary.schema_version).toBe(4); + expect(summary.entry_count).toBe(10); + }); +}); diff --git a/js/packages/cli/tsconfig.json b/js/packages/cli/tsconfig.json new file mode 100644 index 00000000..87de85e8 --- /dev/null +++ b/js/packages/cli/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": ".", + "noEmit": true, + "types": ["node"] + }, + "include": ["src", "test", "bin"] +} diff --git a/js/packages/core/src/index.ts b/js/packages/core/src/index.ts index 0742c553..e57b727d 100644 --- a/js/packages/core/src/index.ts +++ b/js/packages/core/src/index.ts @@ -55,6 +55,14 @@ export { type Entry, type VaultIndex, } from "./schema/entries.js"; +export { + importBackup, + exportBackup, + portableBackupSchema, + BackupImportError, + PORTABLE_FORMAT_VERSION, + type PortableBackup, +} from "./vault/portableBackup.js"; export { canonicalJson, canonicalHash } from "./sync/canonical.js"; export { mergeIndexPayloads, diff --git a/js/packages/core/src/vault/portableBackup.ts b/js/packages/core/src/vault/portableBackup.ts new file mode 100644 index 00000000..78104cd5 --- /dev/null +++ b/js/packages/core/src/vault/portableBackup.ts @@ -0,0 +1,127 @@ +/** + * Portable encrypted profile backups. + * + * Parity target: src/seedpass/core/portable_backup.py, format_version 1. + * The wrapper is plain JSON; the payload is canonical JSON of the index, + * either plaintext ("none") or V3 AES-GCM encrypted with the index key of + * the parent seed ("seed-only"). The checksum is SHA-256 over the canonical + * JSON of the index and is verified on import. + */ + +import { base64 } from "@scure/base"; +import { z } from "zod"; +import { canonicalJson, canonicalHash } from "../sync/canonical.js"; +import { deriveIndexKeyBytes } from "./indexKey.js"; +import { decryptPayload } from "./payload.js"; +import { encryptV3 } from "./aead.js"; +import { utf8 } from "../util/bytes.js"; + +export const PORTABLE_FORMAT_VERSION = 1; + +export const portableBackupSchema = z + .object({ + format_version: z.number().int(), + created_at: z.number().int(), + fingerprint: z.string(), + encryption_mode: z.enum(["seed-only", "none"]), + cipher: z.string(), + checksum: z.string().regex(/^[0-9a-f]{64}$/), + payload: z.string(), + }) + .loose(); + +export type PortableBackup = z.infer; + +export class BackupImportError extends Error { + constructor(message: string) { + super(message); + this.name = "BackupImportError"; + } +} + +/** Import a portable backup wrapper, returning the verified index payload. */ +export async function importBackup( + wrapperJson: string | Uint8Array, + options: { mnemonic?: string } = {}, +): Promise> { + const text = + typeof wrapperJson === "string" ? wrapperJson : new TextDecoder().decode(wrapperJson); + let wrapper: PortableBackup; + try { + wrapper = portableBackupSchema.parse(JSON.parse(text)); + } catch (e) { + throw new BackupImportError(`invalid backup wrapper: ${String(e)}`); + } + if (wrapper.format_version !== PORTABLE_FORMAT_VERSION) { + throw new BackupImportError("Unsupported backup format"); + } + + const payload = base64.decode(wrapper.payload); + let indexBytes: Uint8Array; + if (wrapper.encryption_mode === "seed-only") { + if (!options.mnemonic) { + throw new BackupImportError("parent seed required for seed-only backups"); + } + const key = deriveIndexKeyBytes(options.mnemonic); + try { + indexBytes = await decryptPayload(key, payload); + } catch { + throw new BackupImportError("failed to decrypt backup payload"); + } + } else { + indexBytes = payload; + } + + let index: Record; + try { + index = JSON.parse(new TextDecoder().decode(indexBytes)) as Record; + } catch { + throw new BackupImportError("backup payload is not valid JSON"); + } + + if (canonicalHash(index) !== wrapper.checksum) { + throw new BackupImportError("Checksum mismatch"); + } + return index; +} + +/** Export an index as a portable backup wrapper (format_version 1). */ +export async function exportBackup( + index: Record, + options: { + mnemonic: string; + fingerprint: string; + encrypt?: boolean; + createdAt?: number; + /** Fixture/testing only — production draws a random nonce. */ + nonce?: Uint8Array; + }, +): Promise { + const canonical = canonicalJson(index); + const checksum = canonicalHash(index); + const encrypt = options.encrypt ?? true; + + let payloadBytes: Uint8Array; + let mode: "seed-only" | "none"; + let cipher: string; + if (encrypt) { + const key = deriveIndexKeyBytes(options.mnemonic); + payloadBytes = await encryptV3(key, utf8(canonical), options.nonce); + mode = "seed-only"; + cipher = "aes-gcm"; + } else { + payloadBytes = utf8(canonical); + mode = "none"; + cipher = "none"; + } + + return { + format_version: PORTABLE_FORMAT_VERSION, + created_at: options.createdAt ?? Math.floor(Date.now() / 1000), + fingerprint: options.fingerprint, + encryption_mode: mode, + cipher, + checksum, + payload: base64.encode(payloadBytes), + }; +} diff --git a/js/packages/core/test/backup.test.ts b/js/packages/core/test/backup.test.ts new file mode 100644 index 00000000..3971d84a --- /dev/null +++ b/js/packages/core/test/backup.test.ts @@ -0,0 +1,74 @@ +/** Portable backup parity against Python-generated fixtures. */ + +import { describe, expect, it } from "vitest"; +import { base64 } from "@scure/base"; +import { portableBackup, mnemonics } from "@seedpass/test-vectors"; +import { + importBackup, + exportBackup, + BackupImportError, + canonicalHash, + hexToBytes, +} from "@seedpass/core"; + +const mnemonic = () => mnemonics[portableBackup.mnemonic_id]!; + +describe("portable backup", () => { + it("imports the Python encrypted export", async () => { + const index = await importBackup(JSON.stringify(portableBackup.encrypted_wrapper), { + mnemonic: mnemonic(), + }); + expect(index).toEqual(portableBackup.index); + }); + + it("imports the Python plaintext export", async () => { + const index = await importBackup(JSON.stringify(portableBackup.plaintext_wrapper)); + expect(index).toEqual(portableBackup.index); + }); + + it("computes the same canonical checksum as Python", () => { + expect(canonicalHash(portableBackup.index)).toBe(portableBackup.canonical_json_sha256); + }); + + it("re-exports byte-identically with pinned nonce and timestamp", async () => { + const w = portableBackup.encrypted_wrapper; + const pinnedNonce = base64.decode(w.payload).slice(3, 15); + const reExported = await exportBackup(portableBackup.index, { + mnemonic: mnemonic(), + fingerprint: w.fingerprint, + createdAt: w.created_at, + nonce: pinnedNonce, + }); + expect(reExported).toEqual(w); + }); + + it("roundtrips its own encrypted export", async () => { + const wrapper = await exportBackup(portableBackup.index, { + mnemonic: mnemonic(), + fingerprint: portableBackup.encrypted_wrapper.fingerprint, + }); + const index = await importBackup(JSON.stringify(wrapper), { mnemonic: mnemonic() }); + expect(index).toEqual(portableBackup.index); + }); + + it("rejects a tampered checksum", async () => { + const bad = { + ...portableBackup.plaintext_wrapper, + checksum: hexToBytes(portableBackup.canonical_json_sha256) + .reverse() + .reduce((s, b) => s + b.toString(16).padStart(2, "0"), ""), + }; + await expect(importBackup(JSON.stringify(bad))).rejects.toThrow("Checksum mismatch"); + }); + + it("rejects unsupported format versions", async () => { + const bad = { ...portableBackup.plaintext_wrapper, format_version: 2 }; + await expect(importBackup(JSON.stringify(bad))).rejects.toThrow(BackupImportError); + }); + + it("requires the seed for seed-only backups", async () => { + await expect( + importBackup(JSON.stringify(portableBackup.encrypted_wrapper)), + ).rejects.toThrow("parent seed required"); + }); +}); diff --git a/js/packages/test-vectors/fixtures/manifest.json b/js/packages/test-vectors/fixtures/manifest.json index 78cc032e..7d5cacdd 100644 --- a/js/packages/test-vectors/fixtures/manifest.json +++ b/js/packages/test-vectors/fixtures/manifest.json @@ -15,12 +15,13 @@ "password_kdf.json", "passwords_v1.json", "passwords_v2.json", + "portable_backup.json", "sync_merge.json", "totp.json", "vault_v3_payload.json" ], "fixture_version": 1, "generator": "scripts/generate_ts_port_fixtures.py", - "python_commit": "6d6ad5e2c538a0357e368d29a42e723b5d665c7a", + "python_commit": "2345f614310a701a147872e81c53ccd22f8948d3", "redaction_policy": "All fixtures derive from public BIP-39 test mnemonics (abandon.../legal.../zoo...). Secret-shaped outputs are generated test material, never from a live profile." } diff --git a/js/packages/test-vectors/fixtures/portable_backup.json b/js/packages/test-vectors/fixtures/portable_backup.json new file mode 100644 index 00000000..b479a324 --- /dev/null +++ b/js/packages/test-vectors/fixtures/portable_backup.json @@ -0,0 +1,186 @@ +{ + "canonical_json_sha256": "97a10f8aa357ffc08d086944cb8da2e25fdf779125894556b4ae8c7f483ede1e", + "description": "Portable backup wrapper (portable_backup.py, format_version 1). payload = V3 AES-GCM over canonical JSON of the index, key = index key of the parent seed; checksum = sha256(canonical_json(index)). Nonce/created_at pinned (fixture-only).", + "encrypted_wrapper": { + "checksum": "97a10f8aa357ffc08d086944cb8da2e25fdf779125894556b4ae8c7f483ede1e", + "cipher": "aes-gcm", + "created_at": 1700000000, + "encryption_mode": "seed-only", + "fingerprint": "C557EEC878DFD852", + "format_version": 1, + "payload": "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" + }, + "fixture_version": 1, + "index": { + "entries": { + "0": { + "archived": false, + "custom_fields": [], + "date_added": "2023-11-14T22:13:20+00:00", + "date_modified": "2023-11-14T22:13:20+00:00", + "gen_version": 2, + "kind": "password", + "label": "example.com", + "length": 16, + "links": [], + "modified_ts": 1700000000, + "notes": "password note", + "tags": [ + "web" + ], + "type": "password", + "url": "https://example.com", + "username": "alice" + }, + "1": { + "archived": false, + "date_added": "2023-11-14T22:13:20+00:00", + "date_modified": "2023-11-14T22:13:20+00:00", + "deterministic": true, + "digits": 6, + "index": 0, + "kind": "totp", + "label": "example-totp", + "links": [], + "modified_ts": 1700000000, + "notes": "", + "period": 30, + "tags": [ + "otp" + ], + "type": "totp" + }, + "2": { + "archived": false, + "date_added": "2023-11-14T22:13:20+00:00", + "date_modified": "2023-11-14T22:13:20+00:00", + "deterministic": false, + "digits": 8, + "kind": "totp", + "label": "imported-totp", + "links": [], + "modified_ts": 1700000000, + "notes": "", + "period": 45, + "secret": "JBSWY3DPEHPK3PXP", + "tags": [], + "type": "totp" + }, + "3": { + "archived": false, + "custom_fields": [], + "date_added": "2023-11-14T22:13:20+00:00", + "date_modified": "2023-11-14T22:13:20+00:00", + "index": 3, + "kind": "ssh", + "label": "example-ssh", + "links": [], + "modified_ts": 1700000000, + "notes": "ssh note", + "tags": [], + "type": "ssh" + }, + "4": { + "archived": false, + "date_added": "2023-11-14T22:13:20+00:00", + "date_modified": "2023-11-14T22:13:20+00:00", + "index": 4, + "kind": "nostr", + "label": "example-nostr", + "links": [], + "modified_ts": 1700000000, + "notes": "", + "tags": [], + "type": "nostr" + }, + "5": { + "archived": false, + "custom_fields": [], + "date_added": "2023-11-14T22:13:20+00:00", + "date_modified": "2023-11-14T22:13:20+00:00", + "key": "token", + "kind": "key_value", + "label": "api-token", + "links": [], + "modified_ts": 1700000000, + "notes": "", + "tags": [ + "api" + ], + "type": "key_value", + "value": "abc123" + }, + "6": { + "archived": false, + "content": "hello fixture world", + "custom_fields": [], + "date_added": "2023-11-14T22:13:20+00:00", + "date_modified": "2023-11-14T22:13:20+00:00", + "file_type": "txt", + "kind": "document", + "label": "example-doc", + "links": [], + "modified_ts": 1700000000, + "notes": "", + "tags": [], + "type": "document" + }, + "7": { + "archived": false, + "date_added": "2023-11-14T22:13:20+00:00", + "date_modified": "2023-11-14T22:13:20+00:00", + "index": 7, + "kind": "seed", + "label": "example-seed", + "links": [], + "modified_ts": 1700000000, + "notes": "", + "tags": [], + "type": "seed", + "word_count": 24 + }, + "8": { + "archived": false, + "custom_fields": [], + "date_added": "2023-11-14T22:13:20+00:00", + "date_modified": "2023-11-14T22:13:20+00:00", + "fingerprint": "DC982FD44E32F59A", + "index": 8, + "kind": "managed_account", + "label": "example-managed", + "links": [], + "modified_ts": 1700000000, + "notes": "", + "tags": [], + "type": "managed_account", + "word_count": 12 + }, + "9": { + "archived": false, + "date_added": "2023-11-14T22:13:20+00:00", + "date_modified": "2023-11-14T22:13:20+00:00", + "index": 9, + "key_type": "ed25519", + "kind": "pgp", + "label": "example-pgp", + "links": [], + "modified_ts": 1700000000, + "notes": "", + "tags": [], + "type": "pgp", + "user_id": "fixture@example.com" + } + }, + "schema_version": 4 + }, + "mnemonic_id": "abandon12", + "plaintext_wrapper": { + "checksum": "97a10f8aa357ffc08d086944cb8da2e25fdf779125894556b4ae8c7f483ede1e", + "cipher": "none", + "created_at": 1700000000, + "encryption_mode": "none", + "fingerprint": "C557EEC878DFD852", + "format_version": 1, + "payload": "eyJlbnRyaWVzIjp7IjAiOnsiYXJjaGl2ZWQiOmZhbHNlLCJjdXN0b21fZmllbGRzIjpbXSwiZGF0ZV9hZGRlZCI6IjIwMjMtMTEtMTRUMjI6MTM6MjArMDA6MDAiLCJkYXRlX21vZGlmaWVkIjoiMjAyMy0xMS0xNFQyMjoxMzoyMCswMDowMCIsImdlbl92ZXJzaW9uIjoyLCJraW5kIjoicGFzc3dvcmQiLCJsYWJlbCI6ImV4YW1wbGUuY29tIiwibGVuZ3RoIjoxNiwibGlua3MiOltdLCJtb2RpZmllZF90cyI6MTcwMDAwMDAwMCwibm90ZXMiOiJwYXNzd29yZCBub3RlIiwidGFncyI6WyJ3ZWIiXSwidHlwZSI6InBhc3N3b3JkIiwidXJsIjoiaHR0cHM6Ly9leGFtcGxlLmNvbSIsInVzZXJuYW1lIjoiYWxpY2UifSwiMSI6eyJhcmNoaXZlZCI6ZmFsc2UsImRhdGVfYWRkZWQiOiIyMDIzLTExLTE0VDIyOjEzOjIwKzAwOjAwIiwiZGF0ZV9tb2RpZmllZCI6IjIwMjMtMTEtMTRUMjI6MTM6MjArMDA6MDAiLCJkZXRlcm1pbmlzdGljIjp0cnVlLCJkaWdpdHMiOjYsImluZGV4IjowLCJraW5kIjoidG90cCIsImxhYmVsIjoiZXhhbXBsZS10b3RwIiwibGlua3MiOltdLCJtb2RpZmllZF90cyI6MTcwMDAwMDAwMCwibm90ZXMiOiIiLCJwZXJpb2QiOjMwLCJ0YWdzIjpbIm90cCJdLCJ0eXBlIjoidG90cCJ9LCIyIjp7ImFyY2hpdmVkIjpmYWxzZSwiZGF0ZV9hZGRlZCI6IjIwMjMtMTEtMTRUMjI6MTM6MjArMDA6MDAiLCJkYXRlX21vZGlmaWVkIjoiMjAyMy0xMS0xNFQyMjoxMzoyMCswMDowMCIsImRldGVybWluaXN0aWMiOmZhbHNlLCJkaWdpdHMiOjgsImtpbmQiOiJ0b3RwIiwibGFiZWwiOiJpbXBvcnRlZC10b3RwIiwibGlua3MiOltdLCJtb2RpZmllZF90cyI6MTcwMDAwMDAwMCwibm90ZXMiOiIiLCJwZXJpb2QiOjQ1LCJzZWNyZXQiOiJKQlNXWTNEUEVIUEszUFhQIiwidGFncyI6W10sInR5cGUiOiJ0b3RwIn0sIjMiOnsiYXJjaGl2ZWQiOmZhbHNlLCJjdXN0b21fZmllbGRzIjpbXSwiZGF0ZV9hZGRlZCI6IjIwMjMtMTEtMTRUMjI6MTM6MjArMDA6MDAiLCJkYXRlX21vZGlmaWVkIjoiMjAyMy0xMS0xNFQyMjoxMzoyMCswMDowMCIsImluZGV4IjozLCJraW5kIjoic3NoIiwibGFiZWwiOiJleGFtcGxlLXNzaCIsImxpbmtzIjpbXSwibW9kaWZpZWRfdHMiOjE3MDAwMDAwMDAsIm5vdGVzIjoic3NoIG5vdGUiLCJ0YWdzIjpbXSwidHlwZSI6InNzaCJ9LCI0Ijp7ImFyY2hpdmVkIjpmYWxzZSwiZGF0ZV9hZGRlZCI6IjIwMjMtMTEtMTRUMjI6MTM6MjArMDA6MDAiLCJkYXRlX21vZGlmaWVkIjoiMjAyMy0xMS0xNFQyMjoxMzoyMCswMDowMCIsImluZGV4Ijo0LCJraW5kIjoibm9zdHIiLCJsYWJlbCI6ImV4YW1wbGUtbm9zdHIiLCJsaW5rcyI6W10sIm1vZGlmaWVkX3RzIjoxNzAwMDAwMDAwLCJub3RlcyI6IiIsInRhZ3MiOltdLCJ0eXBlIjoibm9zdHIifSwiNSI6eyJhcmNoaXZlZCI6ZmFsc2UsImN1c3RvbV9maWVsZHMiOltdLCJkYXRlX2FkZGVkIjoiMjAyMy0xMS0xNFQyMjoxMzoyMCswMDowMCIsImRhdGVfbW9kaWZpZWQiOiIyMDIzLTExLTE0VDIyOjEzOjIwKzAwOjAwIiwia2V5IjoidG9rZW4iLCJraW5kIjoia2V5X3ZhbHVlIiwibGFiZWwiOiJhcGktdG9rZW4iLCJsaW5rcyI6W10sIm1vZGlmaWVkX3RzIjoxNzAwMDAwMDAwLCJub3RlcyI6IiIsInRhZ3MiOlsiYXBpIl0sInR5cGUiOiJrZXlfdmFsdWUiLCJ2YWx1ZSI6ImFiYzEyMyJ9LCI2Ijp7ImFyY2hpdmVkIjpmYWxzZSwiY29udGVudCI6ImhlbGxvIGZpeHR1cmUgd29ybGQiLCJjdXN0b21fZmllbGRzIjpbXSwiZGF0ZV9hZGRlZCI6IjIwMjMtMTEtMTRUMjI6MTM6MjArMDA6MDAiLCJkYXRlX21vZGlmaWVkIjoiMjAyMy0xMS0xNFQyMjoxMzoyMCswMDowMCIsImZpbGVfdHlwZSI6InR4dCIsImtpbmQiOiJkb2N1bWVudCIsImxhYmVsIjoiZXhhbXBsZS1kb2MiLCJsaW5rcyI6W10sIm1vZGlmaWVkX3RzIjoxNzAwMDAwMDAwLCJub3RlcyI6IiIsInRhZ3MiOltdLCJ0eXBlIjoiZG9jdW1lbnQifSwiNyI6eyJhcmNoaXZlZCI6ZmFsc2UsImRhdGVfYWRkZWQiOiIyMDIzLTExLTE0VDIyOjEzOjIwKzAwOjAwIiwiZGF0ZV9tb2RpZmllZCI6IjIwMjMtMTEtMTRUMjI6MTM6MjArMDA6MDAiLCJpbmRleCI6Nywia2luZCI6InNlZWQiLCJsYWJlbCI6ImV4YW1wbGUtc2VlZCIsImxpbmtzIjpbXSwibW9kaWZpZWRfdHMiOjE3MDAwMDAwMDAsIm5vdGVzIjoiIiwidGFncyI6W10sInR5cGUiOiJzZWVkIiwid29yZF9jb3VudCI6MjR9LCI4Ijp7ImFyY2hpdmVkIjpmYWxzZSwiY3VzdG9tX2ZpZWxkcyI6W10sImRhdGVfYWRkZWQiOiIyMDIzLTExLTE0VDIyOjEzOjIwKzAwOjAwIiwiZGF0ZV9tb2RpZmllZCI6IjIwMjMtMTEtMTRUMjI6MTM6MjArMDA6MDAiLCJmaW5nZXJwcmludCI6IkRDOTgyRkQ0NEUzMkY1OUEiLCJpbmRleCI6OCwia2luZCI6Im1hbmFnZWRfYWNjb3VudCIsImxhYmVsIjoiZXhhbXBsZS1tYW5hZ2VkIiwibGlua3MiOltdLCJtb2RpZmllZF90cyI6MTcwMDAwMDAwMCwibm90ZXMiOiIiLCJ0YWdzIjpbXSwidHlwZSI6Im1hbmFnZWRfYWNjb3VudCIsIndvcmRfY291bnQiOjEyfSwiOSI6eyJhcmNoaXZlZCI6ZmFsc2UsImRhdGVfYWRkZWQiOiIyMDIzLTExLTE0VDIyOjEzOjIwKzAwOjAwIiwiZGF0ZV9tb2RpZmllZCI6IjIwMjMtMTEtMTRUMjI6MTM6MjArMDA6MDAiLCJpbmRleCI6OSwia2V5X3R5cGUiOiJlZDI1NTE5Iiwia2luZCI6InBncCIsImxhYmVsIjoiZXhhbXBsZS1wZ3AiLCJsaW5rcyI6W10sIm1vZGlmaWVkX3RzIjoxNzAwMDAwMDAwLCJub3RlcyI6IiIsInRhZ3MiOltdLCJ0eXBlIjoicGdwIiwidXNlcl9pZCI6ImZpeHR1cmVAZXhhbXBsZS5jb20ifX0sInNjaGVtYV92ZXJzaW9uIjo0fQ==" + } +} diff --git a/js/packages/test-vectors/src/index.ts b/js/packages/test-vectors/src/index.ts index 874bb5b6..7db51080 100644 --- a/js/packages/test-vectors/src/index.ts +++ b/js/packages/test-vectors/src/index.ts @@ -25,6 +25,7 @@ import legacyPayloadsJson from "../fixtures/legacy_payloads.json"; import nostrSnapshotJson from "../fixtures/nostr_snapshot.json"; import syncMergeJson from "../fixtures/sync_merge.json"; import deltaReplayJson from "../fixtures/delta_replay.json"; +import portableBackupJson from "../fixtures/portable_backup.json"; export interface Bip39Case { id: string; @@ -184,6 +185,24 @@ export const deltaReplay = deltaReplayJson as { final_state: Record; }; +export interface PortableWrapper { + format_version: number; + created_at: number; + fingerprint: string; + encryption_mode: string; + cipher: string; + checksum: string; + payload: string; +} + +export const portableBackup = portableBackupJson as { + mnemonic_id: string; + index: Record; + canonical_json_sha256: string; + encrypted_wrapper: PortableWrapper; + plaintext_wrapper: PortableWrapper; +}; + export const legacyPayloads = legacyPayloadsJson as { mnemonic_id: string; plaintext_utf8: string; diff --git a/js/pnpm-lock.yaml b/js/pnpm-lock.yaml index 6eee5f8e..225a1274 100644 --- a/js/pnpm-lock.yaml +++ b/js/pnpm-lock.yaml @@ -8,6 +8,31 @@ importers: .: {} + packages/cli: + dependencies: + '@seedpass/core': + specifier: workspace:* + version: link:../core + commander: + specifier: ^14.0.2 + version: 14.0.3 + devDependencies: + '@seedpass/test-vectors': + specifier: workspace:* + version: link:../test-vectors + '@types/node': + specifier: ^26.2.0 + version: 26.2.0 + tsx: + specifier: ^4.20.0 + version: 4.23.12 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.7(@types/node@26.2.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(tsx@4.23.12) + packages/core: dependencies: '@noble/curves': @@ -40,7 +65,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.0 - version: 3.2.7(jsdom@30.0.1(@noble/hashes@2.3.0)) + version: 3.2.7(@types/node@26.2.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(tsx@4.23.12) packages/test-vectors: devDependencies: @@ -437,6 +462,9 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/node@26.2.0': + resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==} + '@vitest/expect@3.2.7': resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} @@ -485,6 +513,10 @@ packages: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + css-tree@3.2.1: resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} @@ -676,11 +708,19 @@ packages: resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} engines: {node: '>=20'} + tsx@4.23.12: + resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==} + engines: {node: '>=18.0.0'} + hasBin: true + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} hasBin: true + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + undici@8.10.0: resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==} engines: {node: '>=22.19.0'} @@ -1027,6 +1067,10 @@ snapshots: '@types/estree@1.0.9': {} + '@types/node@26.2.0': + dependencies: + undici-types: 8.3.0 + '@vitest/expect@3.2.7': dependencies: '@types/chai': 5.2.3 @@ -1035,13 +1079,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.7(vite@7.3.6)': + '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@26.2.0)(tsx@4.23.12))': dependencies: '@vitest/spy': 3.2.7 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.6 + vite: 7.3.6(@types/node@26.2.0)(tsx@4.23.12) '@vitest/pretty-format@3.2.7': dependencies: @@ -1087,6 +1131,8 @@ snapshots: check-error@2.1.3: {} + commander@14.0.3: {} + css-tree@3.2.1: dependencies: mdn-data: 2.27.1 @@ -1304,17 +1350,25 @@ snapshots: dependencies: punycode: 2.3.1 + tsx@4.23.12: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + typescript@5.9.3: {} + undici-types@8.3.0: {} + undici@8.10.0: {} - vite-node@3.2.4: + vite-node@3.2.4(@types/node@26.2.0)(tsx@4.23.12): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.6 + vite: 7.3.6(@types/node@26.2.0)(tsx@4.23.12) transitivePeerDependencies: - '@types/node' - jiti @@ -1329,7 +1383,7 @@ snapshots: - tsx - yaml - vite@7.3.6: + vite@7.3.6(@types/node@26.2.0)(tsx@4.23.12): dependencies: esbuild: 0.28.2 fdir: 6.5.0(picomatch@4.0.5) @@ -1338,13 +1392,15 @@ snapshots: rollup: 4.62.4 tinyglobby: 0.2.17 optionalDependencies: + '@types/node': 26.2.0 fsevents: 2.3.3 + tsx: 4.23.12 - vitest@3.2.7(jsdom@30.0.1(@noble/hashes@2.3.0)): + vitest@3.2.7(@types/node@26.2.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(tsx@4.23.12): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.7 - '@vitest/mocker': 3.2.7(vite@7.3.6) + '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@26.2.0)(tsx@4.23.12)) '@vitest/pretty-format': 3.2.7 '@vitest/runner': 3.2.7 '@vitest/snapshot': 3.2.7 @@ -1362,10 +1418,11 @@ snapshots: tinyglobby: 0.2.17 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.6 - vite-node: 3.2.4 + vite: 7.3.6(@types/node@26.2.0)(tsx@4.23.12) + vite-node: 3.2.4(@types/node@26.2.0)(tsx@4.23.12) why-is-node-running: 2.3.0 optionalDependencies: + '@types/node': 26.2.0 jsdom: 30.0.1(@noble/hashes@2.3.0) transitivePeerDependencies: - jiti diff --git a/scripts/generate_ts_port_fixtures.py b/scripts/generate_ts_port_fixtures.py index 33061039..737d5c85 100644 --- a/scripts/generate_ts_port_fixtures.py +++ b/scripts/generate_ts_port_fixtures.py @@ -812,6 +812,53 @@ def gen_delta_replay() -> dict: } +def gen_portable_backup(entries: dict) -> dict: + from utils.checksum import canonical_json_dumps, json_checksum + + mnemonic = MNEMONICS[PRIMARY] + index_data = entries + canonical = canonical_json_dumps(index_data) + checksum = json_checksum(index_data) + + key = base64.urlsafe_b64decode(derive_index_key(mnemonic)) + nonce = hashlib.sha256(b"seedpass-ts-fixture-export-nonce").digest()[:12] + payload = b"V3|" + nonce + AESGCM(key).encrypt(nonce, canonical.encode(), None) + + encrypted_wrapper = { + "format_version": 1, + "created_at": FIXED_UNIX, + "fingerprint": generate_fingerprint(mnemonic), + "encryption_mode": "seed-only", + "cipher": "aes-gcm", + "checksum": checksum, + "payload": base64.b64encode(payload).decode(), + } + plaintext_wrapper = { + "format_version": 1, + "created_at": FIXED_UNIX, + "fingerprint": generate_fingerprint(mnemonic), + "encryption_mode": "none", + "cipher": "none", + "checksum": checksum, + "payload": base64.b64encode(canonical.encode()).decode(), + } + + return { + "description": ( + "Portable backup wrapper (portable_backup.py, format_version 1). " + "payload = V3 AES-GCM over canonical JSON of the index, key = " + "index key of the parent seed; checksum = " + "sha256(canonical_json(index)). Nonce/created_at pinned " + "(fixture-only)." + ), + "mnemonic_id": PRIMARY, + "index": index_data, + "canonical_json_sha256": checksum, + "encrypted_wrapper": encrypted_wrapper, + "plaintext_wrapper": plaintext_wrapper, + } + + def gen_kdf_metadata() -> dict: return { "description": ( @@ -861,6 +908,7 @@ def main() -> None: files["entries_index.json"] = entries_fixture files["vault_v3_payload.json"] = vault_fixture files["nostr_snapshot.json"] = gen_nostr_snapshot(vault_fixture["payload_b64"]) + files["portable_backup.json"] = gen_portable_backup(entries_fixture["entries"]) files["sync_merge.json"] = gen_sync_merge() files["delta_replay.json"] = gen_delta_replay()