TS port slice 4: portable backup, CI, and the agent-blind CLI MVP

- Portable backup (format_version 1): TS imports Python exports, re-exports
  byte-identically under pinned nonce/timestamp, verifies the canonical
  checksum, rejects tampering and unknown versions.
- .github/workflows/ts-parity.yml: typecheck + Node/jsdom parity suite on
  js/ changes, plus a fixture-drift job that regenerates fixtures from
  Python and fails if the committed ones don't match (manifest commit field
  excluded by construction).
- packages/cli — seedpass-js MVP implementing plan section 9.3:
  * reference-first output: entry list/get/search emit sp://entry/<id> refs
    and metadata; secret-bearing fields become has_* flags
  * 'entry reveal' is the only plaintext egress command
  * 'use --clipboard|--exec|--stdin-to' delivers secrets to sinks; --exec
    injects SEEDPASS_SECRET into the child env, never argv or stdout
  * vault export/import over portable backups; capabilities --format json
  * mnemonic comes from SEEDPASS_MNEMONIC env, never the command line
- CLI tests prove the blindness properties: derived password matches the v2
  fixture, TOTP at pinned time, secrets absent from all default output and
  from sink-delivery confirmations.
- 125 core parity tests (Node + jsdom) + 11 CLI tests green.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Fable 5 committed 2026-08-13 10:40:56 -04:00
1 parent 2345f61431
commit ab569fe900
22 files changed
+1362 -14

No files matched your search

+60
View File
@@ -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'
+4 -3
View File
@@ -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/<id>`, 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)
+12
View File
@@ -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);
+27
View File
@@ -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"
}
}
+33
View File
@@ -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<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 },
},
agent_surface: {
reference_scheme: "sp://entry/<id>",
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)",
],
};
}
+6
View File
@@ -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";
+16
View File
@@ -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);
}
+202
View File
@@ -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<OpenedVault> {
if (!opts.vault) throw new Error("--vault <file> 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 <file>", "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 <refOrQuery>")
.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 <text>")
.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 <refOrQuery>")
.description("PLAINTEXT EGRESS: print the secret to stdout")
.option("--at <timestamp>", "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 <refOrQuery>")
.description("deliver a secret to a sink without printing it")
.option("--clipboard", "copy to the system clipboard")
.option("--exec <cmd...>", "run a command with SEEDPASS_SECRET in its env")
.option("--stdin-to <cmd...>", "pipe the secret to a command's stdin")
.option("--at <timestamp>", "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 <destFile>")
.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<string, unknown>, {
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 <srcFile>")
.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<string, { label?: string }>;
io.out(
JSON.stringify({
imported: srcFile,
schema_version: index["schema_version"],
entry_count: Object.keys(entries).length,
}),
);
});
return program;
}
+80
View File
@@ -0,0 +1,80 @@
/**
* Entry references — the agent-safe way to address vault entries.
*
* A reference (`sp://entry/<id>`) 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<string, unknown> {
const out: Record<string, unknown> = { 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;
}
+79
View File
@@ -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}`);
}
}
}
+86
View File
@@ -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<SinkResult> {
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<SinkResult> {
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<SinkResult> {
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)}`,
);
}
+36
View File
@@ -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<OpenedVault> {
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<void> {
const key = deriveIndexKeyBytes(vault.mnemonic);
const payload = await encryptV3(key, utf8(JSON.stringify(vault.index)));
await writeFile(vault.path, payload, { mode: 0o600 });
}
+181
View File
@@ -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<RunResult> {
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<string, unknown>[];
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<string, unknown>[];
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);
});
});
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"noEmit": true,
"types": ["node"]
},
"include": ["src", "test", "bin"]
}
+8
View File
@@ -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,
@@ -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<typeof portableBackupSchema>;
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<Record<string, unknown>> {
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<string, unknown>;
try {
index = JSON.parse(new TextDecoder().decode(indexBytes)) as Record<string, unknown>;
} 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<string, unknown>,
options: {
mnemonic: string;
fingerprint: string;
encrypt?: boolean;
createdAt?: number;
/** Fixture/testing only — production draws a random nonce. */
nonce?: Uint8Array;
},
): Promise<PortableBackup> {
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),
};
}
+74
View File
@@ -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");
});
});
@@ -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."
}
@@ -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": "[email protected]"
}
},
"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=="
}
}
+19
View File
@@ -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<string, unknown>;
};
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<string, unknown>;
canonical_json_sha256: string;
encrypted_wrapper: PortableWrapper;
plaintext_wrapper: PortableWrapper;
};
export const legacyPayloads = legacyPayloadsJson as {
mnemonic_id: string;
plaintext_utf8: string;
+67 -10
View File
@@ -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/[email protected])([email protected](@noble/[email protected]))([email protected])
packages/core:
dependencies:
'@noble/curves':
@@ -40,7 +65,7 @@ importers:
version: 5.9.3
vitest:
specifier: ^3.0.0
version: 3.2.7([email protected](@noble/[email protected]))
version: 3.2.7(@types/[email protected])([email protected](@noble/[email protected]))([email protected])
packages/test-vectors:
devDependencies:
@@ -437,6 +462,9 @@ packages:
'@types/[email protected]':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
'@types/[email protected]':
resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==}
'@vitest/[email protected]':
resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==}
@@ -485,6 +513,10 @@ packages:
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
engines: {node: '>= 16'}
[email protected]:
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
engines: {node: '>=20'}
[email protected]:
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'}
[email protected]:
resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==}
engines: {node: '>=18.0.0'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
[email protected]:
resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==}
engines: {node: '>=22.19.0'}
@@ -1027,6 +1067,10 @@ snapshots:
'@types/[email protected]': {}
'@types/[email protected]':
dependencies:
undici-types: 8.3.0
'@vitest/[email protected]':
dependencies:
'@types/chai': 5.2.3
@@ -1035,13 +1079,13 @@ snapshots:
chai: 5.3.3
tinyrainbow: 2.0.0
'@vitest/[email protected]([email protected])':
'@vitest/[email protected]([email protected](@types/[email protected])([email protected]))':
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/[email protected])([email protected])
'@vitest/[email protected]':
dependencies:
@@ -1087,6 +1131,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
mdn-data: 2.27.1
@@ -1304,17 +1350,25 @@ snapshots:
dependencies:
punycode: 2.3.1
[email protected]:
dependencies:
esbuild: 0.28.2
optionalDependencies:
fsevents: 2.3.3
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
[email protected](@types/[email protected])([email protected]):
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/[email protected])([email protected])
transitivePeerDependencies:
- '@types/node'
- jiti
@@ -1329,7 +1383,7 @@ snapshots:
- tsx
- yaml
[email protected]:
[email protected](@types/[email protected])([email protected]):
dependencies:
esbuild: 0.28.2
fdir: 6.5.0([email protected])
@@ -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
[email protected]([email protected](@noble/[email protected])):
[email protected](@types/[email protected])([email protected](@noble/[email protected]))([email protected]):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.7
'@vitest/mocker': 3.2.7([email protected])
'@vitest/mocker': 3.2.7([email protected](@types/[email protected])([email protected]))
'@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/[email protected])([email protected])
vite-node: 3.2.4(@types/[email protected])([email protected])
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 26.2.0
jsdom: 30.0.1(@noble/[email protected])
transitivePeerDependencies:
- jiti
+48
View File
@@ -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()