Skip to content

feat: e2ee meetings#66

Draft
BreadGenie wants to merge 66 commits into
developfrom
e2ee
Draft

feat: e2ee meetings#66
BreadGenie wants to merge 66 commits into
developfrom
e2ee

Conversation

@BreadGenie

@BreadGenie BreadGenie commented Jun 8, 2026

Copy link
Copy Markdown
Member

No description provided.

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.
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