mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-03 21:27:07 +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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue