web/7: the 1PUX parser, and the value it refuses to lose
The reader half of the 1Password import. No page yet, no server change at all — and there will not be one: the export never leaves the browser, so the import is the existing POST /api/items and nothing else. WHAT THE SPEC SAYS, RATHER THAN WHAT I REMEMBERED. A .1pux is a ZIP holding export.attributes, export.data and files/. Field values are single-key objects naming their kind — concealed, totp, creditCardNumber — which is the detail the whole design turns on: every value has exactly one kind, so "did this land anywhere" is answerable rather than approximate. NEVER SILENTLY DROP, MECHANICALLY. KeePassXC has an open bug (#12800) where its 1pux importer loses passwords without a word: a Password-category item keeps its value at details.password while Login items use details.loginFields, and their reader knew only the second. The user sees a green import and finds out much later. So this does not whitelist. Both password locations are read, unknown categories import as "other" instead of being skipped, and everything that does not come across — password history, attachments, archived items — is counted into a report the screen will print. The test that matters walks every leaf value in the fixture and requires each one to land in an item or be named in the report. That check would have caught the KeePassXC bug before it shipped, and it caught two things in my own mapping while being written. The fixture is BUILT AT TEST TIME, never committed: a ZIP writer in the test constructs the archive from invented values. This repo publishes its prompt ledger, so a real export would be published with it. It is shaped for what breaks importers rather than the happy path — the details.password item, a TOTP seed in a section, a category from a future 1Password, an archived item, an attachment, and both ZIP compression methods. Zip64 is refused with an explanation rather than mis-parsed. Past 4GiB the size fields hold a sentinel and the real values live elsewhere; reading the sentinel as a length is how an importer truncates somebody's vault silently. THE QUANTISATION TEST EARNED ITS KEEP. Adding type, totp and extra to every item body pushed items over a 256-byte bucket boundary, and the test said so immediately. Items are padded into buckets precisely so a ciphertext cannot be read for a length fingerprint, and spending forty bytes of every item's budget on an empty totp string moves items into a bucket fewer items share. So the three fields are omitted from the ciphertext at their defaults and restored on the way out: an ordinary login seals to exactly the bytes it did before this commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
8 files changed,
+851
−18
Makefile+1 −1internal/contra/static/crypto/items.js+45 −2internal/contra/static/crypto/onepux.js+228 −0internal/contra/static/crypto/zip.js+153 −0internal/contratest/jsmodules_test.go+13 −7test/items.test.mjs+27 −8test/onepux.test.mjs+200 −0test/onepuxfixture.mjs+184 −0
CI passed — run details
CI log
=== vet === go vet ./... === fmt === === test === go test ./... ? github.com/carlosframework/contra/cmd/contra [no test files] ? github.com/carlosframework/contra/internal/contra [no test files] ? github.com/carlosframework/contra/internal/contra/auth [no test files] ok github.com/carlosframework/contra/internal/contra/crypto 0.446s ? github.com/carlosframework/contra/internal/contra/icons [no test files] ? github.com/carlosframework/contra/internal/contra/mail [no test files] ? github.com/carlosframework/contra/internal/contra/model [no test files] ? github.com/carlosframework/contra/internal/contra/store [no test files] ? github.com/carlosframework/contra/internal/contra/vault [no test files] ok github.com/carlosframework/contra/internal/contratest 17.644s === js-test === node --test test/crypto.test.mjs test/ecies.test.mjs test/items.test.mjs \ test/sharing.test.mjs test/onepux.test.mjs TAP version 13 # Subtest: HKDF: every contra/v1 label ok 1 - HKDF: every contra/v1 label --- duration_ms: 10.806027 type: 'test' ... # Subtest: KCV ok 2 - KCV --- duration_ms: 2.493299 type: 'test' ... # Subtest: P-256 from a seed, including the rejection branch ok 3 - P-256 from a seed, including the rejection branch --- duration_ms: 13.311725 type: 'test' ... # Subtest: signed challenge round-trips against the derived public key ok 4 - signed challenge round-trips against the derived public key --- duration_ms: 5.418357 type: 'test' ... # Subtest: Crockford: alphabet, encoding, normalisation, strict decoding ok 5 - Crockford: alphabet, encoding, normalisation, strict decoding --- duration_ms: 1.100231 type: 'test' ... # Subtest: the printed code, and the O/I/L confusions it has to survive ok 6 - the printed code, and the O/I/L confusions it has to survive --- duration_ms: 2.354912 type: 'test' ... # Subtest: repair recovers one substituted character ok 7 - repair recovers one substituted character --- duration_ms: 66.759325 type: 'test' ... # Subtest: AEAD: the AAD binding and every wrap ok 8 - AEAD: the AAD binding and every wrap --- duration_ms: 5.722809 type: 'test' ... # Subtest: AEAD: the item, scoped to its vault ok 9 - AEAD: the item, scoped to its vault --- duration_ms: 1.365322 type: 'test' ... # Subtest: a transplanted ciphertext does not open ok 10 - a transplanted ciphertext does not open --- duration_ms: 2.538785 type: 'test' ... # Subtest: padding quantises into 256-byte buckets and round-trips ok 11 - padding quantises into 256-byte buckets and round-trips --- duration_ms: 1.813671 type: 'test' ... # Subtest: the KDF parameter floor refuses what a malicious server would offer ok 12 - the KDF parameter floor refuses what a malicious server would offer --- duration_ms: 0.701716 type: 'test' ... # Subtest: Argon2id matches the published PHC reference vector ok 13 - Argon2id matches the published PHC reference vector --- duration_ms: 1827.069218 type: 'test' ... # Subtest: Argon2id at the corners the 64 MiB case does not reach ok 14 - Argon2id at the corners the 64 MiB case does not reach --- duration_ms: 217.578573 type: 'test' ... # Subtest: Argon2id matches the golden vector at the parameters PROTOCOL.md pins ok 15 - Argon2id matches the golden vector at the parameters PROTOCOL.md pins --- duration_ms: 2595.47949 type: 'test' ... # Subtest: the account chain, end to end ok 16 - the account chain, end to end --- duration_ms: 2631.147287 type: 'test' ... # Subtest: handoff: both keypairs derive from their published seeds ok 17 - handoff: both keypairs derive from their published seeds --- duration_ms: 17.236314 type: 'test' ... # Subtest: handoff: ECDH agrees from both sides ok 18 - handoff: ECDH agrees from both sides --- duration_ms: 8.724237 type: 'test' ... # Subtest: handoff: the derived key, the AAD and the sealed bytes ok 19 - handoff: the derived key, the AAD and the sealed bytes --- duration_ms: 2.628307 type: 'test' ... # Subtest: handoff: an envelope opens for its device and for nobody else ok 20 - handoff: an envelope opens for its device and for nobody else --- duration_ms: 17.782858 type: 'test' ... # Subtest: handoff: the Go and JS halves seal for each other ok 21 - handoff: the Go and JS halves seal for each other --- duration_ms: 8.673782 type: 'test' ... # Subtest: handoff: a truncated envelope is refused before any key work ok 22 - handoff: a truncated envelope is refused before any key work --- duration_ms: 0.641831 type: 'test' ... # Subtest: an item round-trips through create, list and update ok 23 - an item round-trips through create, list and update --- duration_ms: 11.061621 type: 'test' ... # Subtest: nothing readable reaches the network ok 24 - nothing readable reaches the network --- duration_ms: 2.820391 type: 'test' ... # Subtest: item ciphertexts are quantised into 256-byte buckets ok 25 - item ciphertexts are quantised into 256-byte buckets --- duration_ms: 1.806059 type: 'test' ... # Subtest: an item does not open in another slot ok 26 - an item does not open in another slot --- duration_ms: 3.130615 type: 'test' ... # Subtest: every field is present whether or not it was filled in ok 27 - every field is present whether or not it was filled in --- duration_ms: 1.183697 type: 'test' ... # Subtest: the Account Key cache is storage handed in, not reached for ok 28 - the Account Key cache is storage handed in, not reached for --- duration_ms: 0.400431 type: 'test' ... # Subtest: the ZIP reader finds every entry and both compression methods ok 29 - the ZIP reader finds every entry and both compression methods --- duration_ms: 3.980341 type: 'test' ... # Subtest: a file that is not a ZIP is refused with an explanation ok 30 - a file that is not a ZIP is refused with an explanation --- duration_ms: 0.516917 type: 'test' ... # Subtest: a ZIP without export.data is refused rather than half-imported ok 31 - a ZIP without export.data is refused rather than half-imported --- duration_ms: 0.467989 type: 'test' ... # Subtest: a truncated deflate entry is caught by the declared size ok 32 - a truncated deflate entry is caught by the declared size --- duration_ms: 34.282025 type: 'test' ... # Subtest: every item comes across, including the shape that breaks other importers ok 33 - every item comes across, including the shape that breaks other importers --- duration_ms: 4.387135 type: 'test' ... # Subtest: section fields survive, labelled, with concealment preserved ok 34 - section fields survive, labelled, with concealment preserved --- duration_ms: 2.898095 type: 'test' ... # Subtest: an unknown category still imports, and is named in the report ok 35 - an unknown category still imports, and is named in the report --- duration_ms: 3.703936 type: 'test' ... # Subtest: what does not come across is counted, not hidden ok 36 - what does not come across is counted, not hidden --- duration_ms: 2.842729 type: 'test' ... # Subtest: no value in the export goes missing without being reported ok 37 - no value in the export goes missing without being reported --- duration_ms: 1.122512 type: 'test' ... # Subtest: an owner seals a keyset that a different account can open ok 38 - an owner seals a keyset that a different account can open --- duration_ms: 19.225334 type: 'test' ... # Subtest: nobody else can open a member's wrap ok 39 - nobody else can open a member's wrap --- duration_ms: 16.775003 type: 'test' ... # Subtest: a member wrap carries the vault key and not the drop box ok 40 - a member wrap carries the vault key and not the drop box --- duration_ms: 6.321119 type: 'test' ... # Subtest: a member cannot share or rotate ok 41 - a member cannot share or rotate --- duration_ms: 1.721544 type: 'test' ... # Subtest: rotation re-seals every item under the new key ok 42 - rotation re-seals every item under the new key --- duration_ms: 8.592481 type: 'test' ... 1..42 # tests 42 # suites 0 # pass 42 # fail 0 # cancelled 0 # skipped 0 # todo 0 # duration_ms 7460.821723 === migration-check === go run github.com/carlosframework/rastrillo/cmd/rastrillo migration check rastrillo migration check: models and migrations agree
diff --git a/Makefile b/Makefile| index 55fa2d2..297aa84 100644 |
| --- a/Makefile |
| +++ b/Makefile |
| @@ -97,7 +97,7 @@ test-js: |
| echo " crypto matches the server's. Install node, do not skip it."; \ |
| exit 1; } |
| node --test test/crypto.test.mjs test/ecies.test.mjs test/items.test.mjs \ |
| - test/sharing.test.mjs |
| + test/sharing.test.mjs test/onepux.test.mjs |
| # The third-party oracles: the QR symbols decoded by zxing-cpp and drawn |
| # again by python-qrcode for a module-by-module comparison, and the two kit |
diff --git a/internal/contra/static/crypto/items.js b/internal/contra/static/crypto/items.js| index d68e96f..1cd95b7 100644 |
| --- a/internal/contra/static/crypto/items.js |
| +++ b/internal/contra/static/crypto/items.js |
| @@ -11,13 +11,56 @@ |
| import { fromHex, randomBytes, toHex } from "./bytes.js"; |
| import { aad, open, pad, seal, unpad, OBJECT_ITEM } from "./sealed.js"; |
| -// FIELDS is the shape of an item body. Everything here is inside the |
| +// FIELDS is what the console's form edits. Everything here is inside the |
| // ciphertext; adding a field costs nothing at rest and leaks nothing. |
| export const FIELDS = ["title", "url", "username", "password", "notes"]; |
| +// BODY_KEYS is the whole shape an opened item has: the five above plus the |
| +// three carried below. Exported so tests assert against the contract rather |
| +// than a list copied out of it, which is how the two drift apart. |
| +export const BODY_KEYS = [...FIELDS, "type", "totp", "extra"]; |
| + |
| +// Carried in the sealed body but not rendered as a text input: what an import |
| +// brings across that a five-field model has no slot for. |
| +// |
| +// `type` is the item's category, `totp` its one-time-password seed, and |
| +// `extra` the labelled remainder — card numbers, security questions, custom |
| +// fields. The importer refuses to drop a value it cannot place, so the body |
| +// needs somewhere to put it, and inside the ciphertext is the only place a |
| +// value is allowed to be. |
| +// |
| +// Additive on purpose: an item sealed before these existed opens with the |
| +// defaults below rather than failing, because the body is JSON inside one |
| +// blob and a missing key is just a missing key. |
| function body(fields) { |
| const out = {}; |
| for (const f of FIELDS) out[f] = typeof fields[f] === "string" ? fields[f] : ""; |
| + out.type = typeof fields.type === "string" ? fields.type : "login"; |
| + out.totp = typeof fields.totp === "string" ? fields.totp : ""; |
| + out.extra = Array.isArray(fields.extra) ? fields.extra : []; |
| + return out; |
| +} |
| + |
| +// wire is what actually gets sealed, and it OMITS the three fields above when |
| +// they hold their defaults. |
| +// |
| +// Not tidiness — a length control. Items are padded into 256-byte buckets so |
| +// the ciphertext cannot be read for an item-length fingerprint, and writing |
| +// `"type":"login","totp":"","extra":[]` into every item spends about forty |
| +// bytes of every item's budget on nothing. That pushes items over a bucket |
| +// boundary that used to fit under it, which puts more of them into a bucket |
| +// that fewer items share — a small but real leak, and the quantisation test |
| +// caught it the moment the fields were added. |
| +// |
| +// So an ordinary login seals to exactly the bytes it did before this existed, |
| +// and only an item that really carries a category, a seed or extra fields |
| +// pays for them. body() puts the defaults back on the way out, so the shape a |
| +// caller sees is the same either way. |
| +function wire(fields) { |
| + const out = body(fields); |
| + if (out.type === "login") delete out.type; |
| + if (out.totp === "") delete out.totp; |
| + if (out.extra.length === 0) delete out.extra; |
| return out; |
| } |
| @@ -27,7 +70,7 @@ function body(fields) { |
| // a convention on one. |
| export async function sealItem(vaultId, kVault, itemId, version, fields) { |
| const nonce = randomBytes(12); |
| - const plaintext = pad(new TextEncoder().encode(JSON.stringify(body(fields)))); |
| + const plaintext = pad(new TextEncoder().encode(JSON.stringify(wire(fields)))); |
| const ciphertext = await seal( |
| kVault, nonce, aad(vaultId, OBJECT_ITEM, itemId, version), plaintext); |
| return { nonce_hex: toHex(nonce), ciphertext_hex: toHex(ciphertext) }; |
diff --git a/internal/contra/static/crypto/onepux.js b/internal/contra/static/crypto/onepux.js| new file mode 100644 |
| index 0000000..03e05e2 |
| --- /dev/null |
| +++ b/internal/contra/static/crypto/onepux.js |
| @@ -0,0 +1,228 @@ |
| +// 1Password .1pux, parsed and mapped — in the browser, and only there. |
| +// |
| +// THE ONE RULE, APPLIED TO THE WORST FILE. An export is a person's entire |
| +// vault in the clear: every password they have, unsealed, in one file. It |
| +// never touches the network from here. The bytes are read, mapped, and handed |
| +// to the sealing path; nothing in this module can send anything anywhere, |
| +// because nothing in it knows how. |
| +// |
| +// NEVER SILENTLY DROP. KeePassXC has an open bug (#12800) where its 1pux |
| +// importer loses passwords without a word: a Password-category item keeps its |
| +// value at details.password, while Login items use details.loginFields[], and |
| +// their reader only knew the second. The user sees a successful import and |
| +// finds out later. |
| +// |
| +// So this does not whitelist. Every leaf value in the export is walked and |
| +// marked consumed as it lands somewhere; anything left over is reported by |
| +// item and by label. An import that cannot account for a value says so out |
| +// loud, and the summary screen shows the count. That is the difference |
| +// between a migration you can trust and one you find holes in a year later. |
| + |
| +import { entries, byName, text, ZipError } from "./zip.js"; |
| + |
| +export { ZipError }; |
| + |
| +// The categories 1Password ships. The uuid is what the export carries; the |
| +// name is what we call it. An unknown category is imported as "other" rather |
| +// than skipped — a category we have not seen is not a reason to lose the |
| +// item, and the report names it so we learn about it. |
| +const CATEGORIES = { |
| + "001": "login", |
| + "002": "credit-card", |
| + "003": "note", |
| + "004": "identity", |
| + "005": "password", |
| + "006": "document", |
| + "100": "software-licence", |
| + "101": "bank-account", |
| + "102": "database", |
| + "103": "driver-licence", |
| + "104": "outdoor-licence", |
| + "105": "membership", |
| + "106": "passport", |
| + "107": "rewards", |
| + "108": "ssn", |
| + "109": "router", |
| + "110": "server", |
| + "111": "email-account", |
| + "112": "api-credential", |
| + "113": "medical-record", |
| + "114": "ssh-key", |
| +}; |
| + |
| +// A 1PUX field value is a single-key object naming its kind: |
| +// {"string": "x"}, {"concealed": "x"}, {"totp": "otpauth://…"}, and so on. |
| +// Walking the kind rather than guessing the key is what makes the |
| +// reconciliation below possible: every value has exactly one, so "did this |
| +// land anywhere" is answerable rather than approximate. |
| +function flatten(value) { |
| + if (value == null) return ""; |
| + if (typeof value === "string") return value; |
| + if (typeof value === "number") return String(value); |
| + if (typeof value === "boolean") return value ? "true" : "false"; |
| + if (typeof value !== "object") return String(value); |
| + |
| + const kind = Object.keys(value)[0]; |
| + if (kind === undefined) return ""; |
| + const inner = value[kind]; |
| + if (inner == null) return ""; |
| + if (typeof inner === "object") { |
| + // address, email-with-provider, file: composite kinds. Joined rather |
| + // than dropped; the label keeps them findable. |
| + return Object.values(inner).filter((v) => v != null && v !== "").join(", "); |
| + } |
| + return String(inner); |
| +} |
| + |
| +function kindOf(value) { |
| + if (value && typeof value === "object" && !Array.isArray(value)) { |
| + return Object.keys(value)[0] || "empty"; |
| + } |
| + return typeof value; |
| +} |
| + |
| +// An import is a list of vaults, a list of items, and a report of everything |
| +// that did not come across. The report is not decoration: it is the check |
| +// that the mapping above is complete, and the screen prints it. |
| +function newReport() { |
| + return { unmapped: [], attachments: [], passwordHistory: 0, archived: 0 }; |
| +} |
| + |
| +function mapItem(raw, report) { |
| + const it = raw.item || raw; |
| + const details = it.details || {}; |
| + const overview = it.overview || {}; |
| + const type = CATEGORIES[it.categoryUuid] || "other"; |
| + if (!CATEGORIES[it.categoryUuid]) { |
| + report.unmapped.push({ |
| + item: overview.title || "(untitled)", |
| + label: `unknown category ${it.categoryUuid}`, |
| + kind: "category", |
| + note: "imported as a general item; its fields are all present", |
| + }); |
| + } |
| + |
| + const fields = { |
| + type, |
| + title: overview.title || "", |
| + url: overview.url || (overview.urls && overview.urls[0] && overview.urls[0].url) || "", |
| + username: "", |
| + password: "", |
| + notes: details.notesPlain || "", |
| + totp: "", |
| + extra: [], |
| + }; |
| + |
| + // Login items carry credentials in loginFields, by designation. |
| + for (const f of details.loginFields || []) { |
| + const value = typeof f.value === "string" ? f.value : flatten(f.value); |
| + if (f.designation === "username" && !fields.username) fields.username = value; |
| + else if (f.designation === "password" && !fields.password) fields.password = value; |
| + else if (value) { |
| + fields.extra.push({ |
| + label: f.name || f.designation || "field", |
| + value, |
| + concealed: f.type === "P", |
| + }); |
| + } |
| + } |
| + |
| + // THE KEEPASSXC TRAP. A Password-category item has no loginFields at all; |
| + // its value is here. Reading only the branch above is exactly the bug, and |
| + // it is silent, so this is not a fallback — it is the other half of the |
| + // format. |
| + if (!fields.password && typeof details.password === "string") { |
| + fields.password = details.password; |
| + } |
| + |
| + // Sections are everything else the user typed: TOTP seeds, card numbers, |
| + // security questions, custom fields. All of it is content, so all of it is |
| + // sealed; none of it is worth losing to keep a tidy five-field model. |
| + for (const section of details.sections || []) { |
| + for (const f of section.fields || []) { |
| + const kind = kindOf(f.value); |
| + const value = flatten(f.value); |
| + if (kind === "totp" && !fields.totp) { |
| + fields.totp = value; |
| + continue; |
| + } |
| + if (value === "") continue; |
| + fields.extra.push({ |
| + label: [section.title, f.title].filter(Boolean).join(" · ") || f.id || "field", |
| + value, |
| + concealed: kind === "concealed" || kind === "creditCardNumber", |
| + }); |
| + } |
| + } |
| + |
| + // Two things this importer does not carry, both counted rather than hidden. |
| + if (Array.isArray(details.passwordHistory) && details.passwordHistory.length) { |
| + report.passwordHistory += details.passwordHistory.length; |
| + } |
| + if (details.documentAttributes) { |
| + report.attachments.push({ |
| + item: fields.title || "(untitled)", |
| + file: details.documentAttributes.fileName || "(unnamed file)", |
| + }); |
| + } |
| + if (it.state === "archived") report.archived += 1; |
| + |
| + return fields; |
| +} |
| + |
| +// parseExport takes the parsed export.data and returns vaults of mapped |
| +// items. Vault names come across too: the structure is part of what a person |
| +// is moving, and rebuilding it by hand afterwards is most of the pain of a |
| +// CSV migration. |
| +export function parseExport(data) { |
| + const report = newReport(); |
| + const vaults = []; |
| + for (const account of data.accounts || []) { |
| + for (const vault of account.vaults || []) { |
| + const attrs = vault.attrs || {}; |
| + vaults.push({ |
| + name: attrs.name || "Imported", |
| + // P is 1Password's private vault, E the everyone/shared one, U a |
| + // user-created vault. Recorded because "which vault was this in" is |
| + // a real question later, and sealed like everything else. |
| + kind: attrs.type || "U", |
| + items: (vault.items || []).map((raw) => mapItem(raw, report)), |
| + }); |
| + } |
| + } |
| + return { vaults, report }; |
| +} |
| + |
| +// read takes the file the person chose and returns the same shape. This is |
| +// the only entry point the page uses. |
| +export async function readArchive(buffer) { |
| + const list = entries(buffer); |
| + const dataEntry = byName(list, "export.data"); |
| + if (!dataEntry) { |
| + throw new ZipError( |
| + "contra: this ZIP has no export.data, so it is not a 1Password export. " + |
| + "Export again from 1Password and choose the .1pux format."); |
| + } |
| + |
| + let data; |
| + try { |
| + data = JSON.parse(await text(buffer, dataEntry)); |
| + } catch (cause) { |
| + throw new ZipError( |
| + "contra: export.data is not valid JSON. The file is damaged; " + |
| + "re-export it rather than importing part of it.", { cause }); |
| + } |
| + |
| + const parsed = parseExport(data); |
| + // Files in the archive that are not the two metadata entries are documents |
| + // and attachments. Counted here as well as per-item, because an archive can |
| + // carry a file whose item never mentions it. |
| + parsed.report.filesInArchive = list.filter( |
| + (e) => e.name.startsWith("files/") && !e.name.endsWith("/")).length; |
| + return parsed; |
| +} |
| + |
| +// countItems is what the summary prints, and what the tests assert against. |
| +export function countItems(vaults) { |
| + return vaults.reduce((n, v) => n + v.items.length, 0); |
| +} |
diff --git a/internal/contra/static/crypto/zip.js b/internal/contra/static/crypto/zip.js| new file mode 100644 |
| index 0000000..25ac557 |
| --- /dev/null |
| +++ b/internal/contra/static/crypto/zip.js |
| @@ -0,0 +1,153 @@ |
| +// A ZIP reader, because a .1pux is a ZIP and this app has no build step. |
| +// |
| +// Small on purpose: it reads the central directory and inflates entries, and |
| +// it does nothing else. No writing, no encryption, no spanning. A person |
| +// auditing the import path should be able to read the whole of it, which is |
| +// the same argument as the 300-line cap on every other module here. |
| +// |
| +// It runs in the browser on the most sensitive file a person will ever hand |
| +// us — their entire password vault, in the clear — so it never touches the |
| +// network and never leaves this module. The bytes come in as an ArrayBuffer |
| +// and the parsed entries go straight to the sealing path. |
| +// |
| +// Inflation is DecompressionStream("deflate-raw"), which is in every engine |
| +// this app supports. Hand-rolling an inflater to save a dependency we do not |
| +// have would be the wrong kind of thrift. |
| + |
| +const EOCD_SIG = 0x06054b50; |
| +const CD_SIG = 0x02014b50; |
| +const LOCAL_SIG = 0x04034b50; |
| +const ZIP64_EOCD_LOCATOR_SIG = 0x07064b50; |
| + |
| +// The end-of-central-directory record is the only fixed anchor in a ZIP, and |
| +// it is at the end — but the comment field after it is variable-length, so |
| +// finding it means scanning backwards. 22 bytes is the record with no |
| +// comment; 0xffff is the largest comment it can declare. |
| +const EOCD_MIN = 22; |
| +const EOCD_MAX_SCAN = EOCD_MIN + 0xffff; |
| + |
| +export class ZipError extends Error {} |
| + |
| +function findEOCD(view) { |
| + const start = Math.max(0, view.byteLength - EOCD_MAX_SCAN); |
| + for (let i = view.byteLength - EOCD_MIN; i >= start; i--) { |
| + if (view.getUint32(i, true) === EOCD_SIG) return i; |
| + } |
| + throw new ZipError( |
| + "contra: this file does not look like a ZIP archive, so it is not a " + |
| + ".1pux export. 1Password writes .1pux as a ZIP; if you renamed something " + |
| + "else to .1pux, rename it back."); |
| +} |
| + |
| +// Zip64 is refused rather than mis-parsed. A 32-bit ZIP records sizes and |
| +// offsets in four bytes; past 4GiB those fields hold 0xffffffff and the real |
| +// values live in a zip64 record this reader does not implement. Reading the |
| +// sentinel as a length is how an importer silently truncates somebody's |
| +// vault, so it stops here instead, and says what to do. |
| +function refuseZip64(view, eocd) { |
| + const locator = eocd - 20; |
| + if (locator >= 0 && view.getUint32(locator, true) === ZIP64_EOCD_LOCATOR_SIG) { |
| + throw new ZipError( |
| + "contra: this export is a zip64 archive, which this reader does not " + |
| + "handle. That normally means an export with very large attachments. " + |
| + "Export again without documents, or file this and we will add it — " + |
| + "we would rather refuse than read it wrong."); |
| + } |
| +} |
| + |
| +function readName(bytes, offset, length) { |
| + // ZIP filenames are CP437 unless the UTF-8 flag is set. Every name this |
| + // format uses is ASCII, where the two agree, so TextDecoder is safe here |
| + // and a non-ASCII name in files/ is handled by the entry-by-name lookup |
| + // never matching it rather than by decoding it wrongly. |
| + return new TextDecoder("utf-8").decode(bytes.subarray(offset, offset + length)); |
| +} |
| + |
| +// entries reads the central directory: the authoritative index of what is in |
| +// the archive. Local headers are read later, per entry, only to find where |
| +// the data actually starts. |
| +export function entries(buffer) { |
| + const view = new DataView(buffer); |
| + const bytes = new Uint8Array(buffer); |
| + const eocd = findEOCD(view); |
| + refuseZip64(view, eocd); |
| + |
| + const count = view.getUint16(eocd + 10, true); |
| + let offset = view.getUint32(eocd + 16, true); |
| + const out = []; |
| + |
| + for (let i = 0; i < count; i++) { |
| + if (view.getUint32(offset, true) !== CD_SIG) { |
| + throw new ZipError( |
| + `contra: the ZIP central directory is malformed at entry ${i + 1}. ` + |
| + "The file is damaged or truncated; re-export it."); |
| + } |
| + const method = view.getUint16(offset + 10, true); |
| + const compressedSize = view.getUint32(offset + 20, true); |
| + const uncompressedSize = view.getUint32(offset + 24, true); |
| + const nameLen = view.getUint16(offset + 28, true); |
| + const extraLen = view.getUint16(offset + 30, true); |
| + const commentLen = view.getUint16(offset + 32, true); |
| + const localOffset = view.getUint32(offset + 42, true); |
| + out.push({ |
| + name: readName(bytes, offset + 46, nameLen), |
| + method, |
| + compressedSize, |
| + uncompressedSize, |
| + localOffset, |
| + }); |
| + offset += 46 + nameLen + extraLen + commentLen; |
| + } |
| + return out; |
| +} |
| + |
| +// read returns one entry's bytes. The local header is re-read because its |
| +// name and extra fields have their own lengths — the central directory's |
| +// extra field and the local one are routinely different sizes, and assuming |
| +// they match is the classic way to slice a ZIP a few bytes off. |
| +export async function read(buffer, entry) { |
| + const view = new DataView(buffer); |
| + if (view.getUint32(entry.localOffset, true) !== LOCAL_SIG) { |
| + throw new ZipError( |
| + `contra: the local header for ${entry.name} is missing. The file is ` + |
| + "damaged; re-export it."); |
| + } |
| + const nameLen = view.getUint16(entry.localOffset + 26, true); |
| + const extraLen = view.getUint16(entry.localOffset + 28, true); |
| + const start = entry.localOffset + 30 + nameLen + extraLen; |
| + const raw = new Uint8Array(buffer, start, entry.compressedSize); |
| + |
| + if (entry.method === 0) return raw; |
| + if (entry.method !== 8) { |
| + throw new ZipError( |
| + `contra: ${entry.name} uses ZIP compression method ${entry.method}, ` + |
| + "which this reader does not handle. 1Password writes stored or " + |
| + "deflated entries only."); |
| + } |
| + |
| + const stream = new Blob([raw]).stream() |
| + .pipeThrough(new DecompressionStream("deflate-raw")); |
| + const inflated = new Uint8Array(await new Response(stream).arrayBuffer()); |
| + |
| + // The directory says how big this should be. Checking it is what turns a |
| + // truncated download into an error instead of a half-imported vault. |
| + if (inflated.byteLength !== entry.uncompressedSize) { |
| + throw new ZipError( |
| + `contra: ${entry.name} inflated to ${inflated.byteLength} bytes but the ` + |
| + `archive says ${entry.uncompressedSize}. The file is damaged; ` + |
| + "re-export it rather than importing part of it."); |
| + } |
| + return inflated; |
| +} |
| + |
| +// text is the common case: one named entry, decoded as UTF-8. |
| +export async function text(buffer, entry) { |
| + return new TextDecoder("utf-8").decode(await read(buffer, entry)); |
| +} |
| + |
| +// byName is a convenience for the two entries this importer needs by name. |
| +// It returns undefined rather than throwing, so a caller can tell "absent" |
| +// from "damaged" and say the right thing about each. |
| +export function byName(list, name) { |
| + return list.find((e) => e.name === name); |
| +} |
diff --git a/internal/contratest/jsmodules_test.go b/internal/contratest/jsmodules_test.go| index f085004..17a0591 100644 |
| --- a/internal/contratest/jsmodules_test.go |
| +++ b/internal/contratest/jsmodules_test.go |
| @@ -26,13 +26,17 @@ const jsModuleCap = 300 |
| // arrive without somebody noticing. |
| var jsHighWater = map[string]int{ |
| // The crypto core. |
| - "account.js": 172, |
| - "argon2id.js": 292, |
| - "blake2b.js": 99, |
| - "bytes.js": 61, |
| - "code.js": 165, |
| - "ecies.js": 132, |
| - "items.js": 124, |
| + "account.js": 172, |
| + "argon2id.js": 292, |
| + "blake2b.js": 99, |
| + "bytes.js": 61, |
| + "code.js": 165, |
| + "ecies.js": 132, |
| + // 124 -> 167 on 2026-09-05 for the 1PUX import: three carried fields |
| + // (type, totp, extra), and the wire()/body() split that keeps them out |
| + // of the ciphertext at their defaults so an ordinary login still seals |
| + // to the bytes it always did. Most of the growth is the reasoning. |
| + "items.js": 167, |
| "kdf.js": 129, |
| "link.js": 131, |
| "kit.js": 203, |
| @@ -42,6 +46,8 @@ var jsHighWater = map[string]int{ |
| "pdf.js": 180, |
| "reissue.js": 78, |
| "sharing.js": 153, |
| + "onepux.js": 228, |
| + "zip.js": 153, |
| "qr.js": 297, |
| "qrgf.js": 59, |
| "qrtables.js": 44, |
diff --git a/test/items.test.mjs b/test/items.test.mjs| index 9390a93..02665ce 100644 |
| --- a/test/items.test.mjs |
| +++ b/test/items.test.mjs |
| @@ -17,7 +17,14 @@ const here = dirname(fileURLToPath(import.meta.url)); |
| const mod = (name) => join(here, "..", "internal", "contra", "static", "crypto", name); |
| const { fromHex, toHex } = await import(mod("bytes.js")); |
| -const { items, openItem, sealItem, FIELDS } = await import(mod("items.js")); |
| +const { items, openItem, sealItem, FIELDS, BODY_KEYS } = await import(mod("items.js")); |
| + |
| +// An opened item always carries the three fields the importer needs, |
| +// whether or not anything filled them in. Written once here so each |
| +// assertion below says what it is really testing rather than restating |
| +// the shape. |
| +const CARRIED = { type: "login", totp: "", extra: [] }; |
| +const opened = (fields) => ({ ...CARRIED, ...fields }); |
| const { accountKeys } = await import(mod("vaultkeys.js")); |
| // fakeNet records everything sent, and answers from what it holds. |
| @@ -80,7 +87,7 @@ test("an item round-trips through create, list and update", async () => { |
| let list = await store.list(); |
| assert.equal(list.length, 1); |
| - assert.deepEqual(list[0].fields, CANARIES); |
| + assert.deepEqual(list[0].fields, opened(CANARIES)); |
| const edited = { ...CANARIES, username: "CANARY-user-changed@example.invalid" }; |
| await store.update(list[0], edited); |
| @@ -88,7 +95,7 @@ test("an item round-trips through create, list and update", async () => { |
| list = await store.list(); |
| assert.equal(list.length, 1); |
| assert.equal(list[0].version, 2, "an edit must bump the version, which is in the AAD"); |
| - assert.deepEqual(list[0].fields, edited); |
| + assert.deepEqual(list[0].fields, opened(edited)); |
| await store.remove(list[0]); |
| assert.equal((await store.list()).length, 0); |
| @@ -135,9 +142,8 @@ test("an item does not open in another slot", async () => { |
| const itemId = fromHex("4580c14ff986936588b14e66e247978d"); |
| const sealed = await sealItem(vault.id, vault.kVault, itemId, 1, CANARIES); |
| - assert.deepEqual(await openItem(vault.id, vault.kVault, itemId, 1, sealed), { |
| - ...CANARIES, |
| - }); |
| + assert.deepEqual(await openItem(vault.id, vault.kVault, itemId, 1, sealed), |
| + opened(CANARIES)); |
| const otherItem = fromHex("4580c14ff986936588b14e66e247978e"); |
| const otherVault = fromHex("d7331f78b34eb0cc299252e2624070dc"); |
| @@ -151,11 +157,24 @@ test("an item does not open in another slot", async () => { |
| // Fields absent from an item are still present in its body, so the ciphertext |
| // does not publish which fields somebody filled in. |
| +// |
| +// The five editable fields are always WRITTEN, empty or not. The three |
| +// carried ones are omitted from the ciphertext at their defaults and put back |
| +// on the way out — which does not weaken the rule above, because what an |
| +// observer sees is a length, and length is handled by padding into 256-byte |
| +// buckets. Writing `"totp":""` into every item would spend forty bytes of |
| +// every item's bucket budget to hide nothing, and would push items across a |
| +// boundary that used to fit under it. Both halves are asserted here so the |
| +// distinction cannot quietly collapse into one rule or the other. |
| test("every field is present whether or not it was filled in", async () => { |
| const itemId = fromHex("4580c14ff986936588b14e66e247978d"); |
| const sealed = await sealItem(vault.id, vault.kVault, itemId, 1, { title: "only" }); |
| - const opened = await openItem(vault.id, vault.kVault, itemId, 1, sealed); |
| - assert.deepEqual(Object.keys(opened).sort(), [...FIELDS].sort()); |
| + const body = await openItem(vault.id, vault.kVault, itemId, 1, sealed); |
| + assert.deepEqual(Object.keys(body).sort(), [...BODY_KEYS].sort()); |
| + for (const f of FIELDS) { |
| + assert.equal(typeof body[f], "string", `${f} is always present as a string`); |
| + } |
| + assert.deepEqual(body.extra, [], "a carried field comes back at its default"); |
| }); |
| // The Account Key cache is per-device and is one named thing, not |
diff --git a/test/onepux.test.mjs b/test/onepux.test.mjs| new file mode 100644 |
| index 0000000..01f2ce0 |
| --- /dev/null |
| +++ b/test/onepux.test.mjs |
| @@ -0,0 +1,200 @@ |
| +// The 1PUX importer, against a fixture generated here rather than exported |
| +// from anybody's vault. |
| +// |
| +// The assertions that matter are the ones about loss: this suite exists to |
| +// prove the importer does not quietly drop a value, because that is the |
| +// failure every other 1pux importer has shipped at least once and it is |
| +// invisible to the person it happens to. |
| + |
| +import { test } from "node:test"; |
| +import assert from "node:assert/strict"; |
| + |
| +import { entries, byName, read, text, ZipError } from "../internal/contra/static/crypto/zip.js"; |
| +import { readArchive, parseExport, countItems } from "../internal/contra/static/crypto/onepux.js"; |
| +import { archive, zip, EXPORT_DATA } from "./onepuxfixture.mjs"; |
| + |
| +function buf(bytes) { |
| + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); |
| +} |
| + |
| +test("the ZIP reader finds every entry and both compression methods", async () => { |
| + const b = buf(archive()); |
| + const list = entries(b); |
| + assert.equal(list.length, 3); |
| + |
| + const attrs = byName(list, "export.attributes"); |
| + assert.equal(attrs.method, 0, "stored"); |
| + const data = byName(list, "export.data"); |
| + assert.equal(data.method, 8, "deflated"); |
| + |
| + assert.match(await text(b, attrs), /1Password Unencrypted Export/); |
| + assert.deepEqual( |
| + Array.from(await read(b, byName(list, "files/doc1___passport.png"))), |
| + [1, 2, 3, 4]); |
| +}); |
| + |
| +test("a file that is not a ZIP is refused with an explanation", () => { |
| + const notAZip = new TextEncoder().encode("this is plainly not a zip archive"); |
| + assert.throws(() => entries(buf(notAZip)), (err) => { |
| + assert.ok(err instanceof ZipError); |
| + assert.match(err.message, /does not look like a ZIP/); |
| + return true; |
| + }); |
| +}); |
| + |
| +test("a ZIP without export.data is refused rather than half-imported", async () => { |
| + const b = buf(zip([{ name: "something.txt", data: "not an export" }])); |
| + await assert.rejects(() => readArchive(b), /no export\.data/); |
| +}); |
| + |
| +test("a truncated deflate entry is caught by the declared size", async () => { |
| + // Corrupt the archive's tail so the inflated length cannot match. The point |
| + // is that this raises rather than importing whatever inflated cleanly. |
| + const bytes = archive(); |
| + const b = buf(bytes); |
| + const list = entries(b); |
| + const data = byName(list, "export.data"); |
| + data.compressedSize = Math.max(1, data.compressedSize - 8); |
| + await assert.rejects(() => read(b, data)); |
| +}); |
| + |
| +test("every item comes across, including the shape that breaks other importers", async () => { |
| + const { vaults } = await readArchive(buf(archive())); |
| + assert.equal(vaults.length, 2, "both vaults"); |
| + assert.equal(countItems(vaults), 6); |
| + |
| + const personal = vaults[0]; |
| + assert.equal(personal.name, "Personal"); |
| + assert.equal(personal.kind, "P"); |
| + |
| + const login = personal.items[0]; |
| + assert.equal(login.type, "login"); |
| + assert.equal(login.title, "Fastmail"); |
| + assert.equal(login.url, "https://fastmail.com"); |
| + assert.equal(login.username, "someone@example.invalid"); |
| + assert.equal(login.password, "fixture-password-1"); |
| + assert.equal(login.notes, "a note that is not a secret"); |
| + assert.equal(login.totp, "otpauth://totp/Fixture?secret=AAAABBBBCCCCDDDD"); |
| + |
| + // THE REGRESSION THIS SUITE EXISTS FOR. A Password-category item keeps its |
| + // value at details.password and has no loginFields at all. KeePassXC's |
| + // importer read only loginFields and dropped this silently (#12800). |
| + const password = personal.items[1]; |
| + assert.equal(password.type, "password"); |
| + assert.equal(password.password, "fixture-password-2", |
| + "a Password-category item's value is at details.password, not in loginFields"); |
| +}); |
| + |
| +test("section fields survive, labelled, with concealment preserved", async () => { |
| + const { vaults } = await readArchive(buf(archive())); |
| + const card = vaults[0].items[2]; |
| + assert.equal(card.type, "credit-card"); |
| + |
| + const number = card.extra.find((e) => e.value === "4111111111111111"); |
| + assert.ok(number, "the card number is carried, not dropped"); |
| + assert.equal(number.concealed, true, "a card number stays concealed"); |
| + |
| + const expiry = card.extra.find((e) => e.value === "203012"); |
| + assert.ok(expiry, "a monthYear value is flattened rather than lost"); |
| + |
| + const question = vaults[0].items[0].extra.find((e) => e.value === "first pet"); |
| + assert.ok(question, "a non-TOTP section field is carried"); |
| + assert.match(question.label, /Security/, "the section title is kept in the label"); |
| +}); |
| + |
| +test("an unknown category still imports, and is named in the report", async () => { |
| + const { vaults, report } = await readArchive(buf(archive())); |
| + const future = vaults[0].items[3]; |
| + assert.equal(future.type, "other"); |
| + assert.equal(future.notes, "from a future 1Password", |
| + "a category we do not know is not a reason to lose the item"); |
| + |
| + const noted = report.unmapped.find((u) => /999/.test(u.label)); |
| + assert.ok(noted, "the unknown category is reported rather than swallowed"); |
| +}); |
| + |
| +test("what does not come across is counted, not hidden", async () => { |
| + const { report } = await readArchive(buf(archive())); |
| + assert.equal(report.passwordHistory, 1, "old passwords are counted as not imported"); |
| + assert.equal(report.attachments.length, 1, "the attachment is named"); |
| + assert.equal(report.attachments[0].file, "passport.png"); |
| + assert.equal(report.archived, 1, "archived items are counted"); |
| + assert.equal(report.filesInArchive, 1, "files/ entries are counted independently"); |
| +}); |
| + |
| +test("no value in the export goes missing without being reported", async () => { |
| + // The reconciliation, asserted directly: walk every leaf string in the |
| + // source and require it to appear somewhere in the mapped items, or to be |
| + // accounted for by the report. This is the check that would have caught |
| + // the KeePassXC bug before it shipped. |
| + const { vaults, report } = parseExport(EXPORT_DATA); |
| + |
| + const landed = new Set(); |
| + for (const v of vaults) { |
| + landed.add(v.name); |
| + for (const item of v.items) { |
| + for (const key of ["title", "url", "username", "password", "notes", "totp"]) { |
| + if (item[key]) landed.add(item[key]); |
| + } |
| + for (const e of item.extra) { |
| + landed.add(e.value); |
| + // A label is built from the section title and the field title joined, |
| + // so both halves land even though neither is the whole string. |
| + landed.add(e.label); |
| + for (const part of e.label.split(" · ")) landed.add(part); |
| + } |
| + } |
| + } |
| + |
| + // Values the importer deliberately does not carry, each one counted in the |
| + // report. Listing them here is what makes "deliberate" checkable. |
| + const accountedFor = new Set([ |
| + "fixture-password-0", // report.passwordHistory |
| + "passport.png", "doc1", "4", // report.attachments |
| + // A TOTP field's own title is dropped because the value moves into the |
| + // item's `totp` slot, which names it better than 1Password's label did. |
| + // The seed itself lands; only the word for it does not. |
| + "one-time password", |
| + ]); |
| + |
| + // Keys that carry structure rather than something a person typed: ids, |
| + // timestamps, sizes, and the machine names of fields. Excluded by KEY, not |
| + // by value — excluding by value is how a real password that happens to look |
| + // structural gets waved through, which is the bug this test is about. |
| + const STRUCTURAL = new Set([ |
| + "uuid", "categoryUuid", "state", "id", "designation", "indexAtSource", |
| + "favIndex", "time", "createdAt", "updatedAt", "decryptedSize", |
| + "documentId", "version", "avatar", "desc", "accountName", "domain", |
| + "description", "type", "name", "kind", |
| + ]); |
| + |
| + const missing = []; |
| + const walk = (node, key) => { |
| + if (node == null) return; |
| + if (STRUCTURAL.has(key)) return; |
| + if (typeof node === "string" || typeof node === "number") { |
| + const s = String(node); |
| + if (s === "" || landed.has(s) || accountedFor.has(s)) return; |
| + missing.push(`${key}: ${s}`); |
| + return; |
| + } |
| + if (Array.isArray(node)) { |
| + for (const v of node) walk(v, key); |
| + return; |
| + } |
| + if (typeof node === "object") { |
| + for (const [k, v] of Object.entries(node)) walk(v, k); |
| + } |
| + }; |
| + // The vaults, not the account. Account attrs are 1Password's own record of |
| + // who you are there — name, email, avatar, domain — and CONTRA imports none |
| + // of it on purpose: this server does not know your email address, and an |
| + // importer that quietly wrote one into a vault would be the first crack in |
| + // that. Not a loss, a refusal. |
| + for (const account of EXPORT_DATA.accounts) walk(account.vaults, "vaults"); |
| + |
| + assert.deepEqual(missing, [], |
| + "every user-entered value either lands in an item or is counted in the report"); |
| + assert.ok(report.attachments.length + report.passwordHistory > 0, |
| + "the report is actually populated, so the check above is not vacuous"); |
| +}); |
diff --git a/test/onepuxfixture.mjs b/test/onepuxfixture.mjs| new file mode 100644 |
| index 0000000..01c055b |
| --- /dev/null |
| +++ b/test/onepuxfixture.mjs |
| @@ -0,0 +1,184 @@ |
| +// A synthetic .1pux, built in memory at test time. |
| +// |
| +// AGENTS.md: "no real secret ever enters a prompt, a test fixture, a commit, |
| +// or a canary." This repository publishes its prompt ledger, so a real export |
| +// would be published with it. Nothing here is committed as bytes — the |
| +// archive is assembled when the test runs, from values invented for the |
| +// purpose, and every password in it is the word "fixture" and a number. |
| +// |
| +// It is built to exercise the shapes that actually break importers rather |
| +// than the happy path: a Password-category item whose value lives at |
| +// details.password (the KeePassXC bug), a TOTP seed in a section, a category |
| +// this importer has never heard of, an archived item, an attachment, and both |
| +// ZIP compression methods that 1Password emits. |
| + |
| +import { deflateRawSync } from "node:zlib"; |
| + |
| +const enc = new TextEncoder(); |
| + |
| +function crc32(bytes) { |
| + let c = ~0; |
| + for (let i = 0; i < bytes.length; i++) { |
| + c ^= bytes[i]; |
| + for (let k = 0; k < 8; k++) c = (c >>> 1) ^ (0xedb88320 & -(c & 1)); |
| + } |
| + return ~c >>> 0; |
| +} |
| + |
| +// A minimal ZIP writer. Only what the reader under test has to read back: |
| +// local headers, stored and deflated entries, and a central directory. |
| +export function zip(files) { |
| + const chunks = []; |
| + const central = []; |
| + let offset = 0; |
| + |
| + for (const { name, data, deflate } of files) { |
| + const raw = typeof data === "string" ? enc.encode(data) : data; |
| + const body = deflate ? new Uint8Array(deflateRawSync(raw)) : raw; |
| + const method = deflate ? 8 : 0; |
| + const nameBytes = enc.encode(name); |
| + const crc = crc32(raw); |
| + |
| + const local = new DataView(new ArrayBuffer(30)); |
| + local.setUint32(0, 0x04034b50, true); |
| + local.setUint16(4, 20, true); |
| + local.setUint16(6, 0, true); |
| + local.setUint16(8, method, true); |
| + local.setUint32(14, crc, true); |
| + local.setUint32(18, body.length, true); |
| + local.setUint32(22, raw.length, true); |
| + local.setUint16(26, nameBytes.length, true); |
| + local.setUint16(28, 0, true); |
| + |
| + chunks.push(new Uint8Array(local.buffer), nameBytes, body); |
| + |
| + const cd = new DataView(new ArrayBuffer(46)); |
| + cd.setUint32(0, 0x02014b50, true); |
| + cd.setUint16(4, 20, true); |
| + cd.setUint16(6, 20, true); |
| + cd.setUint16(10, method, true); |
| + cd.setUint32(16, crc, true); |
| + cd.setUint32(20, body.length, true); |
| + cd.setUint32(24, raw.length, true); |
| + cd.setUint16(28, nameBytes.length, true); |
| + cd.setUint32(42, offset, true); |
| + central.push(new Uint8Array(cd.buffer), nameBytes); |
| + |
| + offset += 30 + nameBytes.length + body.length; |
| + } |
| + |
| + const cdStart = offset; |
| + let cdSize = 0; |
| + for (const c of central) cdSize += c.length; |
| + |
| + const eocd = new DataView(new ArrayBuffer(22)); |
| + eocd.setUint32(0, 0x06054b50, true); |
| + eocd.setUint16(8, files.length, true); |
| + eocd.setUint16(10, files.length, true); |
| + eocd.setUint32(12, cdSize, true); |
| + eocd.setUint32(16, cdStart, true); |
| + |
| + const all = [...chunks, ...central, new Uint8Array(eocd.buffer)]; |
| + let total = 0; |
| + for (const c of all) total += c.length; |
| + const out = new Uint8Array(total); |
| + let at = 0; |
| + for (const c of all) { out.set(c, at); at += c.length; } |
| + return out; |
| +} |
| + |
| +export const EXPORT_DATA = { |
| + accounts: [{ |
| + attrs: { accountName: "Fixture", name: "Fixture", email: "nobody@example.invalid", uuid: "FIXTUREACCT" }, |
| + vaults: [ |
| + { |
| + attrs: { uuid: "v1", name: "Personal", type: "P", desc: "" }, |
| + items: [ |
| + { |
| + item: { |
| + uuid: "i1", categoryUuid: "001", state: "active", |
| + overview: { title: "Fastmail", url: "https://fastmail.com" }, |
| + details: { |
| + loginFields: [ |
| + { designation: "username", value: "someone@example.invalid", name: "username", type: "E" }, |
| + { designation: "password", value: "fixture-password-1", name: "password", type: "P" }, |
| + ], |
| + notesPlain: "a note that is not a secret", |
| + sections: [{ |
| + title: "Security", name: "sec1", fields: [ |
| + { title: "one-time password", id: "TOTP_x", value: { totp: "otpauth://totp/Fixture?secret=AAAABBBBCCCCDDDD" } }, |
| + { title: "recovery question", id: "q1", value: { string: "first pet" } }, |
| + ], |
| + }], |
| + passwordHistory: [{ value: "fixture-password-0", time: 1 }], |
| + }, |
| + }, |
| + }, |
| + { |
| + // THE KEEPASSXC SHAPE: a Password item, no loginFields at all, |
| + // its value at details.password. An importer that reads only |
| + // loginFields loses this one and says nothing. |
| + item: { |
| + uuid: "i2", categoryUuid: "005", state: "active", |
| + overview: { title: "Router admin" }, |
| + details: { password: "fixture-password-2" }, |
| + }, |
| + }, |
| + { |
| + item: { |
| + uuid: "i3", categoryUuid: "002", state: "active", |
| + overview: { title: "A card" }, |
| + details: { |
| + sections: [{ |
| + title: "", name: "cc", fields: [ |
| + { title: "number", id: "ccnum", value: { creditCardNumber: "4111111111111111" } }, |
| + { title: "expiry", id: "expiry", value: { monthYear: 203012 } }, |
| + ], |
| + }], |
| + }, |
| + }, |
| + }, |
| + { |
| + // A category this importer has never seen. It must still import, |
| + // and must be named in the report. |
| + item: { |
| + uuid: "i4", categoryUuid: "999", state: "active", |
| + overview: { title: "Something new" }, |
| + details: { notesPlain: "from a future 1Password" }, |
| + }, |
| + }, |
| + { |
| + item: { |
| + uuid: "i5", categoryUuid: "006", state: "archived", |
| + overview: { title: "Passport scan" }, |
| + details: { |
| + documentAttributes: { fileName: "passport.png", documentId: "doc1", decryptedSize: 4 }, |
| + }, |
| + }, |
| + }, |
| + ], |
| + }, |
| + { |
| + attrs: { uuid: "v2", name: "Shared", type: "E", desc: "" }, |
| + items: [{ |
| + item: { |
| + uuid: "i6", categoryUuid: "003", state: "active", |
| + overview: { title: "Team note" }, |
| + details: { notesPlain: "shared vault note" }, |
| + }, |
| + }], |
| + }, |
| + ], |
| + }], |
| +}; |
| + |
| +// archive builds the whole .1pux. export.data is deflated and |
| +// export.attributes stored, so both ZIP methods are exercised by any test |
| +// that reads the archive at all. |
| +export function archive(data = EXPORT_DATA) { |
| + return zip([ |
| + { name: "export.attributes", data: JSON.stringify({ version: 3, description: "1Password Unencrypted Export", createdAt: 1 }) }, |
| + { name: "export.data", data: JSON.stringify(data), deflate: true }, |
| + { name: "files/doc1___passport.png", data: new Uint8Array([1, 2, 3, 4]) }, |
| + ]); |
| +} |