Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
231 changes: 231 additions & 0 deletions decrypt-saved-deck-export/decrypt.mjs
Original file line number Diff line number Diff line change
@@ -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 <export.json> --passphrase "..."
* node decrypt.mjs <export.json> --recovery-key "base64..."
* node decrypt.mjs <export.json> # prompts for a passphrase
*
* Prints every decrypted deck's plaintext JSON to stdout (one bundle -> one JSON array), or use
* --out <dir> 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 <export.json> [--passphrase "..."] [--recovery-key "..."] [--out <dir>]'
);
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);
});
}
165 changes: 165 additions & 0 deletions decrypt-saved-deck-export/readme.md
Original file line number Diff line number Diff line change
@@ -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 <export.json> --passphrase "your passphrase"
```

```
node decrypt.mjs <export.json> --recovery-key "your-recovery-key-base64"
```

```
node decrypt.mjs <export.json>
# prompts for a passphrase interactively if neither flag is given
```

Prints every decrypted deck as one JSON array to stdout. Add `--out <dir>` to
write one `<deck name>.json` file per deck into `<dir>` 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": "<base64>",
"kdfIterations": 600000,
"passphraseWrappedMasterKey": "<base64>",
"passphraseWrappedMasterKeyNonce": "<base64>",
"recoveryWrappedMasterKey": "<base64>",
"recoveryWrappedMasterKeyNonce": "<base64>"
},
"decks": [
{
"key": "<opaque server-assigned id>",
"kind": "deck", // or "snapshot"
"ciphertext": "<base64>",
"ciphertextNonce": "<base64>",
"wrappedDek": "<base64>",
"wrappedDekNonce": "<base64>",
"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.
Loading
Loading