diff --git a/docs/credential-sharing.md b/docs/credential-sharing.md index 1ad409b..ec62197 100644 --- a/docs/credential-sharing.md +++ b/docs/credential-sharing.md @@ -292,6 +292,37 @@ automation, write the link explicitly with directory path and team/project/environment names; they live in the user's LogicSRC config directory, never in the project and never contain secret values. +### Finding and exporting secrets by category + +Every secret has a category, worked out from its name: `DATABASE_URL` is `db`, +`TWITTER_API_KEY` is `social`, `SSH_PORT` is `server`, `TMDB_API_KEY` is `api`. +A named service wins over a generic word, so `STRIPE_WEBHOOK_SECRET` is +`finance` (it belongs with the rest of Stripe) rather than `auth`. + +```bash +logicsrc teams categories # the categories and what each catches +logicsrc teams categories acme # how many secrets acme has in each + +logicsrc teams secrets acme # every secret NAME in every vault +logicsrc teams secrets acme --category db # only databases (aliases work: database, sql) +logicsrc teams secrets acme -c social,api # several at once +logicsrc teams secrets acme web prod -s stripe # one vault, names containing "stripe" +logicsrc teams secrets acme --format csv # names as CSV, still no values + +logicsrc teams export acme --category db -o db.csv # decrypt into a CSV +logicsrc teams export acme web prod -o web-prod.csv --yes # one vault, no prompt +``` + +`secrets` never decrypts anything. `export` decrypts on your machine and writes +`team,project,env,category,key,value,updated_at`, one row per secret, mode 0600. +It asks before writing plaintext; pass `--yes` in scripts. Vaults you have no +grant for are skipped and listed rather than failing the export. + +The categories are `crypto`, `ai`, `finance`, `email`, `messaging`, `social`, +`storage`, `db`, `dns`, `analytics`, `devtools`, `cloud`, `server`, `auth`, +`api`, `config` (settings that are not secrets) and `other`. The same table +filters the personal vault: `logicsrc vault list --category db`. + ### Rotating a vault key ```bash diff --git a/docs/opencreds/cli.md b/docs/opencreds/cli.md index 851d878..c966012 100644 --- a/docs/opencreds/cli.md +++ b/docs/opencreds/cli.md @@ -56,7 +56,7 @@ why it is instant on a vault of any size. ```bash opencreds add --name [type flags…] -opencreds list [--type ] [--folder ] [--search ] [--json] +opencreds list [--type ] [--category ] [--folder ] [--search ] [--json] opencreds get [--field ] [--reveal] opencreds edit [flags…] opencreds rm [--purge] @@ -68,6 +68,13 @@ with every secret field masked; `--reveal` prints one field named by `--field`, so revealing is always a deliberate act naming a single value. `--json` output is masked identically — a pipeline is not an authorization. +`list` prints one line per item: short id, type, category, name. The category +(`db`, `social`, `server`, `api`, `finance`, … and `other`) is derived from the +item type, name, account provider and login hosts, and is never stored, so it +cannot disagree between implementations that share the rule table. +`--category db,social` filters on it and accepts aliases (`database`, +`payments`); an unknown word exits 1 and names the valid ones. + Type flags follow the field group names, kebab-cased: `--username`, `--password`, `--totp`, `--url`, `--cardholder-name`, `--number`, `--exp-month`, `--exp-year`, `--code`, @@ -83,7 +90,9 @@ secret need not appear in the shell history or the process list. ```bash opencreds export [--out vault.opencreds] [--passphrase-stdin] opencreds export --plaintext --out vault.json --yes +opencreds export --format csv --out vault.csv --yes opencreds export --format bitwarden-csv --out vault.csv --yes +opencreds export --format csv --category db --out db.csv --yes opencreds import [--dry-run] [--merge skip|replace|duplicate] opencreds import --source bitwarden|onepassword|chrome|lastpass|keepass @@ -92,6 +101,13 @@ opencreds import --source bitwarden|onepassword|chrome|lastpass|keepass `export` writes the encrypted form. `--plaintext` prints what it is about to do and exits 4 without `--yes`. +`--format csv` writes one flat row per item — +`folder,category,type,name,username,password,url,value,totp,notes` — where +`value` is the single opaque secret of a key, account or card. It keeps the key +and account items a Bitwarden CSV has no column for, and is meant for people and +scripts, not re-import. `--format bitwarden-csv` is the one to hand another +password manager. `--category` limits any export format to those categories. + `import` with `--dry-run` reports counts by type, folders to be created, duplicates detected and rows that could not be mapped, and writes nothing. A manifest mismatch exits 3 and writes nothing regardless of flags. diff --git a/packages/cli/src/creds.ts b/packages/cli/src/creds.ts index a558cc6..156544f 100644 --- a/packages/cli/src/creds.ts +++ b/packages/cli/src/creds.ts @@ -18,13 +18,46 @@ import { registerCredsCommands } from "@logicsrc/opencreds/commands"; * account — encrypted end to end and portable as one file rather than a * plaintext CSV. They meet at the `key` item: a synced .env entry, stored. */ +export const VAULT_GUIDE = ` +There are two vaults. Pick the one you need: + + Team secrets .env keys shared with your team (DATABASE_URL, STRIPE_SECRET_KEY, …) + -> logicsrc teams … (this is where the company secrets are) + Personal vault your own logins, cards, SSH keys, notes + -> logicsrc vault … + +Team secrets, the everyday commands: + logicsrc login once per machine + logicsrc teams list the teams you are in + logicsrc teams secrets every secret name + its category + logicsrc teams secrets --category db only database secrets + logicsrc teams secrets -s stripe names containing "stripe" + logicsrc teams export --category db -o db.csv decrypt them into a CSV + logicsrc teams pull write one vault into ./.env + logicsrc teams categories db, social, server, api, cloud, … + +Personal vault: + logicsrc vault init create it (once) + eval "$(logicsrc vault unlock)" unlock for this shell + logicsrc vault add login --name GitHub --username me --password - + logicsrc vault list --category social never shows values + logicsrc vault get GitHub --field login.password --reveal + logicsrc vault export --format csv --category db --out db.csv --yes + logicsrc vault import bitwarden.csv + +Help for any command: logicsrc vault --help (or: logicsrc vault help ) +`; + export function registerOpenCredsCommands(program: Command): void { const vault = program .command("vault") .description( - "OpenCreds: an end-to-end-encrypted vault for logins, cards, identities, notes, keys " + - "and accounts, portable as one file. Also available as the standalone `opencreds` command.", - ); + "Your personal encrypted vault (logins, cards, keys, notes). " + + "Team .env secrets are under `logicsrc teams` — examples below.", + ) + // Most people typing `logicsrc vault` want the TEAM secrets, which live + // under `teams`. Say so first, with commands they can paste. + .addHelpText("after", VAULT_GUIDE); registerCredsCommands(vault); } diff --git a/packages/cli/src/format.ts b/packages/cli/src/format.ts index ba1dc42..f84ab48 100644 --- a/packages/cli/src/format.ts +++ b/packages/cli/src/format.ts @@ -20,3 +20,18 @@ export function print(data: unknown, format: OutputFormat) { console.table(data); } + +/** + * A plain, aligned table: no index column, no quotes, no colour. Easier to read + * than console.table and pipes cleanly into grep, sort and awk. + */ +export function printColumns(rows: Array>): void { + if (rows.length === 0) return; + const headers = Object.keys(rows[0]!); + const cells = rows.map((row) => headers.map((h) => String(row[h] ?? ""))); + const widths = headers.map((h, i) => Math.max(h.length, ...cells.map((c) => c[i]!.length))); + const line = (values: string[]) => + values.map((v, i) => (i === values.length - 1 ? v : v.padEnd(widths[i]!))).join(" ").trimEnd(); + console.log(line(headers.map((h) => h.toUpperCase()))); + for (const c of cells) console.log(line(c)); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index ec15422..8df24de 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -20,6 +20,9 @@ import { teamsAcceptAction, teamsMembersAction, teamsVaultsAction, + teamsCategoriesAction, + teamsSecretsAction, + teamsExportAction, teamsGrantAction, teamsTuiAction, teamsPushAction, @@ -716,7 +719,25 @@ credentials print(credentialEngine().exportCredentialAudit(options.run), options.format as OutputFormat); }); -const teams = program.command("teams").description("Share credentials with teammates by email — end-to-end encrypted team vaults."); +const teams = program + .command("teams") + .description("Share credentials with teammates by email — end-to-end encrypted team vaults.") + .addHelpText( + "after", + ` +Quick start ( is e.g. profullstack; see yours with "logicsrc teams list"): + logicsrc login once per machine + logicsrc teams vaults the vaults: -- + logicsrc teams secrets every secret name + its category + logicsrc teams secrets --category db filter: db, social, server, api, … + logicsrc teams export --category db -o db.csv decrypt into a CSV + logicsrc teams pull vault -> ./.env + logicsrc teams push ./.env -> vault + logicsrc teams grant dev@example.com + +Categories: logicsrc teams categories. Help for one command: logicsrc teams --help +` + ); teams .command("create") @@ -762,6 +783,83 @@ teams .description("List a team's credential vaults.") .action((slug, options) => teamsVaultsAction(slug, options.format as OutputFormat)); +// `--category db --category api` and `--category db,api` both work. +const collectCategory = (value: string, previous: string[] = []) => [...previous, value]; + +teams + .command("categories") + .argument("[slug]", "Team slug; when given, counts that team's secrets per category") + .option("--format ", "table, json, or markdown", "table") + .description("List the secret categories you can filter by (db, social, server, api, …).") + .addHelpText( + "after", + ` +Examples: + logicsrc teams categories the category words and what each one catches + logicsrc teams categories profullstack how many secrets the team has in each + +The category is worked out from the secret's NAME, e.g. DATABASE_URL is db and +TWITTER_API_KEY is social. Use a category with "teams secrets" and "teams export". +` + ) + .action((slug, options) => teamsCategoriesAction(slug, { format: options.format as OutputFormat })); + +teams + .command("secrets") + .alias("ls") + .argument("", "Team slug") + .argument("[project]", "Only this project (omit for every vault in the team)") + .argument("[env]", "Only this environment (prod, staging, …)") + .option("-c, --category ", "db, social, server, api, … (comma-separated or repeated)", collectCategory) + .option("-s, --search ", "only keys whose name contains this") + .option("--format ", "table, json, markdown, or csv", "table") + .description("List secret names and their category. Never decrypts or shows a value.") + .addHelpText( + "after", + ` +Examples: + logicsrc teams secrets profullstack every secret name in the team + logicsrc teams secrets profullstack --category db database secrets only + logicsrc teams secrets profullstack -c social,api two categories at once + logicsrc teams secrets profullstack coinpayportal prod one vault + logicsrc teams secrets profullstack -s stripe names containing "stripe" + logicsrc teams secrets profullstack --format csv > names.csv + +Categories: logicsrc teams categories +` + ) + .action((slug, project, env, options) => + teamsSecretsAction(slug, { project, env, category: options.category, search: options.search }, { format: options.format }) + ); + +teams + .command("export") + .argument("", "Team slug") + .argument("[project]", "Only this project (omit for every vault in the team)") + .argument("[env]", "Only this environment (prod, staging, …)") + .option("-o, --out ", "CSV file to write, or - for stdout", "secrets.csv") + .option("-c, --category ", "db, social, server, api, … (comma-separated or repeated)", collectCategory) + .option("-s, --search ", "only keys whose name contains this") + .option("-y, --yes", "skip the are-you-sure prompt (needed when not in a terminal)") + .description("Decrypt secrets into a CSV: team,project,env,category,key,value,updated_at.") + .addHelpText( + "after", + ` +Examples: + logicsrc teams export profullstack everything you can decrypt -> secrets.csv + logicsrc teams export profullstack --category db -o db.csv + logicsrc teams export profullstack coinpayportal prod -o coinpay-prod.csv + logicsrc teams export profullstack -c social --out - --yes | less + +The file holds every value in the clear (written 0600). Delete it when done: + shred -u secrets.csv +Vaults you have no access to are skipped and listed at the end. +` + ) + .action((slug, project, env, options) => + teamsExportAction(slug, { project, env, category: options.category, search: options.search }, { out: options.out, yes: options.yes }) + ); + teams .command("tui") .alias("ui") diff --git a/packages/cli/src/teams.ts b/packages/cli/src/teams.ts index 716b836..1561f70 100644 --- a/packages/cli/src/teams.ts +++ b/packages/cli/src/teams.ts @@ -3,6 +3,8 @@ import { createHash, randomBytes } from "node:crypto"; import { hostname } from "node:os"; import { spawn } from "node:child_process"; import { createInterface } from "node:readline/promises"; +import { chmodSync, writeFileSync } from "node:fs"; +import { OTHER_CATEGORY, SECRET_CATEGORIES, categorizeSecret, csvLine, parseCategories } from "@logicsrc/opencreds"; import { TeamClient, TeamApiError, @@ -16,9 +18,11 @@ import { identityPath, unwrapVaultKey, wrapVaultKey, - type CredentialEndpoint + decryptValue, + type CredentialEndpoint, + type RemoteVault } from "@logicsrc/plugin-credential-sharing"; -import { print, type OutputFormat } from "./format.js"; +import { print, printColumns, type OutputFormat } from "./format.js"; import { linkedDirectory, requireSecretsLink, writeSecretsLink } from "./secrets-link.js"; /** @@ -516,3 +520,213 @@ export async function teamsTuiAction(options: { theme?: string } = {}): Promise< const { runVaultTui } = await import("./vault-tui-run.js"); await runVaultTui({ client, identity: identity.email, theme: options.theme }); } + +// ------------------------------------------------------------ categories --- +// +// `teams secrets` and `teams export` answer "show me every database password" +// or "give me all the social keys as a spreadsheet". The category comes from +// the key NAME (see @logicsrc/opencreds categories.ts), so listing never has to +// decrypt anything and a list and an export of the same filter always agree. + +export interface SecretFilter { + project?: string; + env?: string; + category?: string | string[]; + search?: string; +} + +interface SecretRow { + team: string; + project: string; + env: string; + vault: string; + category: string; + key: string; + updatedAt: string; +} + +/** Map with at most `limit` promises in flight; a team can hold hundreds of vaults. */ +async function mapLimit(items: readonly T[], limit: number, fn: (item: T) => Promise): Promise { + const out = new Array(items.length); + let next = 0; + const worker = async (): Promise => { + while (next < items.length) { + const index = next++; + out[index] = await fn(items[index]!); + } + }; + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)); + return out; +} + +/** The vaults a filter addresses: all of them, one project's, or one project/env. */ +function selectVaults(vaults: RemoteVault[], filter: SecretFilter): Array { + return vaults + .map((v) => { + const parts = splitVaultName(v.name); + return { ...v, project: parts?.project ?? v.name, env: parts?.env ?? "" }; + }) + .filter((v) => (filter.project ? v.project === filter.project : true)) + .filter((v) => (filter.env ? v.env === filter.env : true)) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +function matcher(filter: SecretFilter): (row: { key: string; category: string }) => boolean { + const categories = parseCategories(filter.category); + const needle = filter.search?.toLowerCase(); + return (row) => + (!categories || categories.has(row.category)) && (!needle || row.key.toLowerCase().includes(needle)); +} + +/** Every secret NAME the filter matches, with its category. Decrypts nothing. */ +async function collectSecretRows(client: TeamClient, slug: string, filter: SecretFilter): Promise { + const keep = matcher(filter); + const { vaults } = await client.listVaults(slug); + const selected = selectVaults(vaults, filter); + if (selected.length === 0) throw new Error(noVaultsMessage(slug, filter)); + const perVault = await mapLimit(selected, 8, async (v) => { + const { secrets } = await client.listSecrets(v.id); + return secrets.map((s) => ({ + team: slug, project: v.project, env: v.env, vault: v.name, + category: categorizeSecret(s.name), key: s.name, updatedAt: s.updatedAt + })); + }); + return perVault.flat().filter(keep); +} + +function noVaultsMessage(slug: string, filter: SecretFilter): string { + const where = [filter.project, filter.env].filter(Boolean).join("/"); + return where + ? `No vault matches ${slug}/${where}. See what exists: logicsrc teams vaults ${slug}` + : `Team "${slug}" has no vaults yet. Push one: logicsrc teams push ${slug} `; +} + +function namesCsv(rows: SecretRow[]): string { + const lines = [csvLine(["team", "project", "env", "category", "key", "updated_at"])]; + for (const r of rows) lines.push(csvLine([r.team, r.project, r.env, r.category, r.key, r.updatedAt])); + return `${lines.join("\n")}\n`; +} + +/** `logicsrc teams categories [team]` — the filter words, with counts when a team is given. */ +export async function teamsCategoriesAction(slug: string | undefined, options: { format: OutputFormat }): Promise { + let counts: Map | undefined; + if (slug) { + const { client } = authedClient(); + counts = new Map(); + for (const row of await collectSecretRows(client, slug, {})) counts.set(row.category, (counts.get(row.category) ?? 0) + 1); + } + const rows = [...SECRET_CATEGORIES, OTHER_CATEGORY].map((c) => ({ + category: c.id, + ...(counts ? { secrets: counts.get(c.id) ?? 0 } : {}), + description: c.description, + aliases: c.aliases.join(", "), + examples: c.examples.join(", ") + })); + if (options.format === "table") { + printColumns(rows.map(({ examples: _examples, ...row }) => row)); + console.error("\nFilter with: logicsrc teams secrets --category db (or: teams export --category db)"); + return; + } + print(rows, options.format); +} + +/** `logicsrc teams secrets [project] [env]` — names and categories, never values. */ +export async function teamsSecretsAction( + slug: string, + filter: SecretFilter, + options: { format: OutputFormat | "csv" } +): Promise { + const { client } = authedClient(); + const rows = await collectSecretRows(client, slug, filter); + if (options.format === "csv") { + process.stdout.write(namesCsv(rows)); + return; + } + if (rows.length === 0) { + console.error("No secrets match. Categories: logicsrc teams categories"); + return; + } + const vaults = new Set(rows.map((r) => r.vault)).size; + console.error(`${rows.length} secret(s) in ${vaults} vault(s). Values stay encrypted; export them with: logicsrc teams export ${slug} …`); + const brief = rows.map((r) => ({ project: r.project, env: r.env, category: r.category, key: r.key })); + if (options.format === "table") printColumns(brief); + else print(options.format === "json" ? rows : brief, options.format); +} + +async function confirmPlaintext(message: string): Promise { + if (!process.stdin.isTTY || !process.stderr.isTTY) return false; + const prompt = createInterface({ input: process.stdin, output: process.stderr }); + try { + return /^y(es)?$/i.test((await prompt.question(`${message} [y/N] `)).trim()); + } finally { + prompt.close(); + } +} + +/** + * `logicsrc teams export [project] [env] --out creds.csv` + * + * Decrypts on this machine and writes one CSV row per secret: + * team,project,env,category,key,value,updated_at. Vaults you hold no grant for + * are skipped and named, rather than failing the whole export. + */ +export async function teamsExportAction( + slug: string, + filter: SecretFilter, + options: { out: string; yes?: boolean } +): Promise { + const { client, identity } = authedClient(); + const keep = matcher(filter); + const { vaults } = await client.listVaults(slug); + const selected = selectVaults(vaults, filter); + if (selected.length === 0) throw new Error(noVaultsMessage(slug, filter)); + + const toStdout = options.out === "-"; + const target = toStdout ? "stdout" : options.out; + if (!options.yes) { + console.error(`About to write decrypted secrets from ${selected.length} vault(s) to ${target} in the clear.`); + console.error("Anyone who can read that file can use every secret in it."); + if (!(await confirmPlaintext("Continue?"))) { + throw new Error("Refused: exporting plaintext secrets needs confirmation. Re-run with --yes to skip the prompt."); + } + } + + const skipped: string[] = []; + const perVault = await mapLimit(selected, 6, async (v) => { + if (!v.hasAccess) { + skipped.push(v.name); + return []; + } + const { secrets } = await client.listSecrets(v.id); + const wanted = secrets + .map((s) => ({ secret: s, category: categorizeSecret(s.name), key: s.name })) + .filter(keep); + if (wanted.length === 0) return []; + let dek: string; + try { + dek = await unwrapVaultKey((await client.getMyGrant(v.id)).wrappedDek, identity.keys); + } catch { + skipped.push(v.name); + return []; + } + return Promise.all( + wanted.map(async ({ secret, category }) => + csvLine([slug, v.project, v.env, category, secret.name, + await decryptValue({ nonce: secret.nonce, ciphertext: secret.ciphertext }, dek), secret.updatedAt]) + ) + ); + }); + + const lines = perVault.flat(); + const csv = `${[csvLine(["team", "project", "env", "category", "key", "value", "updated_at"]), ...lines].join("\n")}\n`; + if (toStdout) { + process.stdout.write(csv); + } else { + writeFileSync(options.out, csv, { encoding: "utf8", mode: 0o600 }); + try { chmodSync(options.out, 0o600); } catch { /* no modes on this platform */ } + } + console.error(`Exported ${lines.length} secret(s) to ${target}.${toStdout ? "" : " Delete it when you are done: shred -u " + options.out}`); + if (skipped.length) { + console.error(`Skipped ${skipped.length} vault(s) you cannot decrypt: ${skipped.sort().join(", ")}`); + } +} diff --git a/packages/opencreds/src/categories.test.ts b/packages/opencreds/src/categories.test.ts new file mode 100644 index 0000000..a7a3978 --- /dev/null +++ b/packages/opencreds/src/categories.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; + +import { + SECRET_CATEGORIES, + SECRET_CATEGORY_IDS, + categorizeItem, + categorizeSecret, + csvLine, + parseCategories, + toSimpleCsv, +} from "./categories.js"; +import { createItem } from "./items.js"; + +describe("categorizeSecret", () => { + it("puts every documented example in its own category", () => { + for (const category of SECRET_CATEGORIES) { + for (const example of category.examples) { + expect([example, categorizeSecret(example)]).toEqual([example, category.id]); + } + } + }); + + it.each([ + ["DATABASE_URL", "db"], + ["SUPABASE_SERVICE_ROLE_KEY", "db"], + ["SUPABAsE_URL", "db"], + ["TWITTER_API_KEY", "social"], + ["X_CLIENT_SECRET", "social"], + ["SSH_PORT", "server"], + ["SEED1_SUDO_PASSWORD", "server"], + ["TMDB_API_KEY", "api"], + // a service beats the generic word: rotate Stripe, get its webhook secret too + ["STRIPE_WEBHOOK_SECRET", "finance"], + ["COINPAYPORTAL_API_KEY", "finance"], + ["SMTP_HOST", "email"], + ["DB_HOST", "db"], + ["R2_SECRET_ACCESS_KEY", "storage"], + ["SUPABASE_S3_SECRET_KEY", "storage"], + ["SYSTEM_MNEMONIC_BTC", "crypto"], + ["JWT_SECRET", "auth"], + ["NODE_ENV", "config"], + ["NEXT_PUBLIC_APP_URL", "config"], + ["PLANETSCALE_DATABASE_URL", "db"], + ["SOMETHING_ELSE", "other"], + ])("%s is %s", (name, category) => { + expect(categorizeSecret(name)).toBe(category); + }); + + it("reads item names and hosts the same way as env names", () => { + expect(categorizeSecret("My Postgres")).toBe("db"); + expect(categorizeSecret("api.stripe.com")).toBe("finance"); + }); +}); + +describe("categorizeItem", () => { + it("uses the item type where it is unambiguous", () => { + expect(categorizeItem(createItem("card", { name: "Visa" }))).toBe("finance"); + expect(categorizeItem(createItem("key", { name: "laptop", key: { keyType: "ssh" } }))).toBe("server"); + }); + + it("classifies a login by its URL when the name says nothing", () => { + const item = createItem("login", { name: "work", login: { uris: [{ uri: "https://www.reddit.com/login" }] } }); + expect(categorizeItem(item)).toBe("social"); + }); + + it("classifies an env key by its name", () => { + expect(categorizeItem(createItem("key", { name: "REDIS_URL", key: { keyType: "env", value: "redis://x" } }))).toBe("db"); + }); +}); + +describe("parseCategories", () => { + it("accepts commas, repeats and aliases", () => { + expect(parseCategories(["db,social", "payments"])).toEqual(new Set(["db", "social", "finance"])); + expect(parseCategories(" DB ")).toEqual(new Set(["db"])); + }); + + it("means no filter when nothing is given", () => { + expect(parseCategories(undefined)).toBeUndefined(); + expect(parseCategories([])).toBeUndefined(); + }); + + it("names the valid words when one is wrong", () => { + expect(() => parseCategories("databse")).toThrow(/Unknown category "databse".*db/); + }); + + it("lists other last so every secret has a category to filter on", () => { + expect(SECRET_CATEGORY_IDS.at(-1)).toBe("other"); + }); +}); + +describe("csv", () => { + it("quotes only what needs quoting, including multi-line keys", () => { + expect(csvLine(["a", 'b"c', "d,e", "-----BEGIN\nKEY-----", undefined])).toBe('a,"b""c","d,e","-----BEGIN\nKEY-----",'); + }); + + it("keeps key and account secrets, which a Bitwarden CSV drops", () => { + const items = [ + createItem("key", { name: "DATABASE_URL", key: { keyType: "env", value: "postgres://u:p@h/db" } }), + createItem("account", { name: "Mastodon", account: { provider: "mastodon", handle: "@me", accessToken: "tok" } }), + ]; + const lines = toSimpleCsv(items, []).trim().split("\n"); + expect(lines[0]).toBe("folder,category,type,name,username,password,url,value,totp,notes"); + expect(lines[1]).toBe(",db,key,DATABASE_URL,,,,postgres://u:p@h/db,,"); + expect(lines[2]).toBe(",social,account,Mastodon,@me,,mastodon,tok,,"); + }); +}); diff --git a/packages/opencreds/src/categories.ts b/packages/opencreds/src/categories.ts new file mode 100644 index 0000000..5e3efa9 --- /dev/null +++ b/packages/opencreds/src/categories.ts @@ -0,0 +1,224 @@ +/** + * Secret categories — "is this a database password, a social login, a server + * key or an API token?" — derived from the NAME alone. + * + * Names only, on purpose: `logicsrc teams secrets` lists a vault without + * decrypting it, and `teams export` has the values in hand. If a value could + * move a secret between categories, the same key would land in one bucket when + * listed and another when exported, and a filter that disagrees with itself is + * worse than no filter. + * + * The rules are an ordered list and the first match wins, with specific + * services before generic words: STRIPE_WEBHOOK_SECRET is `finance` (you want + * it when rotating Stripe), not `auth` (every webhook secret), and SMTP_HOST is + * `email`, not `server`. Each pattern runs against the name upper-cased with + * every non-alphanumeric run turned into "_" and padded with "_" at both ends, + * so `_OPENAI_` is a whole token and `_COINPAY` is a token prefix + * (COINPAYPORTAL_API_KEY). The same normaliser turns an OpenCreds item name or + * login URL ("api.stripe.com") into tokens, so one table serves both vaults. + */ + +import type { Item } from "./types.js"; + +export interface SecretCategory { + /** The name typed on the command line: `--category db`. */ + id: string; + /** One line for `logicsrc teams categories`. */ + description: string; + /** Other words people reach for; `--category payments` means `finance`. */ + aliases: readonly string[]; + /** A few names this category catches, shown as examples. */ + examples: readonly string[]; + patterns: readonly RegExp[]; +} + +const rule = ( + id: string, + description: string, + aliases: string[], + examples: string[], + patterns: RegExp[], +): SecretCategory => Object.freeze({ id, description, aliases, examples, patterns }); + +export const SECRET_CATEGORIES: readonly SecretCategory[] = Object.freeze([ + rule("crypto", "Wallets, seed phrases, chain RPCs, exchanges", ["wallet", "wallets", "web3", "blockchain", "exchange"], + ["SYSTEM_MNEMONIC_BTC", "SOLANA_RPC_URL", "KRAKEN_API_KEY"], [ + /_(MNEMONIC|SEED_PHRASE|WALLET|WALLETCONNECT|RPC|ALCHEMY|INFURA|HELIUS|QUICKNODE|TATUM|BLOCKFROST|BLOCKSTREAM|LNBITS|LIGHTNING|LN|EVM|SOLANA|SOL|ETH|ETHEREUM|BTC|BITCOIN|BCH|DOGE|XRP|XMR|MONERO|POLYGON|BNB|USDC|USDT|ZEROX|UNISWAP|PUMPFUN|CHANGENOW|KRAKEN|BINANCE|COINBASE|BITSTAMP|MOONPAY|SIDESHIFT|GEMINI_API_SECRET|BYBIT|OKX|KUCOIN|DEX|ARB)_/, + /_(ETHERSCAN|BSCSCAN|POLYGONSCAN|SOLSCAN|CRYPTO_?APIS)/, + ]), + rule("ai", "LLM and model providers", ["llm", "ml", "model", "models"], + ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "ELEVENLABS_API_KEY"], [ + /_(OPENAI|ANTHROPIC|CLAUDE|GEMINI|GOOGLE_AI|GROQ|GROK|XAI|MISTRAL|COHERE|DEEPSEEK|PERPLEXITY|MOONSHOT|DASHSCOPE|QWEN|ZAI|OPENROUTER|TOGETHER|FIREWORKS|REPLICATE|HUGGINGFACE|HF|ELEVENLABS|STABILITY|OLLAMA|VOYAGE|AI)_/, + ]), + rule("finance", "Payments, billing, banking and brokerage", ["payment", "payments", "billing", "bank", "banking", "stripe"], + ["STRIPE_SECRET_KEY", "COINPAY_API_KEY", "PLAID_SECRET"], [ + /_(STRIPE|PAYPAL|COINPAY|SQUARE|BRAINTREE|LEMONSQUEEZY|PADDLE|GUMROAD|CHARGEBEE|REVENUECAT|PLAID|SIMPLEFIN|BTCPAY|X402|ALPACA|APCA|FINNHUB|ROBINHOOD|SHOPIFY|MERCHANT)/, + /_(PAY|PAYMENT|PAYMENTS|PAYOUT|PAYOUTS|INVOICE)_/, + /_(PRICE|PLAN)_/, + ]), + rule("email", "Sending and receiving mail", ["mail", "smtp"], + ["RESEND_API_KEY", "SMTP_PASS", "MAILGUN_DOMAIN"], [ + /_(RESEND|MAILGUN|SENDGRID|POSTMARK|SMTP|IMAP|POP3|SES|MAILCHIMP|BREVO|SENDINBLUE|MAILJET|SPARKPOST|MAIL|EMAIL|EMAILS)_/, + ]), + rule("messaging", "SMS, voice, push, chat and realtime", ["sms", "phone", "push", "chat", "realtime"], + ["TWILIO_AUTH_TOKEN", "VAPID_PRIVATE_KEY", "LIVEKIT_API_SECRET"], [ + /_(TWILIO|TELNYX|VONAGE|NEXMO|PLIVO|SMS|PHONE|VAPID|ONESIGNAL|FCM|APNS|PUSHER|SLACK|DISCORD|TELEGRAM|WHATSAPP|LIVEKIT|TURN|JITSI|AGORA)_/, + ]), + rule("social", "Social networks and publishing platforms", ["socials", "oauth-social"], + ["X_CLIENT_SECRET", "REDDIT_CLIENT_ID", "META_APP_SECRET"], [ + /_(TWITTER|X|FACEBOOK|FB|META|INSTAGRAM|THREADS|LINKEDIN|REDDIT|TIKTOK|YOUTUBE|MASTODON|BLUESKY|BSKY|PINTEREST|NOSTR|FARCASTER|DEVTO|HASHNODE|MEDIUM|POSTIZ|TUMBLR|SNAPCHAT|TWITCH|KICK|LEMMY|SOCIAL)_/, + ]), + rule("storage", "Object storage, buckets and file/media hosts", ["s3", "bucket", "files", "media"], + ["R2_SECRET_ACCESS_KEY", "SUPABASE_S3_SECRET_KEY", "CLOUDINARY_API_SECRET"], [ + /_(S3|R2|B2|BUCKET|STORAGE|CLOUDINARY|UPLOADTHING|BACKBLAZE|MINIO|SPACES|DROPBOX|GCS)_/, + ]), + rule("db", "Databases, caches and backends-as-a-service", ["database", "databases", "sql", "cache", "supabase"], + ["DATABASE_URL", "SUPABASE_SERVICE_ROLE_KEY", "REDIS_URL"], [ + /_(DATABASE|DB|POSTGRES|POSTGRESQL|PG|PGHOST|PGUSER|PGPASSWORD|PGDATABASE|MYSQL|MARIADB|MONGO|MONGODB|REDIS|VALKEY|UPSTASH|TURSO|LIBSQL|SQLITE|SQLITECLOUD|DBSTRING|SUPABASE|NEON|PLANETSCALE|COCKROACH|CLICKHOUSE|ELASTIC|ELASTICSEARCH|SURREAL|DYNAMODB|FIRESTORE|GOTRUE)_/, + ]), + rule("dns", "Domains, DNS and TLS certificates", ["domain", "domains", "tls", "ssl", "certs"], + ["PORKBUN_API_KEY", "PORKBUN_SECRET_API_KEY", "CLOUDFLARE_DNS_TOKEN"], [ + /_(PORKBUN|NAMECHEAP|GODADDY|ROUTE53|DNSIMPLE|DNS|CERTBOT|ACME|LETSENCRYPT)_/, + ]), + rule("analytics", "Analytics, error tracking and logging", ["monitoring", "observability", "logging", "tracking"], + ["SENTRY_DSN", "POSTHOG_KEY", "GOOGLE_ANALYTICS_ID"], [ + /_(SENTRY|DATADOG|POSTHOG|ANALYTICS|GA|PLAUSIBLE|UMAMI|MIXPANEL|AMPLITUDE|SEGMENT|LOGTAIL|BETTERSTACK|NEW_RELIC|NEWRELIC|GRAFANA|HONEYCOMB|DATAFAST|MAXMIND)_/, + ]), + rule("devtools", "Source control, package registries, app stores, signing", ["dev", "git", "github", "ci", "registry"], + ["GITHUB_TOKEN", "NPM_TOKEN", "GPG_PRIVATE_KEY"], [ + /_(GITHUB|GH|GITLAB|BITBUCKET|GITEA|NPM|PYPI|DOCKER|GHCR|AUR|CHOCOLATEY|GPG|PGP|PKG|EXPO|EAS|APPLE|CHROME|FIREFOX|EDGE|CODECOV|LINEAR|JIRA|SH1PT)_/, + ]), + rule("cloud", "Cloud and hosting platforms", ["hosting", "paas", "infra"], + ["RAILWAY_API_TOKEN", "CLOUDFLARE_GLOBAL_API_TOKEN", "FIREBASE_PRIVATE_KEY"], [ + /_(AWS|GCP|GCLOUD|GOOGLE_APPLICATION_CREDENTIALS|AZURE|CLOUDFLARE|CF|DIGITALOCEAN|HETZNER|VULTR|LINODE|RAILWAY|VERCEL|NETLIFY|FLY|RENDER|HEROKU|DOPPLER|FIREBASE)_/, + ]), + rule("server", "Servers, SSH, hosts and proxies", ["ssh", "host", "hosts", "vps", "proxy"], + ["SSH_PORT", "HOST_USER", "PROXY_PASSWORD"], [ + /_(SSH|HOST|HOSTNAME|SERVER|VPS|SUDO|ROOT|DROPLET|SFTP|FTP|PROXY|SEED1)_/, + /_SEED\d+_/, + ]), + rule("auth", "App auth: JWT/session/encryption keys, OAuth apps, webhook and signing secrets", ["jwt", "session", "oauth", "signing", "webhook", "encryption"], + ["JWT_SECRET", "SESSION_SECRET", "GOOGLE_CLIENT_SECRET"], [ + /_(JWT|JWKS|SESSION|AUTH|NEXTAUTH|OAUTH|CLIENT_SECRET|CLIENT_ID|ENCRYPTION|ENCRYPT|COOKIE|SIGNING|SIGN|HMAC|WEBHOOK|PEPPER|SALT|TOTP|OTP|PASSKEY|WEBAUTHN|CLERK|AUTH0|CAPTCHA|HCAPTCHA|RECAPTCHA|TURNSTILE|CRON|TICKET|VERIFY|CSRF|PASSWORD|PASS|USERNAME|LOGIN)_/, + /_SHARED_SECRET_/, + ]), + rule("api", "Other third-party API keys and tokens", ["apis", "token", "tokens", "keys"], + ["TMDB_API_KEY", "VALUESERP_API_KEY", "CAPSOLVER_API_KEY"], [ + /_(API_KEY|API_TOKEN|API_SECRET|APIKEY|ACCESS_KEY|ACCESS_TOKEN|REFRESH_TOKEN|TOKEN|KEY|SECRET|PAT|PRIVATE_KEY|SECRET_KEY|LICENSE_KEY|KEY_ID)_/, + ]), + rule("config", "Settings that are not secrets: URLs, ports, flags, limits, names", ["settings", "env", "public"], + ["NODE_ENV", "PORT", "NEXT_PUBLIC_APP_URL"], [ + /^_(NEXT_PUBLIC|PUBLIC|VITE|EXPO_PUBLIC|NODE|APP)_/, + /_(URL|URLS|URI|ORIGIN|ORIGINS|DOMAIN|NAME|ID|PORT|ENV|ENVIRONMENT|MODE|LEVEL|DEBUG|ENABLED|ENABLE|DISABLE|DISABLED|MS|SECONDS|MINUTES|TTL|LIMIT|MAX|MIN|PCT|BPS|USD|CENTS|PATH|DIR|FILE|DESCRIPTION|VERSION|REGION|CONFIG|PROJECT|MODEL|PROVIDER|FORMAT|TIMEOUT|HEADLESS|SIZE|AGENT|INTERVAL|FROM|TO|CONCURRENCY|WIDTH|HEIGHT|FPS|QUALITY|SCOPES|ROLES)_/, + ]), +]); + +/** Everything the rules did not recognise. Always a valid filter value. */ +export const OTHER_CATEGORY: SecretCategory = rule("other", "Anything the rules did not recognise", ["misc", "unknown"], [], []); + +export const SECRET_CATEGORY_IDS: readonly string[] = Object.freeze([...SECRET_CATEGORIES.map((c) => c.id), OTHER_CATEGORY.id]); + +function tokens(text: string): string { + return `_${text.toUpperCase().replace(/[^A-Z0-9]+/g, "_").replace(/^_+|_+$/g, "")}_`; +} + +/** The category of one secret, from its name (an env var, an item name, a URL). */ +export function categorizeSecret(...names: Array): string { + const texts = names.filter((n): n is string => Boolean(n && n.trim())).map(tokens); + for (const category of SECRET_CATEGORIES) { + if (texts.some((text) => category.patterns.some((p) => p.test(text)))) return category.id; + } + return OTHER_CATEGORY.id; +} + +/** + * The category of an OpenCreds item. The item type decides where it is + * unambiguous (a card is money, an ssh key is a server credential); otherwise + * the name, account provider and login hosts are classified like env names. + */ +export function categorizeItem(item: Item): string { + if (item.type === "card") return "finance"; + switch (item.key?.keyType) { + case "ssh": return "server"; + case "pgp": return "devtools"; + case "certificate": return "dns"; + } + const hosts = (item.login?.uris ?? []).map((u) => { + try { + return new URL(u.uri.includes("://") ? u.uri : `https://${u.uri}`).hostname.replace(/^www\./, "").replace(/\.[a-z]+$/, ""); + } catch { + return u.uri; + } + }); + const found = categorizeSecret(item.name, item.account?.provider, ...hosts); + if (found !== OTHER_CATEGORY.id) return found; + if (item.key?.keyType === "api") return "api"; + if (item.key?.keyType === "symmetric") return "auth"; + return found; +} + +/** + * Parse `--category db,social --category api` into canonical ids, accepting + * aliases. Unknown words throw with the list of valid ones, so a typo fails + * loudly instead of silently matching nothing. + */ +export function parseCategories(input: string | readonly string[] | undefined): Set | undefined { + if (input === undefined) return undefined; + const words = (Array.isArray(input) ? input : [input]) + .flatMap((part) => String(part).split(",")) + .map((word) => word.trim().toLowerCase()) + .filter(Boolean); + if (words.length === 0) return undefined; + const all = [...SECRET_CATEGORIES, OTHER_CATEGORY]; + const picked = new Set(); + for (const word of words) { + const match = all.find((c) => c.id === word || c.aliases.includes(word)); + if (!match) { + throw new Error(`Unknown category "${word}". Choose from: ${SECRET_CATEGORY_IDS.join(", ")}.`); + } + picked.add(match.id); + } + return picked; +} + +export const SIMPLE_CSV_COLUMNS = Object.freeze([ + "folder", "category", "type", "name", "username", "password", "url", "value", "totp", "notes", +] as const); + +/** + * One flat row per item, for a spreadsheet or a script: `username`/`password` + * for anything you log in with, and `value` for the one opaque secret of the + * rest — an env or API key, a private key, an access token, a card number. + * Unlike Bitwarden's CSV it keeps key and account items, which is where most + * developer secrets live. It is not an import format; export OpenCreds for that. + */ +export function toSimpleCsv(items: readonly Item[], folders: ReadonlyArray<{ id: string; name: string }>): string { + const folderName = new Map(folders.map((f) => [f.id, f.name])); + const lines = [csvLine(SIMPLE_CSV_COLUMNS)]; + for (const item of items) { + const { login, key, account, card, identity } = item; + lines.push(csvLine([ + item.folderId ? folderName.get(item.folderId) : "", + categorizeItem(item), + item.type, + item.name, + login?.username || account?.handle || account?.email || identity?.username || identity?.email || card?.cardholderName, + login?.password || key?.passphrase || card?.code, + login?.uris?.[0]?.uri || key?.path || account?.provider, + key?.value || key?.privateKey || account?.accessToken || card?.number, + login?.totp, + item.notes, + ])); + } + return `${lines.join("\n")}\n`; +} + +/** One RFC 4180 CSV line; quotes a cell only when it has to. */ +export function csvLine(cells: ReadonlyArray): string { + return cells + .map((cell) => { + const value = cell === undefined || cell === null ? "" : String(cell); + return /[",\n\r]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value; + }) + .join(","); +} diff --git a/packages/opencreds/src/commands.ts b/packages/opencreds/src/commands.ts index 0baf142..3126b66 100644 --- a/packages/opencreds/src/commands.ts +++ b/packages/opencreds/src/commands.ts @@ -24,6 +24,7 @@ import { parseDatabase, readHeader, } from "./database.js"; +import { categorizeItem, parseCategories, toSimpleCsv } from "./categories.js"; import { CSV_LOSSY_FIELDS, IMPORT_SOURCES, parseCsvImport, toBitwardenCsv } from "./importers.js"; import { createItem, @@ -68,6 +69,12 @@ class CliError extends Error { } } +/** + * 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); } @@ -94,7 +101,7 @@ function storeFor(command: Command) { function requireMeta(store: ReturnType) { const meta = store.readMeta(); - if (!meta) fail(`No vault at ${store.baseDir} — run \`opencreds init\` first`, EXIT.USAGE); + if (!meta) fail(`No vault at ${store.baseDir} — create one with \`${cli} init\``, EXIT.USAGE); return meta; } @@ -257,14 +264,27 @@ function applyTypeFlags(command: Command, type: ItemTypeName): Command { 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); - return `${id} ${type} ${item.name}`; + 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 --- @@ -272,6 +292,9 @@ export function registerCredsCommands(parent: Command): void { 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") @@ -311,6 +334,9 @@ export function registerCredsCommands(parent: Command): void { 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) @@ -335,7 +361,7 @@ export function registerCredsCommands(parent: Command): void { 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 `opencreds lock` when you are done.\n", + "every item. Run `" + cli + " lock` when you are done.\n", ); return; } @@ -433,7 +459,13 @@ export function registerCredsCommands(parent: Command): void { // ---------------------------------------------------------------- items --- - const add = parent.command("add").description("add an item"); + 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) @@ -484,11 +516,20 @@ export function registerCredsCommands(parent: Command): void { 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; folder?: string; search?: string; json?: boolean }) { + .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); @@ -500,9 +541,11 @@ export function registerCredsCommands(parent: Command): void { 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)); @@ -523,6 +566,9 @@ export function registerCredsCommands(parent: Command): void { .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") @@ -649,22 +695,35 @@ export function registerCredsCommands(parent: Command): void { 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 or bitwarden-csv", "opencreds") + .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; yes?: boolean }, + 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 payload = await loadPayload(store, userKey); + 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 === "bitwarden-csv"; + const wantsPlaintext = Boolean(opts.plaintext) || opts.format !== "opencreds"; if (wantsPlaintext) { process.stdout.write( @@ -677,6 +736,18 @@ export function registerCredsCommands(parent: Command): void { } } + 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 }); @@ -730,6 +801,10 @@ export function registerCredsCommands(parent: Command): void { .command("import") .argument("", "an OpenCreds database, or a CSV export from another product") .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 ", `force a CSV source (${Object.keys(IMPORT_SOURCES).join(", ")})`) diff --git a/packages/opencreds/src/index.ts b/packages/opencreds/src/index.ts index 6d1f381..168153c 100644 --- a/packages/opencreds/src/index.ts +++ b/packages/opencreds/src/index.ts @@ -197,3 +197,16 @@ export type { UriMatch, VaultMeta, } from "./types.js"; + +export { + SECRET_CATEGORIES, + SECRET_CATEGORY_IDS, + OTHER_CATEGORY, + categorizeSecret, + categorizeItem, + parseCategories, + csvLine, + toSimpleCsv, + SIMPLE_CSV_COLUMNS, + type SecretCategory, +} from "./categories.js"; diff --git a/packages/opencreds/src/prompt.ts b/packages/opencreds/src/prompt.ts index e868116..0e3fceb 100644 --- a/packages/opencreds/src/prompt.ts +++ b/packages/opencreds/src/prompt.ts @@ -8,7 +8,9 @@ */ import { createInterface } from "node:readline"; -import { stdin, stdout } from "node:process"; +// Prompts go to stderr so stdout carries only the answer: `eval "$(opencreds +// unlock)"` then captures the export line and nothing else. +import { stdin, stderr } from "node:process"; /** Read a line with the terminal's echo turned off. */ export async function promptSecret(label: string): Promise { @@ -18,7 +20,7 @@ export async function promptSecret(label: string): Promise { return readLineFromStdin(); } - const rl = createInterface({ input: stdin, output: stdout, terminal: true }); + const rl = createInterface({ input: stdin, output: stderr, terminal: true }); const asMutable = rl as unknown as { output: { write: (chunk: string) => void }; _writeToOutput?: (s: string) => void }; let muted = false; @@ -51,7 +53,7 @@ export async function promptNewSecret(label: string, confirmLabel = "Repeat: "): } export async function promptLine(label: string): Promise { - const rl = createInterface({ input: stdin, output: stdout }); + const rl = createInterface({ input: stdin, output: stderr }); const answer = await new Promise((resolve) => rl.question(label, resolve)); rl.close(); return answer;