Skip to content

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

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

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

Conversation

@raivieiraadriano92

@raivieiraadriano92 raivieiraadriano92 commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

Replaces the single-content-key v1 scheme with a versioned AK/DEK keyring, adds a seamless, no-data-loss migration from existing v1 accounts, makes E2EE always-on, ships an enterprise key-escrow POC, and closes a full red-team campaign against the design (14 hardening fixes, each pinned by an executable attack spec). New writes are v2; existing v1 data stays readable indefinitely through a cheap dual-read path.

What changed (v1 → v2)

v1 v2
Account key single CK (AES-256-GCM) — gated access and encrypted data random AK (AES-256-GCM, wrapKey/unwrapKey only — never touches data), wrapping a versioned DEK keyring with each key_id bound into the wrap as AAD
Data key CK directly primary DEK per key_id; old DEKs retained forever for reads
Wire __enc:<iv>:<ct> (no key id, no AAD) __enc:v2:<key_id>:<iv>:<ct>, AAD-bound (table‖column‖rowId‖keyId)
Device key transport hybrid ECDH + ML-KEM envelope wrapping CK (0x01, AES-KW) same hybrid, upgraded to 0x02: AES-GCM seal over (AK ‖ primary key_id) — the primary pointer travels tamper-proof inside the envelope
Device auth static canary-secret hash ECDSA P-256 challenge-response (single-use nonces bound to user/op/device) + sealed-nonce device–session binding — X-Device-ID is a label, session.deviceId is the identity
Recovery mnemonic was the raw CK 24-word seed → PBKDF2-SHA512 (600k, per-account salt) → a recovery keypair: the phrase is a virtual device, so the AK rotates without invalidating it
Enablement E2EE_ENABLED server flag always-on; encrypted-ness derived from key material / metadata presence — no server-served switch to flip

Both rotations re-encrypt zero data rows: AK rotation re-wraps the keyring + envelopes; DEK rotation flips the primary while every old value keeps naming its own key.

Migration model — absorb + permanent dual-read

  • Absorb: the first trusted, CK-holding device (the migrator) recovers the v1 CK from its own envelope, verifies it against real legacy data before absorbing, inserts it as the reserved read-only "v1" DEK slot, mints the AK + primary DEK "0" + a new recovery phrase, writes an AK envelope for every trusted device and the recovery slot, and calls POST /encryption/upgrade.
  • Possession-gated flip: the server verifies hash(canarySecret) == canary_secret_hash (only a CK holder can produce it — a stolen session can't), then CAS-flips scheme_version 1→2 under a per-user advisory lock. One migrator wins; a 409 loser has persisted nothing and cleanly becomes a follower. The phrase is shown only on 200.
  • Dual-read, write-v2: the codec reads both wire formats and writes only v2; v1 rows decode in place via the "v1" slot forever — no bulk re-upload.
  • Followers self-serve: unwrap their envelope, then run a continuity check (the candidate "v1" slot must decrypt a real synced legacy row) before persisting anything — a hostile flip is a recoverable DoS, never plaintext exposure or data loss.

Enterprise key escrow (POC, THU-804)

Optional operator-controlled third recipient for the AK: build-time-pinned P-256 public key (VITE_ORG_ESCROW_PUBLIC_KEY), server enforces envelope presence (ORG_ESCROW_ENABLED) but holds no key material, recovery only via offline tooling (scripts/org-escrow-keygen.ts / org-escrow-decrypt.ts). Documented trade-off: escrow is classical-only, forfeiting the account's PQ margin.

Scope

191 files, +29.8k/−3.3k. Backend: server-only encryption schema (squashed 0029_e2ee_v2 migration), upgrade/rotate/challenge/bind/step-up API, security emails. Frontend: crypto primitives + key storage, dual-read AAD codec, services (setup/approve/recover/rotate/revoke/migrate/follow), app-init wiring, wizard + settings UI. Plus the e2ee e2e suite, red-team attack specs + harness, and architecture/threat-model docs.

Testing

Unit (primitives, codec, key storage, services), backend (atomic flip/CAS, coverage validation, challenge + bind), e2e against a real Postgres + PowerSync stack (migration, recovery, revocation, multi-device), and the adversarial attack specs as permanent regression gates. CI: the powersync-e2ee job is sharded 4 ways (~9 min PR wall-clock).

Rollout

Ship with MIN_APP_VERSION unset (no behavior change). Once the build is live on every channel, set MIN_APP_VERSION to the v2 threshold: below-min clients get 426/UpgradeRequired, v2 clients flip on first open, followers self-serve. Escrow stays off until a pinned build is fleet-wide. Old rows stay readable via the "v1" slot regardless.

@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) +14277 / -1077
JS bundle size (gzipped) 🟢 644.8 KB → 642.0 KB (-2.8 KB, -0.4%)
Test coverage 🟢 82.25% → 82.27% (+0.0%)
Performance (preview) Preview not ready — Render deploy may have timed out
Accessibility
Best Practices
SEO

Updated Fri, 18 Sep 2026 20:02:25 GMT · run #3052

@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[bot]

This comment was marked as outdated.

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

This comment was marked as outdated.

github-actions[bot]

This comment was marked as outdated.

@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
@github-actions

github-actions Bot commented Aug 27, 2026

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: 402a64ff2da09131bcb09907e778464eccd65604

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

@raivieiraadriano92
raivieiraadriano92 force-pushed the raivieiraadriano92/thu-787-e2ee-v2 branch 2 times, most recently from 4a3af20 to 4c429f3 Compare September 17, 2026 16:59
* chore(THU-787): add e2ee threat model and red-team tooling

* test(THU-787): add e2ee confidentiality oracles

* test(THU-787): add e2ee adversary primitives

* chore(THU-787): cover e2e with lint, typecheck and format

* test(THU-787): pin the C5 signing-identity re-mint

* test(THU-787): pin A1 X-Device-ID spoofing barrier

A revoked device's retained bearer is refused (401) on the challenge
endpoint for any X-Device-ID, so the post-rotation canary re-fetch that
would re-derive the signing key never happens (the C5 residual). The
challenge device id is caller-asserted, gated only by device state, and
grants nothing given the account-wide signing key. Adds encryptionApiRequest
as the reusable A4 raw-request primitive.

* test(THU-787): prove A2 org escrow key substitution (C11 finding)

A malicious server substitutes its own P-256 key on GET /encryption/org-key;
the client wraps the AK to it with no pinning, TOFU, or fingerprint check. The
offline tool with the attacker private key recovers row plaintext, the legit
operator key is locked out, and persistOrgEnvelope stamps its own fingerprint
over the attacker-wrapped blob — false assurance. C11's required pinning is
unmet.

* test(THU-787): cover A3 ciphertext placement and rollback (C3)

Cross-row ciphertext swap fails GCM and codec.decode returns the raw wire
value, so the moved plaintext never renders — C3 relocation holds. Same-cell
rollback to an older ciphertext is AAD-valid (the AAD carries no version or
timestamp) and the client renders stale plaintext undetected — the residual
C3 flags, now witnessed end to end. Adds an editTask helper.

* test(THU-787): pin A4 v1 downgrade resistance (C4)

codec.encode has no v1 (no-AAD) writer — it always emits __enc:v2:<key_id>
with AAD — so no server input produces an AAD-free write. A hostile
scheme_version:1 on the metadata response cannot re-trigger migration on a
set-up device: ensureV2Encryption short-circuits already-v2 when a local AK
exists, before the scheme_version===1 branch. Adds forceSchemeVersion.

* test(THU-787): pin A5 keys-sync channel exfiltration resistance (C13)

The thunderbolt-keys-sync BroadcastChannel is control-only — KeysSyncMessage
has no slot for key material and every handler just drops caches to re-read
authoritative IndexedDB. A forged same-origin barrage (reset, forged
key-staged, key-request for an attacker key_id) injects nothing and cannot
force plaintext: encodeWithoutKeys fails closed while a persisted AK exists.
An unknown key_id resolves request -> stage -> still-missing -> raw, never
plaintext.

* test(THU-787): pin C9 recovery resistance to a lying kdf_salt

recoverWithKey derives the recovery keypair from seed + server kdf_salt, then
checks the derived public halves against the stored recovery_* keys before any
unwrap or registration. A server that lies about kdf_salt yields a non-matching
public key and a clean Invalid recovery key — no access, no leak. Adds the
generic overrideEncryptionMetadata primitive.

* test(THU-787): prove A2b recovery-slot re-anchor takeover (C9 finding)

On a phrase-keeping AK rotation (a routine device revoke), the client wraps
the new AK to the recovery_* public keys the server serves, unverified, and
/encryption/rotate stores whatever recovery keys the client submits. A
malicious server swaps in an attacker recovery keypair during the silent
re-anchor, then recovers the account on a fresh device and reads the victim's
plaintext. Falsifies C9's 're-anchoring cannot be abused for takeover'. Adds
the getKdfSalt reader.

* test(THU-787): pin C10 key material non-extractable at rest

Reads the thunderbolt-keys IndexedDB the app writes and asserts the AK and
device ECDH private key are non-extractable CryptoKeys (exportKey rejects),
and the ML-KEM secret is stored as an encrypted { iv, ciphertext } envelope,
never raw plaintext. A same-origin script or device-image thief can use these
keys but cannot lift their bytes.

* ci(THU-787): add security dimension to deep-review

Path-conditional security lane for crypto/E2EE diffs: computeDeepMode
sets securityMode + sensitiveFiles from a path matcher; the skill spawns
a threat-model-grounded reviewer that loads e2ee-threat-model.md and
fp-rules.txt. Add a 'critical' severity tier (linear ladder, advisory)
across the schemas, orchestrator, skill, and rubric. Feed fp-rules into
the precision gate as the accepted-property exclusion list.

* chore(THU-787): remove unused vuln-scan + triage skills

Generic static scan-then-triage pipeline vendored in Phase 0 but never
exercised — redundant with thunder-red-team's reasoning mode and the
deep-review security dimension. Drops the two skills, the _lib helper,
and scan-extras.txt; prunes the gitignore allowlist and the dangling
references in the threat model, fp-rules, and the red-team skill.

* docs(THU-787): add full-sweep mode and fix spec path in red-team skill

New `all` argument (also the no-arg default) runs the whole reasoning
phase in one invocation — fans every scoped pass out as a fresh-context
subagent, then triages and refutes — so a full hunt no longer needs a
hand-written orchestration prompt. Also point the crypto-spec reference
at the external thunderbird/thunderbolt-spec repo instead of a hardcoded
home-dir path.

* docs(THU-787): harden red-team sweep chain and severity rubric

Fold this run's lessons into the skill: make the all-mode triage +
refutation stages non-optional and explicit (dedicated triage subagent,
convergence ranking, refuter verdict template, reduce-to-known check);
add the standard pass briefing with a known-findings suppression list;
handle idle-without-report and note token counts aren't in-band; refine
the severity rubric (forward-only=High vs historical/takeover=Critical,
downgrade per stacked precondition); add the honest-backend caveat for
live specs.

* docs(THU-787): document red-team harness pipeline and pack contract

* docs(THU-787): add reproduce stage, confidence ladder, and domain extensibility to red-team skill

* docs(THU-787): adopt expected-failure spec convention and CI polarity for red-team harness

* test(THU-869): spec inbound AK-envelope adoption

* test(THU-875): spec silent recovery-slot takeover surviving revocation

* test(THU-876): spec primary_key_id:v1 steering onto legacy CK

* test(THU-873): spec getCallerDevice unbound from session

* test(THU-874): spec plaintext injection into synced models row

* test(THU-877): spec forged v1 envelope poisoning the v1 slot

* test(THU-870): spec agents table plaintext leak

* test(THU-871): spec rotation poison-pill freezing AK rotation

* test(THU-872): spec ungated canary route; fix wrong residual note

* test(THU-878): reverse synced-table oracle; two specs to expected-failure

* test(THU-868): guard config-flag downgrade; fix inverted fp-rule

* test(THU-865): retire expected-failure tag; document attested recovery anchor

* test(THU-873): retire expected-failure tag; spec device-session binding

* test(THU-866): retire the org-key-substitution expected-failure tag

* test(THU-871): spec the DEK-mint collision; retire the poison-pill tag

* test(THU-876): spec the refused primary_key_id steer

* fix(THU-869): gate account-key adoption on a local DEK-0 witness

* test(THU-870): retire the agents plaintext and coverage tags

* test(THU-877): retire the v1 poison tag; spec THU-872 signing-key re-derivation

* test(THU-893): spec the relabelled v1 primary; rebuild the AES-KW epoch pin

* test(THU-887): adapt the recovery-slot spec to the resumable revocation

* test(THU-874): retire the models plaintext-injection tag; record C1 quarantine

* test(THU-875): retire the silent-takeover tag; record C9 closure

* test(THU-872): retire the revoked-signing-key tag; the spec is the regression gate

* test(THU-890): adapt the e2ee harness to the pointer-sealing AK envelope

* test(e2ee): fix step-up auto-submit and adapt 876/893 to the THU-890 pointer model

* perf(e2ee): add warm-harness/skip-build/worker levers and shard CI 4 ways
Integrate main's CLI device management and Lingui i18n with the E2EE v2
work: renumber the e2ee migration to 0031 behind main's 0029/0030, fold
CLI guards into the v2 encryption routes, union the device/session tests,
and fix the task-placeholder ellipsis drift in the e2ee helper.

@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: 402a64ff2da0 · mode: deep · deferred 0 item(s) already reported by other bots (best-effort dedup)

Comment thread shared/e2ee-types.ts
export const keyIdPattern = '^(0|[1-9][0-9]{0,14})$'

/** Whether `keyId` is a well-formed mintable key_id (see `keyIdPattern`). */
export const isMintableKeyId = (keyId: string): boolean => new RegExp(keyIdPattern).test(keyId)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔧 Nit — isMintableKeyId recompiles the same RegExp on every call

Small thing — isMintableKeyId builds a brand-new RegExp from keyIdPattern each time it runs, and it sits on the encode/decode hot path (every resolvePrimaryKeyId, every wire-value classification). Since the pattern is a module constant, could we compile it once at module scope (e.g. const keyIdRegExp = new RegExp(keyIdPattern)) and reuse it? Keeps the string exported for the backend while avoiding the per-call allocation.

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