mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-11-10 06:42:34 +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,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 });
|
||||
}
|
||||
Reference in new issue
Block a user