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:
Anthony Ettinger 2026-09-24 04:22:35 -07:00 • committed by GitHub
parent b873c2bf41
commit 6f26e23e2e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 1261 additions and 34 deletions

View 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);
});
});