feat: e2ee meetings#66
Draft
BreadGenie wants to merge 66 commits into
Draft
Conversation
as they'd be logged in places like nginx logs
Phases 1-3 of the E2EE modernization refactor (see docs/refactors/
e2ee-modernization.md and CONTEXT.md):
Phase 1 - Cryptography core in frontend/src/utils/media/e2ee.ts:
- T1.1: X25519 + Ed25519 keypair helpers (WebCrypto), ECDH agreement,
feature detection
- T1.3: meeting-shared secret + per-sender HKDF chains, per-joiner
ECDH envelope (create/open)
- T1.4: 24-byte v2 frame header (sender_id 4B, generation 4B,
key_version 4B, iv 12B) little-endian encode/decode
- T1.5: SenderChainState (nextFrameKey, wipe)
- T1.6: ReceiverChainState with per-senderId chain tip map, gap/replay
detection, wipe
- 181 vitest tests passing (2 RFC vector tests skipped for Node 24
WebCrypto raw-priv-import limitations; browser supports them)
Phase 2 - SFU + signaling (sfu-server/src):
- T2.1: setupE2eeHandshakeHandler in SocketHandlerManager - relay-only
(server-blind); e2ee:handshake event carries {fromParticipantId,
fromSenderId, x25519PublicKey?, envelope?, toParticipantId?,
toSenderId?}; resolution by senderId -> participantId
- T2.2: AuthManager propagates e2ee_host_public_key from JWT; cleanup
on disconnect clears all v2 fields
- T2.3: enforceE2EEJoinPolicy validates ecdhPublicKey; handleJoinRoom
stores on socket.x25519PublicKey
- T2.4: JWTPayload.e2ee_host_public_key; SFU socket type augmentation
(senderId, x25519PublicKey, e2eeHostPublicKey); assignSenderId
(monotonic per room, persists across rejoins)
Phase 3 - Frappe API + doctype:
- T3.1: e2ee_host_public_key Data field on Sae Meeting
- T3.2: device_keys Custom Field on User (JSON, hidden) installed by
after_app_install -> ensure_e2ee_custom_field
- T3.3: convert_meeting_to_e2ee accepts host pubkey + ed25519 sig +
device_id; broadcast payload includes e2ee_host_public_key
- T3.4: _verify_e2ee_proof_signature: ed25519 sig of (x25519_pub ||
key_version), verified against tabUser.device_keys[device_id].
ed25519_pub using cryptography.hazmat.primitives.asymmetric.ed25519
- T3.5: _add_e2ee_metadata now includes e2ee_host_public_key;
e2ee_salt kept (v1 chat E2EE still needs it)
- meet/api/test/test_e2ee_proof.py: 4 tests covering validators,
signature roundtrip (positive + tampered), convert_meeting_to_e2ee
success/reject paths
Phase 4 of the E2EE modernization refactor: T4.1 - Settings UI: MeetingAccessSettingsTab.vue replaces v1 passphrase generate+share with v2 X25519 + ed25519 flow. T4.2 - v2 handshake signaling: useDeviceIdentity composable (IndexedDB-backed ed25519 keypair + device_id), useE2EEHandshake composable (beginJoinerHandshake, buildHostEnvelope, openJoinerEnvelope), useSFUConnection handler dispatch, SFU join_room callback now returns senderId. T4.4 - pagehide wipe: wipe v2 chain state on pagehide + disconnect. Backend: meet.api.meeting.register_e2ee_device; 6 tests passing.
Adds the v2 meeting context -> per-sender chain -> per-frame AES key plumbing into mediasoup's encoded streams. v1 transforms continue to run in parallel (no-op when no passphrase is set); v2 is gated on isV2E2EERequired() (host's X25519 pubkey present) AND hasV2MeetingContext() (host-side or joiner-side handshake complete). - e2ee.ts: chain registry (v2MeetingSecret, v2SenderChains, v2ReceiverChain, v2PendingSenders, v2PendingReceivers, v2ActiveSenderTransforms); setV2MeetingContext installs transforms on pending senders/receivers; setupSenderTransformV2 / setupReceiverTransformV2; wipeV2MeetingContext tears it all down. - TransportManager: shouldEnableV2E2EETransforms() gate; createProducer and consumer creation call v2 setup in parallel with v1. - SFUClient: isV2E2EERequired(); ConnectionDetails.e2eeHostPublicKey; fetchConnectionDetails populates it from response. - useSFUConnection: meet:e2ee-handshake-complete listener populates registry; pagehide + disconnect wipe it. - 4 new vitest tests cover registry lifecycle + transform dedup. Biome auto-fixed minor lint issues introduced by new code.
ReceiverChainState now distinguishes small gaps (chain is advanced silently to catch up) from large gaps (>= RESYNC_FRAME_GAP_THRESHOLD, default 100, returns 'resync' error). The decryption TransformStream dispatches meet:e2ee-needs-key-resync when it sees a resync error. useSFUConnection listens for the event, wipes the v2 chain registry, and re-runs the joiner handshake. This closes the T4.3 traceability item and is the second half of ADR 0006 (chain tips wiped on pagehide + missed-N-frames). 3 new vitest tests: - small gap advances chain silently - gap >= 100 returns resync error - dispatch fires the custom event with the expected detail shape Biome auto-fixed minor lint issues introduced by new code.
Replaces the v1 passphrase+salt-derived AES key with an HKDF-derived key from the v2 meeting_secret. shouldEncryptChat() now keys off hasV2MeetingContext() rather than e2eeRequired && e2eePassphrase. This is step 1 of dropping v1 E2EE entirely: chat is now safe to use the v2 key path. The v1 passphrase/salt plumbing still exists on SFUClient but is no longer consulted by the chat path. - e2ee.ts: getE2EEChatKeyV2() (HKDF SHA-256, info='meet-e2ee-v2|chat') with a per-(meetingSecret, keyVersion) cache; cleared on wipeV2MeetingContext. - useChat.ts: removes deriveChatKey + e2eePassphrase/e2eeSalt helpers; uses getE2EEChatKeyV2() instead. - 3 new vitest tests cover null without context, determinism, and wipe invalidation. Biome auto-fixed minor lint issues introduced by new code.
Removes all v1 passphrase-based E2EE code paths now that v2 (ECDH bootstrap + per-sender key chains) is the only model. Frontend: - Delete E2EEKeyDialog component. - Meeting.vue: drop passphrase dialog state, 8-char min validation, E2EEKeyDialog template + import; replace with a comment. - useSFUConnection.ts: drop requestE2EEPassphrase dep + interface field; drop the passphrase-prompt branch on join + on reconfigure-for-e2ee; drop setE2EEErrorHandler (decrypt-failure toast); drop passphrase path of handleHostE2EEKeySet (v2 path remains); rename event meet:e2ee-key-set -> meet:e2ee-host-enabled to match its v2 payload. - MeetingAccessSettingsTab.vue: dispatch meet:e2ee-host-enabled with the v2 host keypair detail. - SFUClient: drop e2eePassphrase, setE2EEPassphrase, hasE2EEPassphrase, clearE2EEPassphrase, isE2EEReadyForMedia; drop e2eeSalt from ConnectionDetails + all 3 response interfaces; joinRoom now sends e2ee.enabled = isV2E2EERequired() (no more keyVersion/keyProof handshake). - TransportManager: collapse shouldEnableE2EETransforms + shouldEnableV2E2EETransforms into a single v2-gated method. - e2ee.ts: drop deriveE2EEKey (PBKDF2), computeE2EEKeyProof, encodeFrameHeader, decodeFrameHeader, hasInsertableStreamSupport (v1), setE2EEErrorHandler, createEncryptionTransformStream (v1), createDecryptionTransformStream (v1), setupSenderTransform (v1), setupReceiverTransform (v1), the v1 sequence/replay/failure tracking sets, the E2EEFrameHeader / SFUClientLike / E2EEErrorCode / E2EEErrorHandler types; generateE2EEKeyVersion stays (v1 in name only — it's now the v2 key_version format per CONTEXT.md). - Tests: drop the v1 setupSenderTransform/setupReceiverTransform describe blocks; rename 'e2ee_salt' expectations away in SFUClient.test.ts; drop the e2ee_salt assertion in test_meeting.py. Server: - meeting.py: drop _get_e2ee_salt; drop e2ee_salt from _add_e2ee_metadata and from all 6 response sites. - test_meeting.py: drop the e2ee_salt assertion. Pre-existing test compile error in Meeting.vue:620 remains.
Replaces the v1 passphrase-dialog flow with the v2 model: 1. Host joins the meeting (no E2EE). 2. Host opens the Meeting Access settings tab and toggles E2EE. 3. Guest joins. The v2 ECDH handshake fires via the realtime channel; both sides derive meeting_secret + per-sender chains. 4. Both pages show 2 participants. Drops the v1 'cancelling the key dialog shows the join error' test case — there is no longer a passphrase dialog to cancel. - Adds data-testid='e2ee-toggle' to the E2EE Switch in MeetingAccessSettingsTab.vue (Playwright target). - The e2eKey parameter on joinMeeting/joinAsHost/joinAsGuest is now unused but the helpers tolerate the absent dialog gracefully.
The function was referenced by _add_e2ee_metadata but never defined, causing a NameError at runtime. Reads e2ee_key_proof from the Sae Meeting doctype, matching the v2 storage shape used by SaeMeeting.enable_e2ee().
Custom fields on User aren't always loaded as attributes on the Document instance depending on DocType metadata cache state, so `user.device_keys` raises AttributeError. Switch to `frappe.db.get_value` / `frappe.db.set_value`, matching the pattern already used in _verify_e2ee_proof_signature. Same fix applied to the register_e2ee_device test. Ruff reformatted one expression.
…ated DocType The previous design used a JSON Custom Field on User (device_keys) to hold the per-device ed25519 public keys used for E2EE v2 host identity. This caused two real problems: 1. Custom fields on User aren't always accessible as attributes on the document object depending on DocType metadata cache state, causing AttributeError: 'User' object has no attribute 'device_keys' at runtime in production. 2. The custom field had to be created via a hook (after_app_install) that doesn't run on sites where the app is already installed, so any existing site would see Unknown column 'device_keys' in 'SELECT' until the hook was manually run. Replace the JSON-on-User approach with a dedicated E2EE Device Key DocType. Each row is one (user, device_id) pair carrying the ed25519 public key. The DocType is shipped as JSON in doctype/e2ee_device_key and is created by `bench --site <site> migrate`, so no special installer is needed on existing sites. Update: - meet/api/meeting.py: _verify_e2ee_proof_signature and register_e2ee_device now use the new DocType. - meet/utils/__init__.py: drop the obsolete ensure_e2ee_custom_field hook. - meet/api/test/test_e2ee_proof.py: test helpers updated to use the new DocType (no more user.device_keys attribute access).
…loads The SFU's `enforceE2EEJoinPolicy` still required v1 fields (`keyVersion`, `keyProof`) in the `join_room` request, and the `enforceE2EETransportPolicy` still required `keyVersion` on `create_webrtc_transport`. In v2, neither of these are part of the protocol: - The host's identity is bound to the meeting by the X25519 pubkey broadcast on `meeting:e2ee_enabled` (carried in the Frappe JWT for the SFU, but never checked by the SFU). The ed25519 signature over (x25519_pub || key_version) is verified by Frappe before the host pubkey is broadcast, so the SFU has no role in proof/version checking. - The SFU is server-blind: it just relays ECDH handshake envelopes and stores (sender_id -> peer socket) mappings opaquely. It does not need to know the meeting_secret, the key_version, or the passphrase-derived proof. Drop: - `e2ee.keyVersion` / `e2ee.keyProof` from the join-room policy - `keyVersion` from create-transport policy - `socket.e2eeKeyVersion`, `e2eeSalt`, `e2eeExpectedKeyProof`, `e2eeValidatedKeyProof`, `e2eeHostPublicKey` from the SFU socket type (the host pubkey was assigned but never used; the SFU stores the joiner's X25519 pubkey on the socket for handshake relay) - `e2ee_key_version`, `e2ee_salt`, `e2ee_key_proof`, `e2ee_host_public_key` from the JWT payload type and the Frappe-facing connection-details responses (these are SFU-bound fields, not v2 protocol fields) - `ConnectionDetails.e2eeKeyVersion` and the `create_webrtc_transport.keyVersion` field on the client - `_get_e2ee_key_version` / `_get_e2ee_key_proof` getters on the server (only used by the dropped fields) `e2ee_key_version` is still part of the v2 protocol and remains on the meeting doctype and on the `meeting:e2ee_enabled` realtime event payload (clients use it to track rotation state and as input to the v2 signature verification flow). It just no longer crosses the SFU boundary. The `getE2EEKeyVersion()` accessor stays as a stub returning `null` because it's part of the public SFUClient API surface; callers that need the v2 version read it from the realtime event payload instead.
When the host enables E2EE mid-meeting, every existing participant
(including the host) gets a `meeting:e2ee_enabled` realtime event
that carries the host's X25519 pubkey. The handler
(`useSFUConnection.handleMeetingE2EEEnabled`) refreshes the SFU
token and re-calls `joinRoom` so the new join carries
`e2ee.enabled: true`.
There were two stale-state problems in that path that caused the
SFU to reject the re-join with "E2EE is required for this room":
1. `SFUClient.refreshToken()` did not read `e2ee_host_public_key`
from the Frappe response. After `refresh_sfu_token`, the new
JWT carried `e2ee_required: true`, but the client's view of the
meeting still said "not E2EE" because the host pubkey was
missing. The next `joinRoom` then sent `e2ee.enabled: false`.
2. Even if the realtime event itself is the canonical source of
the host pubkey, the `useSFUConnection` handler did not push
the pubkey back into `sfuClient.connectionDetails` before
re-joining, so the pubkey was effectively dropped on the floor
for the duration of the re-join.
Fix:
- `SFUClient.refreshToken()`: copy `e2ee_host_public_key` from
the Frappe response into `connectionDetails` (preserves null
when the field is absent on a non-E2EE refresh).
- `SFUClient.setE2EERequired(required, { hostPublicKey })`:
new public method to write both flags in one call, used by the
realtime handler so the next `joinRoom` reads a coherent state.
- `useSFUConnection.handleMeetingE2EEEnabled`: call
`sfuClient.setE2EERequired(...)` from the realtime payload
before `joinRoom`, belt-and-suspenders in case the realtime
event fires before the next `refreshToken` roundtrip.
Add two regression tests:
- `refresh_sfu_token` returns the new `e2ee_host_public_key` and
it lands in `connectionDetails`.
- `setE2EERequired(true, { hostPublicKey })` updates
`isV2E2EERequired()` and the stored pubkey.
The v2 transform install path has several gate conditions (insertable stream support, v2 meeting context, chain state) that can each short-circuit the install. When the transform doesn't get installed it's not obvious which gate fired. Add console.warn at each short-circuit in setupSenderTransformV2 / setupReceiverTransformV2 so a quick "console" read in DevTools after enabling E2EE shows which path was taken. - "insertable stream support missing" -> browser doesn't have createEncodedStreams (or it's gated behind a flag) - "no meeting context (deferring)" -> setV2MeetingContext hasn't fired yet for this tab; sender is parked in v2PendingSenders and will be installed when the meeting context lands - "chain is null" -> meeting context was set but the chain could not be created (unexpected) - "createEncodedStreams returned nothing" -> browser has the API but the call returned no streams (often a sign of an encodedInsertableStreams=false setting on the transport) - "sender transform installed" -> success
…sion
Two related bugs prevented the v2 transform from being installed on
participants who joined (or rejoined) after the host had already
enabled E2EE:
1. The joiner handshake was only triggered by the
`meeting:e2ee_enabled` realtime event. That event fires once, to
existing members, when the host enables E2EE. A user who joins
the meeting *after* the event (e.g. page reload, new tab) never
sees it, so no joiner hello is sent to the host, the host never
sends back an envelope, and `v2MeetingSecret` stays null. As a
result, `shouldEnableE2EETransforms` was false and the transform
was never installed on the new senders.
2. Even if the handshake had completed, the joiner was deriving the
envelope with a hard-coded `"v1-"` key version while the host
had stored `v1-<8 hex>` (from `generateE2EEKeyVersion`). The
HKDF info string includes the key version
(`meet-e2ee-v2|envelope|${meetingId}|${keyVersion}`), so the
joiner's HKDF output did not match the host's, and envelope
decryption would have silently failed. The key version was
previously dropped from the connection-details response because
the SFU is server-blind and never needs it; but the *client*
does need it, so it belongs on the client-facing response.
Fix:
- `useSFUConnection.connect`: after the initial `joinRoom` (not the
reconfigure path), if `isV2E2EERequired()` is true and the user is
not the host, kick off `startV2HandshakeAsJoiner` using the host
pubkey and key version from `connectionDetails`. This covers the
fresh-join / reload case.
- `meet/api/meeting.py`: add `e2ee_key_version` back to the
client-facing connection-details responses (member,
refresh_sfu_token, all four guest join paths). Restore
`_get_e2ee_key_version` helper. The SFU-side JWT/connection
types and AuthManager are unchanged — the SFU still doesn't see
the key version, which is correct (server-blind).
- `frontend/src/utils/SFUClient.ts`: store `e2ee_key_version` in
`ConnectionDetails`, copy it through `refreshToken` and
`setE2EERequired`, and have `getE2EEKeyVersion()` actually
return it. The `getE2EEKeyVersion()` stub that returned null is
replaced with a real getter.
- `useSFUConnection.handleMeetingE2EEEnabled`: pass the key version
through `setE2EERequired` for the mid-meeting enable case so
`connectionDetails.e2eeKeyVersion` is consistent with the
realtime payload.
The previous `[E2EE v2] setupSenderTransformV2` console output only fires if the function is reached. If the gate at `shouldEnableE2EETransforms()` returns false (which happens silently), nothing prints and we have no signal to debug. Log the gate state at both call sites (createProducer and createConsumer) with: - e2eeGate: what shouldEnableE2EETransforms() returned - e2eeV2Required: what isV2E2EERequired() returned - hasContext: what hasV2MeetingContext() returned - hasRtpSender / hasRtpReceiver: whether the producer/consumer has the underlying object needed for createEncodedStreams A failing gate will now print the exact (e2eeV2Required, hasContext) combination so the missing precondition is obvious.
The previous gate-log edit referenced `consumeArgs` which is declared inside a try block, so it wasn't in scope at the log site and threw ReferenceError, breaking createConsumer entirely. Use rawConsumerParams.producerId instead (available in scope).
- Renamed all v2-prefixed exports/identifiers in e2ee.ts (setMeetingContext, hasMeetingContext, wipeMeetingContext, encodeFrameHeader, etc.) - Renamed v2-prefixed vars/functions in useSFUConnection, TransportManager, SFUClient - Updated log messages: [E2EE v2] -> [E2EE] - Updated HKDF info strings: meet-e2ee-v2 -> meet-e2ee - Removed v1- prefix from key version wire format (v1-<hex> -> <hex>) - Simplified parseKeyVersion to always return 1 - Updated Python _is_valid_e2ee_version validator for new format - Updated IndexedDB DB name: Meet_E2EE_v2 -> Meet_E2EE - On socket reconnect, wipe E2EE context and re-handshake as joiner - Fixed resync fallback to use server key version instead of v1- stub - Added .opencode/ and test-results/ to .gitignore
…ransform paths - Add RTCRtpScriptTransform worker (Safari/Firefox 130+) with same wire format and ME2E magic marker; send/recv frames encrypted/decrypted on the worker thread using HKDF frame keys plus per-frame Ed25519 signatures. - Replace sequential new-producer subscription with parallel Promise.allSettled in requestExistingProducers and flushBufferedProducers. - E2EE producers start paused on SFU; client resumes producer only after sender transform installs, eliminating the plaintext-startup window. - SFU createConsumer returns real senderId from producer.appData; PLI burst with delays [0, 120, 350, 800] ms after receiver transform setup. - Add SFU request_consumer_keyframe RPC with ownership check and SFUClient.requestConsumerKeyFrame client wrapper; SFU resumeConsumer calls consumer.requestKeyFrame() after resume. - HKDF-direct frame-key derivation keyed by (senderId, mediaType, generation); per-receiver AES key cache (1 entry) and worker RecvState LRU cache (max 64) eliminate per-frame HKDF cost. - SubtleCrypto pre-warm at worker startup (module-top-level digest + dummy Ed25519 verify per signing pubkey); pending-prewarm postMessage payload applied in first rtctransform event so first frame is not slowed by warmup. - Add console.time/performance.mark probes for consumer-created, receiver-transform-setup-done, play-resolved, first-video-frame so the new-joiner timing breakdown is observable from devtools.
…initial PLI - MediasoupManager.createProducer accepts a paused flag and forwards it to ProducerManager; SocketHandlerManager passes e2eeStartPaused from the client. Lets the client hold the producer paused until its sender E2EE transform is installed, so the first frames the SFU forwards are encrypted, not plaintext. - MediasoupManager.createProducer accepts a senderId, merges it into producer.appData, and createConsumer reads it back from the producer's appData so consumers learn which sender the frames came from. Required for per-sender key-chain authentication on the receiver. - MediasoupManager exposes requestConsumerKeyFrame(consumerId) and getConsumerData(consumerId) wrappers around ConsumerManager, used by the new socket RPC and by the client's per-burst PLI logic. - ConsumerManager.createConsumer no longer awaits the initial requestKeyFrame(). PLI/RPLI is a UDP-side signal to the producer; the mediasoup internal round-trip has no observable effect on consumer readiness. Errors are still logged. A previous attempt to setPreferredLayers(0, 0) before resume in createConsumer (the SFU-side low-start) was tried and reverted: it made the producer re-encode at the dropped layer and the client's later tile-adaptive escalation forced a second re-encode at the high layer, roughly doubling first-frame latency in tests. The dominant cost is the producer's first-keyframe response time, not the size of the keyframe.
Rename buildHostEnvelope -> buildResponderEnvelope so a non-host participant that already holds meetingSecret can sign an envelope for a new joiner with their own device media signing key. The joiner verifies the envelope signature against the responder signing key carried on the envelope header, not the host's auth key. The SFU still relays the envelope opaquely; no SFU changes. This means a meeting survives the host going offline: as long as one online participant still has meetingSecret in memory, new joiners can be admitted. If no online participant has meetingSecret, the joiner surfaces E2EE_KEY_HOLDER_TIMEOUT_MESSAGE via toast on the realtime meeting:e2ee_enabled path and as a thrown error on the initial join path, with the message 'No online encrypted participant could provide the E2EE key. Ask someone already in the call to stay online, or recreate the meeting.' Also drop the [E2EE-dbg-*] probes from the worker (recv side only; send side was already cleaned up) and add a vitest for the handshake composable: a responder-signed envelope opens correctly for a joiner and a different responder signing key is rejected. Drive-by: drop the stray 'true' arg at Meeting.vue:628 so applyBackgroundEffectsToLocalStream matches its () => Promise<void> interface and yarn typecheck is clean.
Three review findings against the hostless-admission branch, all
addressed in one commit since they share the same threat-model B
trust boundary on the SFU/join path.
1) AuthManager.updateSocketToken() was unconditionally resetting
socket.e2eeReady = false whenever a refreshed JWT carried
e2ee_required=true, even on sockets that had already completed the
E2EE join. The hourly token refresh does not re-run join_room, so
transport- and media-policy enforcers (enforceE2EETransportPolicy,
enforceE2EEMediaPolicy) would later reject transport creation,
camera re-enable, and new-producer subscription. Now we keep
e2eeReady=true on a token refresh only when the socket was already
e2eeRequired and already e2eeReady. Non-e2ee meetings stay trivially
ready. Also drop the unused computeE2EEReady helper.
2) Host rejoin was unconditionally calling rotateHostE2EEEpoch() and
overwriting the meeting E2EE metadata via convert_meeting_to_e2ee
whenever the host had no in-memory meetingSecret, even though the
meeting was already E2EE-enabled with other participants. That is
an in-place epoch transition, but ADR 0004 and ADR 0006 explicitly
defer epoch transitions. We now route the host through the same
key-holder handshake path as a normal joiner: it announces its
X25519 pubkey and waits for an online key-holder to admit it. If
no online key-holder has meetingSecret, the joiner surfaces the
E2EE_KEY_HOLDER_TIMEOUT_MESSAGE as before. The dedicated
rotateHostE2EEEpoch/ensureE2EEDeviceRegistered helpers in the
composable are removed; first-time host conversion still happens
via the meet:e2ee-host-enabled event in MeetingAccessSettingsTab
and uses frappeRequest directly. signProof/generateE2EEKeyVersion
imports are dropped from the composable.
3) featureDetectX25519() was synchronous but called
crypto.subtle.importKey('raw', ..., 'X25519', ...) without
awaiting. Unsupported browsers threw asynchronously, so the
function returned true and the E2EE toggle stayed enabled. Make
the function async, await the importKey probe, and wire the
result into MeetingAccessSettingsTab: the toggle is disabled
until the probe resolves, the description switches to an
'update your browser' message on unsupported runtimes, and the
watch handler re-checks at click time and toasts on failure. Test
in e2ee.test.ts updated to await the resolution.
Verified: vitest 210 passed, yarn typecheck clean, tsc clean for
sfu-server, targeted biome check passes on every touched file.
Hostless admission accepted the responder signing key from inside the
same untrusted e2ee:handshake envelope, so a non-host participant
could mint an attacker-chosen meetingSecret, sign it with their own
device media signing key, and poison or split joiners into an
attacker-controlled encrypted epoch. The envelope signature was a
proof-of-possession, not a proof of meeting-secret possession.
Close this by making the host the only authenticated distributor:
- The joiner accepts an envelope only when the responder X25519
public key matches the server-published host X25519 public key
AND the envelope signature verifies against the server-published
host Ed25519 signing key. The handshake message's
responderSigningPublicKey field is no longer accepted.
- handleJoinerHello short-circuits when this tab is not the host
(no hostX25519Priv), so a non-host participant never emits
envelopes and can never pose as a key-holder.
- E2EE_KEY_HOLDER_TIMEOUT_MESSAGE copy now says 'The meeting host
is not online to provide the E2EE key. Ask the host to stay
online, or recreate the meeting.' — true v1 behavior.
Also harden the SFU relay for threat model B:
- Validate every e2ee:handshake field: 44-char base64 public keys
for 32-byte raw keys, 512-byte-capped base64 envelope, integer
toSenderId in [0, 0xffffffff], and strict field-shape matching
(a hello must not mix envelope fields; an envelope must not mix
joiner hello fields).
- Route envelope responses only to the target participant's full
socket instead of broadcasting room-wide. Active participants
can no longer amplify arbitrary-sized strings to every full
participant in the room.
- E2eeHandshakeEnvelope type extended with the new fields so the
SFU signature stays accurate.
Tests, typecheck, SFU tsc, and targeted Biome all pass.
Slice 7 of docs/refactors/e2ee-deepening-plan.md.
Decomposes the 198-line useDeviceIdentity composable into a
DeviceIdentityProvider port with two implementations:
- IndexedDBDeviceIdentityProvider (production, current behavior)
- MemoryDeviceIdentityProvider (tests, in-memory; identity
generated fresh on construction, keypairs rotated on
clearCache)
The composable becomes a 24-line thin wrapper that returns the
IndexedDB-backed singleton. The two production callers
(useE2EEConnectionHandshake, E2EESettingsSection) are unchanged.
Tests cover: stability across calls, valid ed25519 keypairs,
public-key base64 round-trip, instance independence, clearCache
keypair rotation with stable device id, IndexedDB persistence
across instances, regeneration after store + localStorage clear,
and the composable singleton contract.
Adds fake-indexeddb to the vitest setup so the production provider
has a real IndexedDB test surface. Verifies: vitest 221/221,
frontend typecheck, SFU tsc, Biome (no new errors), Knip clean.
This reverts commit 9afd499e13d7b0e3666353340039c6bb088cf3cc.
This reverts commit 2c3272f11141c15422a7f730ffdd66d7762f323e.
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.
No description provided.