diff --git a/decrypt-saved-deck-export/decrypt.mjs b/decrypt-saved-deck-export/decrypt.mjs new file mode 100644 index 000000000..6b434448c --- /dev/null +++ b/decrypt-saved-deck-export/decrypt.mjs @@ -0,0 +1,231 @@ +#!/usr/bin/env node +/** + * Standalone decrypt tool for a ProxyPrints saved-decks export bundle + * (docs/proposals/proposal-g-user-accounts-saved-decks.md, "PR-6, post-v1: deck portability" - + * see this directory's readme.md for the full format writeup). This file is the trust anchor for + * "if this site vanishes tomorrow, your decks are still yours": it runs without this site, this + * codebase, or any server existing at all - only Node's own built-in `node:crypto` WebCrypto + * implementation (no npm dependencies whatsoever). + * + * Usage: + * node decrypt.mjs --passphrase "..." + * node decrypt.mjs --recovery-key "base64..." + * node decrypt.mjs # prompts for a passphrase + * + * Prints every decrypted deck's plaintext JSON to stdout (one bundle -> one JSON array), or use + * --out to write one file per deck instead. + * + * License: MIT (mirrors federation-hash-tool/'s precedent for a standalone, dependency-free + * tool meant to run independently of this GPL-3.0 repository's own codebase). + */ + +import { webcrypto } from "node:crypto"; +import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { createInterface } from "node:readline"; +import { join } from "node:path"; + +const { subtle } = webcrypto; + +const AES_ALGO = "AES-GCM"; +const AES_KEY_LENGTH = 256; +const PBKDF2_HASH = "SHA-256"; + +//# region base64 <-> bytes - identical wire format to the browser's own savedDeckCrypto.ts + +function base64ToBytes(base64) { + return new Uint8Array(Buffer.from(base64, "base64")); +} + +//# endregion + +//# region key derivation/unwrap - MUST match frontend/src/common/savedDeckCrypto.ts exactly + +async function derivePassphraseKey(passphrase, salt, iterations) { + const baseKey = await subtle.importKey( + "raw", + new TextEncoder().encode(passphrase), + "PBKDF2", + false, + ["deriveKey"] + ); + return subtle.deriveKey( + { name: "PBKDF2", salt, iterations, hash: PBKDF2_HASH }, + baseKey, + { name: AES_ALGO, length: AES_KEY_LENGTH }, + false, + ["unwrapKey"] + ); +} + +async function importRecoveryKey(recoveryKeyBytes) { + return subtle.importKey("raw", recoveryKeyBytes, AES_ALGO, false, [ + "unwrapKey", + ]); +} + +async function unwrapKey(wrapped, nonce, wrappingKey, unwrappedKeyUsages) { + return subtle.unwrapKey( + "raw", + wrapped, + wrappingKey, + { name: AES_ALGO, iv: nonce }, + { name: AES_ALGO, length: AES_KEY_LENGTH }, + true, + unwrappedKeyUsages + ); +} + +async function decryptPayload(ciphertext, nonce, dek) { + const plaintext = await subtle.decrypt( + { name: AES_ALGO, iv: nonce }, + dek, + ciphertext + ); + return new TextDecoder().decode(plaintext); +} + +//# endregion + +/** Unwraps the bundle's own master key, via either its passphrase or its recovery key - + * whichever the caller supplies. Throws (AES-GCM authentication failure) on a wrong one. */ +async function unlockBundleMasterKey( + bundle, + { passphrase, recoveryKeyBase64 } +) { + const profile = bundle.cryptoProfile; + if (passphrase != null) { + const wrappingKey = await derivePassphraseKey( + passphrase, + base64ToBytes(profile.salt), + profile.kdfIterations + ); + return unwrapKey( + base64ToBytes(profile.passphraseWrappedMasterKey), + base64ToBytes(profile.passphraseWrappedMasterKeyNonce), + wrappingKey, + ["unwrapKey"] + ); + } + const wrappingKey = await importRecoveryKey(base64ToBytes(recoveryKeyBase64)); + return unwrapKey( + base64ToBytes(profile.recoveryWrappedMasterKey), + base64ToBytes(profile.recoveryWrappedMasterKeyNonce), + wrappingKey, + ["unwrapKey"] + ); +} + +/** Decrypts every deck in the bundle, given its already-unwrapped master key. Returns an array + * of `{ key, kind, createdAt, updatedAt, payload }` - `payload` is the plaintext DeckPayload + * object exactly as frontend/src/features/savedDecks/deckPayload.ts defines it (v1 or v2). */ +export async function decryptBundle(bundle, masterKey) { + const results = []; + for (const deck of bundle.decks) { + const dek = await unwrapKey( + base64ToBytes(deck.wrappedDek), + base64ToBytes(deck.wrappedDekNonce), + masterKey, + ["decrypt"] + ); + const plaintext = await decryptPayload( + base64ToBytes(deck.ciphertext), + base64ToBytes(deck.ciphertextNonce), + dek + ); + results.push({ + key: deck.key, + kind: deck.kind, + createdAt: deck.createdAt, + updatedAt: deck.updatedAt, + payload: JSON.parse(plaintext), + }); + } + return results; +} + +function promptHidden(question) { + return new Promise((resolve) => { + const rl = createInterface({ + input: process.stdin, + output: process.stdout, + }); + rl.question(question, (answer) => { + rl.close(); + resolve(answer); + }); + }); +} + +async function main() { + const args = process.argv.slice(2); + const filePath = args.find((arg) => !arg.startsWith("--")); + if (filePath == null) { + console.error( + 'Usage: node decrypt.mjs [--passphrase "..."] [--recovery-key "..."] [--out ]' + ); + process.exit(1); + } + + const passphraseIndex = args.indexOf("--passphrase"); + const recoveryKeyIndex = args.indexOf("--recovery-key"); + const outIndex = args.indexOf("--out"); + let passphrase = passphraseIndex !== -1 ? args[passphraseIndex + 1] : null; + const recoveryKeyBase64 = + recoveryKeyIndex !== -1 ? args[recoveryKeyIndex + 1] : null; + const outDir = outIndex !== -1 ? args[outIndex + 1] : null; + + const bundle = JSON.parse(readFileSync(filePath, "utf-8")); + if (bundle.formatVersion !== 1) { + console.error( + `Unsupported saved-deck export format version: ${bundle.formatVersion} (this tool understands version 1)` + ); + process.exit(1); + } + + if (passphrase == null && recoveryKeyBase64 == null) { + passphrase = await promptHidden("Passphrase: "); + } + + let masterKey; + try { + masterKey = await unlockBundleMasterKey(bundle, { + passphrase, + recoveryKeyBase64, + }); + } catch (e) { + console.error( + recoveryKeyBase64 != null + ? "That recovery key doesn't match this file." + : "That passphrase doesn't match this file." + ); + process.exit(1); + } + + const decrypted = await decryptBundle(bundle, masterKey); + + if (outDir != null) { + mkdirSync(outDir, { recursive: true }); + for (const deck of decrypted) { + const safeName = (deck.payload.name || deck.key).replace( + /[^a-zA-Z0-9_-]+/g, + "_" + ); + writeFileSync( + join(outDir, `${safeName}.json`), + JSON.stringify(deck, null, 2) + ); + } + console.error(`Wrote ${decrypted.length} deck(s) to ${outDir}`); + } else { + console.log(JSON.stringify(decrypted, null, 2)); + } +} + +// Only run as a CLI entrypoint - importing this module (e.g. from this directory's own tests) +// must not trigger stdin prompts or process.exit. +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch((e) => { + console.error(e); + process.exit(1); + }); +} diff --git a/decrypt-saved-deck-export/readme.md b/decrypt-saved-deck-export/readme.md new file mode 100644 index 000000000..231b04764 --- /dev/null +++ b/decrypt-saved-deck-export/readme.md @@ -0,0 +1,165 @@ +# decrypt-saved-deck-export + +Standalone decrypt tool for a ProxyPrints saved-decks export file +(docs/proposals/proposal-g-user-accounts-saved-decks.md, "PR-6, post-v1: deck +portability"). This is the trust anchor for the claim "if this site vanishes +tomorrow, your decks are still yours" — it runs without ProxyPrints, this +codebase, or any server existing at all. Zero npm dependencies: it uses only +Node's own built-in `node:crypto` WebCrypto implementation, the exact same +primitives (AES-256-GCM, PBKDF2-SHA256) the browser itself used to encrypt +your decks in the first place. + +**Status**: works today, standalone. The "Export my decks" button that +produces the file this tool reads is `frontend/src/features/savedDecks/MyDecksPage.tsx`'s +Export action. + +## Requirements + +Node.js 20 or later (needs `node:crypto`'s `webcrypto` export). Nothing else +— no `npm install` required for the tool itself. `node --test` (built into +Node, no dependency) runs this directory's own test suite. + +## Usage + +``` +node decrypt.mjs --passphrase "your passphrase" +``` + +``` +node decrypt.mjs --recovery-key "your-recovery-key-base64" +``` + +``` +node decrypt.mjs +# prompts for a passphrase interactively if neither flag is given +``` + +Prints every decrypted deck as one JSON array to stdout. Add `--out ` to +write one `.json` file per deck into `` instead. + +Run the test suite: + +``` +node --test tests/decrypt.test.mjs +``` + +## The export format (public, versioned) + +This is the actual portability contract — documented here specifically so a +fork, or a completely independent reimplementation, can read a ProxyPrints +saved-decks export without needing this codebase at all. The file is one +JSON object: + +```jsonc +{ + "formatVersion": 1, + "exportedAt": "2026-01-02T00:00:00.000Z", + "cryptoProfile": { + "salt": "", + "kdfIterations": 600000, + "passphraseWrappedMasterKey": "", + "passphraseWrappedMasterKeyNonce": "", + "recoveryWrappedMasterKey": "", + "recoveryWrappedMasterKeyNonce": "" + }, + "decks": [ + { + "key": "", + "kind": "deck", // or "snapshot" + "ciphertext": "", + "ciphertextNonce": "", + "wrappedDek": "", + "wrappedDekNonce": "", + "createdAt": "2026-01-01", + "updatedAt": "2026-01-02" + } + // ... + ] +} +``` + +Every field here is exactly the same opaque bytes the ProxyPrints server +itself stores — nothing in this outer envelope is ever plaintext deck +content. `formatVersion` is this bundle's own public wire-format version, +distinct from the PRIVATE per-deck `version` field described below (which +only exists once decrypted, inside the ciphertext). + +### Unwrapping the master key + +Exactly one of `passphrase` or `recoveryKeyBase64` is used: + +- **Passphrase**: PBKDF2-SHA256 over the passphrase, using `salt` and + `kdfIterations` above, produces an AES-256-GCM key. That key unwraps + `passphraseWrappedMasterKey` (AES-GCM, IV = `passphraseWrappedMasterKeyNonce`) + to recover the master key. +- **Recovery key**: the recovery key IS the key material already (no KDF) — + import it directly as a raw AES-256-GCM key, then unwrap + `recoveryWrappedMasterKey` (IV = `recoveryWrappedMasterKeyNonce`) the same + way. + +### Decrypting each deck + +For every entry in `decks`: + +1. Unwrap `wrappedDek` (AES-GCM, IV = `wrappedDekNonce`) using the master key + from above, to get that deck's DEK (Data Encryption Key). +2. Decrypt `ciphertext` (AES-GCM, IV = `ciphertextNonce`) using the DEK. +3. The result is UTF-8 JSON text — a `DeckPayload` object: + +```jsonc +// v1 (legacy, pre "Revision tracking") +{ + "version": 1, + "name": "...", + "members": [ + /* ... */ + ], + "cardback": null, + "manualOverrides": {}, + "finishSettings": { "cardstock": "...", "foil": false } +} +``` + +```jsonc +// v2 (current - adds revision tracking) +{ + "version": 2, + "name": "...", + "members": [ + /* ... */ + ], + "cardback": null, + "manualOverrides": {}, + "finishSettings": { "cardstock": "...", "foil": false }, + "revision": 3, + "modifiedAt": "2026-01-01T00:00:00.000Z" +} +``` + +`revision` (an integer, incremented on every save of that same row) and +`modifiedAt` (an ISO 8601 timestamp) make an export/import round-trip +self-describing: comparing an imported bundle's `revision`/`modifiedAt` +against a server's current copy of "the same" deck (if you're tracking that +manually — ProxyPrints itself never matches decks by name or key on import, +see the main feature doc) tells you which copy is newer, without either side +needing to compare plaintext. + +A future version (`version: 3`, PR-7 "art provenance") is expected to add an +optional per-slot provenance record to each member; this tool's own +`decrypt.mjs` doesn't special-case any particular version — it just decrypts +the ciphertext and returns whatever JSON comes out, so it will keep working +unmodified once that ships. + +## Design notes + +- **No key material ever touches disk** beyond what's already in the export + file itself — this tool holds the master key and each DEK in memory only, + for the duration of one run. +- **A wrong passphrase or recovery key fails loudly** (an AES-GCM + authentication error) — this tool never silently returns corrupted or + wrong plaintext. +- **An exported file is offline-attackable** by design (same exposure a + server breach of ProxyPrints' own database already has) — its real + protection is passphrase strength plus PBKDF2 at a high iteration count. + Treat an export file with the same care you'd give a password manager + export. diff --git a/decrypt-saved-deck-export/tests/decrypt.test.mjs b/decrypt-saved-deck-export/tests/decrypt.test.mjs new file mode 100644 index 000000000..108b1cb1f --- /dev/null +++ b/decrypt-saved-deck-export/tests/decrypt.test.mjs @@ -0,0 +1,184 @@ +/** + * Zero-dependency test for decrypt.mjs, using Node's own built-in test runner (`node --test`) - + * no npm install needed to verify this tool, matching its own "dependency-minimal" promise. + * Builds a bundle independently (via raw WebCrypto calls mirroring + * frontend/src/common/savedDeckCrypto.ts's own wrap/encrypt logic) rather than importing + * anything from the frontend, so this test also serves as a live cross-check that decrypt.mjs's + * understanding of the wire format hasn't drifted from the browser's. + * + * Run with: node --test decrypt-saved-deck-export/tests/decrypt.test.mjs + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { webcrypto } from "node:crypto"; + +import { decryptBundle } from "../decrypt.mjs"; + +const { subtle } = webcrypto; +const getRandomValues = webcrypto.getRandomValues.bind(webcrypto); + +function bytesToBase64(bytes) { + return Buffer.from(bytes).toString("base64"); +} + +async function buildTestBundle(passphrase, deckPayloads) { + const salt = getRandomValues(new Uint8Array(16)); + const iterations = 100; + const baseKey = await subtle.importKey( + "raw", + new TextEncoder().encode(passphrase), + "PBKDF2", + false, + ["deriveKey"] + ); + const passphraseKey = await subtle.deriveKey( + { name: "PBKDF2", salt, iterations, hash: "SHA-256" }, + baseKey, + { name: "AES-GCM", length: 256 }, + false, + ["wrapKey"] + ); + + const masterKey = await subtle.generateKey( + { name: "AES-GCM", length: 256 }, + true, + ["wrapKey", "unwrapKey"] + ); + const passphraseWrapNonce = getRandomValues(new Uint8Array(12)); + const passphraseWrappedMasterKey = await subtle.wrapKey( + "raw", + masterKey, + passphraseKey, + { name: "AES-GCM", iv: passphraseWrapNonce } + ); + + // Recovery slot isn't exercised by these tests but the format requires it to be present. + const recoveryKeyBytes = getRandomValues(new Uint8Array(32)); + const recoveryKey = await subtle.importKey( + "raw", + recoveryKeyBytes, + "AES-GCM", + false, + ["wrapKey"] + ); + const recoveryWrapNonce = getRandomValues(new Uint8Array(12)); + const recoveryWrappedMasterKey = await subtle.wrapKey( + "raw", + masterKey, + recoveryKey, + { name: "AES-GCM", iv: recoveryWrapNonce } + ); + + const decks = []; + for (const [index, payload] of deckPayloads.entries()) { + const dek = await subtle.generateKey( + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt", "wrapKey", "unwrapKey"] + ); + const dekWrapNonce = getRandomValues(new Uint8Array(12)); + const wrappedDek = await subtle.wrapKey("raw", dek, masterKey, { + name: "AES-GCM", + iv: dekWrapNonce, + }); + const ciphertextNonce = getRandomValues(new Uint8Array(12)); + const ciphertext = await subtle.encrypt( + { name: "AES-GCM", iv: ciphertextNonce }, + dek, + new TextEncoder().encode(JSON.stringify(payload)) + ); + decks.push({ + key: `deck-${index}`, + kind: "deck", + ciphertext: bytesToBase64(new Uint8Array(ciphertext)), + ciphertextNonce: bytesToBase64(ciphertextNonce), + wrappedDek: bytesToBase64(new Uint8Array(wrappedDek)), + wrappedDekNonce: bytesToBase64(dekWrapNonce), + createdAt: "2026-01-01", + updatedAt: "2026-01-02", + }); + } + + return { + formatVersion: 1, + exportedAt: "2026-01-02T00:00:00.000Z", + cryptoProfile: { + salt: bytesToBase64(salt), + kdfIterations: iterations, + passphraseWrappedMasterKey: bytesToBase64( + new Uint8Array(passphraseWrappedMasterKey) + ), + passphraseWrappedMasterKeyNonce: bytesToBase64(passphraseWrapNonce), + recoveryWrappedMasterKey: bytesToBase64( + new Uint8Array(recoveryWrappedMasterKey) + ), + recoveryWrappedMasterKeyNonce: bytesToBase64(recoveryWrapNonce), + }, + decks, + }; +} + +async function unlockWithPassphrase(bundle, passphrase) { + const baseKey = await subtle.importKey( + "raw", + new TextEncoder().encode(passphrase), + "PBKDF2", + false, + ["deriveKey"] + ); + const passphraseKey = await subtle.deriveKey( + { + name: "PBKDF2", + salt: Buffer.from(bundle.cryptoProfile.salt, "base64"), + iterations: bundle.cryptoProfile.kdfIterations, + hash: "SHA-256", + }, + baseKey, + { name: "AES-GCM", length: 256 }, + false, + ["unwrapKey"] + ); + return subtle.unwrapKey( + "raw", + Buffer.from(bundle.cryptoProfile.passphraseWrappedMasterKey, "base64"), + passphraseKey, + { + name: "AES-GCM", + iv: Buffer.from( + bundle.cryptoProfile.passphraseWrappedMasterKeyNonce, + "base64" + ), + }, + { name: "AES-GCM", length: 256 }, + true, + ["unwrapKey", "decrypt"] + ); +} + +test("decryptBundle recovers every deck's plaintext payload given the bundle's own master key", async () => { + const bundle = await buildTestBundle("the real passphrase", [ + { version: 2, name: "Aggro", revision: 3, modifiedAt: "2026-01-01" }, + { version: 1, name: "Legacy Deck" }, + ]); + const masterKey = await unlockWithPassphrase(bundle, "the real passphrase"); + + const decrypted = await decryptBundle(bundle, masterKey); + assert.equal(decrypted.length, 2); + assert.equal(decrypted[0].payload.name, "Aggro"); + assert.equal(decrypted[0].payload.revision, 3); + assert.equal(decrypted[1].payload.name, "Legacy Deck"); + assert.equal(decrypted[1].payload.version, 1); +}); + +test("a wrong master key fails to decrypt (AES-GCM authentication failure), never returns silently-wrong plaintext", async () => { + const bundle = await buildTestBundle("the real passphrase", [ + { version: 2, name: "Aggro", revision: 1, modifiedAt: "2026-01-01" }, + ]); + const wrongMasterKey = await subtle.generateKey( + { name: "AES-GCM", length: 256 }, + true, + ["wrapKey", "unwrapKey"] + ); + await assert.rejects(() => decryptBundle(bundle, wrongMasterKey)); +}); diff --git a/docs/README.md b/docs/README.md index d91d0e392..5c1f23848 100644 --- a/docs/README.md +++ b/docs/README.md @@ -89,8 +89,9 @@ The methodology and the systems it governs. backend `LOCAL_FILE` catalog source type. - [`features/saved-decks.md`](features/saved-decks.md) — zero-knowledge user accounts + server-side saved decks: the crypto design, backend - endpoints, frontend wiring, the shipped PR-5 per-deck share links, and - the still-design-only PR-6/7 addenda. + endpoints, frontend wiring, the shipped PR-5 per-deck share links and + PR-6 deck-portability addendum (export/import + standalone decrypt + tool), and the still-design-only PR-7 addendum. - [`features/consent-toast.md`](features/consent-toast.md) — the reusable, permission-triggered contextual consent toast (issue #204): a bottom-corner accept/decline prompt shown only right before an action @@ -129,16 +130,16 @@ Deployment, incidents, and cross-session lessons. One-word status per doc; see each file for the full survey/spec. -| Doc | Status | -| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | -| [`proposals/proposal-b-bleed-normalization.md`](proposals/proposal-b-bleed-normalization.md) — export-time per-side bleed normalization | BUILDING | -| [`proposals/proposal-c-context-menu-restyle.md`](proposals/proposal-c-context-menu-restyle.md) — right-click/long-press context menu (shipped); restyle direction (HOLD) | PARTIAL | -| [`proposals/proposal-f-public-stats-page.md`](proposals/proposal-f-public-stats-page.md) — public `/stats` transparency page | HOLD | -| [`proposals/proposal-g-user-accounts-saved-decks.md`](proposals/proposal-g-user-accounts-saved-decks.md) — user accounts + saved decks via Discord OAuth (core build + PR-5 share links shipped; see [`features/saved-decks.md`](features/saved-decks.md) — PR-6/7 addenda still HOLD) | PARTIAL | -| [`proposals/proposal-h-unified-display-page.md`](proposals/proposal-h-unified-display-page.md) — one page merging the "Choose Art" editor and PDF export into a live print-sheet preview + card-details rail | PARTIAL | -| [`proposals/proposal-i-docs-as-site-source.md`](proposals/proposal-i-docs-as-site-source.md) — extends the docs/-to-wiki publish pipeline with a second target: rendered site pages + build-time JSON data extracts | BUILDING | -| [`proposals/proposal-i-readme-pipeline.md`](proposals/proposal-i-readme-pipeline.md) — folds `readme.md` into the same pipeline as a third (`readme`) emit mode: content merge map, owner GO decision, and what shipped | SHIPPED | -| [`federation/public-export-v1.md`](federation/public-export-v1.md) — publish-first federation: signed verdict export consumable by mpc-autofill forks and the MIT-lineage proxy tools, no peer required | HOLD | +| Doc | Status | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | +| [`proposals/proposal-b-bleed-normalization.md`](proposals/proposal-b-bleed-normalization.md) — export-time per-side bleed normalization | PARTIAL | +| [`proposals/proposal-c-context-menu-restyle.md`](proposals/proposal-c-context-menu-restyle.md) — right-click/long-press context menu (shipped); restyle direction (HOLD) | PARTIAL | +| [`proposals/proposal-f-public-stats-page.md`](proposals/proposal-f-public-stats-page.md) — public `/stats` transparency page | HOLD | +| [`proposals/proposal-g-user-accounts-saved-decks.md`](proposals/proposal-g-user-accounts-saved-decks.md) — user accounts + saved decks via Discord OAuth (core build + PR-5 share links + PR-6 deck portability shipped; see [`features/saved-decks.md`](features/saved-decks.md) — PR-7 addendum still HOLD) | PARTIAL | +| [`proposals/proposal-h-unified-display-page.md`](proposals/proposal-h-unified-display-page.md) — one page merging the "Choose Art" editor and PDF export into a live print-sheet preview + card-details rail | PARTIAL | +| [`proposals/proposal-i-docs-as-site-source.md`](proposals/proposal-i-docs-as-site-source.md) — extends the docs/-to-wiki publish pipeline with a second target: rendered site pages + build-time JSON data extracts | BUILDING | +| [`proposals/proposal-i-readme-pipeline.md`](proposals/proposal-i-readme-pipeline.md) — folds `readme.md` into the same pipeline as a third (`readme`) emit mode: content merge map, owner GO decision, and what shipped | SHIPPED | +| [`federation/public-export-v1.md`](federation/public-export-v1.md) — publish-first federation: signed verdict export consumable by mpc-autofill forks and the MIT-lineage proxy tools, no peer required | HOLD | Not every shipped proposal-lettered feature has a survey doc here — some (e.g. Proposal A, Proposal D) went straight from idea to shipped PR without diff --git a/docs/features/saved-decks.md b/docs/features/saved-decks.md index e59fd4cf4..fd3bf6bac 100644 --- a/docs/features/saved-decks.md +++ b/docs/features/saved-decks.md @@ -8,8 +8,10 @@ Spec: [`proposals/proposal-g-user-accounts-saved-decks.md`](../proposals/proposa the opaque-blob API (#94, a recreation after #88's stacked-PR base-deletion auto-close — see [`../lessons.md`](../lessons.md)), the client-side crypto module (#89), and the frontend UI wiring (#93). PR-5 (per-deck share links, -the spec's own "PR-5, post-v1" section) landed as a follow-up — see "Per-deck -share links" below. +the spec's own "PR-5, post-v1" section) landed as a follow-up — see +"Per-deck share links" below. "PR-6, post-v1: deck portability" +(export/import + the standalone decrypt tool) shipped as a later, separate +frontend-only change — see its own section below. ## Where it's wired in @@ -104,6 +106,14 @@ cannot decrypt, by design, not by policy. slot's identifier is device-specific and meaningless elsewhere, so only a flag survives on save; the card grid's existing empty-slot/re-search UI is the honest "needs re-picking" placeholder on load, not a bespoke tile. + Currently at `version: 2` (PR-6 "Revision tracking" — see below); a v1 + payload is upgraded forward on load (`parseDeckPayload`), never rejected. + `buildDeckPayload`'s own return (`DeckPayloadContent`) deliberately omits + `version`/`revision`/`modifiedAt` so it stays byte-identical across calls + with unchanged input — those bookkeeping fields are stamped on only at + the moment of encryption (`encryptDeckPayloadForSave`), otherwise the + dirty-check baseline (`selectors.ts`) would see a fresh `modifiedAt` on + every render and permanently misreport "dirty." - `PassphraseSetupModal.tsx` / `UnlockModal.tsx` / `RecoveryKeyDisplay.tsx`: first-save passphrase creation (with the verbatim-spirit unrecoverability warning), once-per-session unlock, and the show-once recovery-key @@ -198,12 +208,65 @@ receives. `frontend/src/features/savedDecks/deckShare.test.ts`, endpoint-level in `MPCAutofill/cardpicker/tests/test_saved_deck_share_views.py`. +## Deck portability (PR-6) + +Formalizes what the zero-knowledge, server-unbound design already implies: +since no key material is ever held by the server, a user's saved decks are +portable by construction. Frontend-only — no backend schema or endpoint +changes, since export/import are fully served by the existing +`getSavedDecks`/`getCryptoProfile`/`saveDeck`/`saveCryptoProfile` endpoints. + +- `deckExportImport.ts`: `buildExportBundle`/`downloadExportBundle` (export; + requires no unlock — it's the same opaque bytes the server already holds, + reshaped into one `.json` file) and `unlockBundleMasterKeyWithPassphrase`/ + `unlockBundleMasterKeyWithRecoveryKey`/`decryptBundleDecks` (import; + decrypts using the **bundle's own** crypto profile, not necessarily the + live session's — a bundle may come from a different account or a + different, compatible instance entirely). `EXPORT_FORMAT_VERSION` (starts + at 1) is this bundle's own public wire-format version — distinct from + `deckPayload.ts`'s private, per-deck `version` field, which lives inside + the ciphertext and is never visible in the outer envelope. +- `ImportDeckModal.tsx`: file picker + passphrase-or-recovery-key prompt for + the bundle, then re-encrypts every decrypted deck under the **current** + session's own master key and calls `saveDeck` with `key: null` for each — + always import-as-new (never overwrites an existing deck by matching key + or name; there's no server-visible name to match against anyway once + titles are encrypted). Uses `encryptFinalizedDeckPayload` (not + `encryptDeckPayloadForSave`) specifically so each imported deck's own + `revision`/`modifiedAt` survive verbatim — importing is a restore, not a + new save. Requires the current session to already be unlocked (an + honest, stated scope limit — importing into a brand-new account with no + crypto profile yet isn't handled by this modal). +- `MyDecksPage.tsx`: "Export my decks" (enabled whenever any deck exists, + even while locked) and "Import decks" (enabled only once unlocked, since + importing needs somewhere to persist the decrypted decks to) sit above + the deck list. +- **Revision tracking**: `deckPayload.ts`'s `DeckPayloadV2` adds `revision` + (an integer, incremented on every save of the SAME server-side row) and + `modifiedAt` (an ISO 8601 timestamp), both PRIVATE — inside the encrypted + payload, never server-visible. A brand-new row (a fresh deck, "Save as + new snapshot," or an imported deck) always starts its own chain at + `revision: 1`; only an update to an already-saved row continues it + (tracked client-side via `savedDeckSessionSlice`'s `lastSavedRevision`). + Bumping to `version: 2` for this addition establishes the "PR-6/PR-7 + shared versioning rule" the spec describes — PR-7's art-provenance + addition (not built) is expected to become `version: 3` of this same + counter, via the same upgrade-dispatch pattern `parseDeckPayload` already + uses for v1 → v2. +- **Standalone decrypt tool** (`decrypt-saved-deck-export/` at the repo + root, mirroring `federation-hash-tool/`'s precedent): a zero-npm-dependency + Node.js script (`decrypt.mjs`) using only `node:crypto`'s built-in + WebCrypto implementation — the exact same primitives the browser used to + encrypt. This is the trust anchor for "if this site vanishes tomorrow, + your decks are still yours": it runs without this site, this codebase, or + any server existing at all. Declared MIT-licensed (a deviation from this + repository's own GPL-3.0, following `federation-hash-tool/`'s existing + precedent for a standalone tool meant to be freely reusable by forks or + independent reimplementations) — see that directory's own readme.md for + the full public wire-format writeup and usage. + ## Not yet built (design-only addenda in the spec doc) -- **PR-6, deck portability**: export/import of the complete encrypted - bundle (no unlock required to export), a versioned public format, and a - standalone decrypt tool as the trust anchor for "if this site vanishes - tomorrow, your decks are still yours." Nothing built. - **PR-7, art provenance**: per-slot provenance (`driveId`, `sourceName`, `sourceType`, optional `contentPhash`, `indexedBy`) in a future `deckPayload` version, so an un-indexed slot renders a direct-from-drive diff --git a/docs/proposals/proposal-g-user-accounts-saved-decks.md b/docs/proposals/proposal-g-user-accounts-saved-decks.md index 0612cccd5..bd06bfcc2 100644 --- a/docs/proposals/proposal-g-user-accounts-saved-decks.md +++ b/docs/proposals/proposal-g-user-accounts-saved-decks.md @@ -10,9 +10,12 @@ recreated after #88's stacked-PR base-deletion auto-close — see docs/lessons.md), the client-side ZK crypto module (#89), and the frontend UI wiring (#93). **Spec CLOSED: no open decisions remain** (see Decisions). §7 (authed vote tier) is fully specified but remains a deliberately separate, -later build — not part of this HOLD's core scope. PR-5 (share links), -PR-6 (deck portability), and PR-7 (art provenance) are design-only, -post-v1 addenda — nothing built for any of them yet. +later build — not part of this HOLD's core scope. PR-5 (share links) and +PR-7 (art provenance) are still design-only, post-v1 addenda — nothing +built for either yet. **PR-6 (deck portability) has since shipped** +(frontend-only — export/import + the standalone decrypt tool; see +docs/features/saved-decks.md's "Deck portability (PR-6)" section) — +nothing else in this file's own scope changed as a result. ## Context — prior art outside this codebase @@ -780,7 +783,7 @@ already built in PR-1's schema. ciphertext; a leaked `shareKey` cannot decrypt or unwrap anything for any _other_ deck, shared or not. -### PR-6, post-v1: deck portability (design only — owner-directed addendum, 2026-07-18; nothing built here) +### PR-6, post-v1: deck portability (SHIPPED — owner-directed addendum, spec written 2026-07-18; built as a frontend-only change, see docs/features/saved-decks.md's "Deck portability (PR-6)" section for what actually landed) Formalizes what the ZK envelope already implies rather than adding new capability: the crypto is deliberately **server-unbound** — no key diff --git a/docs/upstreaming/extractable-primitives.md b/docs/upstreaming/extractable-primitives.md index 4e0aca7ed..62897c4e1 100644 --- a/docs/upstreaming/extractable-primitives.md +++ b/docs/upstreaming/extractable-primitives.md @@ -117,11 +117,12 @@ coupling to the vote system is. ## Docs tooling & federation -| Primitive | File(s) | Problem solved | Candidate consumers | Entanglement | License note | -| ------------------------------ | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | -------------------------------------------- | -------------------------------------------------------------------------------- | -| Docs single-transform pipeline | `.github/scripts/publish_wiki.py`, `publish_site.py` | One shared link-rewrite transform (`transform_links()`) publishing the same `docs/` markdown to both a GitHub wiki and a static site, with a marker-based "only delete pages I generated" safety property | upstream, proxies-at-home, federation peers (any project with `docs/` + wiki + site) | CLEAN | — | -| Upstream wiki drift tracker | `.github/scripts/upstream_wiki_drift.py` | Diffs an external GitHub wiki's git history against a last-seen-SHA table, updates it in place — detection only, never copies wiki prose | proxies-at-home, any fork tracking an upstream project's wiki | CLEAN | — | -| Federation hash tool | `federation-hash-tool/hash_my_cards.py` | Computes a stable perceptual hash of a card image using this fork's crop/classify recipe, so a peer can independently reproduce the same hash and join against a published federation export without transmitting raw images | federation peers | CLEAN (narrowly scoped by design — see note) | MIT, deliberately distinct from the ODbL-licensed export _data_ it joins against | +| Primitive | File(s) | Problem solved | Candidate consumers | Entanglement | License note | +| ------------------------------ | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| Docs single-transform pipeline | `.github/scripts/publish_wiki.py`, `publish_site.py` | One shared link-rewrite transform (`transform_links()`) publishing the same `docs/` markdown to both a GitHub wiki and a static site, with a marker-based "only delete pages I generated" safety property | upstream, proxies-at-home, federation peers (any project with `docs/` + wiki + site) | CLEAN | — | +| Upstream wiki drift tracker | `.github/scripts/upstream_wiki_drift.py` | Diffs an external GitHub wiki's git history against a last-seen-SHA table, updates it in place — detection only, never copies wiki prose | proxies-at-home, any fork tracking an upstream project's wiki | CLEAN | — | +| Federation hash tool | `federation-hash-tool/hash_my_cards.py` | Computes a stable perceptual hash of a card image using this fork's crop/classify recipe, so a peer can independently reproduce the same hash and join against a published federation export without transmitting raw images | federation peers | CLEAN (narrowly scoped by design — see note) | MIT, deliberately distinct from the ODbL-licensed export _data_ it joins against | +| Saved-deck export decrypt tool | `decrypt-saved-deck-export/decrypt.mjs` | Decrypts a ProxyPrints saved-decks export bundle (PBKDF2-SHA256 + AES-256-GCM, via Node's own `node:crypto` WebCrypto) without this codebase, this site, or any server existing at all — zero imports from anywhere in this repo, zero npm dependencies | upstream, proxies-at-home (any zero-knowledge saved-deck implementation using the same wire format) | CLEAN (zero imports at all, narrowly scoped by design — see note) | MIT, same precedent as the federation hash tool row above | ## Detail notes @@ -202,6 +203,23 @@ publisher-only posture) in `docs/federation-v1.md`, which genuinely is fork-only by definition — the hash tool is the one piece of the federation program that cleanly separates from that layer. +**Saved-deck export decrypt tool** — zero imports of any kind, not just zero +fork-specific ones: `decrypt.mjs` re-implements the (tiny) AES-256-GCM/ +PBKDF2-SHA256 wrap/unwrap logic itself using only Node's built-in +`node:crypto`, rather than importing `frontend/src/common/savedDeckCrypto.ts` + +- deliberately, since the whole point is running with none of this + repository's own code present. What's narrow by design is the wire format + (docs/proposals/proposal-g-user-accounts-saved-decks.md's "PR-6, post-v1: + deck portability" section, also reproduced in the tool's own readme.md) - + an arbitrary-but-fixed convention this fork chose for its saved-decks + export, not a universal format. `frontend/src/common/savedDeckCrypto.ts` + and `frontend/src/features/savedDecks/deckPayload.ts` (the in-app + counterparts this tool's logic mirrors) are themselves NOT rowed here - + they predate this ledger's 2026-07-19 sweep and haven't been audited for + it yet; not rowing this tool's own dependencies-that-aren't-actually- + dependencies (see above) doesn't change that gap. + **Back-face name lookup** — `get_back_face_names`/`is_back_face`/ `DOUBLE_FACED_LAYOUTS` touch only `Path`/pydantic parsing of the raw bulk JSON, no fork-only symbol at all in their own bodies — but they live in diff --git a/frontend/src/features/savedDecks/ImportDeckModal.test.tsx b/frontend/src/features/savedDecks/ImportDeckModal.test.tsx new file mode 100644 index 000000000..4956363e3 --- /dev/null +++ b/frontend/src/features/savedDecks/ImportDeckModal.test.tsx @@ -0,0 +1,232 @@ +/** + * "PR-6, post-v1: deck portability" import flow: a bundle exported under one crypto profile + * (its own passphrase) gets decrypted, then persisted under a DIFFERENT (the current session's) + * master key - always as new rows, preserving each deck's own revision/modifiedAt verbatim. + */ + +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import React from "react"; +import { Provider } from "react-redux"; + +import { bytesToBase64, createCryptoProfile } from "@/common/savedDeckCrypto"; +import { localBackend, localBackendURL } from "@/common/test-constants"; +import { buildMockSavedDeckSummary } from "@/features/savedDecks/cryptoTestHandlers"; +import { + buildExportBundle, + serializeExportBundle, +} from "@/features/savedDecks/deckExportImport"; +import { decryptSavedDeckSummary } from "@/features/savedDecks/deckPayload"; +import { ImportDeckModal } from "@/features/savedDecks/ImportDeckModal"; +import { server } from "@/mocks/server"; +import { setupStore } from "@/store/store"; + +function renderModal(props: React.ComponentProps) { + const store = setupStore({ backend: localBackend }); + render( + + + + ); +} + +const TEST_ITERATIONS = 100; +const BUNDLE_PASSPHRASE = "the exported passphrase"; + +function buildRoute(path: string): string { + return `${localBackendURL}/${path}`; +} + +test("importing a bundle decrypts it with ITS OWN passphrase, then persists every deck as new under the current session's master key, preserving revision/modifiedAt", async () => { + const bundleProfile = await createCryptoProfile( + BUNDLE_PASSPHRASE, + TEST_ITERATIONS + ); + const currentSessionProfile = await createCryptoProfile( + "a completely different passphrase", + TEST_ITERATIONS + ); + + const deckA = await buildMockSavedDeckSummary( + "deck-a", + "deck", + { + version: 2, + name: "Aggro Deck", + members: [], + cardback: null, + manualOverrides: {}, + finishSettings: { cardstock: "(S30) Standard Smooth", foil: false }, + revision: 5, + modifiedAt: "2025-06-01T00:00:00.000Z", + }, + bundleProfile.masterKey, + { createdAt: "2025-01-01", updatedAt: "2025-06-01" } + ); + const deckB = await buildMockSavedDeckSummary( + "deck-b", + "snapshot", + { + version: 2, + name: "Backup", + members: [], + cardback: null, + manualOverrides: {}, + finishSettings: { cardstock: "(S30) Standard Smooth", foil: false }, + revision: 1, + modifiedAt: "2025-06-02T00:00:00.000Z", + }, + bundleProfile.masterKey, + { createdAt: "2025-06-02", updatedAt: "2025-06-02" } + ); + + const realBundle = buildExportBundle( + { + exists: true, + salt: bytesToBase64(bundleProfile.salt), + kdfIterations: bundleProfile.iterations, + passphraseWrappedMasterKey: bytesToBase64( + bundleProfile.passphraseWrapped.wrapped + ), + passphraseWrappedMasterKeyNonce: bytesToBase64( + bundleProfile.passphraseWrapped.nonce + ), + recoveryWrappedMasterKey: bytesToBase64( + bundleProfile.recoveryWrapped.wrapped + ), + recoveryWrappedMasterKeyNonce: bytesToBase64( + bundleProfile.recoveryWrapped.nonce + ), + }, + [deckA, deckB] + ); + + const requests: Array = []; + server.use( + http.post(buildRoute("2/saveDeck/"), async ({ request }) => { + const body = await request.json(); + requests.push(body); + return HttpResponse.json( + { key: `imported-${requests.length}` }, + { + status: 200, + } + ); + }) + ); + + const onImported = jest.fn(); + renderModal({ + show: true, + onCancel: jest.fn(), + onImported, + masterKey: currentSessionProfile.masterKey, + }); + + const file = new File([serializeExportBundle(realBundle)], "export.json", { + type: "application/json", + }); + fireEvent.change(screen.getByLabelText("import-file"), { + target: { files: [file] }, + }); + + await screen.findByText(/2 decks found/); + fireEvent.change(screen.getByLabelText("import-passphrase"), { + target: { value: BUNDLE_PASSPHRASE }, + }); + fireEvent.click(screen.getByText("Import")); + + await waitFor(() => expect(onImported).toHaveBeenCalledWith(2)); + expect(requests).toHaveLength(2); + expect(requests.every((r) => r.key === null)).toBe(true); + expect(requests.map((r) => r.kind).sort()).toEqual(["deck", "snapshot"]); + + // Every persisted row decrypts under the CURRENT session's master key (not the bundle's own), + // and keeps its original revision/modifiedAt (deck-portability's whole point). + const decryptedRequests = await Promise.all( + requests.map((body) => + decryptSavedDeckSummary( + { + key: "unused", + kind: body.kind, + ciphertext: body.ciphertext, + ciphertextNonce: body.ciphertextNonce, + wrappedDek: body.wrappedDek, + wrappedDekNonce: body.wrappedDekNonce, + createdAt: "2026-01-01", + updatedAt: "2026-01-01", + }, + currentSessionProfile.masterKey + ) + ) + ); + const names = decryptedRequests.map((d) => d.name).sort(); + expect(names).toEqual(["Aggro Deck", "Backup"]); + const aggro = decryptedRequests.find((d) => d.name === "Aggro Deck")!; + expect(aggro.payload.revision).toEqual(5); + expect(aggro.payload.modifiedAt).toEqual("2025-06-01T00:00:00.000Z"); +}); + +test("a wrong passphrase for the bundle shows an error, without persisting anything", async () => { + const bundleProfile = await createCryptoProfile( + BUNDLE_PASSPHRASE, + TEST_ITERATIONS + ); + const currentSessionProfile = await createCryptoProfile( + "current session passphrase", + TEST_ITERATIONS + ); + const realBundle = buildExportBundle( + { + exists: true, + salt: bytesToBase64(bundleProfile.salt), + kdfIterations: bundleProfile.iterations, + passphraseWrappedMasterKey: bytesToBase64( + bundleProfile.passphraseWrapped.wrapped + ), + passphraseWrappedMasterKeyNonce: bytesToBase64( + bundleProfile.passphraseWrapped.nonce + ), + recoveryWrappedMasterKey: bytesToBase64( + bundleProfile.recoveryWrapped.wrapped + ), + recoveryWrappedMasterKeyNonce: bytesToBase64( + bundleProfile.recoveryWrapped.nonce + ), + }, + [] + ); + + let saveDeckCalled = false; + server.use( + http.post(buildRoute("2/saveDeck/"), async () => { + saveDeckCalled = true; + return HttpResponse.json({ key: "should-not-happen" }, { status: 200 }); + }) + ); + + const onImported = jest.fn(); + renderModal({ + show: true, + onCancel: jest.fn(), + onImported, + masterKey: currentSessionProfile.masterKey, + }); + + const file = new File([serializeExportBundle(realBundle)], "export.json", { + type: "application/json", + }); + fireEvent.change(screen.getByLabelText("import-file"), { + target: { files: [file] }, + }); + + await screen.findByText(/0 decks found/); + fireEvent.change(screen.getByLabelText("import-passphrase"), { + target: { value: "definitely wrong" }, + }); + fireEvent.click(screen.getByText("Import")); + + await screen.findByText("That passphrase doesn't match this file."); + expect(onImported).not.toHaveBeenCalled(); + expect(saveDeckCalled).toBe(false); +}); diff --git a/frontend/src/features/savedDecks/ImportDeckModal.tsx b/frontend/src/features/savedDecks/ImportDeckModal.tsx new file mode 100644 index 000000000..5045c9eaa --- /dev/null +++ b/frontend/src/features/savedDecks/ImportDeckModal.tsx @@ -0,0 +1,238 @@ +/** + * Deck portability's import half (docs/proposals/proposal-g-user-accounts-saved-decks.md, + * "PR-6, post-v1: deck portability"): reads a previously-exported bundle (see + * deckExportImport.ts), decrypts it client-side using the BUNDLE's own passphrase or recovery + * key (never the live session's, since a bundle may come from a different account or a + * different, compatible instance entirely), then persists every deck it contains under the + * CURRENT signed-in account. + * + * Conflict rule (spec's own words): always import-as-new. Every imported deck lands as its own + * row (`key: null`), never overwriting an existing deck by matching key or name - there's no + * server-visible name to match against anyway once titles are encrypted, and overwriting would + * risk destroying newer data with a stale export. Each imported deck keeps its OWN + * `revision`/`modifiedAt` verbatim (via `encryptFinalizedDeckPayload`, not + * `encryptDeckPayloadForSave`) - importing isn't itself "a save", it's a restore, and + * overwriting those fields would break the whole point of tracking them (comparing an imported + * bundle's revision against what's already saved to tell which copy is newer). + * + * Assumes the current session is already unlocked (SavedDeckPanel/MyDecksPage are responsible + * for that, same convention as SaveDeckModal) - importing into a brand-new account with no + * crypto profile yet isn't handled here; the "Import decks" entry point is disabled until then. + */ + +import React, { ChangeEvent, FormEvent, useState } from "react"; +import Button from "react-bootstrap/Button"; +import Form from "react-bootstrap/Form"; +import Modal from "react-bootstrap/Modal"; + +import { + decryptBundleDecks, + ExportBundleV1, + parseExportBundle, + unlockBundleMasterKeyWithPassphrase, + unlockBundleMasterKeyWithRecoveryKey, +} from "@/features/savedDecks/deckExportImport"; +import { encryptFinalizedDeckPayload } from "@/features/savedDecks/deckPayload"; +import { useSaveDeckMutation } from "@/store/api"; + +interface ImportDeckModalProps { + show: boolean; + onCancel: () => void; + /** Called once every deck in the bundle has been persisted, with the count imported. */ + onImported: (count: number) => void; + /** The CURRENT (already-unlocked) session's master key - every imported deck is re-encrypted + * under this key, never the bundle's own. */ + masterKey: CryptoKey; +} + +type UnlockMode = "passphrase" | "recovery"; + +export function ImportDeckModal({ + show, + onCancel, + onImported, + masterKey, +}: ImportDeckModalProps) { + const [saveDeck] = useSaveDeckMutation(); + + const [bundle, setBundle] = useState(null); + const [fileError, setFileError] = useState(null); + const [mode, setMode] = useState("passphrase"); + const [passphrase, setPassphrase] = useState(""); + const [recoveryKeyInput, setRecoveryKeyInput] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + const reset = () => { + setBundle(null); + setFileError(null); + setMode("passphrase"); + setPassphrase(""); + setRecoveryKeyInput(""); + setError(null); + setSubmitting(false); + }; + + const handleFileChange = (event: ChangeEvent) => { + const file = event.target.files?.[0]; + setBundle(null); + setFileError(null); + setError(null); + if (file == null) { + return; + } + // FileReader rather than Blob.text() - broader runtime support (the latter isn't universally + // available, e.g. in this project's own jsdom test environment). + const reader = new FileReader(); + reader.onload = () => { + try { + setBundle(parseExportBundle(reader.result as string)); + } catch (thrown) { + setFileError( + thrown instanceof Error + ? thrown.message + : "That file isn't a valid saved-deck export." + ); + } + }; + reader.onerror = () => + setFileError("That file isn't a valid saved-deck export."); + reader.readAsText(file); + }; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + if (bundle == null) { + return; + } + setError(null); + setSubmitting(true); + const unlockBundle = + mode === "passphrase" + ? unlockBundleMasterKeyWithPassphrase(bundle, passphrase) + : unlockBundleMasterKeyWithRecoveryKey(bundle, recoveryKeyInput.trim()); + unlockBundle + .then((bundleMasterKey) => decryptBundleDecks(bundle, bundleMasterKey)) + .then((decryptedDecks) => + Promise.all( + decryptedDecks.map((decrypted) => + encryptFinalizedDeckPayload(decrypted.payload, masterKey).then( + (encrypted) => + saveDeck({ + key: null, + kind: decrypted.kind, + ciphertext: encrypted.ciphertext, + ciphertextNonce: encrypted.ciphertextNonce, + wrappedDek: encrypted.wrappedDek, + wrappedDekNonce: encrypted.wrappedDekNonce, + }).unwrap() + ) + ) + ) + ) + .then((saved) => { + setSubmitting(false); + onImported(saved.length); + }) + .catch(() => { + setSubmitting(false); + setError( + mode === "passphrase" + ? "That passphrase doesn't match this file." + : "That recovery key doesn't match this file." + ); + }); + }; + + return ( + + + Import decks + + +

+ Choose a previously-exported saved-decks file. Every deck it contains + is imported as a brand-new deck here - nothing existing is ever + overwritten. +

+
+ + Export file + + + {fileError != null &&

{fileError}

} + {bundle != null && ( + <> +

+ {bundle.decks.length} deck + {bundle.decks.length === 1 ? "" : "s"} found - enter the + passphrase (or recovery key) it was exported with to decrypt + them. +

+ {mode === "passphrase" ? ( + + Passphrase + setPassphrase(event.target.value)} + autoFocus + aria-label="import-passphrase" + /> + + ) : ( + + Recovery key + + setRecoveryKeyInput(event.target.value) + } + autoFocus + aria-label="import-recovery-key" + /> + + )} + + + )} + {error != null &&

{error}

} +
+
+ + + + +
+ ); +} diff --git a/frontend/src/features/savedDecks/LoadSafetyModal.test.tsx b/frontend/src/features/savedDecks/LoadSafetyModal.test.tsx index 7cca44ec3..2f28c905f 100644 --- a/frontend/src/features/savedDecks/LoadSafetyModal.test.tsx +++ b/frontend/src/features/savedDecks/LoadSafetyModal.test.tsx @@ -10,6 +10,7 @@ import { useCryptoSession, } from "@/features/savedDecks/cryptoSession"; import { existingProfileHandler } from "@/features/savedDecks/cryptoTestHandlers"; +import { decryptSavedDeckSummary } from "@/features/savedDecks/deckPayload"; import { LoadSafetyModal } from "@/features/savedDecks/LoadSafetyModal"; import { whoamiSignedInNotModerator } from "@/mocks/handlers"; import { server } from "@/mocks/server"; @@ -18,6 +19,24 @@ import { setupStore } from "@/store/store"; const TEST_ITERATIONS = 100; const PASSPHRASE = "the real one"; +/** Decrypts a captured saveDeck request body to assert on the PR-6 "Revision tracking" fields + * living inside its ciphertext. */ +async function decryptSavedRequest(savedRequest: any, masterKey: CryptoKey) { + return decryptSavedDeckSummary( + { + key: savedRequest.key ?? "unused-in-test", + kind: savedRequest.kind, + ciphertext: savedRequest.ciphertext, + ciphertextNonce: savedRequest.ciphertextNonce, + wrappedDek: savedRequest.wrappedDek, + wrappedDekNonce: savedRequest.wrappedDekNonce, + createdAt: "2026-01-01", + updatedAt: "2026-01-01", + }, + masterKey + ); +} + function TestUnlockButton() { const session = useCryptoSession(); return ( @@ -55,6 +74,7 @@ function renderModal( currentDeckKey: string | null; currentDeckName: string | null; lastSavedSerialized: string | null; + lastSavedRevision: number | null; } ) { const store = setupStore({ @@ -92,6 +112,7 @@ test("never-saved project: only a single 'save backup and continue' action, no ' currentDeckKey: null, currentDeckName: null, lastSavedSerialized: null, + lastSavedRevision: null, }); await unlockSession(); @@ -123,6 +144,7 @@ test("already-saved deck: offers Update-in-place vs Save-as-new-snapshot", async currentDeckKey: "existing-deck-key", currentDeckName: "My Existing Deck", lastSavedSerialized: "stale", + lastSavedRevision: 2, }); await unlockSession(); @@ -135,6 +157,10 @@ test("already-saved deck: offers Update-in-place vs Save-as-new-snapshot", async expect(requests).toHaveLength(1); expect(requests[0].key).toBeNull(); expect(requests[0].kind).toEqual("snapshot"); + // PR-6 "Revision tracking" - a brand-new snapshot row starts at 1, never inheriting the + // dirty editor's prior saved row's revision (2, from the preloaded state above). + const decrypted = await decryptSavedRequest(requests[0], profile.masterKey); + expect(decrypted.payload.revision).toEqual(1); }); test("already-saved deck: choosing Update sends the existing deck's key, kind deck", async () => { @@ -153,6 +179,7 @@ test("already-saved deck: choosing Update sends the existing deck's key, kind de currentDeckKey: "existing-deck-key", currentDeckName: "My Existing Deck", lastSavedSerialized: "stale", + lastSavedRevision: 2, }); await unlockSession(); @@ -161,4 +188,7 @@ test("already-saved deck: choosing Update sends the existing deck's key, kind de await waitFor(() => expect(onSafetyCompleted).toHaveBeenCalledTimes(1)); expect(requests[0].key).toEqual("existing-deck-key"); expect(requests[0].kind).toEqual("deck"); + // PR-6 "Revision tracking" - updating the SAME row continues its chain (2 -> 3). + const decrypted = await decryptSavedRequest(requests[0], profile.masterKey); + expect(decrypted.payload.revision).toEqual(3); }); diff --git a/frontend/src/features/savedDecks/LoadSafetyModal.tsx b/frontend/src/features/savedDecks/LoadSafetyModal.tsx index 2bbd8f06c..c52cdd0bc 100644 --- a/frontend/src/features/savedDecks/LoadSafetyModal.tsx +++ b/frontend/src/features/savedDecks/LoadSafetyModal.tsx @@ -79,14 +79,22 @@ export function LoadSafetyModal({ finishSettings, cardDocuments ); - encryptDeckPayloadForSave(payload, session.masterKey) + // "Save as new snapshot" always starts a brand-new row (PR-6 "Revision tracking" - a new + // row's revision chain never inherits from the dirty editor's prior saved row). + const previousRevision = asUpdate + ? currentSavedDeck.lastSavedRevision + : null; + encryptDeckPayloadForSave(payload, session.masterKey, previousRevision) .then((encrypted) => saveDeck({ key: asUpdate ? currentSavedDeck.currentDeckKey : null, kind: asUpdate ? LoadDeckResponseKind.Deck : LoadDeckResponseKind.Snapshot, - ...encrypted, + ciphertext: encrypted.ciphertext, + ciphertextNonce: encrypted.ciphertextNonce, + wrappedDek: encrypted.wrappedDek, + wrappedDekNonce: encrypted.wrappedDekNonce, }).unwrap() ) .then(() => { diff --git a/frontend/src/features/savedDecks/MyDecksPage.test.tsx b/frontend/src/features/savedDecks/MyDecksPage.test.tsx index c4d5e1fbf..0dd4d722d 100644 --- a/frontend/src/features/savedDecks/MyDecksPage.test.tsx +++ b/frontend/src/features/savedDecks/MyDecksPage.test.tsx @@ -148,6 +148,9 @@ test("opening a deck loads it into the project store and navigates to the editor expect(selectCurrentSavedDeck(store.getState())).toMatchObject({ currentDeckKey: "deck-42", currentDeckName: "Control Deck", + // v1 legacy payload (emptyDeckPayload's `version: 1`) upgraded on load - PR-6 "Revision + // tracking" backfills revision 0 for a pre-existing row that never tracked one. + lastSavedRevision: 0, }); }); @@ -210,3 +213,110 @@ test("resetting saved decks requires a second confirming click before calling th fireEvent.click(screen.getByTestId("reset-saved-decks")); await waitFor(() => expect(resetBody).toEqual({ confirm: true })); }); + +test("PR-6 deck portability: Export my decks is disabled with zero decks, enabled once decks exist, and triggers a download", async () => { + const profile = await createCryptoProfile("the real one", TEST_ITERATIONS); + server.use( + whoamiSignedInNotModerator, + existingProfileHandler(profile), + getSavedDecksHandler([]) + ); + renderPage(); + + await screen.findByTestId("unlock-modal"); + fireEvent.change(screen.getByLabelText("unlock-passphrase"), { + target: { value: "the real one" }, + }); + fireEvent.click(screen.getByText("Unlock")); + + const exportButton = await screen.findByTestId("export-my-decks"); + expect(exportButton).toBeDisabled(); +}); + +test("PR-6 deck portability: Export my decks works while STILL LOCKED - the spec's own headline scenario (a user who's forgotten their passphrase can still export)", async () => { + const profile = await createCryptoProfile("the real one", TEST_ITERATIONS); + const namedDeck = await buildMockSavedDeckSummary( + "deck-1", + "deck", + emptyDeckPayload("Standard Aggro"), + profile.masterKey, + { createdAt: "2026-01-01", updatedAt: "2026-01-02" } + ); + server.use( + whoamiSignedInNotModerator, + existingProfileHandler(profile), + getSavedDecksHandler([namedDeck]) + ); + renderPage(); + + // Deliberately never unlocks - the unlock modal auto-shows (locked state), but Export must + // still work without it, per the spec's own explicit requirement. + await screen.findByTestId("unlock-modal"); + + const exportButton = await screen.findByTestId("export-my-decks"); + await waitFor(() => expect(exportButton).not.toBeDisabled()); + + const clickSpy = jest + .spyOn(HTMLAnchorElement.prototype, "click") + .mockImplementation(() => undefined); + fireEvent.click(exportButton); + + await waitFor(() => expect(clickSpy).toHaveBeenCalledTimes(1)); + clickSpy.mockRestore(); +}); + +test("PR-6 deck portability: Export my decks downloads a bundle once decks exist", async () => { + const profile = await createCryptoProfile("the real one", TEST_ITERATIONS); + const namedDeck = await buildMockSavedDeckSummary( + "deck-1", + "deck", + emptyDeckPayload("Standard Aggro"), + profile.masterKey, + { createdAt: "2026-01-01", updatedAt: "2026-01-02" } + ); + server.use( + whoamiSignedInNotModerator, + existingProfileHandler(profile), + getSavedDecksHandler([namedDeck]) + ); + renderPage(); + + await screen.findByTestId("unlock-modal"); + fireEvent.change(screen.getByLabelText("unlock-passphrase"), { + target: { value: "the real one" }, + }); + fireEvent.click(screen.getByText("Unlock")); + + const exportButton = await screen.findByTestId("export-my-decks"); + await waitFor(() => expect(exportButton).not.toBeDisabled()); + + const clickSpy = jest + .spyOn(HTMLAnchorElement.prototype, "click") + .mockImplementation(() => undefined); + fireEvent.click(exportButton); + + await waitFor(() => expect(clickSpy).toHaveBeenCalledTimes(1)); + clickSpy.mockRestore(); +}); + +test("PR-6 deck portability: Import decks is disabled while locked, enabled once unlocked", async () => { + const profile = await createCryptoProfile("the real one", TEST_ITERATIONS); + server.use( + whoamiSignedInNotModerator, + existingProfileHandler(profile), + getSavedDecksHandler([]) + ); + renderPage(); + + await screen.findByTestId("unlock-modal"); + expect(screen.getByTestId("open-import-decks")).toBeDisabled(); + + fireEvent.change(screen.getByLabelText("unlock-passphrase"), { + target: { value: "the real one" }, + }); + fireEvent.click(screen.getByText("Unlock")); + + await waitFor(() => + expect(screen.getByTestId("open-import-decks")).not.toBeDisabled() + ); +}); diff --git a/frontend/src/features/savedDecks/MyDecksPage.tsx b/frontend/src/features/savedDecks/MyDecksPage.tsx index ea6360332..bac6d5cc1 100644 --- a/frontend/src/features/savedDecks/MyDecksPage.tsx +++ b/frontend/src/features/savedDecks/MyDecksPage.tsx @@ -16,17 +16,24 @@ import { useAppDispatch, useAppSelector } from "@/common/types"; import { RightPaddedIcon } from "@/components/icon"; import { useCryptoSession } from "@/features/savedDecks/cryptoSession"; import { + buildExportBundle, + downloadExportBundle, +} from "@/features/savedDecks/deckExportImport"; +import { + deckContentForComparison, DecryptedSavedDeck, decryptSavedDeckSummary, projectFromDeckPayload, serializeDeckPayload, } from "@/features/savedDecks/deckPayload"; +import { ImportDeckModal } from "@/features/savedDecks/ImportDeckModal"; import { LoadSafetyModal } from "@/features/savedDecks/LoadSafetyModal"; import { selectIsCurrentProjectDirty } from "@/features/savedDecks/selectors"; import { ShareDeckModal } from "@/features/savedDecks/ShareDeckModal"; import { UnlockModal } from "@/features/savedDecks/UnlockModal"; import { useDeleteDeckMutation, + useGetCryptoProfileQuery, useGetSavedDecksQuery, useGetWhoamiQuery, useResetSavedDecksMutation, @@ -96,6 +103,11 @@ export function MyDecksPage() { const shouldFetchDecks = session.status === "locked" || session.status === "unlocked"; const savedDecksQuery = useGetSavedDecksQuery({ skip: !shouldFetchDecks }); + // Export requires no unlock (docs/proposals/.../PR-6's own explicit requirement) - fetched + // independently of the crypto session's unlocked state, same gate as savedDecksQuery above. + const cryptoProfileQuery = useGetCryptoProfileQuery({ + skip: !shouldFetchDecks, + }); const [deleteDeck] = useDeleteDeckMutation(); const [resetSavedDecks] = useResetSavedDecksMutation(); @@ -109,6 +121,9 @@ export function MyDecksPage() { const [confirmingReset, setConfirmingReset] = useState(false); const [pendingLoadDeck, setPendingLoadDeck] = useState(null); + const [showImport, setShowImport] = useState(false); + const [exportError, setExportError] = useState(null); + const [importMessage, setImportMessage] = useState(null); const [sharingDeck, setSharingDeck] = useState( null ); @@ -176,12 +191,34 @@ export function MyDecksPage() { setCurrentSavedDeck({ key: deck.key, name, - serialized: serializeDeckPayload(deck.payload), + // Content-only (deckPayload.ts's `deckContentForComparison`) - matches the shape + // `buildDeckPayload` produces, since the dirty-check compares the two directly. The + // full payload's `revision`/`modifiedAt` (PR-6) live separately, in `lastSavedRevision`. + serialized: serializeDeckPayload( + deckContentForComparison(deck.payload) + ), + revision: deck.payload.revision, }) ); router.push("/editor"); }; + const handleExport = () => { + if (savedDecksQuery.data == null || cryptoProfileQuery.data == null) { + return; + } + setExportError(null); + try { + const bundle = buildExportBundle( + cryptoProfileQuery.data, + savedDecksQuery.data.decks + ); + downloadExportBundle(bundle); + } catch (thrown) { + setExportError(thrown instanceof Error ? thrown.message : String(thrown)); + } + }; + // Loss-proof by construction (frontend spec §4): an empty or clean editor loads immediately, // but a dirty one always gets a safety copy saved first - never silently discarded, and never // skippable for a logged-in user (this page requires being logged in to reach at all). @@ -265,6 +302,20 @@ export function MyDecksPage() { } }} /> + {session.masterKey != null && ( + setShowImport(false)} + onImported={(count) => { + setShowImport(false); + setImportMessage( + `Imported ${count} deck${count === 1 ? "" : "s"} as new.` + ); + savedDecksQuery.refetch(); + }} + masterKey={session.masterKey} + /> + )} {sharingDeck != null && sharingDeckSummary != null && (

)} + {shouldFetchDecks && ( +

+ {/* Export requires no unlock (docs/proposals/.../PR-6) - it's the same opaque bytes + the server already holds, so it works whether or not this session has ever unlocked. */} + + {/* Import needs somewhere to PERSIST the decrypted decks to, so it needs THIS + session's own master key unlocked - unlike export. */} + +

+ )} + {exportError != null &&

{exportError}

} + {importMessage != null &&

{importMessage}

} {decrypting && } {decryptError != null &&

{decryptError}

} {session.status === "unlocked" && !decrypting && ( diff --git a/frontend/src/features/savedDecks/SaveDeckModal.test.tsx b/frontend/src/features/savedDecks/SaveDeckModal.test.tsx index 4fb170d28..1ac7f3d64 100644 --- a/frontend/src/features/savedDecks/SaveDeckModal.test.tsx +++ b/frontend/src/features/savedDecks/SaveDeckModal.test.tsx @@ -4,19 +4,38 @@ import React from "react"; import { Provider } from "react-redux"; import { createCryptoProfile } from "@/common/savedDeckCrypto"; -import { SourceType } from "@/common/schema_types"; +import { LoadDeckResponseKind, SourceType } from "@/common/schema_types"; import { localBackend, projectSelectedImage1 } from "@/common/test-constants"; import { CryptoSessionProvider, useCryptoSession, } from "@/features/savedDecks/cryptoSession"; import { existingProfileHandler } from "@/features/savedDecks/cryptoTestHandlers"; +import { decryptSavedDeckSummary } from "@/features/savedDecks/deckPayload"; import { SaveDeckModal } from "@/features/savedDecks/SaveDeckModal"; import { whoamiSignedInNotModerator } from "@/mocks/handlers"; import { server } from "@/mocks/server"; import { selectCurrentSavedDeck } from "@/store/slices/savedDeckSessionSlice"; import { setupStore } from "@/store/store"; +/** Decrypts a captured saveDeck request body the same way the server-stored ciphertext would be + * decrypted on load, to assert on the PR-6 "Revision tracking" fields living inside it. */ +async function decryptSavedRequest(savedRequest: any, masterKey: CryptoKey) { + return decryptSavedDeckSummary( + { + key: savedRequest.key ?? "unused-in-test", + kind: savedRequest.kind ?? LoadDeckResponseKind.Deck, + ciphertext: savedRequest.ciphertext, + ciphertextNonce: savedRequest.ciphertextNonce, + wrappedDek: savedRequest.wrappedDek, + wrappedDekNonce: savedRequest.wrappedDekNonce, + createdAt: "2026-01-01", + updatedAt: "2026-01-01", + }, + masterKey + ); +} + const TEST_ITERATIONS = 100; const PASSPHRASE = "the real one"; @@ -96,7 +115,12 @@ test("saving a brand-new deck (no prior key) records the key the server returns" expect(selectCurrentSavedDeck(store.getState())).toMatchObject({ currentDeckKey: "new-deck-key", currentDeckName: "My New Deck", + lastSavedRevision: 1, }); + // PR-6 "Revision tracking" - a brand-new row always starts at revision 1. + const decrypted = await decryptSavedRequest(savedRequest, profile.masterKey); + expect(decrypted.payload.revision).toEqual(1); + expect(decrypted.payload.modifiedAt).toBeTruthy(); }); test("saving over an already-loaded deck sends its existing key (an update, not a new deck)", async () => { @@ -117,6 +141,7 @@ test("saving over an already-loaded deck sends its existing key (an update, not currentDeckKey: "existing-key", currentDeckName: "Existing Deck", lastSavedSerialized: null, + lastSavedRevision: 3, }, }); render( @@ -135,6 +160,10 @@ test("saving over an already-loaded deck sends its existing key (an update, not await waitFor(() => expect(savedRequest).not.toBeNull()); expect(savedRequest.key).toEqual("existing-key"); + // PR-6 "Revision tracking" - continuing the SAME row's chain increments from its last known + // revision (3, from the preloaded savedDeckSession state above), never restarting at 1. + const decrypted = await decryptSavedRequest(savedRequest, profile.masterKey); + expect(decrypted.payload.revision).toEqual(4); }); test("warns about local-file-sourced cards that won't restore on another device", async () => { diff --git a/frontend/src/features/savedDecks/SaveDeckModal.tsx b/frontend/src/features/savedDecks/SaveDeckModal.tsx index 65b8adb24..6270e7c3d 100644 --- a/frontend/src/features/savedDecks/SaveDeckModal.tsx +++ b/frontend/src/features/savedDecks/SaveDeckModal.tsx @@ -78,20 +78,32 @@ export function SaveDeckModal({ show, onCancel, onSaved }: SaveDeckModalProps) { finishSettings, cardDocuments ); - encryptDeckPayloadForSave(payload, session.masterKey) + // Only an update to the SAME already-saved row continues its revision chain (PR-6 + // "Revision tracking") - a brand-new row (no currentDeckKey yet) always starts at 1. + const previousRevision = + currentSavedDeck.currentDeckKey != null + ? currentSavedDeck.lastSavedRevision + : null; + encryptDeckPayloadForSave(payload, session.masterKey, previousRevision) .then((encrypted) => saveDeck({ key: currentSavedDeck.currentDeckKey, kind: LoadDeckResponseKind.Deck, - ...encrypted, - }).unwrap() + ciphertext: encrypted.ciphertext, + ciphertextNonce: encrypted.ciphertextNonce, + wrappedDek: encrypted.wrappedDek, + wrappedDekNonce: encrypted.wrappedDekNonce, + }) + .unwrap() + .then((response) => ({ response, revision: encrypted.revision })) ) - .then((response) => { + .then(({ response, revision }) => { dispatch( setCurrentSavedDeck({ key: response.key, name: finalName, serialized: serializeDeckPayload(payload), + revision, }) ); onSaved(); diff --git a/frontend/src/features/savedDecks/SavedDeckPanel.test.tsx b/frontend/src/features/savedDecks/SavedDeckPanel.test.tsx index 28a6dfc02..100a1821f 100644 --- a/frontend/src/features/savedDecks/SavedDeckPanel.test.tsx +++ b/frontend/src/features/savedDecks/SavedDeckPanel.test.tsx @@ -51,6 +51,7 @@ test("authenticated, a saved deck is loaded: shows the reverse breadcrumb", asyn currentDeckKey: "some-key", currentDeckName: "My Deck", lastSavedSerialized: null, + lastSavedRevision: null, }, }); diff --git a/frontend/src/features/savedDecks/SharedDeckViewer.tsx b/frontend/src/features/savedDecks/SharedDeckViewer.tsx index 3b004fe22..cff01594f 100644 --- a/frontend/src/features/savedDecks/SharedDeckViewer.tsx +++ b/frontend/src/features/savedDecks/SharedDeckViewer.tsx @@ -13,7 +13,7 @@ import { getWorkerImageURL } from "@/common/image"; import { CardDocument, CardDocuments } from "@/common/types"; import { DeckPayloadMemberFace, - DeckPayloadV1, + DeckPayloadV2, } from "@/features/savedDecks/deckPayload"; import { APIGetCards } from "@/store/api"; @@ -21,7 +21,9 @@ interface SharedDeckViewerProps { backendURL: string; name: string; sharedAt: string; - payload: DeckPayloadV1; + // decryptSharedDeck (deckShare.ts) always upgrades to the latest payload shape (v2) before + // handing it back - a recipient never sees a raw, un-upgraded v1 payload. + payload: DeckPayloadV2; } function SlotFace({ diff --git a/frontend/src/features/savedDecks/cryptoTestHandlers.ts b/frontend/src/features/savedDecks/cryptoTestHandlers.ts index 21018b53d..0d2033318 100644 --- a/frontend/src/features/savedDecks/cryptoTestHandlers.ts +++ b/frontend/src/features/savedDecks/cryptoTestHandlers.ts @@ -11,8 +11,8 @@ import { bytesToBase64 } from "@/common/savedDeckCrypto"; import { LoadDeckResponseKind, SavedDeckSummary } from "@/common/schema_types"; import { localBackendURL } from "@/common/test-constants"; import { - DeckPayloadV1, - encryptDeckPayloadForSave, + DeckPayload, + encryptFinalizedDeckPayload, } from "@/features/savedDecks/deckPayload"; function buildRoute(path: string): string { @@ -104,11 +104,11 @@ export function resetSavedDecksHandler(onReset: (body: any) => void) { export async function buildMockSavedDeckSummary( key: string, kind: "deck" | "snapshot", - payload: DeckPayloadV1, + payload: DeckPayload, masterKey: CryptoKey, timestamps: { createdAt: string; updatedAt: string } ): Promise { - const encrypted = await encryptDeckPayloadForSave(payload, masterKey); + const encrypted = await encryptFinalizedDeckPayload(payload, masterKey); return { key, kind: diff --git a/frontend/src/features/savedDecks/deckExportImport.test.ts b/frontend/src/features/savedDecks/deckExportImport.test.ts new file mode 100644 index 000000000..f8bf3b305 --- /dev/null +++ b/frontend/src/features/savedDecks/deckExportImport.test.ts @@ -0,0 +1,131 @@ +/** + * "PR-6, post-v1: deck portability" (docs/proposals/proposal-g-user-accounts-saved-decks.md) - + * export/import bundle round-trip, both unlock paths (passphrase and recovery key), and the + * format-version guard that keeps a future incompatible bundle from being silently misread. + */ + +import { createCryptoProfile } from "@/common/savedDeckCrypto"; +import { bytesToBase64 } from "@/common/savedDeckCrypto"; +import { CryptoProfileResponse } from "@/common/schema_types"; +import { buildMockSavedDeckSummary } from "@/features/savedDecks/cryptoTestHandlers"; +import { + buildExportBundle, + decryptBundleDecks, + EXPORT_FORMAT_VERSION, + parseExportBundle, + serializeExportBundle, + unlockBundleMasterKeyWithPassphrase, + unlockBundleMasterKeyWithRecoveryKey, +} from "@/features/savedDecks/deckExportImport"; + +const TEST_ITERATIONS = 100; +const PASSPHRASE = "the real one"; + +function cryptoProfileResponse(profile: { + salt: Uint8Array; + iterations: number; + passphraseWrapped: { + wrapped: Uint8Array; + nonce: Uint8Array; + }; + recoveryWrapped: { + wrapped: Uint8Array; + nonce: Uint8Array; + }; +}): CryptoProfileResponse { + return { + exists: true, + salt: bytesToBase64(profile.salt), + kdfIterations: profile.iterations, + passphraseWrappedMasterKey: bytesToBase64( + profile.passphraseWrapped.wrapped + ), + passphraseWrappedMasterKeyNonce: bytesToBase64( + profile.passphraseWrapped.nonce + ), + recoveryWrappedMasterKey: bytesToBase64(profile.recoveryWrapped.wrapped), + recoveryWrappedMasterKeyNonce: bytesToBase64(profile.recoveryWrapped.nonce), + }; +} + +test("buildExportBundle refuses to export without a real crypto profile", () => { + expect(() => + buildExportBundle( + { + exists: false, + salt: null, + kdfIterations: null, + passphraseWrappedMasterKey: null, + passphraseWrappedMasterKeyNonce: null, + recoveryWrappedMasterKey: null, + recoveryWrappedMasterKeyNonce: null, + }, + [] + ) + ).toThrow(/No saved-deck crypto profile/); +}); + +test("parseExportBundle rejects an unsupported formatVersion and malformed bundles", () => { + expect(() => + parseExportBundle(JSON.stringify({ formatVersion: 99, decks: [] })) + ).toThrow(/Unsupported saved-deck export format version: 99/); + expect(() => + parseExportBundle(JSON.stringify({ formatVersion: EXPORT_FORMAT_VERSION })) + ).toThrow(/Malformed/); +}); + +test("export -> serialize -> parse -> unlock (passphrase) -> decrypt round-trips every deck, including its revision", async () => { + const profile = await createCryptoProfile(PASSPHRASE, TEST_ITERATIONS); + const deck = await buildMockSavedDeckSummary( + "deck-1", + "deck", + { + version: 2, + name: "Commander Deck", + members: [], + cardback: null, + manualOverrides: {}, + finishSettings: { cardstock: "(S30) Standard Smooth", foil: false }, + revision: 3, + modifiedAt: "2026-01-01T00:00:00.000Z", + }, + profile.masterKey, + { createdAt: "2026-01-01", updatedAt: "2026-01-02" } + ); + + const bundle = buildExportBundle(cryptoProfileResponse(profile), [deck]); + expect(bundle.formatVersion).toEqual(EXPORT_FORMAT_VERSION); + + const reparsed = parseExportBundle(serializeExportBundle(bundle)); + expect(reparsed.decks).toHaveLength(1); + + const bundleMasterKey = await unlockBundleMasterKeyWithPassphrase( + reparsed, + PASSPHRASE + ); + const decrypted = await decryptBundleDecks(reparsed, bundleMasterKey); + expect(decrypted).toHaveLength(1); + expect(decrypted[0].name).toEqual("Commander Deck"); + expect(decrypted[0].payload.revision).toEqual(3); + expect(decrypted[0].payload.modifiedAt).toEqual("2026-01-01T00:00:00.000Z"); +}); + +test("unlockBundleMasterKeyWithRecoveryKey also unwraps the bundle's master key", async () => { + const profile = await createCryptoProfile(PASSPHRASE, TEST_ITERATIONS); + const bundle = buildExportBundle(cryptoProfileResponse(profile), []); + + const bundleMasterKey = await unlockBundleMasterKeyWithRecoveryKey( + bundle, + bytesToBase64(profile.recoveryKeyBytes) + ); + expect(bundleMasterKey).toBeDefined(); +}); + +test("a wrong passphrase fails to unlock the bundle's master key", async () => { + const profile = await createCryptoProfile(PASSPHRASE, TEST_ITERATIONS); + const bundle = buildExportBundle(cryptoProfileResponse(profile), []); + + await expect( + unlockBundleMasterKeyWithPassphrase(bundle, "wrong passphrase") + ).rejects.toThrow(); +}); diff --git a/frontend/src/features/savedDecks/deckExportImport.ts b/frontend/src/features/savedDecks/deckExportImport.ts new file mode 100644 index 000000000..2002ad415 --- /dev/null +++ b/frontend/src/features/savedDecks/deckExportImport.ts @@ -0,0 +1,182 @@ +/** + * Deck portability (docs/proposals/proposal-g-user-accounts-saved-decks.md, "PR-6, post-v1: + * deck portability"): export/import of the complete encrypted bundle a saved-decks account + * holds. Every field in the bundle is exactly the same opaque, already-encrypted bytes the + * server itself stores - `buildExportBundle` never sees deck contents in plaintext, and export + * requires no unlock (see MyDecksPage's "Export my decks" wiring). Import DOES need a + * passphrase or recovery key, but it's the BUNDLE's own (via `unlockBundleMasterKeyWith*` + * below) - not necessarily the live session's - since a bundle may be re-imported on a + * different account or a different (compatible) instance entirely. + * + * `EXPORT_FORMAT_VERSION` is this bundle's own PUBLIC wire-format version (starts at 1) - + * distinct from deckPayload.ts's PRIVATE, per-deck `version` field, which lives inside each + * deck's encrypted ciphertext and is never visible here. This format is documented publicly + * (this file, plus docs/features/saved-decks.md) specifically so a fork, or a completely + * independent reimplementation, could read an exported bundle without this codebase at all - + * see the standalone decrypt tool at `tools/decrypt-saved-deck-export/`, the trust anchor for + * that claim. + */ + +import { + base64ToBytes, + unlockWithPassphrase, + unlockWithRecoveryKey, + WrappedKey, +} from "@/common/savedDeckCrypto"; +import { CryptoProfileResponse, SavedDeckSummary } from "@/common/schema_types"; +import { + DecryptedSavedDeck, + decryptSavedDeckSummary, +} from "@/features/savedDecks/deckPayload"; + +export const EXPORT_FORMAT_VERSION = 1; + +export interface ExportBundleCryptoProfile { + salt: string; + kdfIterations: number; + passphraseWrappedMasterKey: string; + passphraseWrappedMasterKeyNonce: string; + recoveryWrappedMasterKey: string; + recoveryWrappedMasterKeyNonce: string; +} + +/** Identical wire shape to `SavedDeckSummary` - literally the same opaque bytes the server + * already holds for this row, just also written into the exported file. */ +export type ExportBundleDeck = SavedDeckSummary; + +export interface ExportBundleV1 { + formatVersion: 1; + exportedAt: string; + cryptoProfile: ExportBundleCryptoProfile; + decks: Array; +} + +/** + * Builds the export bundle. Requires NO unlock (docs/proposals/.../PR-6's own explicit + * requirement) - a user who's forgotten their passphrase can still export, since this is just + * the same opaque bytes the server already stores, reshaped into one file. + */ +export function buildExportBundle( + cryptoProfile: CryptoProfileResponse, + decks: Array +): ExportBundleV1 { + if ( + !cryptoProfile.exists || + cryptoProfile.salt == null || + cryptoProfile.kdfIterations == null || + cryptoProfile.passphraseWrappedMasterKey == null || + cryptoProfile.passphraseWrappedMasterKeyNonce == null || + cryptoProfile.recoveryWrappedMasterKey == null || + cryptoProfile.recoveryWrappedMasterKeyNonce == null + ) { + throw new Error( + "No saved-deck crypto profile to export yet - save a deck first." + ); + } + return { + formatVersion: EXPORT_FORMAT_VERSION, + exportedAt: new Date().toISOString(), + cryptoProfile: { + salt: cryptoProfile.salt, + kdfIterations: cryptoProfile.kdfIterations, + passphraseWrappedMasterKey: cryptoProfile.passphraseWrappedMasterKey, + passphraseWrappedMasterKeyNonce: + cryptoProfile.passphraseWrappedMasterKeyNonce, + recoveryWrappedMasterKey: cryptoProfile.recoveryWrappedMasterKey, + recoveryWrappedMasterKeyNonce: + cryptoProfile.recoveryWrappedMasterKeyNonce, + }, + decks: decks.map((deck) => ({ + key: deck.key, + kind: deck.kind, + ciphertext: deck.ciphertext, + ciphertextNonce: deck.ciphertextNonce, + wrappedDek: deck.wrappedDek, + wrappedDekNonce: deck.wrappedDekNonce, + createdAt: deck.createdAt, + updatedAt: deck.updatedAt, + })), + }; +} + +export function serializeExportBundle(bundle: ExportBundleV1): string { + return JSON.stringify(bundle, null, 2); +} + +export function parseExportBundle(serialized: string): ExportBundleV1 { + const parsed = JSON.parse(serialized); + if (parsed?.formatVersion !== EXPORT_FORMAT_VERSION) { + throw new Error( + `Unsupported saved-deck export format version: ${parsed?.formatVersion}` + ); + } + if (!Array.isArray(parsed.decks) || parsed.cryptoProfile == null) { + throw new Error("Malformed saved-deck export file."); + } + return parsed as ExportBundleV1; +} + +/** Browser-only: triggers a download of the bundle as a timestamped `.json` file. */ +export function downloadExportBundle(bundle: ExportBundleV1): void { + const blob = new Blob([serializeExportBundle(bundle)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + try { + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = `proxyprints-saved-decks-${bundle.exportedAt.slice( + 0, + 10 + )}.json`; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + } finally { + URL.revokeObjectURL(url); + } +} + +/** Unwraps the BUNDLE's own master key using its own passphrase - deliberately independent of + * whatever crypto profile the live session (if any) already has, since a bundle can be + * re-imported on a different account or instance entirely. Throws on a wrong passphrase (AES-GCM + * authentication failure), same as the live unlock path. */ +export async function unlockBundleMasterKeyWithPassphrase( + bundle: ExportBundleV1, + passphrase: string +): Promise { + const wrapped: WrappedKey = { + wrapped: base64ToBytes(bundle.cryptoProfile.passphraseWrappedMasterKey), + nonce: base64ToBytes(bundle.cryptoProfile.passphraseWrappedMasterKeyNonce), + }; + return unlockWithPassphrase( + passphrase, + base64ToBytes(bundle.cryptoProfile.salt), + bundle.cryptoProfile.kdfIterations, + wrapped + ); +} + +/** As above, via the bundle's own recovery key instead of its passphrase. */ +export async function unlockBundleMasterKeyWithRecoveryKey( + bundle: ExportBundleV1, + recoveryKeyBase64: string +): Promise { + const wrapped: WrappedKey = { + wrapped: base64ToBytes(bundle.cryptoProfile.recoveryWrappedMasterKey), + nonce: base64ToBytes(bundle.cryptoProfile.recoveryWrappedMasterKeyNonce), + }; + return unlockWithRecoveryKey(base64ToBytes(recoveryKeyBase64), wrapped); +} + +/** Decrypts every deck in a bundle using the bundle's own (already-unwrapped) master key. A + * failure on any single entry aborts the whole import rather than silently skipping a row - + * partial imports would be a confusing, hard-to-notice way to lose data. */ +export async function decryptBundleDecks( + bundle: ExportBundleV1, + bundleMasterKey: CryptoKey +): Promise> { + return Promise.all( + bundle.decks.map((deck) => decryptSavedDeckSummary(deck, bundleMasterKey)) + ); +} diff --git a/frontend/src/features/savedDecks/deckPayload.test.ts b/frontend/src/features/savedDecks/deckPayload.test.ts new file mode 100644 index 000000000..ba5ac071e --- /dev/null +++ b/frontend/src/features/savedDecks/deckPayload.test.ts @@ -0,0 +1,179 @@ +/** + * PR-6 "Revision tracking" (docs/proposals/proposal-g-user-accounts-saved-decks.md) coverage: + * the v1 -> v2 upgrade path, the content/revision split that keeps the dirty-check baseline + * stable, and the two encrypt entry points (`encryptDeckPayloadForSave` bumps revision, + * `encryptFinalizedDeckPayload` preserves it verbatim - the one import needs). + */ + +import { createCryptoProfile } from "@/common/savedDeckCrypto"; +import { LoadDeckResponseKind } from "@/common/schema_types"; +import { FinishSettingsState, Project } from "@/common/types"; +import { + buildDeckPayload, + countDeviceLocalSlots, + DECK_PAYLOAD_VERSION, + deckContentForComparison, + DeckPayloadV1, + decryptSavedDeckSummary, + encryptDeckPayloadForSave, + encryptFinalizedDeckPayload, + parseDeckPayload, + serializeDeckPayload, +} from "@/features/savedDecks/deckPayload"; + +const TEST_ITERATIONS = 100; + +const emptyFinishSettings: FinishSettingsState = { + cardstock: "(S30) Standard Smooth", + foil: false, +}; + +const emptyProject: Project = { + members: [], + nextMemberId: 0, + cardback: null, + mostRecentlySelectedSlot: null, + manualOverrides: {}, +}; + +function v1Payload(name: string): DeckPayloadV1 { + return { + version: 1, + name, + members: [], + cardback: null, + manualOverrides: {}, + finishSettings: emptyFinishSettings, + }; +} + +test("buildDeckPayload's output never carries version/revision/modifiedAt - only content", () => { + const content = buildDeckPayload( + "My Deck", + emptyProject, + emptyFinishSettings, + {} + ); + expect(content).not.toHaveProperty("version"); + expect(content).not.toHaveProperty("revision"); + expect(content).not.toHaveProperty("modifiedAt"); + expect(countDeviceLocalSlots(content)).toEqual(0); +}); + +test("buildDeckPayload is deterministic across calls with identical inputs - the dirty-check's own invariant", () => { + const first = serializeDeckPayload( + buildDeckPayload("My Deck", emptyProject, emptyFinishSettings, {}) + ); + const second = serializeDeckPayload( + buildDeckPayload("My Deck", emptyProject, emptyFinishSettings, {}) + ); + expect(first).toEqual(second); +}); + +test("parseDeckPayload upgrades a legacy v1 payload to v2, backfilling revision 0 and a fallback modifiedAt", () => { + const serialized = JSON.stringify(v1Payload("Legacy Deck")); + const upgraded = parseDeckPayload(serialized, "2026-01-02T00:00:00.000Z"); + expect(upgraded.version).toEqual(2); + expect(upgraded.revision).toEqual(0); + expect(upgraded.modifiedAt).toEqual("2026-01-02T00:00:00.000Z"); + expect(upgraded.name).toEqual("Legacy Deck"); +}); + +test("parseDeckPayload reads a v2 payload as-is, and rejects an unrecognised version", () => { + const v2 = { + version: 2, + name: "Modern Deck", + members: [], + cardback: null, + manualOverrides: {}, + finishSettings: emptyFinishSettings, + revision: 5, + modifiedAt: "2026-01-01T00:00:00.000Z", + }; + expect(parseDeckPayload(JSON.stringify(v2))).toEqual(v2); + expect(() => parseDeckPayload(JSON.stringify({ version: 99 }))).toThrow( + "Unsupported saved deck payload version: 99" + ); +}); + +test("deckContentForComparison strips version/revision/modifiedAt so a loaded deck's baseline matches a freshly-rebuilt draft", () => { + const content = buildDeckPayload( + "Round Trip", + emptyProject, + emptyFinishSettings, + {} + ); + const finalized = { + ...content, + version: DECK_PAYLOAD_VERSION as 2, + revision: 7, + modifiedAt: "2026-01-01T00:00:00.000Z", + }; + expect(serializeDeckPayload(deckContentForComparison(finalized))).toEqual( + serializeDeckPayload(content) + ); +}); + +test("encryptDeckPayloadForSave bumps revision from previousRevision, and stamps a fresh modifiedAt", async () => { + const { masterKey } = await createCryptoProfile( + "passphrase", + TEST_ITERATIONS + ); + const content = buildDeckPayload( + "Bumped", + emptyProject, + emptyFinishSettings, + {} + ); + const freshRow = await encryptDeckPayloadForSave(content, masterKey, null); + expect(freshRow.revision).toEqual(1); + + const continuedRow = await encryptDeckPayloadForSave( + content, + masterKey, + freshRow.revision + ); + expect(continuedRow.revision).toEqual(2); + + const decrypted = await decryptSavedDeckSummary( + { + key: "k", + kind: LoadDeckResponseKind.Deck, + ciphertext: continuedRow.ciphertext, + ciphertextNonce: continuedRow.ciphertextNonce, + wrappedDek: continuedRow.wrappedDek, + wrappedDekNonce: continuedRow.wrappedDekNonce, + createdAt: "2026-01-01", + updatedAt: "2026-01-01", + }, + masterKey + ); + expect(decrypted.payload.revision).toEqual(2); + expect(decrypted.payload.modifiedAt).toEqual(continuedRow.modifiedAt); +}); + +test("encryptFinalizedDeckPayload preserves an already-finalized payload's revision/modifiedAt verbatim - the import path's requirement", async () => { + const { masterKey } = await createCryptoProfile( + "passphrase", + TEST_ITERATIONS + ); + const finalized = { + ...buildDeckPayload("Imported", emptyProject, emptyFinishSettings, {}), + version: DECK_PAYLOAD_VERSION as 2, + revision: 42, + modifiedAt: "2020-01-01T00:00:00.000Z", + }; + const encrypted = await encryptFinalizedDeckPayload(finalized, masterKey); + const decrypted = await decryptSavedDeckSummary( + { + key: "k", + kind: LoadDeckResponseKind.Deck, + ...encrypted, + createdAt: "2026-01-01", + updatedAt: "2026-01-01", + }, + masterKey + ); + expect(decrypted.payload.revision).toEqual(42); + expect(decrypted.payload.modifiedAt).toEqual("2020-01-01T00:00:00.000Z"); +}); diff --git a/frontend/src/features/savedDecks/deckPayload.ts b/frontend/src/features/savedDecks/deckPayload.ts index 1a1c0551d..2837e3e08 100644 --- a/frontend/src/features/savedDecks/deckPayload.ts +++ b/frontend/src/features/savedDecks/deckPayload.ts @@ -2,6 +2,14 @@ * The plaintext shape encrypted wholesale (including its own `name`) as a saved deck's * ciphertext - see docs/proposals/proposal-g-user-accounts-saved-decks.md §8. Nothing in this * shape, or its serialized JSON string form, is ever sent to the server unencrypted. + * + * v2 (PR-6, "Revision tracking"): adds `revision`/`modifiedAt`, both PRIVATE fields living + * inside the encrypted payload (never server-visible) that make an export/import round-trip + * self-describing - see that section for the full rationale. This `version` field doubles as + * the "PR-6/PR-7 shared versioning rule" the spec refers to: there is deliberately no + * separately-named `formatVersion` field inside this private payload - PR-7's art-provenance + * addition is expected to become v3 of this same counter, via the same upgrade-dispatch pattern + * `parseDeckPayload` already uses below for v1 -> v2. */ import { @@ -26,7 +34,9 @@ import { SlotProjectMembers, } from "@/common/types"; -export const DECK_PAYLOAD_VERSION = 1; +/** The current (latest) version every fresh save produces. Bump alongside adding a new + * `DeckPayloadVN` interface and a new `case` in `parseDeckPayload`'s upgrade dispatch below. */ +export const DECK_PAYLOAD_VERSION = 2; export interface DeckPayloadMemberFace { query: ProjectMember["query"]; @@ -46,15 +56,36 @@ export interface DeckPayloadMember { back: DeckPayloadMemberFace | null; } -export interface DeckPayloadV1 { - version: 1; +/** The fields every version shares - everything a deck payload needs MINUS the version tag and + * MINUS any version-specific bookkeeping (v2's `revision`/`modifiedAt`). Kept as its own type so + * `buildDeckPayload`'s output (used for dirty-checking and previews, see below) never itself + * carries `modifiedAt` - a fresh timestamp on every call would otherwise make an unchanged + * project compare as "dirty" against its own last-saved baseline every single render. */ +export interface DeckPayloadContent { name: string; members: Array; - cardback: string | null; + cardback: Project["cardback"]; manualOverrides: Project["manualOverrides"]; finishSettings: FinishSettingsState; } +export interface DeckPayloadV1 extends DeckPayloadContent { + version: 1; +} + +export interface DeckPayloadV2 extends DeckPayloadContent { + version: 2; + /** Incremented on every save of this same server-side row (never on a brand-new row - "Save + * as new snapshot", and import, both always start a fresh row at revision 1). */ + revision: number; + /** ISO 8601 timestamp of the save that produced this revision. */ + modifiedAt: string; +} + +/** Any version this codebase can still read (never write - `buildDeckPayload` only ever + * produces the latest, `DECK_PAYLOAD_VERSION`-tagged shape). */ +export type DeckPayload = DeckPayloadV1 | DeckPayloadV2; + function toPayloadFace( face: ProjectMember | null, isDeviceLocal: (identifier: string | undefined) => boolean @@ -70,18 +101,23 @@ function toPayloadFace( }; } +/** + * Builds the CONTENT of a deck payload - no version tag, no revision/modifiedAt. Used for: the + * Save modal's preview (local-file warning count), the dirty-check baseline (selectors.ts), and + * as the input to `encryptDeckPayloadForSave` below, which stamps the version/revision/ + * modifiedAt fields on right before encryption. + */ export function buildDeckPayload( name: string, project: Project, finishSettings: FinishSettingsState, cardDocuments: CardDocuments -): DeckPayloadV1 { +): DeckPayloadContent { const isDeviceLocal = (identifier: string | undefined): boolean => identifier != null && cardDocuments[identifier]?.sourceType === SourceType.LocalFile; return { - version: DECK_PAYLOAD_VERSION, name, members: project.members.map((member: SlotProjectMembers) => ({ front: toPayloadFace(member.front, isDeviceLocal), @@ -94,25 +130,67 @@ export function buildDeckPayload( } /** - * Canonical string form of a payload - the encryption plaintext, and also the dirty-check - * comparison baseline (comparing two of these strings is cheaper and simpler than a deep - * object comparison, and is exactly as precise since both sides go through this same function). + * Canonical string form of a payload (or payload content) - the encryption plaintext, and also + * the dirty-check comparison baseline (comparing two of these strings is cheaper and simpler + * than a deep object comparison, and is exactly as precise since both sides go through this + * same function). */ -export function serializeDeckPayload(payload: DeckPayloadV1): string { +export function serializeDeckPayload( + payload: DeckPayloadContent | DeckPayload +): string { return JSON.stringify(payload); } -export function parseDeckPayload(serialized: string): DeckPayloadV1 { +/** + * Strips version/revision/modifiedAt bookkeeping from an already-parsed payload, leaving just + * the content fields - the same shape `buildDeckPayload` returns. Used wherever a FULL decrypted + * payload (e.g. freshly loaded from the server) needs to become a dirty-check baseline: without + * this, the baseline would carry `modifiedAt`/`revision` that a freshly-rebuilt payload from the + * live editor never has, so every load would immediately compare as "dirty". + */ +export function deckContentForComparison( + payload: DeckPayload +): DeckPayloadContent { + const { name, members, cardback, manualOverrides, finishSettings } = payload; + return { name, members, cardback, manualOverrides, finishSettings }; +} + +/** + * Parses a decrypted payload string, upgrading any older version forward to the latest shape + * this codebase understands. Never throws on a recognised OLDER version - real, already-saved + * decks exist at v1 today, and rejecting them the moment v2 ships would break every one of them. + * `fallbackModifiedAt` (typically the deck's own server-side `updatedAt`) backfills v1's missing + * `modifiedAt` - the closest honest proxy available, since v1 never tracked this itself. + */ +export function parseDeckPayload( + serialized: string, + fallbackModifiedAt?: string +): DeckPayloadV2 { const parsed = JSON.parse(serialized); - if (parsed?.version !== DECK_PAYLOAD_VERSION) { - throw new Error( - `Unsupported saved deck payload version: ${parsed?.version}` - ); + switch (parsed?.version) { + case 2: + return parsed as DeckPayloadV2; + case 1: { + const v1 = parsed as DeckPayloadV1; + return { + ...v1, + version: 2, + // Never actually revised under v1's tracking (it didn't exist) - 0 so the very next real + // save (revision 1) always reads as strictly newer than an untouched legacy row. + revision: 0, + modifiedAt: fallbackModifiedAt ?? new Date(0).toISOString(), + }; + } + default: + throw new Error( + `Unsupported saved deck payload version: ${parsed?.version}` + ); } - return parsed as DeckPayloadV1; } -export function countDeviceLocalSlots(payload: DeckPayloadV1): number { +export function countDeviceLocalSlots(payload: { + members: Array; +}): number { return payload.members.reduce( (count, member) => count + @@ -127,7 +205,7 @@ export function countDeviceLocalSlots(payload: DeckPayloadV1): number { * directly. `deviceLocal` slots simply have no `selectedImage` here - the card grid already * renders that as an empty, re-pickable slot with the original search query intact. */ -export function projectFromDeckPayload(payload: DeckPayloadV1): { +export function projectFromDeckPayload(payload: DeckPayload): { project: Omit; finishSettings: FinishSettingsState; name: string; @@ -171,12 +249,16 @@ export interface EncryptedDeckFields { } /** - * Encrypts a payload for the wire, under a FRESH per-save DEK - simpler than tracking and - * reusing an existing deck's DEK across updates, and the server has no preference either way - * (post_save_deck just overwrites whatever ciphertext/wrappedDek it's given, create or update). + * Encrypts an ALREADY-FINALIZED payload (i.e. one that already carries its own version/ + * revision/modifiedAt, exactly as it should be persisted) verbatim, under a FRESH per-save DEK - + * simpler than tracking and reusing an existing deck's DEK across updates, and the server has no + * preference either way (post_save_deck just overwrites whatever ciphertext/wrappedDek it's + * given, create or update). Used directly by deck-portability import (docs/proposals/.../PR-6): + * an imported deck's `revision`/`modifiedAt` must survive re-encryption unchanged, since they're + * what makes a later re-export/re-import round-trip self-describing. */ -export async function encryptDeckPayloadForSave( - payload: DeckPayloadV1, +export async function encryptFinalizedDeckPayload( + payload: DeckPayload, masterKey: CryptoKey ): Promise { const { dek, wrappedDek } = await createDeckKey(masterKey); @@ -192,18 +274,50 @@ export async function encryptDeckPayloadForSave( }; } +export interface EncryptDeckPayloadForSaveResult extends EncryptedDeckFields { + revision: number; + modifiedAt: string; +} + +/** + * The ordinary save path: stamps `content` with the latest version tag plus a freshly-bumped + * `revision`/`modifiedAt` (docs/proposals/.../PR-6 "Revision tracking"), then encrypts. Pass + * `previousRevision` as the row's last-known revision when overwriting the SAME server-side row + * (an "update"); pass `null` for a brand-new row (a fresh deck, "Save as new snapshot", or an + * imported-as-new deck that's being re-saved rather than persisted verbatim) so it starts at 1. + */ +export async function encryptDeckPayloadForSave( + content: DeckPayloadContent, + masterKey: CryptoKey, + previousRevision: number | null +): Promise { + const revision = (previousRevision ?? 0) + 1; + const modifiedAt = new Date().toISOString(); + const payload: DeckPayloadV2 = { + ...content, + version: DECK_PAYLOAD_VERSION, + revision, + modifiedAt, + }; + const encrypted = await encryptFinalizedDeckPayload(payload, masterKey); + return { ...encrypted, revision, modifiedAt }; +} + export interface DecryptedSavedDeck { key: string; kind: LoadDeckResponseKind; name: string; createdAt: string; updatedAt: string; - payload: DeckPayloadV1; + payload: DeckPayloadV2; } -/** Reverses encryptDeckPayloadForSave - unwraps the deck's DEK with the (already-unlocked) - * master key, then decrypts and parses its payload. Throws (AES-GCM auth failure) on a wrong - * master key or any tampered ciphertext/wrapped-DEK byte. */ +/** Reverses encryptDeckPayloadForSave/encryptFinalizedDeckPayload - unwraps the deck's DEK with + * the given (already-unlocked) master key, then decrypts and parses its payload. Throws + * (AES-GCM auth failure) on a wrong master key or any tampered ciphertext/wrapped-DEK byte. + * `masterKey` need not be the LIVE session's master key - deck-portability import + * (docs/proposals/.../PR-6) calls this with a bundle's own (possibly different-account) master + * key to decrypt an imported entry before re-encrypting it under the current session's key. */ export async function decryptSavedDeckSummary( summary: SavedDeckSummary, masterKey: CryptoKey @@ -218,7 +332,7 @@ export async function decryptSavedDeckSummary( base64ToBytes(summary.ciphertextNonce), dek ); - const payload = parseDeckPayload(plaintext); + const payload = parseDeckPayload(plaintext, summary.updatedAt); return { key: summary.key, kind: summary.kind, diff --git a/frontend/src/features/savedDecks/deckShare.ts b/frontend/src/features/savedDecks/deckShare.ts index df98f2391..7b66ee8d2 100644 --- a/frontend/src/features/savedDecks/deckShare.ts +++ b/frontend/src/features/savedDecks/deckShare.ts @@ -28,7 +28,7 @@ import { } from "@/common/savedDeckCrypto"; import { GetSharedDeckResponse } from "@/common/schema_types"; import { - DeckPayloadV1, + DeckPayloadV2, parseDeckPayload, } from "@/features/savedDecks/deckPayload"; @@ -74,7 +74,9 @@ export function buildShareUrl( export interface DecryptedSharedDeck { name: string; - payload: DeckPayloadV1; + // parseDeckPayload always upgrades to the latest shape (currently v2) - a recipient never + // sees a raw, un-upgraded v1 payload. + payload: DeckPayloadV2; sharedAt: string; } diff --git a/frontend/src/store/slices/savedDeckSessionSlice.ts b/frontend/src/store/slices/savedDeckSessionSlice.ts index 9ed81bfcf..496cacbd9 100644 --- a/frontend/src/store/slices/savedDeckSessionSlice.ts +++ b/frontend/src/store/slices/savedDeckSessionSlice.ts @@ -14,14 +14,22 @@ import { RootState } from "@/store/store"; export interface SavedDeckSessionState { currentDeckKey: string | null; currentDeckName: string | null; - /** The serialized deck payload (see deckPayload.ts) at the moment of the last load/save. */ + /** The serialized deck CONTENT (deckPayload.ts's `DeckPayloadContent` - no version/revision/ + * modifiedAt) at the moment of the last load/save - the dirty-check baseline. */ lastSavedSerialized: string | null; + /** The last-known `revision` (deckPayload.ts's PR-6 "Revision tracking") for `currentDeckKey` - + * null when there's no saved row yet, or it predates revision tracking (a legacy v1 payload, + * upgraded to revision 0 on load - see parseDeckPayload). The next save of this SAME row + * increments from here; a brand-new row (Save As New / import) always starts fresh at 1 and + * never reads this value. */ + lastSavedRevision: number | null; } const initialState: SavedDeckSessionState = { currentDeckKey: null, currentDeckName: null, lastSavedSerialized: null, + lastSavedRevision: null, }; export const savedDeckSessionSlice = createAppSlice({ @@ -34,16 +42,19 @@ export const savedDeckSessionSlice = createAppSlice({ key: string; name: string; serialized: string; + revision: number | null; }> ) => { state.currentDeckKey = action.payload.key; state.currentDeckName = action.payload.name; state.lastSavedSerialized = action.payload.serialized; + state.lastSavedRevision = action.payload.revision; }, clearCurrentSavedDeck: (state) => { state.currentDeckKey = null; state.currentDeckName = null; state.lastSavedSerialized = null; + state.lastSavedRevision = null; }, }, }); diff --git a/frontend/tests/SavedDecks.spec.ts b/frontend/tests/SavedDecks.spec.ts index 6b84363c8..51c08ff37 100644 --- a/frontend/tests/SavedDecks.spec.ts +++ b/frontend/tests/SavedDecks.spec.ts @@ -1,6 +1,16 @@ import { expect } from "@playwright/test"; +import * as fs from "fs/promises"; +import { http, HttpResponse } from "msw"; +import * as os from "os"; +import * as path from "path"; -import { noProfileHandler } from "@/features/savedDecks/cryptoTestHandlers"; +import { createCryptoProfile } from "@/common/savedDeckCrypto"; +import { + buildMockSavedDeckSummary, + existingProfileHandler, + getSavedDecksHandler, + noProfileHandler, +} from "@/features/savedDecks/cryptoTestHandlers"; import { cardDocumentsThreeResults, defaultHandlers, @@ -12,6 +22,9 @@ import { import { test } from "../playwright.setup"; import { importText, loadPageWithDefaultBackend } from "./test-utils"; +const TEST_ITERATIONS = 100; +const PASSPHRASE = "the real one"; + const threeCardHandlers = [ cardDocumentsThreeResults, sourceDocumentsOneResult, @@ -117,4 +130,150 @@ test.describe("saved decks", () => { page.getByTestId("display-toolbar").getByRole("button", { name: "Save" }) ).not.toBeVisible(); }); + + // PR-6, post-v1 "deck portability" (docs/proposals/proposal-g-user-accounts-saved-decks.md) - + // real-browser coverage for the one piece jsdom can't exercise faithfully: an actual file + // download (Export) and an actual selection (Import), both driven by the + // browser's real WebCrypto rather than jest's jsdom polyfill. + test("Export my decks downloads a bundle the standalone tool's own wire format understands", async ({ + page, + network, + }) => { + const profile = await createCryptoProfile(PASSPHRASE, TEST_ITERATIONS); + const namedDeck = await buildMockSavedDeckSummary( + "deck-1", + "deck", + { + version: 2, + name: "Standard Aggro", + members: [], + cardback: null, + manualOverrides: {}, + finishSettings: { cardstock: "(S30) Standard Smooth", foil: false }, + revision: 1, + modifiedAt: "2026-01-01T00:00:00.000Z", + }, + profile.masterKey, + { createdAt: "2026-01-01", updatedAt: "2026-01-02" } + ); + network.use( + whoamiSignedInNotModerator, + existingProfileHandler(profile), + getSavedDecksHandler([namedDeck]), + ...defaultHandlers + ); + await loadPageWithDefaultBackend(page, "editor"); + + await page.getByRole("link", { name: "My Decks" }).click(); + await page.getByLabel("unlock-passphrase").fill(PASSPHRASE); + await page.getByRole("button", { name: "Unlock" }).click(); + await expect(page.getByTestId("named-decks-list")).toContainText( + "Standard Aggro" + ); + + // Documentary screenshot of the shipped Export/Import UI (see PR body for the path). + await page.screenshot({ + path: path.join(os.tmpdir(), "pr6-my-decks-export-import.png"), + }); + + const [download] = await Promise.all([ + page.waitForEvent("download"), + page.getByTestId("export-my-decks").click(), + ]); + const downloadPath = await download.path(); + const bundleText = await fs.readFile(downloadPath, "utf-8"); + const bundle = JSON.parse(bundleText); + expect(bundle.formatVersion).toEqual(1); + expect(bundle.decks).toHaveLength(1); + expect(bundle.cryptoProfile.kdfIterations).toEqual(TEST_ITERATIONS); + }); + + test("Import decks persists every deck in a selected export file as new, under the current session's key", async ({ + page, + network, + }) => { + const bundleProfile = await createCryptoProfile( + "the bundle's own passphrase", + TEST_ITERATIONS + ); + const currentProfile = await createCryptoProfile( + PASSPHRASE, + TEST_ITERATIONS + ); + const { buildExportBundle, serializeExportBundle } = await import( + "@/features/savedDecks/deckExportImport" + ); + const { bytesToBase64 } = await import("@/common/savedDeckCrypto"); + const importedDeck = await buildMockSavedDeckSummary( + "imported-deck", + "deck", + { + version: 2, + name: "Imported Deck", + members: [], + cardback: null, + manualOverrides: {}, + finishSettings: { cardstock: "(S30) Standard Smooth", foil: false }, + revision: 2, + modifiedAt: "2025-01-01T00:00:00.000Z", + }, + bundleProfile.masterKey, + { createdAt: "2025-01-01", updatedAt: "2025-01-02" } + ); + const bundle = buildExportBundle( + { + exists: true, + salt: bytesToBase64(bundleProfile.salt), + kdfIterations: bundleProfile.iterations, + passphraseWrappedMasterKey: bytesToBase64( + bundleProfile.passphraseWrapped.wrapped + ), + passphraseWrappedMasterKeyNonce: bytesToBase64( + bundleProfile.passphraseWrapped.nonce + ), + recoveryWrappedMasterKey: bytesToBase64( + bundleProfile.recoveryWrapped.wrapped + ), + recoveryWrappedMasterKeyNonce: bytesToBase64( + bundleProfile.recoveryWrapped.nonce + ), + }, + [importedDeck] + ); + const bundleFilePath = path.join(os.tmpdir(), "pr6-import-fixture.json"); + await fs.writeFile(bundleFilePath, serializeExportBundle(bundle)); + + const saveDeckRequests: Array = []; + network.use( + whoamiSignedInNotModerator, + existingProfileHandler(currentProfile), + getSavedDecksHandler([]), + http.post("http://127.0.0.1:8000/2/saveDeck/", async ({ request }) => { + saveDeckRequests.push(await request.json()); + return HttpResponse.json( + { key: `new-${saveDeckRequests.length}` }, + { status: 200 } + ); + }), + ...defaultHandlers + ); + await loadPageWithDefaultBackend(page, "editor"); + + await page.getByRole("link", { name: "My Decks" }).click(); + await page.getByLabel("unlock-passphrase").fill(PASSPHRASE); + await page.getByRole("button", { name: "Unlock" }).click(); + await expect(page.getByTestId("open-import-decks")).toBeEnabled(); + + await page.getByTestId("open-import-decks").click(); + await page.getByLabel("import-file").setInputFiles(bundleFilePath); + await expect(page.getByText("1 deck found")).toBeVisible(); + await page + .getByLabel("import-passphrase") + .fill("the bundle's own passphrase"); + await page.getByRole("button", { name: "Import", exact: true }).click(); + + await expect(page.getByText("Imported 1 deck as new.")).toBeVisible(); + expect(saveDeckRequests).toHaveLength(1); + expect(saveDeckRequests[0].key).toBeNull(); + }); });