diff --git a/packages/opencreds/src/commands.ts b/packages/opencreds/src/commands.ts index 7caf02f..a20a0fa 100644 --- a/packages/opencreds/src/commands.ts +++ b/packages/opencreds/src/commands.ts @@ -25,8 +25,13 @@ import { readHeader, } 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 { CSV_LOSSY_FIELDS, toBitwardenCsv } from "./importers.js"; +import { + isKnownSource, + routeImport, + sourceHelp, + SOURCE_NAMES, +} from "./import-router.js"; import { createItem, decryptItems, @@ -802,7 +807,7 @@ export function registerCredsCommands(parent: Command): void { .command("import") .argument( "", - "an OpenCreds database, a Bitwarden JSON export, or a CSV export from another product", + "an OpenCreds database, a Bitwarden JSON export, a 1Password .1pux, or a CSV export", ) .description("import into the vault") .addHelpText("after", examples(` @@ -811,7 +816,10 @@ export function registerCredsCommands(parent: Command): void { $CLI import backup.opencreds restore an OpenCreds export`)) .option("--dry-run", "report what would happen and write nothing") .option("--merge ", "skip, replace or duplicate", "skip") - .option("--source ", `force a CSV source (${Object.keys(IMPORT_SOURCES).join(", ")})`) + .option( + "--source ", + `where the export came from, when it cannot be told from the file (${SOURCE_NAMES.join(", ")})`, + ) .option("--passphrase-stdin", "read the database passphrase from stdin") .option("--allow-unregistered-namespace", "open a database whose namespace is not registered") .action(async function ( @@ -835,37 +843,52 @@ export function registerCredsCommands(parent: Command): void { fail(`Unknown merge strategy "${opts.merge}"`, EXIT.USAGE); } - let text: string; + if (opts.source && !isKnownSource(opts.source)) { + fail( + `Unknown --source "${opts.source}".\n Valid sources: ${SOURCE_NAMES.join(", ")}`, + EXIT.USAGE, + ); + } + + // Read as bytes: a .1pux is a ZIP, so decoding as UTF-8 up front would + // corrupt it before anything got the chance to look. + let buf: Buffer; try { - text = readFileSync(file, "utf8"); + buf = readFileSync(file); } catch { fail(`Could not read ${file}`, EXIT.USAGE); } + const text = buf.toString("utf8"); let incoming: DatabasePayload; 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("{"); - const isBitwardenJson = isJson && looksLikeBitwardenText(text); + // One router decides which reader owns the file, so --source can name + // any product rather than only a CSV one. + const route = routeImport(buf, opts.source); - if (isBitwardenJson) { - const parsed = parseBitwardenJson(text); - if (parsed.items.length === 0) { + if (!route.isOpenCredsDatabase) { + const parsed = route.parsed; + if (!parsed || parsed.items.length === 0) { + const why = route.reason ?? parsed?.skipped[0]?.reason; fail( - parsed.skipped[0]?.reason ?? `Nothing to import from ${file}`, + [ + why && why !== "Unrecognised export format" + ? `${why}` + : `Could not identify the export format of ${file}`, + "", + " Say where it came from with --source :", + ` ${sourceHelp()}`, + ].join("\n"), 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) { + sourceLabel = parsed.source ?? "unknown"; + process.stdout.write(` Source ${file} (${route.description})\n\n`); + } else { const db = parseDatabase(text); const header = readHeader(db); process.stdout.write( @@ -893,20 +916,6 @@ export function registerCredsCommands(parent: Command): void { } process.stdout.write(` Manifest verified — ${incoming.items.length} items, ${incoming.folders.length} folders\n\n`); sourceLabel = "opencreds"; - } else { - const parsed = parseCsvImport(text, opts.source ? { source: opts.source } : {}); - if (!parsed.source) { - fail( - parsed.skipped[0]?.reason === "Unrecognised export format" - ? `Could not identify the export format of ${file}; pass --source` - : `Nothing to import from ${file}`, - EXIT.VALIDATION, - ); - } - incoming = { folders: parsed.folders, items: parsed.items }; - skipped = parsed.skipped; - sourceLabel = IMPORT_SOURCES[parsed.source]!.label; - process.stdout.write(` Source ${file} (${sourceLabel} CSV)\n\n`); } const existing = await loadPayload(store, userKey); diff --git a/packages/opencreds/src/import-router.test.ts b/packages/opencreds/src/import-router.test.ts new file mode 100644 index 0000000..7c1f3b7 --- /dev/null +++ b/packages/opencreds/src/import-router.test.ts @@ -0,0 +1,263 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, writeFileSync, readFileSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + ALL_SOURCES, + SOURCE_NAMES, + detectContainer, + isKnownSource, + looksLikeOnePasswordExport, + parseOnePasswordExport, + readZipEntry, + routeImport, + stableUuid, +} from "./index.js"; + +/** + * Build a real ZIP with the system zip, so the reader is tested against bytes + * some other implementation produced rather than ones we wrote ourselves. + */ +function makeZip(files: Record): Buffer { + const dir = mkdtempSync(join(tmpdir(), "oc-zip-")); + for (const [name, content] of Object.entries(files)) { + const path = join(dir, name); + mkdirSync(join(path, ".."), { recursive: true }); + writeFileSync(path, content); + } + const out = join(dir, "out.zip"); + execFileSync("zip", ["-q", "-r", out, ...Object.keys(files)], { cwd: dir }); + return readFileSync(out); +} + +/** A .1pux in 1Password's documented shape. */ +function onePasswordExport(items: unknown[], vaultName = "Personal"): Buffer { + const doc = { + accounts: [ + { + attrs: { name: "Test" }, + vaults: [ + { + attrs: { uuid: "vault-1", name: vaultName, type: "P" }, + items: items.map((item) => ({ item })), + }, + ], + }, + ], + }; + return makeZip({ "export.data": JSON.stringify(doc) }); +} + +describe("the minimal ZIP reader", () => { + it("reads a stored and a deflated entry written by the system zip", () => { + // A short string stores; a long repetitive one deflates. Both paths matter. + const zip = makeZip({ "small.txt": "hi", "big.txt": "x".repeat(5000) }); + expect(readZipEntry(zip, "small.txt")?.toString()).toBe("hi"); + expect(readZipEntry(zip, "big.txt")?.toString()).toBe("x".repeat(5000)); + }); + + it("returns null for an entry that is not there", () => { + expect(readZipEntry(makeZip({ "a.txt": "a" }), "missing.txt")).toBeNull(); + }); +}); + +describe("telling one container from another", () => { + it("knows a zip, a json and a csv apart", () => { + expect(detectContainer(makeZip({ "a.txt": "a" }))).toBe("zip"); + expect(detectContainer(Buffer.from(' {"a":1}'))).toBe("json"); + expect(detectContainer(Buffer.from("name,url\na,b\n"))).toBe("csv"); + }); +}); + +describe("--source names a product, not a file format", () => { + it("lists every product it accepts", () => { + expect(SOURCE_NAMES).toContain("bitwarden"); + expect(SOURCE_NAMES).toContain("onepassword"); + expect(SOURCE_NAMES).toContain("lastpass"); + expect(SOURCE_NAMES).toContain("opencreds"); + expect(isKnownSource("nordpass")).toBe(true); + expect(isKnownSource("nonesuch")).toBe(false); + }); + + it("rejects an unknown source by name rather than guessing", () => { + const route = routeImport(Buffer.from("name,url\na,b\n"), "nonesuch"); + expect(route.parsed).toBeNull(); + expect(route.reason).toMatch(/Unknown --source/); + }); + + it("forces a CSV whose headers would not have identified it", () => { + // Bare two-column CSV: no detector claims this, so it needs --source. + const csv = "name,secret\nmybank,hunter2\n"; + expect(routeImport(Buffer.from(csv)).parsed?.source).toBeNull(); + const forced = routeImport(Buffer.from(csv), "lastpass"); + expect(forced.parsed?.source).toBe("lastpass"); + }); + + it("says so when a product does not export the container it was given", () => { + const route = routeImport(Buffer.from('{"items":[],"encrypted":false}'), "lastpass"); + expect(route.parsed).toBeNull(); + expect(route.reason).toMatch(/does not export JSON/); + }); + + it("routes an OpenCreds database back to the caller, which owns the passphrase", () => { + const db = JSON.stringify({ opencreds: "0.1", type: "opencreds.database", payload: "x" }); + const route = routeImport(Buffer.from(db)); + expect(route.isOpenCredsDatabase).toBe(true); + expect(route.parsed).toBeNull(); + }); + + it("still detects Bitwarden JSON without being told", () => { + const route = routeImport(Buffer.from('{"encrypted":false,"folders":[],"items":[]}')); + expect(route.description).toBe("Bitwarden JSON"); + }); + + it("keeps 1Password and Apple apart, whose columns nearly collide", () => { + // 1Password: title,url,username,password,otpauth,notes,type + // Apple: title,url,username,password,notes,otpauth (no type) + // Adding Apple's detector without this stole every 1Password CSV. + const onepassword = "title,url,username,password,otpauth,notes,type\na,b,c,d,e,f,Login\n"; + const apple = "title,url,username,password,notes,otpauth\na,b,c,d,e,f\n"; + expect(routeImport(Buffer.from(onepassword)).parsed?.source).toBe("onepassword"); + expect(routeImport(Buffer.from(apple)).parsed?.source).toBe("apple"); + }); + + it("every advertised source has a label", () => { + for (const name of SOURCE_NAMES) { + expect(ALL_SOURCES[name]!.label.length).toBeGreaterThan(0); + } + }); +}); + +describe("reading a 1Password .1pux", () => { + it("recognises the archive by its export.data", () => { + expect(looksLikeOnePasswordExport(onePasswordExport([]))).toBe(true); + expect(looksLikeOnePasswordExport(makeZip({ "other.txt": "x" }))).toBe(false); + }); + + it("reads a login with its urls, totp and vault", () => { + const buf = onePasswordExport([ + { + uuid: "abcdefghijklmnopqrstuvwxyz", + categoryUuid: "001", + createdAt: 1590000000, + updatedAt: 1600000000, + favIndex: 1, + overview: { + title: "Example", + url: "https://example.com", + urls: [{ url: "https://example.com" }, { url: "https://alt.example" }], + }, + details: { + loginFields: [ + { designation: "username", value: "ann" }, + { designation: "password", value: "hunter2" }, + ], + notesPlain: "a note", + sections: [ + { fields: [{ id: "totp", title: "one-time password", value: { totp: "otpauth://x" } }] }, + ], + }, + }, + ]); + + const parsed = parseOnePasswordExport(buf); + expect(parsed.source).toBe("onepassword"); + expect(parsed.items).toHaveLength(1); + const item = parsed.items[0]!; + expect(item.type).toBe("login"); + expect(item.name).toBe("Example"); + expect(item.login?.username).toBe("ann"); + expect(item.login?.password).toBe("hunter2"); + expect(item.login?.totp).toBe("otpauth://x"); + expect(item.login?.uris.map((u) => u.uri)).toEqual([ + "https://example.com", + "https://alt.example", + ]); + expect(item.notes).toBe("a note"); + expect(item.favorite).toBe(true); + // Seconds, not milliseconds. + expect(item.createdAt).toBe(new Date(1590000000 * 1000).toISOString()); + expect(parsed.folders).toEqual([ + { id: stableUuid("1password:vault", "vault-1"), name: "Personal" }, + ]); + }); + + it("gives the same ids every time, so a re-import dedupes", () => { + const make = () => + parseOnePasswordExport( + onePasswordExport([ + { uuid: "stableid", categoryUuid: "001", overview: { title: "x" }, details: {} }, + ]), + ); + expect(make().items[0]!.id).toBe(make().items[0]!.id); + }); + + it("reads a card, splitting 1Password's monthYear", () => { + const parsed = parseOnePasswordExport( + onePasswordExport([ + { + uuid: "card1", + categoryUuid: "002", + overview: { title: "Amex" }, + details: { + sections: [ + { + fields: [ + { id: "cardholder", value: { string: "A Ettinger" } }, + { id: "ccnum", value: { creditCardNumber: "3782" } }, + { id: "cvv", value: { concealed: "1234" } }, + { id: "expiry", value: { monthYear: 202801 } }, + ], + }, + ], + }, + }, + ]), + ); + const card = parsed.items[0]!; + expect(card.type).toBe("card"); + expect(card.card?.number).toBe("3782"); + expect(card.card?.expMonth).toBe("1"); + expect(card.card?.expYear).toBe("2028"); + expect(card.card?.code).toBe("1234"); + }); + + it("leaves trashed items behind and says so", () => { + const parsed = parseOnePasswordExport( + onePasswordExport([ + { uuid: "a", categoryUuid: "001", trashed: true, overview: { title: "gone" }, details: {} }, + { uuid: "b", categoryUuid: "001", overview: { title: "kept" }, details: {} }, + ]), + ); + expect(parsed.items.map((i) => i.name)).toEqual(["kept"]); + expect(parsed.skipped[0]?.reason).toMatch(/trash/i); + }); + + it("turns a category it does not model into a note rather than dropping it", () => { + const parsed = parseOnePasswordExport( + onePasswordExport([ + { + uuid: "p1", + categoryUuid: "106", + overview: { title: "Passport" }, + details: { + sections: [{ fields: [{ id: "number", title: "Number", value: { string: "X123" } }] }], + }, + }, + ]), + ); + const item = parsed.items[0]!; + expect(item.type).toBe("note"); + expect(item.notes).toMatch(/Passport/); + expect(item.fields?.[0]).toMatchObject({ name: "Number", value: "X123" }); + }); + + it("refuses a zip that is not a .1pux", () => { + const parsed = parseOnePasswordExport(makeZip({ "nope.txt": "x" })); + expect(parsed.items).toEqual([]); + expect(parsed.skipped[0]?.reason).toMatch(/not a \.1pux/i); + }); +}); diff --git a/packages/opencreds/src/import-router.ts b/packages/opencreds/src/import-router.ts new file mode 100644 index 0000000..0cc5842 --- /dev/null +++ b/packages/opencreds/src/import-router.ts @@ -0,0 +1,177 @@ +/** + * One place that decides which reader owns a file. + * + * Before this, format was inferred inline in the import command by + * `text.startsWith("{")`, and `--source` only ever reached the CSV reader. So a + * JSON or archive export could not be forced at all: if the sniff was wrong there + * was no way to say "this came from 1Password". + * + * A source here names a *product*, not a file format. Bitwarden exports JSON and + * CSV; 1Password exports .1pux and CSV. Saying `--source onepassword` says where + * the file came from, and the container is still decided by looking at the bytes. + */ +import { IMPORT_SOURCES, parseCsvImport } from "./importers.js"; +import { looksLikeBitwardenText, parseBitwardenJson } from "./bitwarden.js"; +import { + looksLikeOnePasswordExport, + parseOnePasswordExport, +} from "./onepassword.js"; +import { looksLikeZip } from "./zip.js"; +import type { ParsedImport } from "./types.js"; + +/** What the bytes are, independent of which product made them. */ +export type Container = "opencreds" | "json" | "csv" | "zip"; + +export interface SourceInfo { + /** What a person calls it. */ + label: string; + /** Containers this product is known to export. */ + containers: Container[]; +} + +/** + * Every product that can be named with --source. + * + * `opencreds` is included so an OpenCreds database can be forced too — it is the + * one format that is also our own, and a person moving between logicsrc vaults + * should not have to know it is the default JSON branch. + */ +export const ALL_SOURCES: Readonly> = Object.freeze({ + opencreds: { label: "OpenCreds / logicsrc", containers: ["opencreds"] }, + bitwarden: { label: "Bitwarden", containers: ["json", "csv"] }, + onepassword: { label: "1Password", containers: ["zip", "csv"] }, + lastpass: { label: "LastPass", containers: ["csv"] }, + keepass: { label: "KeePass", containers: ["csv"] }, + nordpass: { label: "NordPass", containers: ["csv"] }, + dashlane: { label: "Dashlane", containers: ["csv"] }, + protonpass: { label: "Proton Pass", containers: ["csv"] }, + roboform: { label: "RoboForm", containers: ["csv"] }, + apple: { label: "Apple Passwords", containers: ["csv"] }, + firefox: { label: "Firefox", containers: ["csv"] }, + chrome: { label: "Chrome / Edge", containers: ["csv"] }, +}); + +/** The names `--source` accepts, for help text and error messages. */ +export const SOURCE_NAMES: readonly string[] = Object.freeze(Object.keys(ALL_SOURCES)); + +/** A one-line list of what can be passed to --source. */ +export function sourceHelp(): string { + return SOURCE_NAMES.map((name) => `${name} (${ALL_SOURCES[name]!.label})`).join(", "); +} + +export function isKnownSource(name: string): boolean { + return Object.hasOwn(ALL_SOURCES, name); +} + +/** What kind of file is this? */ +export function detectContainer(buf: Buffer): Container { + if (looksLikeZip(buf)) return "zip"; + const head = buf.subarray(0, 64).toString("utf8").trimStart(); + if (head.startsWith("{") || head.startsWith("[")) return "json"; + return "csv"; +} + +export interface RouteResult { + /** null when nothing could read it. */ + parsed: ParsedImport | null; + /** Set when the file is an OpenCreds database — the caller owns that path. */ + isOpenCredsDatabase: boolean; + /** Human-readable description of what was chosen, for the run's output. */ + description: string; + /** Why nothing could read it, when parsed is null. */ + reason?: string; +} + +/** + * Decide which reader owns a file and run it. + * + * An OpenCreds database is handed back rather than parsed: it needs a passphrase + * prompt and a manifest check that only the command can do. + */ +export function routeImport(buf: Buffer, forced?: string): RouteResult { + const container = detectContainer(buf); + const text = container === "zip" ? "" : buf.toString("utf8"); + + if (forced && !isKnownSource(forced)) { + return { + parsed: null, + isOpenCredsDatabase: false, + description: "", + reason: `Unknown --source "${forced}". Valid sources: ${SOURCE_NAMES.join(", ")}`, + }; + } + + // 1Password's archive. Forced or sniffed, it is the only zip we read. + if (container === "zip") { + if (forced && forced !== "onepassword") { + return { + parsed: null, + isOpenCredsDatabase: false, + description: "", + reason: `${ALL_SOURCES[forced]!.label} does not export a ZIP archive`, + }; + } + if (!looksLikeOnePasswordExport(buf)) { + return { + parsed: null, + isOpenCredsDatabase: false, + description: "", + reason: "A ZIP, but not a 1Password .1pux (no export.data inside)", + }; + } + return { + parsed: parseOnePasswordExport(buf), + isOpenCredsDatabase: false, + description: "1Password .1pux", + }; + } + + if (container === "json") { + const bitwarden = looksLikeBitwardenText(text); + + if (forced === "bitwarden" || (!forced && bitwarden)) { + return { + parsed: parseBitwardenJson(text), + isOpenCredsDatabase: false, + description: "Bitwarden JSON", + }; + } + if (forced === "opencreds" || !forced) { + // Left to the caller: only it can prompt for a passphrase. + return { parsed: null, isOpenCredsDatabase: true, description: "OpenCreds database" }; + } + return { + parsed: null, + isOpenCredsDatabase: false, + description: "", + reason: `${ALL_SOURCES[forced]!.label} does not export JSON — its export is CSV`, + }; + } + + // Everything else is treated as CSV, forced to a source or detected by header. + if (forced === "opencreds") { + return { + parsed: null, + isOpenCredsDatabase: false, + description: "", + reason: "An OpenCreds database is JSON, and this file is not", + }; + } + const csvSource = forced && Object.hasOwn(IMPORT_SOURCES, forced) ? forced : undefined; + if (forced && !csvSource) { + return { + parsed: null, + isOpenCredsDatabase: false, + description: "", + reason: `No CSV reader for ${ALL_SOURCES[forced]!.label}`, + }; + } + const parsed = parseCsvImport(text, csvSource ? { source: csvSource } : {}); + return { + parsed, + isOpenCredsDatabase: false, + description: parsed.source + ? `${IMPORT_SOURCES[parsed.source]?.label ?? parsed.source} CSV` + : "CSV", + }; +} diff --git a/packages/opencreds/src/importers.ts b/packages/opencreds/src/importers.ts index 25a644b..ddf2f4f 100644 --- a/packages/opencreds/src/importers.ts +++ b/packages/opencreds/src/importers.ts @@ -308,6 +308,118 @@ function mapKeePassRow(row: Record, folders: ReturnType); } +/** NordPass: name, url, username, password, note, folder, type, card columns. */ +function mapNordPassRow(row: Record, folders: ReturnType): Item { + const common = { + name: firstOf(row, "name"), + notes: firstOf(row, "note", "notes"), + folderId: folders.idFor(firstOf(row, "folder")), + }; + const type = firstOf(row, "type").toLowerCase(); + + if (type === "credit_card" || firstOf(row, "cardnumber")) { + const expiry = firstOf(row, "expirydate"); + const [month = "", year = ""] = expiry.includes("/") ? expiry.split("/") : ["", ""]; + return createItem("card", { + ...common, + card: { + cardholderName: firstOf(row, "cardholdername"), + number: firstOf(row, "cardnumber"), + code: firstOf(row, "cvc", "cvv"), + expMonth: month.trim(), + expYear: expandYear(year.trim()), + }, + } as Partial); + } + + if (type === "note" || (!firstOf(row, "password") && !firstOf(row, "url"))) { + return createItem("note", common as Partial); + } + + const uri = firstOf(row, "url"); + return createItem("login", { + ...common, + name: common.name || hostOf(uri), + login: { + username: firstOf(row, "username"), + password: firstOf(row, "password"), + uris: uri ? [{ uri, match: "domain" as const }] : [], + }, + } as Partial); +} + +/** Dashlane: title, username, username2, password, note, url, category, otpSecret. */ +function mapDashlaneRow(row: Record, folders: ReturnType): Item { + const uri = firstOf(row, "url"); + return createItem("login", { + name: firstOf(row, "title", "name") || hostOf(uri), + notes: firstOf(row, "note", "notes"), + folderId: folders.idFor(firstOf(row, "category")), + login: { + // Dashlane keeps alternates in username2/username3; the first is the one + // it signs in with. + username: firstOf(row, "username", "username2", "username3"), + password: firstOf(row, "password"), + totp: firstOf(row, "otpsecret", "otpurl"), + uris: uri ? [{ uri, match: "domain" as const }] : [], + }, + } as Partial); +} + +/** Proton Pass: type, name, url, email, username, password, note, totp, vault. */ +function mapProtonPassRow(row: Record, folders: ReturnType): Item { + const common = { + name: firstOf(row, "name"), + notes: firstOf(row, "note", "notes"), + folderId: folders.idFor(firstOf(row, "vault")), + }; + const type = firstOf(row, "type").toLowerCase(); + if (type === "note") return createItem("note", common as Partial); + + const uri = firstOf(row, "url"); + return createItem("login", { + ...common, + name: common.name || hostOf(uri), + login: { + // Proton splits the two; whichever is filled is the sign-in name. + username: firstOf(row, "username", "email"), + password: firstOf(row, "password"), + totp: firstOf(row, "totp"), + uris: uri ? [{ uri, match: "domain" as const }] : [], + }, + } as Partial); +} + +/** RoboForm: Name, Url, MatchUrl, Login, Pwd, Note, Folder. */ +function mapRoboFormRow(row: Record, folders: ReturnType): Item { + const uri = firstOf(row, "url", "matchurl"); + return createItem("login", { + name: firstOf(row, "name") || hostOf(uri), + notes: firstOf(row, "note", "notes"), + folderId: folders.idFor(firstOf(row, "folder")), + login: { + username: firstOf(row, "login", "username"), + password: firstOf(row, "pwd", "password"), + uris: uri ? [{ uri, match: "domain" as const }] : [], + }, + } as Partial); +} + +/** Apple Passwords: Title, URL, Username, Password, Notes, OTPAuth. */ +function mapAppleRow(row: Record): Item { + const uri = firstOf(row, "url"); + return createItem("login", { + name: firstOf(row, "title") || hostOf(uri), + notes: firstOf(row, "notes", "note"), + login: { + username: firstOf(row, "username"), + password: firstOf(row, "password"), + totp: firstOf(row, "otpauth"), + uris: uri ? [{ uri, match: "domain" as const }] : [], + }, + } as Partial); +} + /** * Supported sources. Each `detect` looks at the header row, so a person can * drop in a file without first telling us where it came from. @@ -335,6 +447,40 @@ export const IMPORT_SOURCES: Readonly> = Object.fre detect: (headers) => headers.includes("url") && headers.includes("username") && headers.includes("type"), map: mapOnePasswordRow, }, + nordpass: { + label: "NordPass", + detect: (headers) => headers.includes("cardholdername") && headers.includes("folder"), + map: mapNordPassRow, + }, + dashlane: { + label: "Dashlane", + detect: (headers) => headers.includes("otpsecret") || headers.includes("username2"), + map: mapDashlaneRow, + }, + protonpass: { + label: "Proton Pass", + detect: (headers) => headers.includes("vault") && headers.includes("totp"), + map: mapProtonPassRow, + }, + roboform: { + label: "RoboForm", + detect: (headers) => headers.includes("matchurl") || (headers.includes("pwd") && headers.includes("login")), + map: mapRoboFormRow, + }, + apple: { + label: "Apple Passwords", + // 1Password's CSV is title,url,username,password,otpauth,notes,**type** and + // would otherwise match this too. Apple's export has no type column. + detect: (headers) => + headers.includes("otpauth") && headers.includes("title") && !headers.includes("type"), + map: mapAppleRow, + }, + firefox: { + label: "Firefox", + detect: (headers) => + headers.includes("formactionorigin") || headers.includes("timepasswordchanged"), + map: mapChromeRow, + }, chrome: { label: "Chrome", detect: (headers) => headers.includes("url") && headers.includes("username") && headers.includes("password"), @@ -348,7 +494,21 @@ export const IMPORT_SOURCES: Readonly> = Object.fre * Order matters: Chrome's columns are a subset of 1Password's, and LastPass's * overlap both, so the more specific detector has to be asked first. */ -export const DETECT_ORDER: readonly string[] = ["bitwarden", "lastpass", "keepass", "onepassword", "chrome"]; +export const DETECT_ORDER: readonly string[] = [ + "bitwarden", + "nordpass", + "dashlane", + "protonpass", + "roboform", + "lastpass", + "keepass", + "firefox", + // 1Password before Apple: their columns overlap and 1Password's `type` is the + // only thing that separates them, so ask the one that owns it first. + "onepassword", + "apple", + "chrome", +]; export function detectSource(headers: string[]): string | null { for (const key of DETECT_ORDER) { diff --git a/packages/opencreds/src/index.ts b/packages/opencreds/src/index.ts index e4e45f0..86d10f3 100644 --- a/packages/opencreds/src/index.ts +++ b/packages/opencreds/src/index.ts @@ -126,6 +126,26 @@ export { export { parseBitwardenJson, looksLikeBitwardenJson } from "./bitwarden.js"; +export { + parseOnePasswordExport, + looksLikeOnePasswordExport, + stableUuid, +} from "./onepassword.js"; + +export { looksLikeZip, readZipEntry, zipEntryNames } from "./zip.js"; + +export { + ALL_SOURCES, + SOURCE_NAMES, + sourceHelp, + isKnownSource, + detectContainer, + routeImport, + type Container, + type SourceInfo, + type RouteResult, +} from "./import-router.js"; + export { validateItem, validateDatabase, diff --git a/packages/opencreds/src/onepassword.ts b/packages/opencreds/src/onepassword.ts new file mode 100644 index 0000000..20e7467 --- /dev/null +++ b/packages/opencreds/src/onepassword.ts @@ -0,0 +1,495 @@ +/** + * 1Password's native export (.1pux). + * + * The CSV importer has covered 1Password for a while, but .1pux is what + * 1Password 8's own Export button produces, and it is the only one of its + * formats that keeps vaults, TOTP secrets, custom sections, password history + * and the whole of a card or identity. The CSV is a handful of columns. + * + * The file is a ZIP holding `export.data`, a single JSON document. Read with the + * minimal reader in ./zip.ts so this package keeps its one dependency. + * + * Not verified against a real 1Password export — the schema below is 1Password's + * documented 1PUX format and the tests drive it from fixtures. Run with + * --dry-run first. + */ +import { createHash } from "node:crypto"; + +import { createItem } from "./items.js"; +import { hostOf } from "./importers.js"; +import { readZipEntry, looksLikeZip } from "./zip.js"; +import { MAX_HISTORY_ENTRIES } from "./types.js"; +import type { + CustomField, + Folder, + HistoryEntry, + Item, + ItemUri, + ParsedImport, + SkippedRow, +} from "./types.js"; + +/** The entry every .1pux carries. */ +const EXPORT_ENTRY = "export.data"; + +/** + * 1Password's category uuids. Only the ones we model as a typed item are named; + * anything else becomes a note carrying its fields, so nothing is dropped. + */ +const CATEGORY = Object.freeze({ + LOGIN: "001", + CARD: "002", + NOTE: "003", + IDENTITY: "004", + PASSWORD: "005", +}); + +/** Readable names for the categories that degrade to a note. */ +const CATEGORY_LABEL: Readonly> = Object.freeze({ + "006": "Document", + "100": "Software License", + "101": "Bank Account", + "102": "Database", + "103": "Driver License", + "104": "Outdoor License", + "105": "Membership", + "106": "Passport", + "107": "Rewards Program", + "108": "Social Security Number", + "109": "Wireless Router", + "110": "Server", + "111": "Email Account", + "112": "API Credential", + "113": "Medical Record", +}); + +function str(value: unknown): string { + if (typeof value === "string") return value; + return value == null ? "" : String(value); +} + +/** + * A stable id derived from 1Password's own. + * + * 1Password item ids are 26-character base32, not RFC UUIDs, so they cannot be + * adopted directly. Hashing them into a v5-shaped UUID keeps the useful property + * — the same export imported twice produces the same ids, so the second run + * reports its items as already present instead of duplicating the vault. + */ +export function stableUuid(namespace: string, id: string): string { + const hash = createHash("sha256").update(`${namespace}:${id}`).digest(); + const bytes = Buffer.from(hash.subarray(0, 16)); + // Stamp version 5 and the RFC 4122 variant so the result is a well-formed UUID. + bytes[6] = (bytes[6]! & 0x0f) | 0x50; + bytes[8] = (bytes[8]! & 0x3f) | 0x80; + const hex = bytes.toString("hex"); + return [ + hex.slice(0, 8), + hex.slice(8, 12), + hex.slice(12, 16), + hex.slice(16, 20), + hex.slice(20, 32), + ].join("-"); +} + +/** 1Password stores seconds; everything here speaks ISO. */ +function isoFrom(seconds: unknown): string { + const n = Number(seconds); + if (!Number.isFinite(n) || n <= 0) return ""; + return new Date(n * 1000).toISOString(); +} + +/** + * Flatten a section field's value. + * + * `value` is an object with exactly one key naming its type — `{string: "x"}`, + * `{concealed: "x"}`, `{totp: "otpauth://…"}` and so on. The key is the type, so + * it is returned alongside the text. + */ +function fieldValue(value: unknown): { kind: string; text: string } { + if (value == null || typeof value !== "object") return { kind: "string", text: str(value) }; + const entries = Object.entries(value as Record); + if (entries.length === 0) return { kind: "string", text: "" }; + const [kind, raw] = entries[0]!; + + if (raw && typeof raw === "object") { + // address is {street, city, state, zip, country}; email is {email_address,…} + const obj = raw as Record; + if (kind === "email") return { kind, text: str(obj.email_address) }; + if (kind === "address") { + const parts = [obj.street, obj.city, obj.state, obj.zip, obj.country] + .map(str) + .filter(Boolean); + return { kind, text: parts.join(", ") }; + } + return { kind, text: JSON.stringify(raw) }; + } + + if (kind === "monthYear") { + // 202801 means January 2028. + const s = str(raw); + return { kind, text: s.length === 6 ? `${s.slice(0, 4)}-${s.slice(4)}` : s }; + } + if (kind === "date") return { kind, text: isoFrom(raw) || str(raw) }; + return { kind, text: str(raw) }; +} + +interface FlatField { + id: string; + title: string; + kind: string; + text: string; +} + +/** Every section field, flattened, keeping its id so typed items can pick. */ +function flattenSections(details: Record): FlatField[] { + const sections = details.sections; + if (!Array.isArray(sections)) return []; + const out: FlatField[] = []; + for (const section of sections) { + const fields = (section as { fields?: unknown })?.fields; + if (!Array.isArray(fields)) continue; + for (const field of fields) { + const f = field as { id?: unknown; title?: unknown; value?: unknown }; + const { kind, text } = fieldValue(f.value); + out.push({ id: str(f.id), title: str(f.title), kind, text }); + } + } + return out; +} + +/** Section fields that no typed group claimed, kept as custom fields. */ +function leftoverFields(flat: FlatField[], claimed: Set): CustomField[] { + const out: CustomField[] = []; + for (const f of flat) { + if (claimed.has(f.id)) continue; + if (!f.text) continue; + const name = f.title || f.id; + if (!name) continue; + out.push({ + name, + value: f.text, + type: f.kind === "concealed" ? "hidden" : "text", + ...(f.kind === "concealed" ? { hidden: true } : {}), + }); + } + return out; +} + +function pick(flat: FlatField[], ...ids: string[]): string { + for (const id of ids) { + const found = flat.find((f) => f.id === id && f.text); + if (found) return found.text; + } + return ""; +} + +function loginField(details: Record, designation: string): string { + const fields = details.loginFields; + if (!Array.isArray(fields)) return ""; + for (const field of fields) { + const f = field as { designation?: unknown; value?: unknown }; + if (str(f.designation) === designation) return str(f.value); + } + return ""; +} + +function overviewUris(overview: Record): ItemUri[] { + const out: ItemUri[] = []; + const seen = new Set(); + const push = (uri: string) => { + if (!uri || seen.has(uri)) return; + seen.add(uri); + out.push({ uri, match: "domain" }); + }; + push(str(overview.url)); + const urls = overview.urls; + if (Array.isArray(urls)) { + for (const entry of urls) push(str((entry as { url?: unknown })?.url)); + } + return out; +} + +function historyFrom(details: Record): HistoryEntry[] { + const raw = details.passwordHistory; + if (!Array.isArray(raw)) return []; + const out: HistoryEntry[] = []; + for (const entry of raw) { + const h = entry as { value?: unknown; time?: unknown }; + const password = str(h.value); + if (!password) continue; + out.push({ password, changedAt: isoFrom(h.time) }); + } + out.sort((a, b) => (a.changedAt < b.changedAt ? 1 : a.changedAt > b.changedAt ? -1 : 0)); + return out.slice(0, MAX_HISTORY_ENTRIES); +} + +/** The TOTP secret, wherever in the sections it ended up. */ +function totpFrom(flat: FlatField[]): string { + return flat.find((f) => f.kind === "totp" && f.text)?.text ?? ""; +} + +/** + * Parse a .1pux archive into vault items. + * + * Trashed items are left behind: they are deleted as far as the person is + * concerned, and restoring them into a fresh vault as live entries would be a + * surprise. They are reported as skipped rather than silently dropped. + */ +export function parseOnePasswordExport(buf: Buffer): ParsedImport { + let raw: Buffer | null; + try { + raw = readZipEntry(buf, EXPORT_ENTRY); + } catch (err) { + return { + source: null, + items: [], + folders: [], + skipped: [{ row: 0, reason: (err as Error).message }], + }; + } + if (!raw) { + return { + source: null, + items: [], + folders: [], + skipped: [{ row: 0, reason: `No ${EXPORT_ENTRY} in the archive — not a .1pux` }], + }; + } + + let doc: unknown; + try { + doc = JSON.parse(raw.toString("utf8")); + } catch { + return { + source: null, + items: [], + folders: [], + skipped: [{ row: 0, reason: `${EXPORT_ENTRY} is not valid JSON` }], + }; + } + + const accounts = (doc as { accounts?: unknown })?.accounts; + if (!Array.isArray(accounts)) { + return { + source: null, + items: [], + folders: [], + skipped: [{ row: 0, reason: "Not a 1Password export: no accounts" }], + }; + } + + const items: Item[] = []; + const skipped: SkippedRow[] = []; + const folders: Folder[] = []; + const folderIds = new Map(); + let row = 0; + + // A 1Password vault is the nearest thing it has to a folder. + const folderFor = (uuid: string, name: string): string | null => { + const clean = name.trim(); + if (!uuid || !clean) return null; + let id = folderIds.get(uuid); + if (!id) { + id = stableUuid("1password:vault", uuid); + folderIds.set(uuid, id); + folders.push({ id, name: clean }); + } + return id; + }; + + for (const account of accounts) { + const vaults = (account as { vaults?: unknown })?.vaults; + if (!Array.isArray(vaults)) continue; + + for (const vault of vaults) { + const attrs = ((vault as { attrs?: unknown })?.attrs ?? {}) as Record; + const folderId = folderFor(str(attrs.uuid), str(attrs.name)); + const vaultItems = (vault as { items?: unknown })?.items; + if (!Array.isArray(vaultItems)) continue; + + for (const wrapper of vaultItems) { + row += 1; + const item = ((wrapper as { item?: unknown })?.item ?? wrapper) as Record; + if (!item || typeof item !== "object") { + skipped.push({ row, reason: "Not an object" }); + continue; + } + if (item.trashed === true) { + skipped.push({ row, reason: "In the 1Password trash" }); + continue; + } + + const details = (item.details ?? {}) as Record; + const overview = (item.overview ?? {}) as Record; + const flat = flattenSections(details); + const category = str(item.categoryUuid); + const uuid = str(item.uuid); + + const common: Record = { + ...(uuid ? { id: stableUuid("1password:item", uuid) } : {}), + name: str(overview.title), + notes: str(details.notesPlain), + favorite: Number(item.favIndex) > 0, + folderId, + }; + const createdAt = isoFrom(item.createdAt); + const updatedAt = isoFrom(item.updatedAt); + if (createdAt) common.createdAt = createdAt; + if (updatedAt) common.updatedAt = updatedAt; + + try { + if (category === CATEGORY.CARD) { + const claimed = new Set(["cardholder", "type", "ccnum", "cvv", "expiry"]); + items.push( + withId( + createItem("card", { + ...common, + fields: leftoverFields(flat, claimed), + card: { + cardholderName: pick(flat, "cardholder"), + brand: pick(flat, "type"), + number: pick(flat, "ccnum"), + expMonth: monthOf(pick(flat, "expiry")), + expYear: yearOf(pick(flat, "expiry")), + code: pick(flat, "cvv"), + }, + } as Partial), + common.id as string | undefined, + ), + ); + continue; + } + + if (category === CATEGORY.IDENTITY) { + const claimed = new Set([ + "firstname", "initial", "lastname", "company", "jobtitle", + "email", "defphone", "address", "username", "website", + ]); + const address = flat.find((f) => f.id === "address"); + items.push( + withId( + createItem("identity", { + ...common, + fields: leftoverFields(flat, claimed), + identity: { + firstName: pick(flat, "firstname"), + middleName: pick(flat, "initial"), + lastName: pick(flat, "lastname"), + company: pick(flat, "company"), + email: pick(flat, "email"), + phone: pick(flat, "defphone", "cellphone", "homephone"), + username: pick(flat, "username"), + address1: address?.text ?? "", + }, + } as Partial), + common.id as string | undefined, + ), + ); + continue; + } + + if (category === CATEGORY.NOTE) { + items.push( + withId( + createItem("note", { + ...common, + fields: leftoverFields(flat, new Set()), + } as Partial), + common.id as string | undefined, + ), + ); + continue; + } + + if (category === CATEGORY.LOGIN || category === CATEGORY.PASSWORD) { + const uris = overviewUris(overview); + const password = + category === CATEGORY.PASSWORD + ? str(details.password) || loginField(details, "password") + : loginField(details, "password"); + const history = historyFrom(details); + items.push( + withId( + createItem("login", { + ...common, + name: str(overview.title) || hostOf(uris[0]?.uri ?? ""), + fields: leftoverFields(flat, new Set()), + ...(history.length > 0 ? { history } : {}), + login: { + username: loginField(details, "username"), + password, + totp: totpFrom(flat), + uris, + }, + } as Partial), + common.id as string | undefined, + ), + ); + continue; + } + + // Every other category — passports, licences, servers — becomes a note + // carrying its fields, so an import never quietly loses one. + const label = CATEGORY_LABEL[category] ?? `category ${category}`; + items.push( + withId( + createItem("note", { + ...common, + notes: [str(details.notesPlain), `(1Password ${label})`] + .filter(Boolean) + .join("\n\n"), + fields: leftoverFields(flat, new Set()), + } as Partial), + common.id as string | undefined, + ), + ); + } catch (err) { + skipped.push({ row, reason: (err as Error).message }); + } + } + } + } + + const used = new Set(items.map((i) => i.folderId).filter(Boolean) as string[]); + return { + source: "onepassword", + items, + folders: folders.filter((f) => used.has(f.id)), + skipped, + }; +} + +/** createItem refuses a caller-supplied id, so it is applied afterwards. */ +function withId(item: Item, id?: string): Item { + if (id) item.id = id; + return item; +} + +/** "2028-01" or "01/2028" → the month alone. */ +function monthOf(expiry: string): string { + const m = expiry.match(/^(\d{4})-(\d{1,2})$/); + if (m) return String(Number(m[2])); + const s = expiry.match(/^(\d{1,2})\D(\d{2,4})$/); + return s ? String(Number(s[1])) : ""; +} + +/** "2028-01" or "01/2028" → the year alone, four digits. */ +function yearOf(expiry: string): string { + const m = expiry.match(/^(\d{4})-(\d{1,2})$/); + if (m) return m[1]!; + const s = expiry.match(/^(\d{1,2})\D(\d{2,4})$/); + if (!s) return ""; + const y = s[2]!; + return y.length === 2 ? `20${y}` : y; +} + +/** Is this buffer a .1pux? */ +export function looksLikeOnePasswordExport(buf: Buffer): boolean { + if (!looksLikeZip(buf)) return false; + try { + return readZipEntry(buf, EXPORT_ENTRY) !== null; + } catch { + return false; + } +} diff --git a/packages/opencreds/src/zip.ts b/packages/opencreds/src/zip.ts new file mode 100644 index 0000000..1169e6f --- /dev/null +++ b/packages/opencreds/src/zip.ts @@ -0,0 +1,103 @@ +/** + * Just enough ZIP to read one named entry. + * + * 1Password's native export (.1pux) and Proton Pass's are ZIP archives holding a + * JSON document. Pulling in a zip library for that would be the only runtime + * dependency this package has beyond commander, so the central directory is read + * here instead: node's zlib already does the decompression, and the container + * format is a few dozen lines of offsets. + * + * Deliberately not a general ZIP implementation. No encryption, no multi-disk, + * no ZIP64, and only the two compression methods an export actually uses. + */ +import { inflateRawSync } from "node:zlib"; + +const SIG_EOCD = 0x06054b50; +const SIG_CENTRAL = 0x02014b50; +const SIG_LOCAL = 0x04034b50; + +const STORED = 0; +const DEFLATED = 8; + +export function looksLikeZip(buf: Buffer): boolean { + return buf.length > 4 && buf.readUInt32LE(0) === SIG_LOCAL; +} + +interface CentralEntry { + name: string; + method: number; + compressedSize: number; + localHeaderOffset: number; +} + +/** + * Find the end-of-central-directory record. + * + * It sits at the end of the file, after a comment of up to 64KB, so it is found + * by scanning backwards for its signature rather than by a fixed offset. + */ +function findEocd(buf: Buffer): number { + const minOffset = Math.max(0, buf.length - 0xffff - 22); + for (let i = buf.length - 22; i >= minOffset; i--) { + if (buf.readUInt32LE(i) === SIG_EOCD) return i; + } + return -1; +} + +function readCentralDirectory(buf: Buffer): CentralEntry[] { + const eocd = findEocd(buf); + if (eocd < 0) throw new Error("Not a ZIP archive: no end-of-central-directory record"); + + const count = buf.readUInt16LE(eocd + 10); + let offset = buf.readUInt32LE(eocd + 16); + const entries: CentralEntry[] = []; + + for (let i = 0; i < count; i++) { + if (offset + 46 > buf.length || buf.readUInt32LE(offset) !== SIG_CENTRAL) { + throw new Error("Corrupt ZIP central directory"); + } + const method = buf.readUInt16LE(offset + 10); + const compressedSize = buf.readUInt32LE(offset + 20); + const nameLen = buf.readUInt16LE(offset + 28); + const extraLen = buf.readUInt16LE(offset + 30); + const commentLen = buf.readUInt16LE(offset + 32); + const localHeaderOffset = buf.readUInt32LE(offset + 42); + const name = buf.toString("utf8", offset + 46, offset + 46 + nameLen); + + entries.push({ name, method, compressedSize, localHeaderOffset }); + offset += 46 + nameLen + extraLen + commentLen; + } + + return entries; +} + +/** Every entry name in the archive, for diagnostics. */ +export function zipEntryNames(buf: Buffer): string[] { + return readCentralDirectory(buf).map((e) => e.name); +} + +/** + * Read one entry by name, or null if the archive has no such entry. + * + * The local header repeats the name and extra-field lengths, and its extra field + * can differ in length from the central one, so the data offset is computed from + * the local header rather than assumed. + */ +export function readZipEntry(buf: Buffer, name: string): Buffer | null { + const entry = readCentralDirectory(buf).find((e) => e.name === name); + if (!entry) return null; + + const lh = entry.localHeaderOffset; + if (lh + 30 > buf.length || buf.readUInt32LE(lh) !== SIG_LOCAL) { + throw new Error(`Corrupt ZIP local header for ${name}`); + } + const nameLen = buf.readUInt16LE(lh + 26); + const extraLen = buf.readUInt16LE(lh + 28); + const start = lh + 30 + nameLen + extraLen; + const end = start + entry.compressedSize; + const data = buf.subarray(start, end); + + if (entry.method === STORED) return Buffer.from(data); + if (entry.method === DEFLATED) return inflateRawSync(data); + throw new Error(`Unsupported ZIP compression method ${entry.method} for ${name}`); +}