mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-01 20:33:50 +00:00
Import from any password manager, and let a person say which (#207)
Two gaps. Format was decided inline in the import command by whether the text started with a brace, 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". And 1Password's own export button produces a .1pux, which nothing here could open. A source now names a product rather than 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. One router owns that decision instead of the command, and when nothing can read a file it prints every source that can be named rather than the bare "pass --source" it used to. 1Password's .1pux is a ZIP holding a single JSON document. Pulling in a zip library for that would have been this package's only dependency beyond commander, so the central directory is read here: node's zlib already does the decompression, and the container is a few dozen lines of offsets. Deliberately not a general ZIP implementation -- no encryption, no ZIP64, only the two compression methods an export uses. The .1pux reader keeps vaults as folders, TOTP secrets, custom sections, password history and the whole of a card. 1Password item ids are 26-character base32 rather than UUIDs, so they are hashed into a v5-shaped UUID: the same export imported twice produces the same ids, which is what makes a re-import report its items as already present instead of duplicating the vault. Trashed items are left behind and reported, since restoring deleted entries into a fresh vault would be a surprise. A category we do not model -- a passport, a server, a licence -- becomes a note carrying its fields, so an import never quietly loses one. Six more CSV products join the existing five: NordPass, Dashlane, Proton Pass, RoboForm, Apple Passwords and Firefox. Adding Apple broke 1Password: their columns are nearly identical and only 1Password's `type` separates them, so Apple now requires its absence and 1Password is asked first. There is a test for that pair, because the failure mode is silent -- every 1Password CSV had started importing as Apple. LastPass and KeePass needed nothing: LastPass only ever exports CSV, which was already read, and the same is true of KeePass's CSV. Verified end to end: the real 4,395-item Bitwarden export still reads, a .1pux round-trips through the CLI, an unidentifiable CSV prints the source list and then imports once told, and an unknown source name is refused by name. The zip reader is tested against archives the system zip produced rather than ones we wrote. 195 tests pass. The 1Password mapping is built from the documented 1PUX schema and driven by fixtures, not from a real 1Password export. Run it with --dry-run first. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b873c2bf41
commit
6f26e23e2e
7 changed files with 1261 additions and 34 deletions
|
|
@ -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(
|
||||
"<file>",
|
||||
"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 <strategy>", "skip, replace or duplicate", "skip")
|
||||
.option("--source <name>", `force a CSV source (${Object.keys(IMPORT_SOURCES).join(", ")})`)
|
||||
.option(
|
||||
"--source <name>",
|
||||
`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 <name>:",
|
||||
` ${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);
|
||||
|
|
|
|||
263
packages/opencreds/src/import-router.test.ts
Normal file
263
packages/opencreds/src/import-router.test.ts
Normal file
|
|
@ -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<string, string>): 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);
|
||||
});
|
||||
});
|
||||
177
packages/opencreds/src/import-router.ts
Normal file
177
packages/opencreds/src/import-router.ts
Normal file
|
|
@ -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<Record<string, SourceInfo>> = 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",
|
||||
};
|
||||
}
|
||||
|
|
@ -308,6 +308,118 @@ function mapKeePassRow(row: Record<string, string>, folders: ReturnType<typeof f
|
|||
} as Partial<Item>);
|
||||
}
|
||||
|
||||
/** NordPass: name, url, username, password, note, folder, type, card columns. */
|
||||
function mapNordPassRow(row: Record<string, string>, folders: ReturnType<typeof folderAssigner>): 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<Item>);
|
||||
}
|
||||
|
||||
if (type === "note" || (!firstOf(row, "password") && !firstOf(row, "url"))) {
|
||||
return createItem("note", common as Partial<Item>);
|
||||
}
|
||||
|
||||
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<Item>);
|
||||
}
|
||||
|
||||
/** Dashlane: title, username, username2, password, note, url, category, otpSecret. */
|
||||
function mapDashlaneRow(row: Record<string, string>, folders: ReturnType<typeof folderAssigner>): 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<Item>);
|
||||
}
|
||||
|
||||
/** Proton Pass: type, name, url, email, username, password, note, totp, vault. */
|
||||
function mapProtonPassRow(row: Record<string, string>, folders: ReturnType<typeof folderAssigner>): 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<Item>);
|
||||
|
||||
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<Item>);
|
||||
}
|
||||
|
||||
/** RoboForm: Name, Url, MatchUrl, Login, Pwd, Note, Folder. */
|
||||
function mapRoboFormRow(row: Record<string, string>, folders: ReturnType<typeof folderAssigner>): 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<Item>);
|
||||
}
|
||||
|
||||
/** Apple Passwords: Title, URL, Username, Password, Notes, OTPAuth. */
|
||||
function mapAppleRow(row: Record<string, string>): 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<Item>);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<Record<string, ImportSource>> = 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<Record<string, ImportSource>> = 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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
495
packages/opencreds/src/onepassword.ts
Normal file
495
packages/opencreds/src/onepassword.ts
Normal file
|
|
@ -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<Record<string, string>> = 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<string, unknown>);
|
||||
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<string, unknown>;
|
||||
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<string, unknown>): 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<string>): 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<string, unknown>, 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<string, unknown>): ItemUri[] {
|
||||
const out: ItemUri[] = [];
|
||||
const seen = new Set<string>();
|
||||
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<string, unknown>): 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<string, string>();
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
const overview = (item.overview ?? {}) as Record<string, unknown>;
|
||||
const flat = flattenSections(details);
|
||||
const category = str(item.categoryUuid);
|
||||
const uuid = str(item.uuid);
|
||||
|
||||
const common: Record<string, unknown> = {
|
||||
...(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<Item>),
|
||||
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<Item>),
|
||||
common.id as string | undefined,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (category === CATEGORY.NOTE) {
|
||||
items.push(
|
||||
withId(
|
||||
createItem("note", {
|
||||
...common,
|
||||
fields: leftoverFields(flat, new Set()),
|
||||
} as Partial<Item>),
|
||||
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<Item>),
|
||||
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<Item>),
|
||||
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;
|
||||
}
|
||||
}
|
||||
103
packages/opencreds/src/zip.ts
Normal file
103
packages/opencreds/src/zip.ts
Normal file
|
|
@ -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}`);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue