Proposal G, PR4a: client-side zero-knowledge crypto module - #89
Merged
Conversation
Per §8: frontend/src/common/savedDeckCrypto.ts, using only the browser's native WebCrypto (crypto.subtle) - no custom crypto primitives. Key model: a single random AES-256-GCM master key, generated once at first save and never regenerated. Every deck's DEK is wrapped by it; both the passphrase-derived key (PBKDF2-SHA256) and the user's recovery key (256 random bits, no KDF - already high-entropy) only ever wrap this one master key. A passphrase change re-wraps just the master key - nothing deck-related changes, since DEKs are wrapped by a key that never changes across that operation (this is the same self-consistency fix just applied to the spec doc, now actually implemented and tested). High-level orchestration functions PR4b will call directly: createCryptoProfile (first save - generates master key + recovery key, wraps the master key both ways), unlockWithPassphrase/ unlockWithRecoveryKey (the two unlock paths), changePassphrase (re-wraps the master key only), createDeckKey/unlockDeckKey (per-deck DEK lifecycle), encryptDeckPayload/decryptDeckPayload (the actual content encryption). Lower-level primitives (wrapKey/unwrapKey, key derivation/ generation, base64<->bytes) are also exported/available where PR4b needs finer control. Tests (savedDeckCrypto.test.ts, 11 cases) cover every item in §8's "Tests required" list: encrypt/decrypt round-trip, wrong passphrase fails to unwrap, ciphertext tamper -> AES-GCM authentication failure (not silent corruption), wrong DEK fails, recovery-key round-trip (create -> forget passphrase -> recover -> set new passphrase -> old passphrase now fails), a recovery key generated before a later passphrase change still works, and a deck encrypted before a passphrase change is still readable after via the master key alone (proving no deck-level re-encryption/re-wrapping happens). Two real environment gaps found and fixed along the way: - jsdom (this project's jest test environment) only implements crypto.getRandomValues, not the full SubtleCrypto API - added a jest.setup.ts polyfill using Node's own spec-compliant node:crypto webcrypto implementation (the same API surface real browsers implement), scoped to only apply when crypto.subtle is actually missing. - TypeScript 5.9's DOM lib types made Uint8Array generic over its buffer type, and WebCrypto's BufferSource parameters now specifically require Uint8Array<ArrayBuffer> rather than the wider Uint8Array<ArrayBufferLike> - annotated every type position accordingly (runtime code unaffected, this is a types-only change). Verified: tsc --noEmit, next lint, prettier --check all clean; the full 337-test jest suite (up from 304 before this branch started, reflecting other work merged to master in the meantime) passes with zero regressions from the jest.setup.ts polyfill.
4 tasks
WilfordGrimley
added a commit
that referenced
this pull request
Jul 18, 2026
* Proposal G, PR4b: carry forward frontend prerequisites from PR3/PR4a frontend/src/common/schema_types.ts (regenerated saved-deck types) and frontend/src/store/api.ts's Kind->VoteQueueRequestKind import fix come from PR #88 (claude/proposal-g-saved-decks-api); savedDeckCrypto.ts, its tests, and the jest.setup.ts crypto.subtle polyfill come from PR #89 (claude/proposal-g-crypto-module). Both PRs are still open, so this branch is based on master directly (not stacked) and carries identical copies of just the frontend files this UI work needs, to avoid a 3-deep PR stack. Verified byte-identical against each source branch; tsc --noEmit and the crypto module's jest suite both pass on this base. Deviation note for the merge-time checklist: once PR #88 and/or #89 merge to master, this branch's copies of these files will already match what lands there — rebase onto master before merging PR4b to drop the now-redundant duplicate commit cleanly (or let git no-op it; content is identical either way). * Proposal G, PR4b: carry forward sign-in relocation from PR2, add My Decks nav entry Mounts AuthWidget in the navbar (relocated off /whatsthat) and adds a "My Decks" top-level nav entry, gated on an authenticated whoami session, alongside it. The AuthWidget/Navbar/whatsthat changes are identical to PR #86 (claude/proposal-g-signin-navbar), carried forward for the same reason as the prior commit: PR4b needs sign-in visible everywhere for "My Decks" to be reachable, and #86 is still open. Deliberately did NOT carry over that branch's stale removal of the Display (beta) nav entry - that branch predates Proposal H (#87), which added Display; dropping it here would be a regression, not a carry-forward. /myDecks route itself doesn't exist yet - added in a following commit. * Proposal G, PR4b: deck payload/dirty-check plumbing + RTK Query wiring - features/savedDecks/deckPayload.ts: the plaintext shape encrypted wholesale (including its own name), serialize/parse helpers, and deviceLocal marking for LocalFile-sourced slots (identifiers are device-specific and meaningless elsewhere, so only the flag survives - the card grid's existing empty-slot UI becomes the honest re-pick placeholder). - store/slices/savedDeckSessionSlice.ts: tracks which saved deck (if any) the editor represents - session-only, deliberately not wired into listenerMiddleware's localStorage persistence. - projectSlice.loadProject / finishSettingsSlice.loadFinishSettings: atomic whole-project replacement, needed for loading a saved deck (no existing reducer does this - every other one merges into place). - features/savedDecks/selectors.ts: selectIsCurrentProjectDirty, per the frontend spec's exact definition (differs from last load/save, or is non-empty with no prior save at all). - store/api.ts: the 7 saved-deck/crypto-profile RTK Query endpoints (SavedDecks/CryptoProfile cache tags, credentials: "include" + CSRF header matching the existing moderation-write convention), with skip options so anonymous sessions never fire a doomed authenticated request. Own-caught fix while wiring the recovery UI: PR4a's recovery-flow test exercised changePassphrase but never reissued a recovery key, even though the ZK addendum's recovery flow explicitly re-wraps BOTH slots (passphrase under the new passphrase, recovery under a FRESH recovery key) once the old recovery key has actually been used - an ordinary passphrase change (already covered by a separate test) correctly leaves the recovery slot alone, but the full recovery path is a distinct case that wasn't covered in savedDeckCrypto.ts at all. Added rewrapMasterKeyWithNewRecoveryKey and extended the recovery-flow test to cover the new key end-to-end, including that the superseded old recovery key no longer unwraps the new slot. * Proposal G, PR4b: CryptoSessionProvider (in-memory master key context) A plain React Context (not Redux, since CryptoKey isn't serializable), mounted in Layout.tsx alongside ClientSearchContextProvider. Exposes status (anonymous/loading/no-profile/locked/unlocked), the unlocked master key, and createProfile/unlockWithPassphrase/ recoverAndSetNewPassphrase/lock - wired to the getCryptoProfile/ saveCryptoProfile endpoints added in the previous commit. The master key never persists anywhere, so it clears itself on every reload; lock() just does that sooner. 6 tests cover every status transition and the recovery flow's fresh recovery key end to end (createProfile, wrong/correct passphrase unlock, recover-and-reissue, lock), using a small harness component in the absence of any existing renderHook precedent in this codebase's test suite - matches the established render()+screen+MSW convention instead. * Proposal G, PR4b: passphrase creation + unlock modals - RecoveryKeyDisplay: the show-once recovery key step (download/print/copy + an explicit "I've saved this" acknowledgement gate before continuing), shared by both modals below since both flows end with a fresh recovery key to show. - PassphraseSetupModal: the first-save flow - passphrase + confirm, the verbatim-spirit unrecoverability warning, then RecoveryKeyDisplay. - UnlockModal: the once-per-session unlock prompt, with a "Forgot your passphrase?" branch into the recovery flow (paste recovery key + set a new passphrase -> reissues a fresh recovery key via recoverAndSetNewPassphrase -> RecoveryKeyDisplay again). Own-caught bug, found via a genuine test failure (not flakiness): status in cryptoSession.tsx fell through to "anonymous" whenever isAuthenticated was false - including the instant before the whoami query itself had even resolved. UnlockModal's tests failed with a misleading "wrong passphrase" error because they could submit before the crypto profile had loaded, since nothing signaled that loading state (masterKey != null ? "unlocked" : cryptoProfileQuery.data == null ? "loading" : ... never entered from the "anonymous" branch). Fixed by giving whoami's own in-flight state a distinct "loading" status ahead of the isAuthenticated check, added a regression test that delays the whoami response and asserts "loading" appears first, and added an isProfileLoading guard to UnlockModal (belt and suspenders: both the submit handler and the button's disabled state) so a real click during that window can never misfire either. * Proposal G, PR4b: My Decks page - deckPayload.ts: encryptDeckPayloadForSave (fresh per-save DEK; the server has no preference between create/update) and decryptSavedDeckSummary (unwrap DEK -> decrypt -> parse), the wire-format encrypt/decrypt pair the Save action and this page both need. - MyDecksPage: lists every saved deck, decrypted client-side once the crypto session is unlocked (prompting via UnlockModal automatically when locked). Named decks and snapshots render as separate groups. "Open in editor" loads the decrypted project/finishSettings into Redux and records the current-deck breadcrumb state, then navigates to /editor. Per-deck delete (confirm via window.confirm, matching the existing moderation-panel convention for destructive actions - no dedicated confirm-modal component exists in this codebase to reuse). A "Lock" action clears the in-memory master key. Account reset is reachable from both the locked AND unlocked states (getSavedDecks is fetched independently of decryption) since its entire purpose is recovering access when unlock is impossible - gated on an explicit second confirming click naming the exact deck count, not a modal. - Deviation: "Discord-gated" account reset is satisfied by requiring an already-authenticated session (the same as every other saved-deck action) rather than adding a fresh Discord re-auth redirect - the backend's post_reset_saved_decks has no freshness/recency check of its own to justify one, so a redirect step would be security theater without backend enforcement behind it. - /myDecks route (frontend/src/pages/myDecks.tsx), matching the nav entry already added. 6 tests cover every session state (anonymous, no-profile, locked-then- unlock, decrypted list grouping), the open-in-editor redux/navigation wiring, delete-with-confirmation, and the two-click reset gate. * Proposal G, PR4b: editor wiring - Save action, breadcrumb, load safety flow - SaveDeckModal: the explicit Save action (name prompt pre-filled from the current deck, local-file-slot warning, encrypts and calls saveDeck, records the returned key). Assumes the crypto session is already unlocked. - LoadSafetyModal: the loss-proof-by-construction load flow (frontend spec §4) - dirty + logged-in always saves a safety copy first, never skippable. Offers "Update {name}" vs "Save as new snapshot" when the current content is itself an already-saved deck; just an inline- renameable snapshot save (no skip option) when it was never saved. - SavedDeckPanel: the reverse breadcrumb ("Editing: {name}" / "Unsaved project") plus the Save button, rendered only when authenticated. Clicking Save runs PassphraseSetupModal or UnlockModal first if the crypto session isn't ready. Also raises the one-time anonymous->login adopt-by-save toast (informational only - the Toasts system has no action-button support, and extending shared toast infra for one caller wasn't worth it, so it just points at the Save button below). - Wired SavedDeckPanel into ProjectEditor's action cluster, and LoadSafetyModal into MyDecksPage's "Open in editor" (dirty-check via selectIsCurrentProjectDirty; empty/clean editors still load immediately, no prompt). 15 new tests across the three modals/panel, using a small status-exposing test harness to reliably wait for the crypto session to actually unlock before interacting (a bare "field is present" check isn't a real signal, since these components render their form regardless of lock state and just no-op an early submit). * Fix import-sort lint errors caught by a whole-project next lint pass selectors.ts and api.ts had passed every earlier per-file eslint/prettier check in this branch's individual commits, but a full-project `next lint` (not run until now) caught two real simple-import-sort/imports errors - per-file lint runs don't always agree with a whole-project pass on import ordering across an entire changed import block. No behavior change. * Proposal G, PR4b: real-browser Playwright smoke coverage 3 specs verified live in an actual browser (not jsdom - real WebCrypto, real Next.js routing, real Bootstrap modals): the editor's Save action/ breadcrumb render once signed in, the My Decks nav entry is hidden anonymously and appears once signed in, and the empty-state message renders correctly. Ran locally with a temporary executablePath override for this sandbox's browser-binary version mismatch (never run playwright install per environment policy); both playwright.config.ts and tests/global-setup.ts were reverted back to their committed state before this commit - only the new spec file is included. --------- Co-authored-by: Claude <noreply@anthropic.com>
4 tasks
WilfordGrimley
pushed a commit
that referenced
this pull request
Jul 18, 2026
Task-end wiki/docs check (CLAUDE.md): this changed what a USER sees (My Decks page, editor Save/breadcrumb, navbar sign-in) - all 5 sequenced PRs (#85, #86, #94, #89, #93) are now merged, so there's real behavior to document. Covers the zero-knowledge crypto mental model, backend endpoints/constants, frontend file map, the still-design-only PR-5/PR-6 addenda, and the owner-only Discord-credentials/legal-review pointers. Added to docs/README.md's flat index. Wiki note (cloud session, per CLAUDE.md convention): the project's GitHub wiki itself (a separate, generated-view target from docs/) likely wants a new "Saved Decks" user-facing page once this feature is visible in production - flagging here rather than editing it directly, since that's the documented cloud-session convention.
WilfordGrimley
added a commit
that referenced
this pull request
Jul 19, 2026
…gns, docs (design/docs only) (#99) * Proposal G spec: PR-6 design for deck portability (design only) Formalizes what the zero-knowledge, server-unbound crypto design already implies: export/import of the complete encrypted bundle (no unlock required for export - it's the same ciphertext the server already holds), a versioned public format as the actual portability contract, a standalone decrypt tool as the trust anchor ("if this site vanishes tomorrow, your decks are still yours"), honest offline-attackability limits, and an explicit rejection of any server-bound key material. Nothing built in this commit - the owner's addendum was explicit that this lands with a later PR-6. Also updates the doc's stale header status line (still said "BUILDING... PR1/PR2 opened" from before any of the 5 sequenced PRs had merged) now that schema+backend (#85), sign-in relocation (#86), the saved-decks API (#94, recreated after #88's base-deletion auto-close), the crypto module (#89), and the frontend UI wiring (#93) have all landed on master - and adds the portability sentence to the legal data-inventory paragraph, per the addendum's explicit instruction. * docs: add docs/features/saved-decks.md now that Proposal G has merged Task-end wiki/docs check (CLAUDE.md): this changed what a USER sees (My Decks page, editor Save/breadcrumb, navbar sign-in) - all 5 sequenced PRs (#85, #86, #94, #89, #93) are now merged, so there's real behavior to document. Covers the zero-knowledge crypto mental model, backend endpoints/constants, frontend file map, the still-design-only PR-5/PR-6 addenda, and the owner-only Discord-credentials/legal-review pointers. Added to docs/README.md's flat index. Wiki note (cloud session, per CLAUDE.md convention): the project's GitHub wiki itself (a separate, generated-view target from docs/) likely wants a new "Saved Decks" user-facing page once this feature is visible in production - flagging here rather than editing it directly, since that's the documented cloud-session convention. * Proposal G spec: PR-7 design for art provenance (design only) Per-slot provenance (driveId, sourceName, sourceType, optional contentPhash, indexedBy) in a future deckPayload version (bumps formatVersion per PR-6's own versioning rule), so an un-indexed slot renders a direct-from-drive thumbnail with a "not in this catalog" badge + origin link instead of breaking. States the moderation-bypass rationale explicitly (user's own private data, client-side fetch, never served/cached by this server) rather than leaving it implicit. XML 2.0 gains three optional, backwards-compatible attributes for third-party phash->federation-verdict joins. Hard line: provenance never enters the federation verdict export, which stays conclusions-only. Addendum clarifications folded in: importing any XML version with un-indexed drive IDs still leaves those slots viewable via the same direct-drive rendering (the badge/link only appear with 2.0+ provenance present); web PDF export of un-indexed slots is explicitly out of scope for PR-7 (v1 answer is view + print-via-desktop-tool guidance, not a foregone-conclusion export fallback). Nothing built - per the owner's explicit instruction, this is spec-only. Also updates docs/features/saved-decks.md's "not yet built" list and the proposal doc's Future-work/header pointers to include PR-7 alongside PR-5/PR-6. * docs/README.md: fix stale Proposal G status + PR-5/6/7 addenda references The Plans & proposals status table still said HOLD for Proposal G even though the core build has fully shipped (only the PR-5/6/7 addenda remain HOLD) - matches proposal-c's existing PARTIAL precedent for the same shape (some shipped, some still HOLD). Also fixed the features/saved-decks.md summary bullet, which still said "PR-5/PR-6" before PR-7 was added. * Proposal G spec: PR-6 revision/modifiedAt fields + deck roaming note Two small fields added to PR-6's encrypted-payload envelope: revision (int, incremented per save) and modifiedAt (timestamp) - private inside the payload like everything else, bumping formatVersion per PR-6/PR-7's shared versioning rule. Purpose: makes an export/import round-trip self-describing (a bundle can be compared against the server's current copy without any server-side plaintext comparison) and seeds any future cross-instance sync with conflict-detection for free. Also adds a "Deck roaming" future-work paragraph after "Deck sharing": cross-instance blob sync is ZK-compatible in principle (only ciphertext would travel, never keys) but is a full protocol in its own right (discovery, consent, conflict surfacing, deletion propagation) - explicitly out of scope until federation has real peers to sync between. Manual export/import (PR-6) is the supported path today; the new revision/modifiedAt fields exist in part to make that manual path safe without committing to automatic sync's unsolved questions. Nothing built - design-only, per the owner's instruction. --------- Co-authored-by: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Independent of #85/#86/#88 — pure client-side code, no backend/API dependency. Based directly on
master. Fourth PR in the Proposal G sequence (schema+backend [#85] → Discord OAuth UX [#86] → saved-decks API [#88] → this PR, crypto foundation → a follow-up PR wiring it into the actual save/load UI).Description
Per
docs/proposals/proposal-g-user-accounts-saved-decks.md§8 (the zero-knowledge amendment):frontend/src/common/savedDeckCrypto.ts, using only the browser's native WebCrypto (crypto.subtle) — no custom crypto primitives.Key model: a single random AES-256-GCM master key, generated once at first save and never regenerated. Every deck's DEK is wrapped by it; both the passphrase-derived key (PBKDF2-SHA256) and the user's recovery key (256 random bits, no KDF needed — already high-entropy) only ever wrap this one master key. A passphrase change re-wraps just the master key — nothing deck-related changes, since DEKs are wrapped by a key that never changes across that operation.
High-level orchestration (what a follow-up UI-wiring PR will call directly):
createCryptoProfile(first save),unlockWithPassphrase/unlockWithRecoveryKey(the two unlock paths),changePassphrase(re-wraps the master key only),createDeckKey/unlockDeckKey(per-deck DEK lifecycle),encryptDeckPayload/decryptDeckPayload(the actual content encryption).Tests (
savedDeckCrypto.test.ts, 11 cases) cover every item in §8's "Tests required" list: encrypt/decrypt round-trip, wrong passphrase fails to unwrap, ciphertext tamper → AES-GCM authentication failure (not silent corruption), wrong DEK fails, the full recovery-key round-trip (forget passphrase → recover → set new passphrase → old passphrase now fails), a recovery key generated before a later passphrase change still working, and a deck encrypted before a passphrase change still being readable after via the master key alone.Two real environment gaps found and fixed:
crypto.getRandomValues, not the fullSubtleCryptoAPI — added ajest.setup.tspolyfill using Node's own spec-compliantnode:cryptowebcrypto implementation (the same API surface real browsers implement), scoped to only apply whencrypto.subtleis actually missing.Uint8Arraygeneric over its buffer type, and WebCrypto'sBufferSourceparameters now specifically requireUint8Array<ArrayBuffer>rather than the widerUint8Array<ArrayBufferLike>— annotated every type position accordingly (runtime code unaffected, types-only change).A spec self-consistency bug caught while designing this module: the original §8 amendment said "a passphrase change re-wraps every deck's DEK," which contradicted the "Recovery key" section's own explicit statement that the master key never changes across a passphrase change. Fixed in a separate doc-only commit on #85's branch before writing this module, so the code here matches a single, self-consistent design.
Checklist
pre-commitand installed the hooks withpre-commit installbefore creating any commits.tsc --noEmit,next lint,prettier --checkall clean.jest.setup.tspolyfill.Merge-time checklist
Generated by Claude Code