2026-07-23: E2EE to-device mailbox relationship gate (M-E2EE-6) โ
What shipped โ
POST /auth/key-directory/to-devicenow refuses a deposit unless the sender and the recipient share aconnectionsdoc (checked both participant orders, ANY status), via a newhasConnectionBetweenhelper inkeyDirectory.js. A no-connection drop is reported asM_FORBIDDEN, NON-retryable: the client acks it without re-sharing (retrying cannot help a stranger deposit), while rate-limit drops stay retryable so real key shares fail closed as before. Self-deposits skip the gate (your own mailbox is always yours; the multi-device future shares keys to your other devices this way).planToDeviceWritesaccepts an object verdict ({allowed, errcode, retryable}) from the per-recipient gate alongside the legacy boolean, so drop class is decided at the gate.
Why the June deferral concerns dissolved โ
- "Needs a composite index": it does not. The gate is equality-only +
limit(1), served by Firestore's merged single-field indexes. A same-shaped two-way query (with a third equality, so a fortiori) already runs live in the wave flow (wave.service.js hasActiveConnection), which is the empirical proof on dev. - "Risks dropping a legit key share on a timing edge": the sole origin of a to-device key share is message-send (
shareRoomKeyinsideencryptMessage), which requires a settledconnectionId;acceptWavewrites the connection doc and performs no encryption at accept time. The gate is deliberately status-blind (doc existence, notstatus == 'active'), so an archived (met/expired) chat a peer reopens still re-keys until the 90-day idle purge deletes the doc, which is what finally closes the pair's channel.
Forward-note (recorded in code) โ
Bonfire Megolm group sessions (CHAT_ENCRYPTION.md section 5, deferred until bonfires ship) distribute group keys over these same pairwise channels, and bonfire members need NOT be pairwise-connected. When bonfires land, this gate must learn a second predicate (shared bonfire membership) or bonfire key delivery will be dropped here.
Verified โ
- 10 new tests in
keyDirectory.test.js(28 total in the file): either-order hit, stranger miss, status-blind archived hit,M_FORBIDDENnon-retryable batch semantics (sibling writes survive, batch not marked retryable), mixed forbidden + rate-limit drops keeping the batch retryable, and themakeToDeviceGatecomposition set (stranger refused before the limiter, self-deposit skips the lookup, per-device cost charged, limiter denial defaults retryable, forgotten-flag permanent denial defaults non-retryable). npm run validate -- --workspace services/api/authgreen (15/15 including the full auth suite).- No client change needed:
firestoreTransport.jsalready acks non-retryable failures without re-sharing (WI-2 contract).
Pre-PR adversarial review findings (applied) โ
- Retryable default hardened: an object verdict that omits
retryablenow defaults from its errcode (transient only forM_LIMIT_EXCEEDED), so a future permanent-denial producer (the bonfire predicate) that forgets the flag cannot default into the client's fail-closed re-share loop. Regression test added. - Gate extracted as
makeToDeviceGate(injectable limiter) and the previously untested wiring covered: stranger refused before the limiter is consulted, self-deposit skips the relationship lookup, connected recipient still charged at per-device cost. - Ordering validated: gate-before-limiter is cheaper AND cleaner (the durable limiter is itself a Firestore transaction; limiter-first would cost a read + write and litter a rate-bucket doc per stranger deposit, gate-first costs two reads and writes nothing).
- Pre-existing rules softness filed as #700: a connection doc's provenance is a real wave to the sender, but rules do not yet require the wave to be ACCEPTED, so a declined wave's recipient can still open the thread + key channel inside the wave's 7-day TTL. Separate PR (review-sensitive rules surface).
Tracker updates in this PR โ
SECURITY_REMEDIATION.md: M-E2EE-6 next-iteration item marked DONE with verification.2026-06-16-branch-review-remediation.md: WI-10 part 2 deferral resolved.