Read a Bitwarden JSON export natively (#205)

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 03:59:41 -07:00 • committed by GitHub
parent ad4879b0fe
commit 1f9c25fcd2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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);
});
});