mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-01 20:33:50 +00:00
Keep what the Bitwarden export actually says
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>
This commit is contained in:
parent
1f9c25fcd2
commit
2d39c70bb9
2 changed files with 149 additions and 10 deletions
|
|
@ -172,6 +172,70 @@ describe("reading a Bitwarden JSON export", () => {
|
||||||
expect(parsed.skipped[0]?.reason).toBe("Not valid JSON");
|
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", () => {
|
it("carries favourite through", () => {
|
||||||
const parsed = parseBitwardenJson(
|
const parsed = parseBitwardenJson(
|
||||||
exportOf([{ type: 1, name: "x", favorite: true, login: {} }]),
|
exportOf([{ type: 1, name: "x", favorite: true, login: {} }]),
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,17 @@
|
||||||
*/
|
*/
|
||||||
import { createItem } from "./items.js";
|
import { createItem } from "./items.js";
|
||||||
import { expandYear, hostOf } from "./importers.js";
|
import { expandYear, hostOf } from "./importers.js";
|
||||||
|
import { MAX_HISTORY_ENTRIES } from "./types.js";
|
||||||
import type {
|
import type {
|
||||||
CustomField,
|
CustomField,
|
||||||
FieldKind,
|
FieldKind,
|
||||||
Folder,
|
Folder,
|
||||||
|
HistoryEntry,
|
||||||
Item,
|
Item,
|
||||||
ItemUri,
|
ItemUri,
|
||||||
ParsedImport,
|
ParsedImport,
|
||||||
SkippedRow,
|
SkippedRow,
|
||||||
|
UriMatch,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
|
||||||
/** Bitwarden's numeric item types. */
|
/** Bitwarden's numeric item types. */
|
||||||
|
|
@ -31,6 +34,21 @@ const BW_FIELD_KIND: Readonly<Record<number, FieldKind>> = Object.freeze({
|
||||||
3: "linked",
|
3: "linked",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bitwarden's numeric URI match rules, in our vocabulary.
|
||||||
|
*
|
||||||
|
* `null`/absent means domain, which is also Bitwarden's default. Assuming
|
||||||
|
* "domain" for all of them would quietly widen a login pinned to an exact URL.
|
||||||
|
*/
|
||||||
|
const BW_URI_MATCH: Readonly<Record<number, UriMatch>> = Object.freeze({
|
||||||
|
0: "domain",
|
||||||
|
1: "host",
|
||||||
|
2: "startsWith",
|
||||||
|
3: "exact",
|
||||||
|
4: "regex",
|
||||||
|
5: "never",
|
||||||
|
});
|
||||||
|
|
||||||
interface BitwardenFile {
|
interface BitwardenFile {
|
||||||
encrypted?: boolean;
|
encrypted?: boolean;
|
||||||
folders?: Array<{ id?: string; name?: string }>;
|
folders?: Array<{ id?: string; name?: string }>;
|
||||||
|
|
@ -73,6 +91,23 @@ function str(value: unknown): string {
|
||||||
return value == null ? "" : String(value);
|
return value == null ? "" : String(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const UUID_RE =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adopt Bitwarden's own item id.
|
||||||
|
*
|
||||||
|
* `createItem` deliberately refuses a caller-supplied id and always mints a
|
||||||
|
* fresh one, so this is applied afterwards. Bitwarden ids are already UUIDs, and
|
||||||
|
* keeping them is what makes a re-import idempotent: the same export run twice
|
||||||
|
* reports its items as already present under "skip" rather than duplicating the
|
||||||
|
* whole vault. A value that is not a UUID is ignored rather than trusted.
|
||||||
|
*/
|
||||||
|
function adoptId(item: Item, id: string): Item {
|
||||||
|
if (UUID_RE.test(id)) item.id = id;
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
function bitwardenFields(raw: unknown): CustomField[] {
|
function bitwardenFields(raw: unknown): CustomField[] {
|
||||||
if (!Array.isArray(raw)) return [];
|
if (!Array.isArray(raw)) return [];
|
||||||
const out: CustomField[] = [];
|
const out: CustomField[] = [];
|
||||||
|
|
@ -97,13 +132,40 @@ function bitwardenUris(raw: unknown): ItemUri[] {
|
||||||
if (!Array.isArray(raw)) return [];
|
if (!Array.isArray(raw)) return [];
|
||||||
const out: ItemUri[] = [];
|
const out: ItemUri[] = [];
|
||||||
for (const entry of raw) {
|
for (const entry of raw) {
|
||||||
const uri =
|
if (typeof entry === "string") {
|
||||||
typeof entry === "string" ? entry : str((entry as { uri?: unknown })?.uri);
|
if (entry) out.push({ uri: entry, match: "domain" });
|
||||||
if (uri) out.push({ uri, match: "domain" });
|
continue;
|
||||||
|
}
|
||||||
|
const obj = (entry ?? {}) as { uri?: unknown; match?: unknown };
|
||||||
|
const uri = str(obj.uri);
|
||||||
|
if (!uri) continue;
|
||||||
|
// null/absent is Bitwarden's own default of domain.
|
||||||
|
const match = obj.match == null ? "domain" : BW_URI_MATCH[Number(obj.match)];
|
||||||
|
out.push({ uri, ...(match ? { match } : {}) });
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bitwarden's password history, newest first.
|
||||||
|
*
|
||||||
|
* `lastUsedDate` is when that password stopped being current, which is our
|
||||||
|
* `changedAt`. Capped at the spec's limit rather than carried whole.
|
||||||
|
*/
|
||||||
|
function bitwardenHistory(raw: unknown): HistoryEntry[] {
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
const out: HistoryEntry[] = [];
|
||||||
|
for (const entry of raw) {
|
||||||
|
if (!entry || typeof entry !== "object") continue;
|
||||||
|
const h = entry as { password?: unknown; lastUsedDate?: unknown };
|
||||||
|
const password = str(h.password);
|
||||||
|
if (!password) continue;
|
||||||
|
out.push({ password, changedAt: str(h.lastUsedDate) });
|
||||||
|
}
|
||||||
|
out.sort((a, b) => (a.changedAt < b.changedAt ? 1 : a.changedAt > b.changedAt ? -1 : 0));
|
||||||
|
return out.slice(0, MAX_HISTORY_ENTRIES);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a Bitwarden JSON export into vault items.
|
* Parse a Bitwarden JSON export into vault items.
|
||||||
*
|
*
|
||||||
|
|
@ -173,7 +235,18 @@ export function parseBitwardenJson(text: string): ParsedImport {
|
||||||
}
|
}
|
||||||
|
|
||||||
const folderId = str(raw.folderId);
|
const folderId = str(raw.folderId);
|
||||||
|
// Bitwarden ids are already UUIDs, so carrying them makes a re-import
|
||||||
|
// idempotent: the same file run twice reports its items as already present
|
||||||
|
// under the "skip" strategy rather than duplicating the whole vault.
|
||||||
|
const id = str(raw.id);
|
||||||
|
// creationDate/revisionDate are the only record of when a password was last
|
||||||
|
// rotated. Restamping them to "now" on import destroys that permanently.
|
||||||
|
const createdAt = str(raw.creationDate);
|
||||||
|
const updatedAt = str(raw.revisionDate);
|
||||||
const common = {
|
const common = {
|
||||||
|
...(id ? { id } : {}),
|
||||||
|
...(createdAt ? { createdAt } : {}),
|
||||||
|
...(updatedAt ? { updatedAt } : {}),
|
||||||
name: str(raw.name),
|
name: str(raw.name),
|
||||||
notes: str(raw.notes),
|
notes: str(raw.notes),
|
||||||
favorite: raw.favorite === true,
|
favorite: raw.favorite === true,
|
||||||
|
|
@ -186,7 +259,7 @@ export function parseBitwardenJson(text: string): ParsedImport {
|
||||||
if (type === BW_TYPE.CARD) {
|
if (type === BW_TYPE.CARD) {
|
||||||
const card = (raw.card ?? {}) as Record<string, unknown>;
|
const card = (raw.card ?? {}) as Record<string, unknown>;
|
||||||
items.push(
|
items.push(
|
||||||
createItem("card", {
|
adoptId(createItem("card", {
|
||||||
...common,
|
...common,
|
||||||
card: {
|
card: {
|
||||||
cardholderName: str(card.cardholderName),
|
cardholderName: str(card.cardholderName),
|
||||||
|
|
@ -196,7 +269,7 @@ export function parseBitwardenJson(text: string): ParsedImport {
|
||||||
expYear: expandYear(str(card.expYear)),
|
expYear: expandYear(str(card.expYear)),
|
||||||
code: str(card.code),
|
code: str(card.code),
|
||||||
},
|
},
|
||||||
} as Partial<Item>),
|
} as Partial<Item>), id),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -204,7 +277,7 @@ export function parseBitwardenJson(text: string): ParsedImport {
|
||||||
if (type === BW_TYPE.IDENTITY) {
|
if (type === BW_TYPE.IDENTITY) {
|
||||||
const identity = (raw.identity ?? {}) as Record<string, unknown>;
|
const identity = (raw.identity ?? {}) as Record<string, unknown>;
|
||||||
items.push(
|
items.push(
|
||||||
createItem("identity", {
|
adoptId(createItem("identity", {
|
||||||
...common,
|
...common,
|
||||||
identity: {
|
identity: {
|
||||||
title: str(identity.title),
|
title: str(identity.title),
|
||||||
|
|
@ -226,13 +299,13 @@ export function parseBitwardenJson(text: string): ParsedImport {
|
||||||
passportNumber: str(identity.passportNumber),
|
passportNumber: str(identity.passportNumber),
|
||||||
licenseNumber: str(identity.licenseNumber),
|
licenseNumber: str(identity.licenseNumber),
|
||||||
},
|
},
|
||||||
} as Partial<Item>),
|
} as Partial<Item>), id),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === BW_TYPE.NOTE) {
|
if (type === BW_TYPE.NOTE) {
|
||||||
items.push(createItem("note", common as Partial<Item>));
|
items.push(adoptId(createItem("note", common as Partial<Item>), id));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -243,17 +316,19 @@ export function parseBitwardenJson(text: string): ParsedImport {
|
||||||
|
|
||||||
const login = (raw.login ?? {}) as Record<string, unknown>;
|
const login = (raw.login ?? {}) as Record<string, unknown>;
|
||||||
const uris = bitwardenUris(login.uris);
|
const uris = bitwardenUris(login.uris);
|
||||||
|
const history = bitwardenHistory(raw.passwordHistory);
|
||||||
items.push(
|
items.push(
|
||||||
createItem("login", {
|
adoptId(createItem("login", {
|
||||||
...common,
|
...common,
|
||||||
name: common.name || hostOf(uris[0]?.uri ?? ""),
|
name: common.name || hostOf(uris[0]?.uri ?? ""),
|
||||||
|
...(history.length > 0 ? { history } : {}),
|
||||||
login: {
|
login: {
|
||||||
username: str(login.username),
|
username: str(login.username),
|
||||||
password: str(login.password),
|
password: str(login.password),
|
||||||
totp: str(login.totp),
|
totp: str(login.totp),
|
||||||
uris,
|
uris,
|
||||||
},
|
},
|
||||||
} as Partial<Item>),
|
} as Partial<Item>), id),
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
skipped.push({ row, reason: (err as Error).message });
|
skipped.push({ row, reason: (err as Error).message });
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue