Skip to content

feat(THU-787): E2EE v2 AK/DEK key hierarchy + seamless v1→v2 migration - #1223

Open
raivieiraadriano92 wants to merge 50 commits into
mainfrom
raivieiraadriano92/thu-787-e2ee-v2
Open

feat(THU-787): E2EE v2 AK/DEK key hierarchy + seamless v1→v2 migration#1223
raivieiraadriano92 wants to merge 50 commits into
mainfrom
raivieiraadriano92/thu-787-e2ee-v2

Conversation

@raivieiraadriano92

@raivieiraadriano92 raivieiraadriano92 commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

THU-787 — E2EE v2 (AK/DEK key hierarchy) + seamless v1→v2 migration

Replaces the single-content-key v1 scheme with a versioned AK/DEK keyring and adds a seamless, no-data-loss migration from existing v1 accounts. New writes are v2; existing v1 data stays readable indefinitely through a cheap dual-read path.

Stacked on the min-version-gate branch (thu-796) — the backend gate is the rollout control that makes the account flip safe at fleet scale (below-min clients can't sync a flipped account). Setting MIN_APP_VERSION is a deploy-time config change, not part of this diff.

What changed (v1 → v2)

v1 v2
Account key single CK (AES-256-GCM) AK (AES-KW) derived from a 24-word seed + kdf_salt, wrapping a versioned DEK keyring
Data key CK directly primary DEK per key_id; old DEKs retained for reads
Wire __enc:<iv>:<ct> (no key id, no AAD) __enc:v2:<key_id>:<iv>:<ct>, AAD-bound (table‖column‖rowId‖keyId)
Device auth canary secret hash ECDSA P-256 challenge-response
Device key transport hybrid ECDH + ML-KEM envelope wrapping CK same envelope wrapping AK

Migration model — absorb + permanent dual-read

  • Absorb: the first trusted, CK-holding v2 device (the migrator) recovers the v1 CK from its own device envelope, absorbs it into the keyring as a reserved read-only "v1" DEK slot (wrapped under the new AK), mints the AK + a new recovery phrase, writes an AK envelope for every trusted device, and atomically flips scheme_version 1→2.
  • Dual-read, write-v2: the codec reads both wire formats and writes only v2. v1 rows remain v1 on the wire and decode in place via the "v1" slot — no bulk re-upload.
  • Followers self-serve on next open: pull their AK envelope, unwrap the AK, stage the keyring, and run a device-side continuity check (the absorbed "v1" slot must decrypt-consistency-match the device's own v1 CK) before adopting — so a hostile flip from a stolen session is a recoverable DoS, never a plaintext leak or data loss.

Key safety properties

  • Atomic flip — server advisory lock + CAS scheme_version 1→2 (single flip, wins the concurrent-migrator race); the recovery phrase is shown only on a 200.
  • Non-destructive local upgrade — formerly-v1 devices retain their v1 key material through the first v2 boot (local-only CK recovery); the ML-KEM secret is re-encrypted at rest on read.
  • CK-possession enforced device-side — the v1 possession anchor was dropped server-side, so the migrator flip is session-gated; the follower continuity check is the authoritative CK-possession guard.
  • Kill-switch — old rows stay readable via the "v1" slot, so lowering MIN_APP_VERSION pauses new flips with zero data loss.

Scope

86 files, ~13k/2.6k. Backend: scheme_version column + 0028 migration, POST /encryption/upgrade, keyring/challenge DAL. Frontend: AK/DEK crypto primitives + key storage, versioned AAD-bound codec + dual-read, v2 services (setup/rotation/recovery + migrator/follower), app-init wiring, sync-setup wizard, encryption settings UI. Plus the v1→v2 e2e suite and updated architecture docs.

Testing

Unit (crypto primitives, dual-read codec, key storage, migration service), backend (upgrade endpoint atomic flip / CAS / coverage rejection, challenge-response), and e2e (web-first migrator, desktop-first follower, offline device joins late, below-min device does not sync).

Rollout

Ship the v2 build with MIN_APP_VERSION unset (no behavior change). Once the build is live on every channel, set MIN_APP_VERSION → v2 threshold: below-min clients are rejected by the backend gate and shown UpgradeRequired; v2 clients flip on first open, followers self-serve.

@raivieiraadriano92 raivieiraadriano92 self-assigned this Aug 17, 2026
@raivieiraadriano92 raivieiraadriano92 changed the title Raivieiraadriano92/thu 787 e2ee v2 feat(THU-787): E2EE v2 AK/DEK key hierarchy + seamless v1→v2 migration Aug 17, 2026
@github-actions

Copy link
Copy Markdown

Semgrep Security Scan

No security issues found.

@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown

PR Metrics

Metric Value
Lines changed (prod code) +7492 / -729
JS bundle size (gzipped) 🟡 613.6 KB → 631.9 KB (+18.3 KB, +3.0%)
Test coverage 🟢 81.75% → 81.93% (+0.2%)
Performance (preview) Preview not ready — Render deploy may have timed out
Accessibility
Best Practices
SEO

Updated Thu, 27 Aug 2026 20:46:13 GMT · run #2810

@raivieiraadriano92
raivieiraadriano92 marked this pull request as ready for review August 17, 2026 16:21
@raivieiraadriano92
raivieiraadriano92 marked this pull request as draft August 17, 2026 16:21
@raivieiraadriano92
raivieiraadriano92 marked this pull request as ready for review August 17, 2026 17:36
@raivieiraadriano92
raivieiraadriano92 force-pushed the raivieiraadriano92/thu-787-e2ee-v2 branch from 49da24b to 820b6f3 Compare August 20, 2026 11:22

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔭 thunder-deep-review (advisory)

Complements the other bots — surfaces only what they did not flag. Never approves, never requests changes, never gates merge.
head: 2d8f7b34e811 · mode: deep · deferred 0 item(s) already reported by other bots (best-effort dedup)

Additional notes (couldn't anchor to a diff line)

  • backend/src/api/account.ts — 🚫 Blocking — Revoking a device on a not-yet-migrated v1 account fails with 403
  • src/services/encryption.ts — 🔧 Nit — AK persisted before the DEK keyring in follow/approve paths — no encode self-heal if it crashes between

@raivieiraadriano92
raivieiraadriano92 force-pushed the raivieiraadriano92/thu-787-e2ee-v2 branch from d14bd77 to d392b76 Compare August 21, 2026 10:26

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔭 thunder-deep-review (advisory)

Complements the other bots — surfaces only what they did not flag. Never approves, never requests changes, never gates merge.
head: 8121499945c0 · mode: deep · deferred 0 item(s) already reported by other bots (best-effort dedup)

switch (message.type) {
case 'invalidate':
dropKeyringCaches()
break

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Convention — DEK unwrap failure is swallowed with no logging

In resolveDEK the unwrap is wrapped in a bare catch {} that collapses every possible error into { failure: 'unwrap-failed' } without logging anything. A genuinely unexpected error (a corrupted wrapped blob, a WebCrypto/platform hiccup) then looks identical to the ordinary post-revocation wrong-AK case, and we lose the underlying cause entirely. Since the house style leans toward letting errors surface, could we at least console.warn(err) here (or bind the error) so a real fault isn't invisible during debugging? The expected wrong-key case can still map to the same failure without discarding the cause.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔭 thunder-deep-review (advisory)

Complements the other bots — surfaces only what they did not flag. Never approves, never requests changes, never gates merge.
head: 5dd8b77debe3 · mode: deep · deferred 0 item(s) already reported by other bots (best-effort dedup)

Additional notes (couldn't anchor to a diff line)

  • backend/src/api/account.ts:23 — 📐 Convention — The ChallengeProof Elysia validator is copy-pasted into two route files
  • backend/src/lib/canary.ts:78 — 🔧 Nit — Docstring claims a failed proof "burns the nonce", but in-transaction callers roll it back
  • src/services/encryption.ts — 📐 Convention — Raw local-DB scan lives in the service layer instead of the DAL

@raivieiraadriano92
raivieiraadriano92 force-pushed the raivieiraadriano92/thu-796-app-min-version-gate-backend-enforcement-fe-upgrade-blocker branch from af56512 to 438b058 Compare August 27, 2026 19:21
Base automatically changed from raivieiraadriano92/thu-796-app-min-version-gate-backend-enforcement-fe-upgrade-blocker to main August 27, 2026 19:41
@raivieiraadriano92
raivieiraadriano92 force-pushed the raivieiraadriano92/thu-787-e2ee-v2 branch from 5dd8b77 to 0293182 Compare August 27, 2026 20:39
@github-actions

Copy link
Copy Markdown

Preview environment deployed 🚀

Service URL
Marketing / blog / docs https://thunderbolt-pr-1223.preview.thunderbolt.io
App https://app-pr-1223.preview.thunderbolt.io
API https://api-pr-1223.preview.thunderbolt.io
Keycloak https://auth-pr-1223.preview.thunderbolt.io
PowerSync https://powersync-pr-1223.preview.thunderbolt.io

Stack: preview-pr-1223 · Commit: 0293182d01b241b0ebb4b4625605c20b2e8a9678

Auto-destroys on PR close/merge. Login via the bundled Keycloak realm — demo@thunderbolt.io / demo by default.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant