diff --git a/packages/opencreds/src/bitwarden.test.ts b/packages/opencreds/src/bitwarden.test.ts new file mode 100644 index 0000000..24c1577 --- /dev/null +++ b/packages/opencreds/src/bitwarden.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from "vitest"; + +import { looksLikeBitwardenJson, parseBitwardenJson } from "./index.js"; +import { looksLikeBitwardenText } from "./bitwarden.js"; + +/** A minimal export in Bitwarden's real shape. */ +function exportOf(items: unknown[], folders: unknown[] = []) { + return JSON.stringify({ encrypted: false, folders, items }); +} + +describe("telling a Bitwarden export from an OpenCreds database", () => { + it("accepts a Bitwarden export", () => { + expect(looksLikeBitwardenText(exportOf([]))).toBe(true); + }); + + it("rejects an OpenCreds database, which also starts with a brace", () => { + // The whole bug: this used to be the only branch, so a Bitwarden export was + // handed to the OpenCreds reader and died as "Not an OpenCreds database". + const db = JSON.stringify({ + opencreds: "0.1", + type: "opencreds.database", + protected: true, + payload: "…ciphertext…", + }); + expect(looksLikeBitwardenText(db)).toBe(false); + }); + + it("rejects text that is not JSON at all", () => { + expect(looksLikeBitwardenText("name,username\na,b\n")).toBe(false); + }); + + it("needs more than an items array to claim a file", () => { + expect(looksLikeBitwardenJson({ items: [] })).toBe(false); + expect(looksLikeBitwardenJson({ items: [], encrypted: false })).toBe(true); + }); +}); + +describe("reading a Bitwarden JSON export", () => { + it("reads a login with its uris, username and password", () => { + const parsed = parseBitwardenJson( + exportOf([ + { + type: 1, + name: "Example", + login: { + username: "ann", + password: "hunter2", + totp: "otpauth://x", + uris: [{ uri: "https://example.com" }, { uri: "https://alt.example" }], + }, + }, + ]), + ); + expect(parsed.source).toBe("bitwarden"); + expect(parsed.items).toHaveLength(1); + const item = parsed.items[0]!; + expect(item.type).toBe("login"); + expect(item.login?.username).toBe("ann"); + expect(item.login?.password).toBe("hunter2"); + expect(item.login?.totp).toBe("otpauth://x"); + // Both URIs survive; the CSV export would have kept only the first. + expect(item.login?.uris.map((u) => u.uri)).toEqual([ + "https://example.com", + "https://alt.example", + ]); + }); + + it("names an untitled login after its host", () => { + const parsed = parseBitwardenJson( + exportOf([{ type: 1, name: "", login: { uris: [{ uri: "https://www.example.com/x" }] } }]), + ); + expect(parsed.items[0]!.name).toBe("example.com"); + }); + + it("keeps custom fields, including which ones are hidden", () => { + const parsed = parseBitwardenJson( + exportOf([ + { + type: 1, + name: "x", + login: {}, + fields: [ + { name: "Company ID", value: "abc", type: 0 }, + { name: "PIN", value: "1234", type: 1 }, + // Named but empty: a placeholder the user made on purpose, so it is + // kept. Only a field with neither name nor value is dropped. + { name: "placeholder", value: "", type: 0 }, + { name: "", value: "", type: 0 }, + ], + }, + ]), + ); + const fields = parsed.items[0]!.fields ?? []; + expect(fields).toHaveLength(3); + expect(fields[0]).toMatchObject({ name: "Company ID", value: "abc", type: "text" }); + expect(fields[1]).toMatchObject({ name: "PIN", type: "hidden", hidden: true }); + expect(fields[2]).toMatchObject({ name: "placeholder", value: "" }); + }); + + it("reads cards, expanding a two-digit year", () => { + const parsed = parseBitwardenJson( + exportOf([ + { + type: 3, + name: "amex", + card: { cardholderName: "A E", brand: "Amex", number: "3782", expMonth: "4", expYear: "28", code: "123" }, + }, + ]), + ); + const item = parsed.items[0]!; + expect(item.type).toBe("card"); + expect(item.card?.expYear).toBe("2028"); + expect(item.card?.number).toBe("3782"); + }); + + it("reads identities and secure notes", () => { + const parsed = parseBitwardenJson( + exportOf([ + { type: 4, name: "me", identity: { firstName: "Ann", lastName: "Lee", email: "a@b.c" } }, + { type: 2, name: "note", notes: "remember this", secureNote: { type: 0 } }, + ]), + ); + expect(parsed.items.map((i) => i.type)).toEqual(["identity", "note"]); + expect(parsed.items[0]!.identity?.firstName).toBe("Ann"); + expect(parsed.items[1]!.notes).toBe("remember this"); + }); + + it("resolves folders by Bitwarden's own id, and keeps only the ones used", () => { + const parsed = parseBitwardenJson( + exportOf( + [{ type: 1, name: "x", folderId: "f1", login: {} }], + [ + { id: "f1", name: "Email" }, + { id: "f2", name: "Unused" }, + ], + ), + ); + expect(parsed.folders).toEqual([{ id: "f1", name: "Email" }]); + expect(parsed.items[0]!.folderId).toBe("f1"); + }); + + it("drops a folderId that names no folder rather than inventing one", () => { + const parsed = parseBitwardenJson( + exportOf([{ type: 1, name: "x", folderId: "ghost", login: {} }], []), + ); + expect(parsed.items[0]!.folderId).toBeNull(); + expect(parsed.folders).toEqual([]); + }); + + it("refuses an encrypted export instead of storing ciphertext as passwords", () => { + const text = JSON.stringify({ encrypted: true, folders: [], items: ["2.aBc|dEf"] }); + const parsed = parseBitwardenJson(text); + expect(parsed.items).toEqual([]); + expect(parsed.skipped[0]?.reason).toMatch(/encrypt/i); + }); + + it("skips an unreadable row and keeps the rest, reporting the row number", () => { + const parsed = parseBitwardenJson( + exportOf([ + { type: 1, name: "first", login: {} }, + { type: 99, name: "weird" }, + { type: 1, name: "third", login: {} }, + ]), + ); + expect(parsed.items.map((i) => i.name)).toEqual(["first", "third"]); + expect(parsed.skipped).toEqual([{ row: 2, reason: "Unknown Bitwarden item type 99" }]); + }); + + it("reports bad JSON rather than throwing", () => { + const parsed = parseBitwardenJson("{not json"); + expect(parsed.source).toBeNull(); + expect(parsed.skipped[0]?.reason).toBe("Not valid JSON"); + }); + + it("carries favourite through", () => { + const parsed = parseBitwardenJson( + exportOf([{ type: 1, name: "x", favorite: true, login: {} }]), + ); + expect(parsed.items[0]!.favorite).toBe(true); + }); +}); diff --git a/packages/opencreds/src/bitwarden.ts b/packages/opencreds/src/bitwarden.ts new file mode 100644 index 0000000..52a974d --- /dev/null +++ b/packages/opencreds/src/bitwarden.ts @@ -0,0 +1,274 @@ +/** + * Bitwarden JSON import. + * + * The JSON export is what Bitwarden's own UI hands you by default, and it is the + * only one of its formats that keeps folders, custom fields, multiple URIs and + * full card/identity detail — its CSV drops all of that. Until this existed, any + * file starting with `{` was assumed to be an OpenCreds database, so a Bitwarden + * export was rejected with "Not an OpenCreds database" and had to be converted + * by hand first. It is read natively here. + */ +import { createItem } from "./items.js"; +import { expandYear, hostOf } from "./importers.js"; +import type { + CustomField, + FieldKind, + Folder, + Item, + ItemUri, + ParsedImport, + SkippedRow, +} from "./types.js"; + +/** Bitwarden's numeric item types. */ +const BW_TYPE = Object.freeze({ LOGIN: 1, NOTE: 2, CARD: 3, IDENTITY: 4 }); + +/** Bitwarden's numeric custom-field types, in our vocabulary. */ +const BW_FIELD_KIND: Readonly> = Object.freeze({ + 0: "text", + 1: "hidden", + 2: "boolean", + 3: "linked", +}); + +interface BitwardenFile { + encrypted?: boolean; + folders?: Array<{ id?: string; name?: string }>; + items?: Array>; +} + +/** + * Does this JSON look like a Bitwarden export? + * + * Deliberately narrow: an `items` array plus either a `folders` array or the + * `encrypted` flag. An OpenCreds database has neither at its top level, so the + * two formats never collide. + */ +export function looksLikeBitwardenJson(value: unknown): boolean { + if (!value || typeof value !== "object") return false; + const file = value as BitwardenFile; + if (!Array.isArray(file.items)) return false; + return Array.isArray(file.folders) || typeof file.encrypted === "boolean"; +} + +/** + * Same question, asked of raw text. + * + * The caller has a file that starts with "{" and has to decide whether the + * OpenCreds reader or this one owns it, before either has parsed anything. + */ +export function looksLikeBitwardenText(text: string): boolean { + // Cheap prefilter: every Bitwarden export has a top-level "items" array, so an + // OpenCreds database is rejected without paying to parse it. + if (!text.includes('"items"')) return false; + try { + return looksLikeBitwardenJson(JSON.parse(text)); + } catch { + return false; + } +} + +function str(value: unknown): string { + if (typeof value === "string") return value; + return value == null ? "" : String(value); +} + +function bitwardenFields(raw: unknown): CustomField[] { + if (!Array.isArray(raw)) return []; + const out: CustomField[] = []; + for (const entry of raw) { + if (!entry || typeof entry !== "object") continue; + const field = entry as { name?: unknown; value?: unknown; type?: unknown }; + const name = str(field.name); + const value = str(field.value); + if (!name && !value) continue; + const kind = BW_FIELD_KIND[Number(field.type)] ?? "text"; + out.push({ + name, + value, + type: kind, + ...(kind === "hidden" ? { hidden: true } : {}), + }); + } + return out; +} + +function bitwardenUris(raw: unknown): ItemUri[] { + if (!Array.isArray(raw)) return []; + const out: ItemUri[] = []; + for (const entry of raw) { + const uri = + typeof entry === "string" ? entry : str((entry as { uri?: unknown })?.uri); + if (uri) out.push({ uri, match: "domain" }); + } + return out; +} + +/** + * Parse a Bitwarden JSON export into vault items. + * + * An encrypted export is refused rather than half-read: its `items` are opaque + * strings, so a best-effort parse would store ciphertext as if it were a + * password and the vault would look full of junk that never decrypts. + */ +export function parseBitwardenJson(text: string): ParsedImport { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return { + source: null, + items: [], + folders: [], + skipped: [{ row: 0, reason: "Not valid JSON" }], + }; + } + + if (!looksLikeBitwardenJson(parsed)) { + return { + source: null, + items: [], + folders: [], + skipped: [{ row: 0, reason: "Not a Bitwarden export" }], + }; + } + + const file = parsed as BitwardenFile; + if (file.encrypted === true) { + return { + source: "bitwarden", + items: [], + folders: [], + skipped: [ + { + row: 0, + reason: + "Encrypted Bitwarden export — re-export with encryption turned off", + }, + ], + }; + } + + // Bitwarden's own folder ids are kept, so each item's folderId resolves + // directly and two folders sharing a name stay distinct. + const folders: Folder[] = []; + const knownFolderIds = new Set(); + for (const folder of file.folders ?? []) { + const id = str(folder?.id); + const name = str(folder?.name).trim(); + if (!id || !name) continue; + folders.push({ id, name }); + knownFolderIds.add(id); + } + + const items: Item[] = []; + const skipped: SkippedRow[] = []; + + (file.items ?? []).forEach((raw, index) => { + // 1-based, to match how the CSV importer reports a row. + const row = index + 1; + if (!raw || typeof raw !== "object") { + skipped.push({ row, reason: "Not an object" }); + return; + } + + const folderId = str(raw.folderId); + const common = { + name: str(raw.name), + notes: str(raw.notes), + favorite: raw.favorite === true, + folderId: folderId && knownFolderIds.has(folderId) ? folderId : null, + fields: bitwardenFields(raw.fields), + }; + + const type = Number(raw.type); + try { + if (type === BW_TYPE.CARD) { + const card = (raw.card ?? {}) as Record; + items.push( + createItem("card", { + ...common, + card: { + cardholderName: str(card.cardholderName), + brand: str(card.brand), + number: str(card.number), + expMonth: str(card.expMonth), + expYear: expandYear(str(card.expYear)), + code: str(card.code), + }, + } as Partial), + ); + return; + } + + if (type === BW_TYPE.IDENTITY) { + const identity = (raw.identity ?? {}) as Record; + items.push( + createItem("identity", { + ...common, + identity: { + title: str(identity.title), + firstName: str(identity.firstName), + middleName: str(identity.middleName), + lastName: str(identity.lastName), + username: str(identity.username), + company: str(identity.company), + email: str(identity.email), + phone: str(identity.phone), + address1: str(identity.address1), + address2: str(identity.address2), + address3: str(identity.address3), + city: str(identity.city), + state: str(identity.state), + postalCode: str(identity.postalCode), + country: str(identity.country), + ssn: str(identity.ssn), + passportNumber: str(identity.passportNumber), + licenseNumber: str(identity.licenseNumber), + }, + } as Partial), + ); + return; + } + + if (type === BW_TYPE.NOTE) { + items.push(createItem("note", common as Partial)); + return; + } + + if (type !== BW_TYPE.LOGIN) { + skipped.push({ row, reason: `Unknown Bitwarden item type ${str(raw.type)}` }); + return; + } + + const login = (raw.login ?? {}) as Record; + const uris = bitwardenUris(login.uris); + items.push( + createItem("login", { + ...common, + name: common.name || hostOf(uris[0]?.uri ?? ""), + login: { + username: str(login.username), + password: str(login.password), + totp: str(login.totp), + uris, + }, + } as Partial), + ); + } catch (err) { + skipped.push({ row, reason: (err as Error).message }); + } + }); + + // Hand back only folders something actually landed in, so importing one item + // out of a big export does not create sixteen empty folders beside it. + const used = new Set( + items.map((item) => item.folderId).filter(Boolean) as string[], + ); + return { + source: "bitwarden", + items, + folders: folders.filter((folder) => used.has(folder.id)), + skipped, + }; +} diff --git a/packages/opencreds/src/commands.ts b/packages/opencreds/src/commands.ts index 3126b66..7caf02f 100644 --- a/packages/opencreds/src/commands.ts +++ b/packages/opencreds/src/commands.ts @@ -26,6 +26,7 @@ import { } from "./database.js"; import { categorizeItem, parseCategories, toSimpleCsv } from "./categories.js"; import { CSV_LOSSY_FIELDS, IMPORT_SOURCES, parseCsvImport, toBitwardenCsv } from "./importers.js"; +import { looksLikeBitwardenText, parseBitwardenJson } from "./bitwarden.js"; import { createItem, decryptItems, @@ -799,7 +800,10 @@ export function registerCredsCommands(parent: Command): void { parent .command("import") - .argument("", "an OpenCreds database, or a CSV export from another product") + .argument( + "", + "an OpenCreds database, a Bitwarden JSON export, 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 @@ -842,8 +846,26 @@ export function registerCredsCommands(parent: Command): void { let sourceLabel: string; let skipped: Array<{ row: number; reason: string }> = []; + // A Bitwarden JSON export also starts with "{". It used to be handed + // straight to parseDatabase and rejected as "Not an OpenCreds database", + // which is why importing one meant converting it by hand first. Sniff + // the shape before deciding which reader owns the file. const isJson = text.trimStart().startsWith("{"); - if (isJson) { + const isBitwardenJson = isJson && looksLikeBitwardenText(text); + + if (isBitwardenJson) { + const parsed = parseBitwardenJson(text); + if (parsed.items.length === 0) { + fail( + parsed.skipped[0]?.reason ?? `Nothing to import from ${file}`, + EXIT.VALIDATION, + ); + } + incoming = { folders: parsed.folders, items: parsed.items }; + skipped = parsed.skipped; + sourceLabel = "bitwarden"; + process.stdout.write(` Source ${file} (Bitwarden JSON)\n\n`); + } else if (isJson) { const db = parseDatabase(text); const header = readHeader(db); process.stdout.write( diff --git a/packages/opencreds/src/importers.ts b/packages/opencreds/src/importers.ts index 159788d..25a644b 100644 --- a/packages/opencreds/src/importers.ts +++ b/packages/opencreds/src/importers.ts @@ -124,7 +124,7 @@ function truthy(value: string): boolean { } /** Best-effort hostname, used to name an item whose export had no title. */ -function hostOf(uri: string): string { +export function hostOf(uri: string): string { if (!uri) return ""; try { return new URL(uri).hostname.replace(/^www\./, ""); @@ -134,7 +134,7 @@ function hostOf(uri: string): string { } /** Two-digit years are expanded to 20xx; a card that expired in 1926 is a typo. */ -function expandYear(value: string): string { +export function expandYear(value: string): string { const clean = value.trim(); if (/^\d{2}$/.test(clean)) return `20${clean}`; return clean; diff --git a/packages/opencreds/src/index.ts b/packages/opencreds/src/index.ts index 168153c..e4e45f0 100644 --- a/packages/opencreds/src/index.ts +++ b/packages/opencreds/src/index.ts @@ -124,6 +124,8 @@ export { type ImportSource, } from "./importers.js"; +export { parseBitwardenJson, looksLikeBitwardenJson } from "./bitwarden.js"; + export { validateItem, validateDatabase,