/** * The OpenCreds CLI, registered onto a commander parent. * * These commands ship twice — as `logicsrc creds …` and as the standalone * `opencreds` binary — from this one implementation, because the specification * treats CLI behaviour (flags, output shapes, exit codes) as a conformance * surface and a subcommand that quietly diverged would make the two different * contracts. */ import { mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs"; import { dirname, join } from "node:path"; import type { Command } from "commander"; import { auditEvent } from "./audit.js"; import { emitFixtures, formatReport, runConformance } from "./conformance.js"; import { DATABASE_EXTENSION, buildManifest, exportDatabase, exportPlaintextDatabase, mergePayload, openDatabase, parseDatabase, readHeader, } from "./database.js"; import { categorizeItem, parseCategories, toSimpleCsv } from "./categories.js"; import { CSV_LOSSY_FIELDS, toBitwardenCsv } from "./importers.js"; import { isKnownSource, routeImport, sourceHelp, SOURCE_NAMES, } from "./import-router.js"; import { createItem, decryptItems, encryptItem, isItemType, maskItem, readField, recordPasswordChange, updateItem, } from "./items.js"; import { confirm, promptNewSecret, promptSecret, resolveSecretFlag } from "./prompt.js"; import { createVaultStore, opencredsHome } from "./store.js"; import { SESSION_ENV, clearSession, encodeSession, persistSession, readSession } from "./session.js"; import { ITEM_TYPE, ITEM_TYPE_NAMES, OPENCREDS_VERSION, type DatabasePayload, type Item, type ItemTypeName, type MergeStrategy, } from "./types.js"; import { createVault, resetRecoveryKey, rewrapUserKey, unlockVault, unlockWithRecoveryKey } from "./vault-key.js"; import { formatDiagnostics, hasErrors, validateDocument } from "./validate.js"; /** Exit codes are part of the contract; see docs/opencreds/cli.md. */ export const EXIT = { OK: 0, USAGE: 1, VALIDATION: 2, CRYPTO: 3, REFUSED: 4, } as const; class CliError extends Error { constructor( message: string, readonly code: number, ) { super(message); } } /** * The command as the person typed it — `opencreds` standalone, `logicsrc vault` * when mounted — so every hint and example is one they can paste as is. */ let cli = "opencreds"; function fail(message: string, code: number): never { throw new CliError(message, code); } /** Run a command body, mapping a thrown CliError onto its exit code. */ async function run(body: () => Promise): Promise { try { await body(); } catch (err) { const code = err instanceof CliError ? err.code : EXIT.USAGE; process.stderr.write(`${(err as Error).message}\n`); process.exitCode = code; } } interface GlobalOptions { home?: string; } function storeFor(command: Command) { const opts = command.optsWithGlobals(); return createVaultStore(opts.home ?? opencredsHome()); } function requireMeta(store: ReturnType) { const meta = store.readMeta(); if (!meta) fail(`No vault at ${store.baseDir} — create one with \`${cli} init\``, EXIT.USAGE); return meta; } /** * The user key for this invocation. * * A live session is used when there is one; otherwise the master password is * asked for. Nothing else unlocks a vault. */ async function unlock(store: ReturnType): Promise { const meta = requireMeta(store); const session = readSession(store.baseDir); if (session) return session; const password = await promptSecret("Master password: "); try { return await unlockVault(meta, password); } catch (err) { store.appendAudit( auditEvent({ action: "vault.unlock_failed", namespace: meta.namespace, profile: meta.profile, outcome: "failed" }), ); fail((err as Error).message, EXIT.CRYPTO); } } async function loadPayload(store: ReturnType, userKey: Uint8Array): Promise { const meta = requireMeta(store); const { items, failed } = await decryptItems(userKey, store.listEnvelopes(), meta.namespace); if (failed.length > 0) { // Report and continue: a single corrupt row must not hide the rest of a vault. for (const failure of failed) { process.stderr.write(`warning: could not decrypt ${failure.id} — ${failure.error}\n`); } } return { folders: store.readFolders(), items }; } async function saveItem( store: ReturnType, userKey: Uint8Array, item: Item, ): Promise { const meta = requireMeta(store); store.writeEnvelope(await encryptItem(userKey, item, meta.namespace)); } /** Find an item by exact id, then by exact name, then by unique prefix. */ function resolveItem(items: Item[], needle: string): Item { const byId = items.find((item) => item.id === needle); if (byId) return byId; const byName = items.filter((item) => item.name === needle); if (byName.length === 1) return byName[0]!; if (byName.length > 1) fail(`"${needle}" matches ${byName.length} items; use an id`, EXIT.USAGE); const byPrefix = items.filter((item) => item.id.startsWith(needle)); if (byPrefix.length === 1) return byPrefix[0]!; if (byPrefix.length > 1) fail(`"${needle}" matches ${byPrefix.length} items; use a longer id`, EXIT.USAGE); return fail(`No item matches "${needle}"`, EXIT.USAGE); } /** Type flags, kebab-cased from the field-group names. */ const TYPE_FLAGS: Record> = { login: [ ["--username ", "username"], ["--password ", "password", true], ["--totp ", "totp", true], ], card: [ ["--cardholder-name ", "cardholderName"], ["--brand ", "brand"], ["--number ", "number", true], ["--exp-month ", "expMonth"], ["--exp-year ", "expYear"], ["--code ", "code", true], ], identity: [ ["--title ", "title"], ["--first-name ", "firstName"], ["--middle-name ", "middleName"], ["--last-name ", "lastName"], ["--company ", "company"], ["--email ", "email"], ["--phone ", "phone"], ["--address1 ", "address1"], ["--address2 ", "address2"], ["--city ", "city"], ["--state ", "state"], ["--postal-code ", "postalCode"], ["--country ", "country"], ["--ssn ", "ssn", true], ["--passport-number ", "passportNumber", true], ["--license-number ", "licenseNumber", true], ], note: [], key: [ ["--key-type ", "keyType"], ["--algorithm ", "algorithm"], ["--public-key ", "publicKey"], ["--private-key ", "privateKey", true], ["--passphrase ", "passphrase", true], ["--fingerprint ", "fingerprint"], ["--value ", "value", true], ["--path ", "path"], ["--mode ", "mode"], ], account: [ ["--provider ", "provider"], ["--account-id ", "accountId"], ["--handle ", "handle"], ["--email ", "email"], ["--access-token ", "accessToken", true], ["--refresh-token ", "refreshToken", true], ["--token-type ", "tokenType"], ["--environment ", "environment"], ], }; function optionKey(flag: string): string { const long = flag.split(" ")[0]!.replace(/^--/, ""); return long.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()); } /** Build the field group from the parsed flags, resolving any `-` from stdin. */ async function groupFromOptions(type: ItemTypeName, opts: Record): Promise> { const group: Record = {}; for (const [flag, field] of TYPE_FLAGS[type]) { const raw = opts[optionKey(flag)]; if (raw === undefined) continue; const value = await resolveSecretFlag(String(raw)); if (value !== undefined) group[field] = value; } if (type === "login" && typeof opts.url === "string") { group.uris = [{ uri: opts.url, match: (opts.match as string) ?? "domain" }]; } if (type === "account" && Array.isArray(opts.scope)) { group.scopes = opts.scope as string[]; } if (type === "key" && typeof opts.file === "string") { // Reading a key from a file is the common path; it avoids a multi-line // secret in an argument vector entirely. const body = readFileSync(opts.file, "utf8"); group.privateKey ??= body; group.path ??= opts.file; } return group; } function applyTypeFlags(command: Command, type: ItemTypeName): Command { for (const [flag] of TYPE_FLAGS[type]) { command.option(flag, undefined); } if (type === "login") { command.option("--url ", "matching URI"); command.option("--match ", "URI match rule", "domain"); } if (type === "account") { command.option("--scope ", "OAuth scope (repeatable)"); } if (type === "key") { command.option("--file ", "read the private key from a file"); } return command; } function categoriesOrFail(input: string | undefined): Set | undefined { try { return parseCategories(input); } catch (error) { return fail((error as Error).message, EXIT.USAGE); } } function printItemLine(item: Item): string { const type = item.type.padEnd(8); const id = item.id.slice(0, 8); const category = categorizeItem(item).padEnd(9); return `${id} ${type} ${category} ${item.name}`; } /** Register every OpenCreds command onto `parent`. */ export function registerCredsCommands(parent: Command): void { const names: string[] = []; for (let c: Command | null = parent; c; c = c.parent) names.unshift(c.name()); cli = names.join(" "); const examples = (lines: string) => `\nExamples:\n${lines.replace(/^\n/, "").replace(/\$CLI/g, cli)}\n`; parent.option("--home ", "vault directory (default $OPENCREDS_HOME)"); // ---------------------------------------------------------------- vault --- parent .command("init") .description("create a vault") .addHelpText("after", examples(` $CLI init create your vault (asks for a master password) $CLI status is there a vault, is it unlocked, what is in it`)) .option("--namespace ", "domain-separation namespace", "opencreds") .option("--iterations ", "PBKDF2 iterations", (v: string) => Number.parseInt(v, 10)) .option("--password-stdin", "read the master password from stdin instead of prompting twice") .option("--force", "replace an existing vault") .action(async function ( this: Command, opts: { namespace: string; iterations?: number; force?: boolean; passwordStdin?: boolean }, ) { await run(async () => { const store = storeFor(this); if (store.exists() && !opts.force) { fail(`A vault already exists at ${store.baseDir}; pass --force to replace it`, EXIT.REFUSED); } // Scripted provisioning reads one line and skips the confirmation; a // person gets asked twice, because a typo'd master password is an // empty vault they cannot open. const password = opts.passwordStdin ? ((await resolveSecretFlag("-")) as string) : await promptNewSecret("Master password: ", "Repeat master password: "); if (password.length === 0) fail("A master password is required", EXIT.USAGE); const { meta, recoveryKey } = await createVault(password, { namespace: opts.namespace, ...(opts.iterations ? { params: { kdf: "pbkdf2-sha256" as const, iterations: opts.iterations } } : {}), }); store.writeMeta(meta); store.appendAudit(auditEvent({ action: "vault.create", namespace: meta.namespace, profile: meta.profile })); process.stdout.write(`Vault created at ${store.baseDir}\n\n`); process.stdout.write(` Recovery key ${recoveryKey}\n\n`); process.stdout.write( "Write this down now. It is the only way back into the vault without the\n" + "master password, it is not stored anywhere, and it will not be shown again.\n", ); }); }); parent .command("unlock") .description("start a session") .addHelpText("after", examples(` eval "$($CLI unlock)" unlock for this shell only (nothing written to disk) $CLI unlock --persist --timeout 30 unlock for scripts for 30 minutes; \`lock\` ends it`)) .option("--persist", "write the session to a 0600 file instead of printing a token") .option("--password-stdin", "read the master password from stdin") .option("--timeout ", "session lifetime when persisted", (v: string) => Number.parseInt(v, 10), 15) .action(async function (this: Command, opts: { persist?: boolean; timeout: number; passwordStdin?: boolean }) { await run(async () => { const store = storeFor(this); const meta = requireMeta(store); const password = opts.passwordStdin ? ((await resolveSecretFlag("-")) as string) : await promptSecret("Master password: "); let userKey: Uint8Array; try { userKey = await unlockVault(meta, password); } catch (err) { store.appendAudit(auditEvent({ action: "vault.unlock_failed", outcome: "failed" })); fail((err as Error).message, EXIT.CRYPTO); } store.appendAudit(auditEvent({ action: "vault.unlock", namespace: meta.namespace, profile: meta.profile })); if (opts.persist) { const path = persistSession(userKey, opts.timeout, store.baseDir); process.stdout.write(`Session written to ${path}, expiring in ${opts.timeout} minutes.\n`); process.stdout.write( "That file holds the key to this vault. Anything that can read it can read\n" + "every item. Run `" + cli + " lock` when you are done.\n", ); return; } process.stdout.write(`export ${SESSION_ENV}="${encodeSession(userKey)}"\n`); }); }); parent .command("lock") .description("end a persisted session") .action(async function (this: Command) { await run(async () => { const store = storeFor(this); const removed = clearSession(store.baseDir); process.stdout.write( removed ? "Session cleared.\n" : `No persisted session. If you exported ${SESSION_ENV}, unset it.\n`, ); }); }); parent .command("status") .description("vault presence, lock state and item counts (works locked)") .option("--json", "machine-readable output") .action(async function (this: Command, opts: { json?: boolean }) { await run(async () => { const store = storeFor(this); const meta = store.readMeta(); const envelopes = store.listEnvelopes(); const counts: Partial> = {}; for (const envelope of envelopes) { const name = ITEM_TYPE_NAMES.find((n) => ITEM_TYPE[n] === envelope.type); if (name) counts[name] = (counts[name] ?? 0) + 1; } const unlocked = Boolean(readSession(store.baseDir)); const report = { vault: store.baseDir, present: Boolean(meta), unlocked, opencreds: OPENCREDS_VERSION, namespace: meta?.namespace, profile: meta?.profile, kdf: meta ? `${meta.kdf}/${meta.kdfIterations}` : undefined, itemCount: envelopes.length, types: counts, }; if (opts.json) { process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); return; } if (!meta) { process.stdout.write(`No vault at ${store.baseDir}\n`); return; } process.stdout.write(` Vault ${store.baseDir}\n`); process.stdout.write(` State ${unlocked ? "unlocked" : "locked"}\n`); process.stdout.write(` Namespace ${meta.namespace} (${meta.profile} profile)\n`); process.stdout.write(` KDF ${meta.kdf}, ${meta.kdfIterations} iterations\n`); process.stdout.write(` Items ${envelopes.length}\n`); for (const name of ITEM_TYPE_NAMES) { if (counts[name]) process.stdout.write(` ${name.padEnd(10)}${counts[name]}\n`); } }); }); parent .command("recover") .description("unlock with the recovery key and set a new master password") .action(async function (this: Command) { await run(async () => { const store = storeFor(this); const meta = requireMeta(store); const recoveryKey = await promptSecret("Recovery key: "); let userKey: Uint8Array; try { userKey = await unlockWithRecoveryKey(meta, recoveryKey); } catch (err) { fail((err as Error).message, EXIT.CRYPTO); } const password = await promptNewSecret("New master password: ", "Repeat: "); const rewrapped = await rewrapUserKey(meta, userKey, password); const reset = await resetRecoveryKey(rewrapped, userKey); store.writeMeta(reset.meta); store.appendAudit(auditEvent({ action: "vault.recovery_reset", namespace: meta.namespace })); process.stdout.write(`Master password changed. Not one item was re-encrypted.\n\n`); process.stdout.write(` New recovery key ${reset.recoveryKey}\n\n`); process.stdout.write("The old recovery key no longer works.\n"); }); }); // ---------------------------------------------------------------- items --- const add = parent .command("add") .description("add an item") .addHelpText("after", examples(` $CLI add login --name GitHub --username me --password - password read from stdin $CLI add key --name DATABASE_URL --key-type env --value - one .env secret $CLI add login --help every flag for one type`)); for (const type of ITEM_TYPE_NAMES) { const sub = add .command(type) .description(`add a ${type}`) .requiredOption("--name ", "display name") .option("--notes ", "notes") .option("--folder ", "folder name") .option("--favorite", "mark as a favorite") .option("--json", "print the created item as masked JSON"); applyTypeFlags(sub, type); sub.action(async function (this: Command, opts: Record) { await run(async () => { const store = storeFor(this); const userKey = await unlock(store); const group = await groupFromOptions(type, opts); let folderId: string | null = null; if (typeof opts.folder === "string" && opts.folder !== "") { const folders = store.readFolders(); let folder = folders.find((f) => f.name === opts.folder); if (!folder) { folder = { id: globalThis.crypto.randomUUID(), name: opts.folder }; store.writeFolders([...folders, folder]); } folderId = folder.id; } const item = createItem(type, { name: String(opts.name), notes: typeof opts.notes === "string" ? opts.notes : "", favorite: Boolean(opts.favorite), folderId, [type]: group, } as Partial); await saveItem(store, userKey, item); store.appendAudit(auditEvent({ action: "item.create", itemId: item.id, itemType: type })); if (opts.json) { process.stdout.write(`${JSON.stringify(maskItem(item), null, 2)}\n`); return; } process.stdout.write(`Added ${type} ${item.id}\n`); }); }); } parent .command("list") .description("list items; never prints secret values") .addHelpText("after", examples(` $CLI list everything (never shows values) $CLI list --category db database credentials only $CLI list -c social,api two categories at once $CLI list --type login --search github`)) .option("--type ", "filter by item type") .option("-c, --category ", "filter by category: db, social, server, api, … (comma-separated)") .option("--folder ", "filter by folder") .option("--search ", "match against the item name") .option("--json", "machine-readable output, masked identically") .action(async function ( this: Command, opts: { type?: string; category?: string; folder?: string; search?: string; json?: boolean }, ) { await run(async () => { const store = storeFor(this); const userKey = await unlock(store); const { items, folders } = await loadPayload(store, userKey); if (opts.type && !isItemType(opts.type)) { fail(`Unknown type "${opts.type}"; expected one of ${ITEM_TYPE_NAMES.join(", ")}`, EXIT.USAGE); } const folderId = opts.folder ? folders.find((f) => f.name === opts.folder)?.id : undefined; if (opts.folder && !folderId) fail(`No folder named "${opts.folder}"`, EXIT.USAGE); const categories = categoriesOrFail(opts.category); const needle = opts.search?.toLowerCase(); const filtered = items .filter((item) => (opts.type ? item.type === opts.type : true)) .filter((item) => (categories ? categories.has(categorizeItem(item)) : true)) .filter((item) => (folderId ? item.folderId === folderId : true)) .filter((item) => (needle ? item.name.toLowerCase().includes(needle) : true)) .sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id)); if (opts.json) { process.stdout.write(`${JSON.stringify(filtered.map((item) => maskItem(item)), null, 2)}\n`); return; } if (filtered.length === 0) { process.stdout.write("No matching items.\n"); return; } for (const item of filtered) process.stdout.write(`${printItemLine(item)}\n`); }); }); parent .command("get") .argument("", "item id or name") .description("show one item, with every secret masked") .addHelpText("after", examples(` $CLI get GitHub the item, secrets masked $CLI get GitHub --field login.password --reveal one value, in the clear`)) .option("--field ", "a single dotted field path, e.g. login.password") .option("--reveal", "print the value of --field in the clear") .option("--json", "machine-readable output, masked identically") .action(async function (this: Command, needle: string, opts: { field?: string; reveal?: boolean; json?: boolean }) { await run(async () => { const store = storeFor(this); const userKey = await unlock(store); const { items } = await loadPayload(store, userKey); const item = resolveItem(items, needle); if (opts.reveal) { // Revealing is always a deliberate act naming a single value. There // is no flag that prints a whole item in the clear, because there is // no workflow that needs one. if (!opts.field) fail("--reveal needs --field naming a single value", EXIT.USAGE); const value = readField(item, opts.field); if (value === undefined) fail(`No field "${opts.field}" on this item`, EXIT.USAGE); process.stdout.write(`${value}\n`); return; } const masked = maskItem(item); if (opts.field) { const value = readField(masked, opts.field); if (value === undefined) fail(`No field "${opts.field}" on this item`, EXIT.USAGE); process.stdout.write(`${value}\n`); return; } process.stdout.write(`${JSON.stringify(masked, null, 2)}\n`); }); }); const edit = parent.command("edit").description("edit an item"); for (const type of ITEM_TYPE_NAMES) { const sub = edit .command(type) .argument("", "item id or name") .description(`edit a ${type}`) .option("--name ", "display name") .option("--notes ", "notes") .option("--favorite ", "true or false"); applyTypeFlags(sub, type); sub.action(async function (this: Command, needle: string, opts: Record) { await run(async () => { const store = storeFor(this); const userKey = await unlock(store); const { items } = await loadPayload(store, userKey); const item = resolveItem(items, needle); if (item.type !== type) fail(`${item.id} is a ${item.type}, not a ${type}`, EXIT.USAGE); const group = await groupFromOptions(type, opts); const patch: Partial = {}; if (typeof opts.name === "string") patch.name = opts.name; if (typeof opts.notes === "string") patch.notes = opts.notes; if (opts.favorite !== undefined) patch.favorite = String(opts.favorite) === "true"; // A password change is recorded in the item's own history before the // new value overwrites the old one — otherwise the value being replaced // is the one that gets lost. let next = item; if (type === "login" && typeof group.password === "string" && group.password !== item.login?.password) { next = recordPasswordChange(next, group.password); delete group.password; } next = updateItem(next, { ...patch, [type]: group } as Partial); await saveItem(store, userKey, next); store.appendAudit(auditEvent({ action: "item.update", itemId: next.id, itemType: type })); process.stdout.write(`Updated ${next.id}\n`); }); }); } parent .command("rm") .argument("", "item id or name") .description("delete an item") .option("--purge", "delete irrecoverably rather than moving to the trash") .action(async function (this: Command, needle: string, opts: { purge?: boolean }) { await run(async () => { const store = storeFor(this); const userKey = await unlock(store); const { items } = await loadPayload(store, userKey); const item = resolveItem(items, needle); if (opts.purge) { store.deleteEnvelope(item.id); store.appendAudit(auditEvent({ action: "item.purge", itemId: item.id, itemType: item.type })); process.stdout.write(`Purged ${item.id}\n`); return; } const envelope = store.readEnvelope(item.id); if (!envelope) fail(`No stored item ${item.id}`, EXIT.USAGE); const now = new Date(); store.writeEnvelope({ ...envelope, deletedAt: now.toISOString(), purgeAfter: new Date(now.getTime() + 30 * 86_400_000).toISOString(), }); store.appendAudit(auditEvent({ action: "item.delete", itemId: item.id, itemType: item.type })); process.stdout.write(`Moved ${item.id} to the trash; recoverable for 30 days\n`); }); }); parent .command("restore") .argument("", "item id") .description("restore an item from the trash") .action(async function (this: Command, id: string) { await run(async () => { const store = storeFor(this); await unlock(store); const envelope = store.readEnvelope(id); if (!envelope) fail(`No item ${id}`, EXIT.USAGE); store.writeEnvelope({ ...envelope, deletedAt: null, purgeAfter: null }); store.appendAudit(auditEvent({ action: "item.restore", itemId: id })); process.stdout.write(`Restored ${id}\n`); }); }); // ------------------------------------------------------------- database --- parent .command("export") .description("export the vault as an OpenCreds database") .addHelpText("after", examples(` $CLI export --format csv --out vault.csv --yes everything, one flat row per item $CLI export --format csv --category db --out db.csv --yes one category $CLI export --out backup.opencreds encrypted backup (asks for a passphrase)`)) .option("--out ", "output file", `vault${DATABASE_EXTENSION}`) .option("--passphrase-stdin", "read the export passphrase from stdin") .option("--plaintext", "write every secret in the clear (requires --yes)") .option("--format ", "opencreds, csv (one flat row per item) or bitwarden-csv", "opencreds") .option("-c, --category ", "only items in these categories: db, social, server, api, …") .option("--yes", "confirm a plaintext export") .action(async function ( this: Command, opts: { out: string; passphraseStdin?: boolean; plaintext?: boolean; format: string; category?: string; yes?: boolean }, ) { await run(async () => { if (!["opencreds", "csv", "bitwarden-csv"].includes(opts.format)) { fail(`Unknown format "${opts.format}"; expected opencreds, csv or bitwarden-csv`, EXIT.USAGE); } if (opts.format !== "opencreds" && this.getOptionValueSource("out") === "default") opts.out = "vault.csv"; const categories = categoriesOrFail(opts.category); const store = storeFor(this); const meta = requireMeta(store); const userKey = await unlock(store); const loaded = await loadPayload(store, userKey); const payload = categories ? { ...loaded, items: loaded.items.filter((item) => categories.has(categorizeItem(item))) } : loaded; const wantsPlaintext = Boolean(opts.plaintext) || opts.format !== "opencreds"; if (wantsPlaintext) { process.stdout.write( `About to write ${payload.items.length} items to ${opts.out} with every secret in the clear.\n` + "This file cannot be un-leaked, and every password in it should be treated\n" + "as exposed if it is.\n", ); if (!opts.yes && !(await confirm("Continue?"))) { fail("Refused: a plaintext export needs --yes", EXIT.REFUSED); } } if (opts.format === "csv") { writeFileSync(opts.out, toSimpleCsv(payload.items, payload.folders), { encoding: "utf8", mode: 0o600 }); try { chmodSync(opts.out, 0o600); } catch { /* no modes on this platform */ } store.appendAudit(auditEvent({ action: "database.export_plaintext", itemCount: payload.items.length })); process.stdout.write(`Wrote ${opts.out} — ${payload.items.length} items, every secret in the clear.\n`); return; } if (opts.format === "bitwarden-csv") { const { csv, dropped } = toBitwardenCsv(payload.items, payload.folders); writeFileSync(opts.out, csv, { encoding: "utf8", mode: 0o600 }); try { chmodSync(opts.out, 0o600); } catch { /* no modes on this platform */ } store.appendAudit( auditEvent({ action: "database.export_plaintext", itemCount: payload.items.length }), ); process.stdout.write(`Wrote ${opts.out}\n`); const droppedEntries = Object.entries(dropped); if (droppedEntries.length > 0) { process.stdout.write("\nNo CSV has a column for these, so they were not written:\n"); for (const [what, count] of droppedEntries) process.stdout.write(` ${String(count).padStart(4)} ${what}\n`); process.stdout.write(`\nThe fields a CSV always loses: ${CSV_LOSSY_FIELDS.join(", ")}.\n`); } return; } if (opts.plaintext) { const db = await exportPlaintextDatabase(payload, { namespace: meta.namespace, acknowledged: true, }); writeFileSync(opts.out, `${JSON.stringify(db, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); try { chmodSync(opts.out, 0o600); } catch { /* no modes on this platform */ } store.appendAudit(auditEvent({ action: "database.export_plaintext", itemCount: payload.items.length })); process.stdout.write(`Wrote ${opts.out} — unprotected, ${payload.items.length} items.\n`); return; } const passphrase = opts.passphraseStdin ? ((await resolveSecretFlag("-")) as string) : await promptNewSecret("Export passphrase: ", "Repeat: "); const db = await exportDatabase(payload, { namespace: meta.namespace, passphrase }); writeFileSync(opts.out, `${JSON.stringify(db, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); store.appendAudit(auditEvent({ action: "database.export", itemCount: payload.items.length })); process.stdout.write( `Wrote ${opts.out} — encrypted, ${payload.items.length} items, ${payload.folders.length} folders.\n`, ); }); }); parent .command("import") .argument( "", "an OpenCreds database, a Bitwarden JSON export, a 1Password .1pux, or a CSV export", ) .description("import into the vault") .addHelpText("after", examples(` $CLI import bitwarden.csv --dry-run see what would be imported $CLI import bitwarden.csv import it (Bitwarden, 1Password, Chrome, LastPass or KeePass CSV) $CLI import backup.opencreds restore an OpenCreds export`)) .option("--dry-run", "report what would happen and write nothing") .option("--merge ", "skip, replace or duplicate", "skip") .option( "--source ", `where the export came from, when it cannot be told from the file (${SOURCE_NAMES.join(", ")})`, ) .option("--passphrase-stdin", "read the database passphrase from stdin") .option("--allow-unregistered-namespace", "open a database whose namespace is not registered") .action(async function ( this: Command, file: string, opts: { dryRun?: boolean; merge: string; source?: string; passphraseStdin?: boolean; allowUnregisteredNamespace?: boolean; }, ) { await run(async () => { const store = storeFor(this); const meta = requireMeta(store); const userKey = await unlock(store); const strategy = opts.merge as MergeStrategy; if (!["skip", "replace", "duplicate"].includes(strategy)) { fail(`Unknown merge strategy "${opts.merge}"`, EXIT.USAGE); } if (opts.source && !isKnownSource(opts.source)) { fail( `Unknown --source "${opts.source}".\n Valid sources: ${SOURCE_NAMES.join(", ")}`, EXIT.USAGE, ); } // Read as bytes: a .1pux is a ZIP, so decoding as UTF-8 up front would // corrupt it before anything got the chance to look. let buf: Buffer; try { buf = readFileSync(file); } catch { fail(`Could not read ${file}`, EXIT.USAGE); } const text = buf.toString("utf8"); let incoming: DatabasePayload; let sourceLabel: string; let skipped: Array<{ row: number; reason: string }> = []; // One router decides which reader owns the file, so --source can name // any product rather than only a CSV one. const route = routeImport(buf, opts.source); if (!route.isOpenCredsDatabase) { const parsed = route.parsed; if (!parsed || parsed.items.length === 0) { const why = route.reason ?? parsed?.skipped[0]?.reason; fail( [ why && why !== "Unrecognised export format" ? `${why}` : `Could not identify the export format of ${file}`, "", " Say where it came from with --source :", ` ${sourceHelp()}`, ].join("\n"), EXIT.VALIDATION, ); } incoming = { folders: parsed.folders, items: parsed.items }; skipped = parsed.skipped; sourceLabel = parsed.source ?? "unknown"; process.stdout.write(` Source ${file} (${route.description})\n\n`); } else { const db = parseDatabase(text); const header = readHeader(db); process.stdout.write( ` Source ${file} (opencreds ${header.opencreds}, ` + `${header.protected ? "encrypted" : "PLAINTEXT"}, namespace ${header.namespace})\n`, ); if (header.generator) { process.stdout.write(` Exported ${header.exportedAt} by ${header.generator.name} ${header.generator.version}\n`); } const passphrase = header.protected ? opts.passphraseStdin ? ((await resolveSecretFlag("-")) as string) : await promptSecret("Database passphrase: ") : undefined; try { incoming = await openDatabase(db, { ...(passphrase !== undefined ? { passphrase } : {}), allowUnregisteredNamespace: opts.allowUnregisteredNamespace, }); } catch (err) { // A manifest mismatch writes nothing, regardless of flags. fail((err as Error).message, EXIT.CRYPTO); } process.stdout.write(` Manifest verified — ${incoming.items.length} items, ${incoming.folders.length} folders\n\n`); sourceLabel = "opencreds"; } const existing = await loadPayload(store, userKey); const merged = mergePayload(existing, incoming, strategy); const counts: Partial> = {}; for (const item of incoming.items) counts[item.type] = (counts[item.type] ?? 0) + 1; for (const name of ITEM_TYPE_NAMES) { if (counts[name]) process.stdout.write(` ${name.padEnd(10)}${String(counts[name]).padStart(4)}\n`); } process.stdout.write( `\n Folders ${merged.outcome.foldersAdded} new, ${merged.outcome.foldersMerged} merged\n`, ); process.stdout.write( ` Outcome ${merged.outcome.added} added, ${merged.outcome.replaced} replaced, ` + `${merged.outcome.duplicated} duplicated, ${merged.outcome.skipped} skipped (${strategy})\n`, ); if (skipped.length > 0) { process.stdout.write(` Skipped ${skipped.length} rows\n`); for (const row of skipped.slice(0, 20)) { process.stdout.write(` row ${row.row}: ${row.reason}\n`); } if (skipped.length > 20) process.stdout.write(` … and ${skipped.length - 20} more\n`); } if (opts.dryRun) { process.stdout.write("\n Nothing written. Re-run without --dry-run to import.\n"); return; } store.writeFolders(merged.folders); for (const item of merged.items) { store.writeEnvelope(await encryptItem(userKey, item, meta.namespace)); } store.appendAudit(auditEvent({ action: "database.import", itemCount: incoming.items.length })); process.stdout.write(`\n Imported into ${store.baseDir}\n`); }); }); // ------------------------------------------------------------ validate ---- parent .command("validate") .argument("[file]", "a database or item document; omit with --stdin") .description("check that a document conforms") .option("--stdin", "read the document from stdin") .option("--json", "machine-readable diagnostics") .action(async function (this: Command, file: string | undefined, opts: { stdin?: boolean; json?: boolean }) { await run(async () => { let text: string; if (opts.stdin || !file) { text = await resolveSecretFlag("-") as string; } else { try { text = readFileSync(file, "utf8"); } catch { fail(`Could not read ${file}`, EXIT.USAGE); } } let parsed: unknown; try { parsed = JSON.parse(text); } catch (err) { fail(`Not valid JSON: ${(err as Error).message}`, EXIT.VALIDATION); } const { kind, diagnostics } = validateDocument(parsed); const failed = hasErrors(diagnostics); if (opts.json) { process.stdout.write(`${JSON.stringify({ kind, conformant: !failed, diagnostics }, null, 2)}\n`); } else { // A warning is not a failure, so a document that only warns still // gets told it conforms — otherwise a plaintext database, whose // warning is the whole point of it, looks broken. if (!failed) process.stdout.write(`OK — a conforming OpenCreds ${kind}\n`); if (diagnostics.length > 0) process.stdout.write(`${formatDiagnostics(diagnostics)}\n`); } if (failed) process.exitCode = EXIT.VALIDATION; }); }); parent .command("conformance") .description("run the OpenCreds conformance suite against this implementation") .option("--json", "emit the conformance report as JSON") .option("--emit-fixtures ", "write the generated fixture set to a directory") .action(async function (this: Command, opts: { json?: boolean; emitFixtures?: string }) { await run(async () => { if (opts.emitFixtures) { const fixtures = await emitFixtures(); for (const [name, content] of Object.entries(fixtures)) { const target = join(opts.emitFixtures, name); mkdirSync(dirname(target), { recursive: true }); writeFileSync( target, typeof content === "string" ? content : `${JSON.stringify(content, null, 2)}\n`, "utf8", ); } process.stdout.write(`Wrote ${Object.keys(fixtures).length} fixtures to ${opts.emitFixtures}\n`); return; } const report = await runConformance(); process.stdout.write( opts.json ? `${JSON.stringify(report, null, 2)}\n` : `${formatReport(report)}\n`, ); // A failed MUST is a validation failure, not a crash. if (!report.conformant) process.exitCode = EXIT.VALIDATION; }); }); parent .command("manifest") .argument("", "a plaintext database") .description("recompute the manifest of a plaintext database") .action(async function (this: Command, file: string) { await run(async () => { const db = parseDatabase(readFileSync(file, "utf8")); if (db.protected) fail("Only a plaintext database can be re-manifested here", EXIT.USAGE); const manifest = await buildManifest({ folders: db.folders ?? [], items: db.items ?? [] }); process.stdout.write(`${JSON.stringify(manifest, null, 2)}\n`); }); }); }