mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-11-06 04:42:35 +00:00
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:
1 parent
2345f61431
commit
ab569fe900
22 files changed
+1362
-14
No files matched your search
@@ -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'
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)",
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)}`,
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src", "test", "bin"]
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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=="
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Generated
+67
-10
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in new issue
Block a user