mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-01 20:33:50 +00:00
Four things the first pass threw away, all of them recorded in the mapping notes from the 2026-09-02 hand conversion and all of them silent. Item ids were regenerated. Bitwarden ids are already UUIDs, so keeping them is what makes a re-import idempotent: run the same export twice and the second run reports its items as already present under "skip", rather than duplicating the entire vault. createItem deliberately refuses a caller-supplied id and always mints a fresh one, so the id is adopted after construction, and only when it really is a UUID. Timestamps were restamped to "now". creationDate and revisionDate are the only record of when a password was last rotated, and overwriting them destroys that permanently. The oldest item in a real export dates to 2018; every one of them would have been dated today. Password history was dropped entirely. It is carried now, newest first, mapping lastUsedDate to changedAt and capped at MAX_HISTORY_ENTRIES. 189 items in a real export have history, so this was not a rare case. URI match rules were hardcoded to "domain". Bitwarden stores them as a number -- 0 domain, 1 host, 2 startsWith, 3 exact, 4 regex, 5 never, with null meaning domain -- and flattening them quietly widens a login pinned to an exact URL, which is a security change rather than a cosmetic one. Verified on the same 4,395-item export: all 4,395 ids carried across, stable across two runs, 189 items with history recovered, and the oldest createdAt still 2018-02-22. That export happens to use domain matching throughout, so the match mapping is covered by tests rather than by it. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
245 lines
8.6 KiB
TypeScript
245 lines
8.6 KiB
TypeScript
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("maps Bitwarden's numeric URI match rules instead of assuming domain", () => {
|
|
// Assuming "domain" for all of them quietly widens a login pinned to an
|
|
// exact URL, which is a security change, not a cosmetic one.
|
|
const parsed = parseBitwardenJson(
|
|
exportOf([
|
|
{
|
|
type: 1,
|
|
name: "x",
|
|
login: {
|
|
uris: [
|
|
{ uri: "https://a.test", match: 3 },
|
|
{ uri: "https://b.test", match: 5 },
|
|
{ uri: "https://c.test", match: null },
|
|
{ uri: "https://d.test" },
|
|
],
|
|
},
|
|
},
|
|
]),
|
|
);
|
|
expect(parsed.items[0]!.login?.uris).toEqual([
|
|
{ uri: "https://a.test", match: "exact" },
|
|
{ uri: "https://b.test", match: "never" },
|
|
{ uri: "https://c.test", match: "domain" },
|
|
{ uri: "https://d.test", match: "domain" },
|
|
]);
|
|
});
|
|
|
|
it("keeps Bitwarden's item id, so a re-import dedupes instead of duplicating", () => {
|
|
const id = "56126b05-485c-44c2-a6fc-acb70001e348";
|
|
const parsed = parseBitwardenJson(exportOf([{ type: 1, id, name: "x", login: {} }]));
|
|
expect(parsed.items[0]!.id).toBe(id);
|
|
});
|
|
|
|
it("keeps the original timestamps, the only record of when a password rotated", () => {
|
|
const parsed = parseBitwardenJson(
|
|
exportOf([
|
|
{
|
|
type: 1,
|
|
name: "x",
|
|
login: {},
|
|
creationDate: "2021-01-21T00:06:52.377Z",
|
|
revisionDate: "2023-05-02T11:00:00.000Z",
|
|
},
|
|
]),
|
|
);
|
|
expect(parsed.items[0]!.createdAt).toBe("2021-01-21T00:06:52.377Z");
|
|
expect(parsed.items[0]!.updatedAt).toBe("2023-05-02T11:00:00.000Z");
|
|
});
|
|
|
|
it("carries password history newest first, capped at the spec limit", () => {
|
|
const history = Array.from({ length: 25 }, (_, i) => ({
|
|
password: `p${i}`,
|
|
// Ascending dates, so the newest is the last one written.
|
|
lastUsedDate: `2024-01-${String(i + 1).padStart(2, "0")}T00:00:00.000Z`,
|
|
}));
|
|
const parsed = parseBitwardenJson(
|
|
exportOf([{ type: 1, name: "x", login: {}, passwordHistory: history }]),
|
|
);
|
|
const got = parsed.items[0]!.history ?? [];
|
|
expect(got).toHaveLength(20);
|
|
expect(got[0]).toMatchObject({ password: "p24" });
|
|
expect(got[19]).toMatchObject({ password: "p5" });
|
|
});
|
|
|
|
it("carries favourite through", () => {
|
|
const parsed = parseBitwardenJson(
|
|
exportOf([{ type: 1, name: "x", favorite: true, login: {} }]),
|
|
);
|
|
expect(parsed.items[0]!.favorite).toBe(true);
|
|
});
|
|
});
|