Read a Bitwarden JSON export natively

Importing a Bitwarden export failed with "Not an OpenCreds database" and had to
be converted by hand first. The import command decided what a file was by
`text.trimStart().startsWith("{")`, and treated every JSON as an OpenCreds
database. A Bitwarden export starts with a brace too, so it went to parseDatabase
and was rejected there.

JSON is the format Bitwarden's own UI hands you by default, and the only one of
its formats that keeps folders, custom fields, multiple URIs per login and full
card/identity detail. Its CSV drops all of that, so "export as CSV instead" is a
lossy workaround rather than an answer.

The shape is now sniffed before deciding which reader owns the file: an `items`
array plus either a `folders` array or the `encrypted` flag. An OpenCreds
database has neither at its top level, so the two never collide, and a cheap
substring test means a large database is not parsed twice to find that out.

Everything the format carries is mapped: all four item types, Bitwarden's own
folder ids (so two folders sharing a name stay distinct), custom fields with
their hidden flag, every URI rather than only the first, TOTP secrets, and
favourites. An untitled login is still named after its host. A folderId naming no
folder is dropped rather than inventing a folder, and only folders something
actually landed in come back, so importing one item out of a big export does not
create sixteen empty folders beside it.

An encrypted export is refused outright instead of half-read. Its items are
opaque strings, so a best-effort parse would store ciphertext as if it were a
password and leave a vault full of junk that never decrypts.

Verified against a real 4,395-item export: 4,387 logins, 3 cards, 4 identities,
1 note, 16 folders, zero skipped, parsed in 56ms. Every count matches the source
file, and the command that used to fail now reports "(Bitwarden JSON)".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-09-24 10:54:06 +00:00
parent ad4879b0fe
commit eb013aebdd
5 changed files with 483 additions and 4 deletions

View file

@ -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);
});
});

View file

@ -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<Record<number, FieldKind>> = Object.freeze({
0: "text",
1: "hidden",
2: "boolean",
3: "linked",
});
interface BitwardenFile {
encrypted?: boolean;
folders?: Array<{ id?: string; name?: string }>;
items?: Array<Record<string, unknown>>;
}
/**
* 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<string>();
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<string, unknown>;
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<Item>),
);
return;
}
if (type === BW_TYPE.IDENTITY) {
const identity = (raw.identity ?? {}) as Record<string, unknown>;
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<Item>),
);
return;
}
if (type === BW_TYPE.NOTE) {
items.push(createItem("note", common as Partial<Item>));
return;
}
if (type !== BW_TYPE.LOGIN) {
skipped.push({ row, reason: `Unknown Bitwarden item type ${str(raw.type)}` });
return;
}
const login = (raw.login ?? {}) as Record<string, unknown>;
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<Item>),
);
} 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,
};
}

View file

@ -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("<file>", "an OpenCreds database, or a CSV export from another product")
.argument(
"<file>",
"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(

View file

@ -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;

View file

@ -124,6 +124,8 @@ export {
type ImportSource,
} from "./importers.js";
export { parseBitwardenJson, looksLikeBitwardenJson } from "./bitwarden.js";
export {
validateItem,
validateDatabase,