mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-03 05:07:10 +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
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