feat(THU-787): E2EE v2 AK/DEK key hierarchy + seamless v1→v2 migration - #1223
raivieiraadriano92 wants to merge 73 commits into
Conversation
Semgrep Security ScanNo security issues found. |
PR Metrics
Updated Fri, 18 Sep 2026 20:02:25 GMT · run #3052 |
49da24b to
820b6f3
Compare
d14bd77 to
d392b76
Compare
af56512 to
438b058
Compare
|
Preview environment deployed 🚀
Stack: Auto-destroys on PR close/merge. Login via the bundled Keycloak realm — |
4a3af20 to
4c429f3
Compare
* 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.
There was a problem hiding this comment.
🔭 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)
| 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) |
There was a problem hiding this comment.
🔧 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.
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)
CK(AES-256-GCM) — gated access and encrypted dataAK(AES-256-GCM,wrapKey/unwrapKeyonly — never touches data), wrapping a versioned DEK keyring with eachkey_idbound into the wrap as AADCKdirectlyDEKperkey_id; old DEKs retained forever for reads__enc:<iv>:<ct>(no key id, no AAD)__enc:v2:<key_id>:<iv>:<ct>, AAD-bound (table‖column‖rowId‖keyId)CK(0x01, AES-KW)0x02: AES-GCM seal over(AK ‖ primary key_id)— the primary pointer travels tamper-proof inside the envelopeX-Device-IDis a label,session.deviceIdis the identityE2EE_ENABLEDserver flagBoth 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
"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 callsPOST /encryption/upgrade.hash(canarySecret) == canary_secret_hash(only a CK holder can produce it — a stolen session can't), then CAS-flipsscheme_version1→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."v1"slot forever — no bulk re-upload."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_v2migration), 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-e2eejob is sharded 4 ways (~9 min PR wall-clock).Rollout
Ship with
MIN_APP_VERSIONunset (no behavior change). Once the build is live on every channel, setMIN_APP_VERSIONto 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.