diff --git a/README.md b/README.md
index a2a7bfd1..8f7b8cdc 100644
--- a/README.md
+++ b/README.md
@@ -33,7 +33,7 @@ Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level perfo
- Optimized for stability in large meetings (+100 p.)
- Support for multiple screen sharing streams
- Non-persistent, secure chat
-- End-to-end encryption with hybrid key distribution
+- End-to-end encryption with passphrase-in-link key distribution
- Meeting recording
- Meeting transcription & Summary (currently in beta)
- Telephony integration
@@ -48,52 +48,42 @@ Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level perfo
### End-to-end encryption
-La Suite Meet supports end-to-end encryption (E2EE) for meetings, ensuring that the media server (LiveKit SFU) cannot access audio/video content. Two encryption modes are available:
+La Suite Meet supports end-to-end encryption (E2EE) for meetings, so the media server (LiveKit SFU) cannot read audio, video or screen-share content.
-#### Basic encryption
+#### How it works
-- Passphrase-based — the encryption key is embedded in the meeting URL hash (`#passphrase`)
-- Uses LiveKit's built-in Worker + `crypto.subtle` (AES-GCM) for frame encryption
-- Sharing the meeting link shares the encryption key
-- No account or onboarding required
-- Security depends on keeping the link private
+- Each encrypted meeting carries a 48-character random passphrase appended to the URL hash (`#…`). The server never sees it; sharing the meeting link shares the key.
+- Frames are encrypted in the browser via LiveKit's Worker + `crypto.subtle` (AES-GCM); only the media payload is encrypted, codec headers stay clear so the SFU can still packetize RTP.
+- The runtime "is this call encrypted?" decision keys off the URL hash, not the database flag — a compromised server cannot fabricate a passphrase that all participants happen to share.
+- The DB flag (`Room.is_encrypted`) is a hint used at room creation time only (so the Create button knows to generate a hash) and to detect link/server inconsistencies.
-#### Advanced encryption
+#### Opt-in by user
-- Key managed by [La Suite Encryption](https://github.com/suitenumerique/encryption) — the symmetric key never leaves the vault iframe
-- Uses XChaCha20-Poly1305 (libsodium) via the VaultClient iframe for frame encryption
-- Key distribution uses `vaultClient.shareKeys()` (hybrid PKI with X25519 + post-quantum slot)
-- All participants must complete encryption onboarding (key generation + backup) before joining
-- Requires a Chromium-based browser (Chrome, Edge, Brave) — uses the Insertable Streams API
+End-to-end encryption is a per-user preference. In **Settings → Security**, signed-in users can enable "End-to-end encryption" — from then on every meeting they create is encrypted by default. Joining is unaffected: if a meeting URL has a passphrase, the joining client uses it.
-**Frame encryption (both modes):**
+#### Pause / resume for recording and transcription
-- Codec header bytes (VP8 payload descriptor) are preserved unencrypted — required for proper RTP packetization
-- Only the media payload is encrypted, with a per-frame random nonce
-- The server (LiveKit SFU) only forwards encrypted data it cannot read
+While encryption is on, the SFU cannot record or transcribe (it has nothing to read). When an admin (or, if no admin is present, the longest-present participant — provided a pause has already been observed in the session) starts a recording or transcription:
-**Trust levels (advanced mode):**
-| Badge | Level | Description |
-|-------|-------|-------------|
-| 🟢 Green shield | Verified | User completed encryption onboarding (public key registered). Identity cryptographically verified. |
-| 🔵 Blue shield | Authenticated | User signed in via ProConnect/OIDC. Identity server-verified. |
-| 🟡 Orange warning | Anonymous | User not signed in. Self-declared name. Admin should verify identity before accepting. |
+1. A confirmation dialog warns that encryption will be paused.
+2. On confirm, an `ENCRYPTION_PAUSED` message is broadcast over a LiveKit reliable data channel. While the sender hasn't yet flipped its own state, that message is itself encrypted — which is the trust anchor: only callers holding the passphrase can produce frames everyone can decrypt.
+3. Each receiver disables E2EE locally and republishes its tracks unencrypted.
+4. Late joiners send an `ENCRYPTION_STATUS_PROBE` so the leader can re-emit the announcement to them.
+5. When **both** recording and transcription stop, the participant who paused broadcasts `ENCRYPTION_RESUMED` and everyone re-enables E2EE with the same URL passphrase.
-**Security guarantees:**
+The pause state is intentionally session-only and never persisted — `Room.is_encrypted` does not flip.
-- Encrypted rooms enforce restricted access (lobby approval required)
-- Trust information (`is_authenticated`, `email`) comes from server-signed JWT tokens — cannot be spoofed
-- Recording and transcription are not available in encrypted rooms (server cannot decrypt media)
+#### Phone / SIP participants
-**Configuration:**
+Phone and other external devices can't decrypt our frames. When one joins an encrypted room, the backend webhook detects them, broadcasts a system notice (admins see a snackbar with an "Open settings" CTA), and removes the external participant. The admin can then disable encryption from the Security settings and the user can dial in again.
+
+#### Configuration
```env
ENCRYPTION_ENABLED=true
-ENCRYPTION_VAULT_URL=https://data.encryption.example.fr
-ENCRYPTION_INTERFACE_URL=https://encryption.example.fr
```
-When the encryption service is deployed and configured, rooms can use advanced encryption. Without it, only basic (passphrase) encryption is available.
+Setting `ENCRYPTION_ENABLED=false` disables the user preference toggle entirely; existing encrypted rooms stay encrypted but no new ones can be created.
La Suite Meet is fully self-hostable and released under the MIT License, ensuring complete control and flexibility. It's simple to [get started](https://visio.numerique.gouv.fr/) or [request a demo](mailto:visio@numerique.gouv.fr).
diff --git a/src/backend/core/api/__init__.py b/src/backend/core/api/__init__.py
index a751ae17..49999d2d 100644
--- a/src/backend/core/api/__init__.py
+++ b/src/backend/core/api/__init__.py
@@ -73,11 +73,8 @@ def get_frontend_configuration(request):
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
},
}
- if settings.ENCRYPTION_ENABLED and settings.ENCRYPTION_VAULT_URL:
- frontend_configuration["encryption"] = {
- "enabled": True,
- "vault_url": settings.ENCRYPTION_VAULT_URL,
- "interface_url": settings.ENCRYPTION_INTERFACE_URL,
- }
+ frontend_configuration["encryption"] = {
+ "enabled": settings.ENCRYPTION_ENABLED,
+ }
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
return Response(frontend_configuration)
diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py
index 4d52d373..a4e9f4da 100644
--- a/src/backend/core/api/serializers.py
+++ b/src/backend/core/api/serializers.py
@@ -30,7 +30,16 @@ class UserSerializer(serializers.ModelSerializer):
class Meta:
model = models.User
- fields = ["id", "sub", "email", "full_name", "short_name", "timezone", "language"]
+ fields = [
+ "id",
+ "sub",
+ "email",
+ "full_name",
+ "short_name",
+ "timezone",
+ "language",
+ "default_encryption",
+ ]
read_only_fields = ["id", "sub", "email", "full_name", "short_name"]
@@ -75,22 +84,6 @@ class ResourceAccessSerializerMixin:
"Only owners of a room can assign other users as owners."
)
- # In advanced encrypted rooms, new accesses require an encrypted_symmetric_key
- # so the new member can decrypt the room's streams. Without it, they'd have
- # access but no key — which is useless and confusing.
- # Future: a sharing UI (like Docs) could provide the key via vault shareKeys.
- if not self.instance and "resource" in data:
- resource = data["resource"]
- if (
- hasattr(resource, 'encryption_mode')
- and resource.encryption_mode == models.EncryptionMode.ADVANCED
- and not data.get("encrypted_symmetric_key")
- ):
- raise serializers.ValidationError(
- "Adding members to advanced encrypted rooms requires "
- "an encrypted_symmetric_key for the new user."
- )
-
return data
def validate_resource(self, resource):
@@ -115,7 +108,7 @@ class ResourceAccessSerializer(
class Meta:
model = models.ResourceAccess
- fields = ["id", "user", "resource", "role", "encrypted_symmetric_key"]
+ fields = ["id", "user", "resource", "role"]
read_only_fields = ["id"]
def update(self, instance, validated_data):
@@ -145,24 +138,15 @@ class RoomSerializer(serializers.ModelSerializer):
class Meta:
model = models.Room
- fields = ["id", "name", "slug", "configuration", "access_level", "pin_code", "encryption_mode"]
+ fields = ["id", "name", "slug", "configuration", "access_level", "pin_code", "is_encrypted"]
read_only_fields = ["id", "slug", "pin_code"]
- def validate_access_level(self, value):
- """Encrypted rooms must stay restricted — prevent downgrading access level."""
+ def validate_is_encrypted(self, value):
+ """Encryption is decided at room creation and cannot be flipped afterwards."""
instance = self.instance
- if instance and instance.encryption_enabled and value != models.RoomAccessLevel.RESTRICTED:
+ if instance and instance.is_encrypted != value:
raise serializers.ValidationError(
- "Encrypted rooms require restricted access level to enforce lobby approval."
- )
- return value
-
- def validate_encryption_mode(self, value):
- """Once encryption is enabled on a room, it cannot be disabled or downgraded."""
- instance = self.instance
- if instance and instance.encryption_enabled and value == models.EncryptionMode.NONE:
- raise serializers.ValidationError(
- "Encryption cannot be disabled once enabled on a room."
+ "Encryption flag cannot be changed after room creation."
)
return value
@@ -208,33 +192,18 @@ class RoomSerializer(serializers.ModelSerializer):
room_id = f"{instance.id!s}"
username = request.query_params.get("username", None)
- # In encrypted rooms, authenticated users must use their real name from
- # the OIDC profile (ProConnect) — they cannot choose an arbitrary name.
- if instance.encryption_enabled and request.user.is_authenticated:
- username = request.user.full_name or request.user.email
-
output["livekit"] = utils.generate_livekit_config(
room_id=room_id,
user=request.user,
username=username,
configuration=configuration,
is_admin_or_owner=is_admin_or_owner,
- encryption_mode=instance.encryption_mode,
)
else:
del output["pin_code"]
output["is_administrable"] = is_admin_or_owner
- # Include the current user's encrypted symmetric key for advanced E2EE
- if request.user.is_authenticated and instance.encryption_mode == models.EncryptionMode.ADVANCED:
- try:
- access = instance.accesses.get(user=request.user)
- if access.encrypted_symmetric_key:
- output["encrypted_symmetric_key"] = access.encrypted_symmetric_key
- except models.ResourceAccess.DoesNotExist:
- pass
-
return output
@@ -317,7 +286,6 @@ class RequestEntrySerializer(BaseValidationOnlySerializer):
"""Validate request entry data."""
username = serializers.CharField(required=True, allow_blank=True)
- ephemeral_public_key = serializers.CharField(required=False, allow_blank=True, default='')
class ParticipantEntrySerializer(BaseValidationOnlySerializer):
@@ -325,9 +293,6 @@ class ParticipantEntrySerializer(BaseValidationOnlySerializer):
participant_id = serializers.UUIDField(required=True)
allow_entry = serializers.BooleanField(required=True)
- encrypted_key = serializers.CharField(required=False, allow_blank=True, default='')
- admin_ephemeral_public_key = serializers.CharField(required=False, allow_blank=True, default='')
- encrypted_vault_key = serializers.CharField(required=False, allow_blank=True, default='')
class CreationCallbackSerializer(BaseValidationOnlySerializer):
diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py
index 495853d1..4dbdf7c5 100644
--- a/src/backend/core/api/viewsets.py
+++ b/src/backend/core/api/viewsets.py
@@ -281,32 +281,19 @@ class RoomViewSet(
def perform_create(self, serializer):
"""Set the current user as owner of the newly created room."""
- encryption_mode = serializer.validated_data.get("encryption_mode", models.EncryptionMode.NONE)
+ is_encrypted = serializer.validated_data.get("is_encrypted", False)
# Block encrypted room creation if encryption is not enabled on this instance
- if encryption_mode != models.EncryptionMode.NONE and not settings.ENCRYPTION_ENABLED:
+ if is_encrypted and not settings.ENCRYPTION_ENABLED:
raise drf_exceptions.ValidationError(
- {"encryption_mode": "Encryption is not enabled on this server."}
+ {"is_encrypted": "Encryption is not enabled on this server."}
)
- # Advanced encryption requires the vault service to be configured
- if encryption_mode == models.EncryptionMode.ADVANCED and not getattr(settings, 'ENCRYPTION_VAULT_URL', ''):
- raise drf_exceptions.ValidationError(
- {"encryption_mode": "Advanced encryption requires the encryption service to be configured."}
- )
-
- # Encrypted rooms must use restricted access to enforce lobby approval
- # before the encryption key is shared with participants.
- if encryption_mode != models.EncryptionMode.NONE:
- serializer.validated_data["access_level"] = models.RoomAccessLevel.RESTRICTED
-
room = serializer.save()
- encrypted_symmetric_key = self.request.data.get("encrypted_symmetric_key", "")
models.ResourceAccess.objects.create(
resource=room,
user=self.request.user,
role=models.RoleChoices.OWNER,
- encrypted_symmetric_key=encrypted_symmetric_key,
)
if callback_id := self.request.data.get("callback_id"):
@@ -335,12 +322,6 @@ class RoomViewSet(
options = serializer.validated_data.get("options")
room = self.get_object()
- if room.encryption_enabled:
- return drf_response.Response(
- {"detail": "Recording is not available in encrypted rooms."},
- status=drf_status.HTTP_403_FORBIDDEN,
- )
-
# May raise exception if an active or initiated recording already exist for the room
recording = models.Recording.objects.create(
room=room,
@@ -425,20 +406,6 @@ class RoomViewSet(
room = self.get_object()
validated_data = serializer.validated_data
- # Advanced encrypted rooms require authentication
- if room.encryption_mode == models.EncryptionMode.ADVANCED and not request.user.is_authenticated:
- return drf_response.Response(
- {"detail": "This meeting requires authentication to join."},
- status=drf_status.HTTP_403_FORBIDDEN,
- )
-
- # In encrypted rooms, authenticated users must use their real name
- # from the OIDC profile — they cannot choose an arbitrary name.
- if room.encryption_enabled and request.user.is_authenticated:
- validated_data["username"] = (
- request.user.full_name or request.user.email
- )
-
lobby_service = LobbyService()
participant, livekit = lobby_service.request_entry(
@@ -480,9 +447,6 @@ class RoomViewSet(
room_id=room.id,
participant_id=str(serializer.validated_data.get("participant_id")),
allow_entry=serializer.validated_data.get("allow_entry"),
- encrypted_key=serializer.validated_data.get("encrypted_key", ''),
- admin_ephemeral_public_key=serializer.validated_data.get("admin_ephemeral_public_key", ''),
- encrypted_vault_key=serializer.validated_data.get("encrypted_vault_key", ''),
)
return drf_response.Response({"message": "Participant was updated."})
@@ -511,13 +475,6 @@ class RoomViewSet(
participants = lobby_service.list_waiting_participants(room.id)
- # Only expose email and ephemeral keys in encrypted rooms.
- # Strip them otherwise to avoid leaking personal data.
- if not room.encryption_enabled:
- for p in participants:
- p.pop("email", None)
- p.pop("ephemeral_public_key", None)
-
return drf_response.Response({"participants": participants})
@decorators.action(
@@ -620,12 +577,6 @@ class RoomViewSet(
room = self.get_object()
- if room.encryption_enabled:
- return drf_response.Response(
- {"error": "Transcription is not available in encrypted rooms."},
- status=drf_status.HTTP_403_FORBIDDEN,
- )
-
try:
SubtitleService().start_subtitle(room)
except SubtitleException:
diff --git a/src/backend/core/migrations/0019_room_encryption_enabled.py b/src/backend/core/migrations/0019_room_encryption_enabled.py
deleted file mode 100644
index ea571dbc..00000000
--- a/src/backend/core/migrations/0019_room_encryption_enabled.py
+++ /dev/null
@@ -1,20 +0,0 @@
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ("core", "0018_rename_active_application_is_active"),
- ]
-
- operations = [
- migrations.AddField(
- model_name="room",
- name="encryption_enabled",
- field=models.BooleanField(
- default=False,
- help_text="Whether end-to-end encryption is enabled for this room.",
- verbose_name="Encryption enabled",
- ),
- ),
- ]
diff --git a/src/backend/core/migrations/0019_room_is_encrypted_user_default_encryption.py b/src/backend/core/migrations/0019_room_is_encrypted_user_default_encryption.py
new file mode 100644
index 00000000..a888a9ad
--- /dev/null
+++ b/src/backend/core/migrations/0019_room_is_encrypted_user_default_encryption.py
@@ -0,0 +1,36 @@
+"""Add Room.is_encrypted and User.default_encryption.
+
+`is_encrypted` is a boolean for now because passphrase-in-URL is the only
+supported mode. A future migration may turn it into a CharField/enum if a
+local-keys (vault) mode is reintroduced.
+"""
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("core", "0018_rename_active_application_is_active"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="room",
+ name="is_encrypted",
+ field=models.BooleanField(
+ default=False,
+ help_text="Whether end-to-end encryption is enabled for this room.",
+ verbose_name="Encryption enabled",
+ ),
+ ),
+ migrations.AddField(
+ model_name="user",
+ name="default_encryption",
+ field=models.BooleanField(
+ default=False,
+ help_text="Whether new meetings created by this user are end-to-end encrypted by default.",
+ verbose_name="Default to end-to-end encryption",
+ ),
+ ),
+ ]
diff --git a/src/backend/core/migrations/0020_room_encryption_mode.py b/src/backend/core/migrations/0020_room_encryption_mode.py
deleted file mode 100644
index 02a295bb..00000000
--- a/src/backend/core/migrations/0020_room_encryption_mode.py
+++ /dev/null
@@ -1,51 +0,0 @@
-"""Replace encryption_enabled boolean with encryption_mode enum."""
-
-from django.db import migrations, models
-
-
-def migrate_encryption_enabled_to_mode(apps, schema_editor):
- """Convert existing encryption_enabled=True rooms to encryption_mode='basic'."""
- Room = apps.get_model("core", "Room")
- Room.objects.filter(encryption_enabled=True).update(encryption_mode="basic")
-
-
-def migrate_mode_to_encryption_enabled(apps, schema_editor):
- """Reverse: set encryption_enabled=True for any non-'none' encryption_mode."""
- Room = apps.get_model("core", "Room")
- Room.objects.exclude(encryption_mode="none").update(encryption_enabled=True)
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ("core", "0019_room_encryption_enabled"),
- ]
-
- operations = [
- # 1. Add the new encryption_mode field
- migrations.AddField(
- model_name="room",
- name="encryption_mode",
- field=models.CharField(
- choices=[
- ("none", "No encryption"),
- ("basic", "Basic encryption"),
- ("advanced", "Advanced encryption"),
- ],
- default="none",
- help_text="End-to-end encryption mode for this room.",
- max_length=20,
- verbose_name="Encryption mode",
- ),
- ),
- # 2. Migrate existing data
- migrations.RunPython(
- migrate_encryption_enabled_to_mode,
- migrate_mode_to_encryption_enabled,
- ),
- # 3. Remove the old boolean field
- migrations.RemoveField(
- model_name="room",
- name="encryption_enabled",
- ),
- ]
diff --git a/src/backend/core/migrations/0021_resourceaccess_encrypted_symmetric_key.py b/src/backend/core/migrations/0021_resourceaccess_encrypted_symmetric_key.py
deleted file mode 100644
index d71ba9ee..00000000
--- a/src/backend/core/migrations/0021_resourceaccess_encrypted_symmetric_key.py
+++ /dev/null
@@ -1,23 +0,0 @@
-"""Add encrypted_symmetric_key to ResourceAccess for advanced E2EE mode."""
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ("core", "0020_room_encryption_mode"),
- ]
-
- operations = [
- migrations.AddField(
- model_name="resourceaccess",
- name="encrypted_symmetric_key",
- field=models.TextField(
- blank=True,
- default="",
- help_text="Vault-wrapped symmetric encryption key for advanced E2EE mode. Each user's copy is encrypted for their own vault public key.",
- verbose_name="Encrypted symmetric key",
- ),
- ),
- ]
diff --git a/src/backend/core/models.py b/src/backend/core/models.py
index 7fa4e881..56b5e3d6 100644
--- a/src/backend/core/models.py
+++ b/src/backend/core/models.py
@@ -98,14 +98,6 @@ class RoomAccessLevel(models.TextChoices):
RESTRICTED = "restricted", _("Restricted Access")
-class EncryptionMode(models.TextChoices):
- """Encryption mode choices for rooms."""
-
- NONE = "none", _("No encryption")
- BASIC = "basic", _("Basic encryption")
- ADVANCED = "advanced", _("Advanced encryption")
-
-
class BaseModel(models.Model):
"""
Serves as an abstract base model for other models, ensuring that records are validated
@@ -208,6 +200,14 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
"Unselect this instead of deleting accounts."
),
)
+ default_encryption = models.BooleanField(
+ _("Default to end-to-end encryption"),
+ default=False,
+ help_text=_(
+ "Whether new meetings created by this user are "
+ "end-to-end encrypted by default."
+ ),
+ )
objects = auth_models.UserManager()
@@ -332,15 +332,6 @@ class ResourceAccess(BaseModel):
role = models.CharField(
max_length=20, choices=RoleChoices.choices, default=RoleChoices.MEMBER
)
- encrypted_symmetric_key = models.TextField(
- blank=True,
- default='',
- verbose_name=_("Encrypted symmetric key"),
- help_text=_(
- "Vault-wrapped symmetric encryption key for advanced E2EE mode. "
- "Each user's copy is encrypted for their own vault public key."
- ),
- )
class Meta:
db_table = "meet_resource_access"
@@ -405,12 +396,14 @@ class Room(Resource):
choices=RoomAccessLevel.choices,
default=settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
)
- encryption_mode = models.CharField(
- max_length=20,
- choices=EncryptionMode.choices,
- default=EncryptionMode.NONE,
- verbose_name=_("Encryption mode"),
- help_text=_("End-to-end encryption mode for this room."),
+ # Boolean for now: today the only encryption mode is the passphrase-in-URL
+ # one. If a follow-up adds a stronger "local-keys" mode (private keys held
+ # in a vault iframe), this can grow into a CharField with choices like
+ # `none / passphrase / local_keys`.
+ is_encrypted = models.BooleanField(
+ default=False,
+ verbose_name=_("Encryption enabled"),
+ help_text=_("Whether end-to-end encryption is enabled for this room."),
)
configuration = models.JSONField(
blank=True,
@@ -466,11 +459,6 @@ class Room(Resource):
"""Check if a room is public"""
return self.access_level == RoomAccessLevel.PUBLIC
- @property
- def encryption_enabled(self):
- """Check if any encryption mode is active."""
- return self.encryption_mode != EncryptionMode.NONE
-
@staticmethod
def generate_unique_pin_code(length):
"""Generate a unique n-digit PIN code"""
diff --git a/src/backend/core/services/livekit_events.py b/src/backend/core/services/livekit_events.py
index d24c241e..385ed65e 100644
--- a/src/backend/core/services/livekit_events.py
+++ b/src/backend/core/services/livekit_events.py
@@ -18,6 +18,10 @@ from core.recording.services.recording_events import (
)
from .lobby import LobbyService
+from .participants_management import (
+ ParticipantsManagement,
+ ParticipantsManagementException,
+)
from .telephony import TelephonyException, TelephonyService
logger = getLogger(__name__)
@@ -185,6 +189,76 @@ class LiveKitEventsService:
f"Failed to process limit reached event for recording {recording}"
) from e
+ def _handle_participant_joined(self, data):
+ """Handle 'participant_joined' event.
+
+ When a SIP/phone participant joins an end-to-end encrypted room they
+ cannot decrypt anything. We:
+ 1. Send an in-band notification (chat-style) to everyone, which
+ surfaces an admin snackbar and a system message in the chat;
+ 2. Eject the SIP participant — the admin can then disable encryption
+ from the Security settings and the user can dial back in.
+
+ This is the simplest reliable fallback. A future improvement is a
+ dedicated "audio guard" agent that joins encrypted rooms and plays a
+ recorded prompt to SIP participants instead of disconnecting them.
+ """
+
+ participant = getattr(data, "participant", None)
+ if participant is None:
+ return
+
+ # LiveKit ParticipantInfo.Kind: 0 = STANDARD, 1 = INGRESS, 2 = EGRESS,
+ # 3 = SIP, 4 = AGENT. We treat both SIP and INGRESS as "external
+ # device that can't run our E2EE code".
+ kind = getattr(participant, "kind", 0)
+ is_external_device = kind in (1, 3)
+ if not is_external_device:
+ return
+
+ try:
+ room_id = uuid.UUID(data.room.name)
+ except ValueError:
+ return
+
+ try:
+ room = models.Room.objects.get(id=room_id)
+ except models.Room.DoesNotExist:
+ return
+
+ if not room.is_encrypted:
+ return
+
+ # 1. Broadcast a system notice — frontends decode this on the
+ # "encryption-state" or notifications channel and show a snackbar.
+ try:
+ utils.notify_participants(
+ room_name=str(room_id),
+ notification_data={
+ "type": "external_device_blocked",
+ "participant_identity": participant.identity,
+ "participant_name": participant.name or participant.identity,
+ },
+ )
+ except utils.NotificationError:
+ logger.exception(
+ "Failed to notify room about blocked external device"
+ )
+
+ # 2. Disconnect the external participant so they don't sit in a
+ # silent encrypted room. The admin can disable encryption and the
+ # user can dial in again.
+ try:
+ ParticipantsManagement().remove(
+ room_name=str(room_id), identity=participant.identity
+ )
+ except ParticipantsManagementException:
+ logger.exception(
+ "Failed to remove external device participant %s from encrypted room %s",
+ participant.identity,
+ room_id,
+ )
+
def _handle_room_started(self, data):
"""Handle 'room_started' event."""
diff --git a/src/backend/core/services/lobby.py b/src/backend/core/services/lobby.py
index dee555a4..9aa07708 100644
--- a/src/backend/core/services/lobby.py
+++ b/src/backend/core/services/lobby.py
@@ -46,36 +46,19 @@ class LobbyParticipant:
username: str
color: str
id: str
+ # Whether the user signed in (e.g. via ProConnect). Surfaced to admins so
+ # they can decide whether to accept self-declared identities.
is_authenticated: bool = False
- email: Optional[str] = None
- suite_user_id: Optional[str] = None
- ephemeral_public_key: str = ''
- encrypted_key: str = ''
- admin_ephemeral_public_key: str = ''
- encrypted_vault_key: str = ''
- def to_dict(self) -> Dict[str, str]:
+ def to_dict(self) -> Dict[str, object]:
"""Serialize the participant object to a dict representation."""
- result = {
+ return {
"status": self.status.value,
"username": self.username,
"id": self.id,
"color": self.color,
"is_authenticated": self.is_authenticated,
}
- if self.email:
- result["email"] = self.email
- if self.suite_user_id:
- result["suite_user_id"] = self.suite_user_id
- if self.ephemeral_public_key:
- result["ephemeral_public_key"] = self.ephemeral_public_key
- if self.encrypted_key:
- result["encrypted_key"] = self.encrypted_key
- if self.admin_ephemeral_public_key:
- result["admin_ephemeral_public_key"] = self.admin_ephemeral_public_key
- if self.encrypted_vault_key:
- result["encrypted_vault_key"] = self.encrypted_vault_key
- return result
@classmethod
def from_dict(cls, data: dict) -> "LobbyParticipant":
@@ -89,13 +72,7 @@ class LobbyParticipant:
username=data["username"],
id=data["id"],
color=data["color"],
- is_authenticated=data.get("is_authenticated", False),
- email=data.get("email"),
- suite_user_id=data.get("suite_user_id"),
- ephemeral_public_key=data.get("ephemeral_public_key", ''),
- encrypted_key=data.get("encrypted_key", ''),
- admin_ephemeral_public_key=data.get("admin_ephemeral_public_key", ''),
- encrypted_vault_key=data.get("encrypted_vault_key", ''),
+ is_authenticated=bool(data.get("is_authenticated", False)),
)
except (KeyError, ValueError) as e:
logger.exception("Error creating Participant from dict:")
@@ -139,16 +116,11 @@ class LobbyService:
1. The room is public (open to everyone)
2. The room has TRUSTED access level and the user is authenticated
- Encrypted rooms never bypass the lobby — participants must go through
- the lobby key exchange to receive the encryption key.
-
Note: Room access levels can change while participants are waiting in the lobby.
This function only checks the current state and should be called each time
a participant requests entry to ensure consistent access control, even for
participants who have already begun waiting.
"""
- if hasattr(room, 'encryption_mode') and room.encryption_mode != 'none':
- return False
return room.is_public or (
room.access_level == models.RoomAccessLevel.TRUSTED
and user.is_authenticated
@@ -159,7 +131,6 @@ class LobbyService:
room,
request,
username: str,
- ephemeral_public_key: str = '',
) -> Tuple[LobbyParticipant, Optional[Dict]]:
"""Request entry to a room for a participant.
@@ -186,6 +157,7 @@ class LobbyService:
username=username,
id=participant_id,
color=utils.generate_color(participant_id),
+ is_authenticated=request.user.is_authenticated,
)
else:
participant.status = LobbyParticipantStatus.ACCEPTED
@@ -198,7 +170,6 @@ class LobbyService:
configuration=room.configuration,
is_admin_or_owner=False,
participant_id=participant_id,
- encryption_mode=room.encryption_mode,
)
return participant, livekit_config
@@ -206,34 +177,16 @@ class LobbyService:
if participant is None:
participant = self.enter(
- room.id, participant_id, username,
+ room.id,
+ participant_id,
+ username,
is_authenticated=request.user.is_authenticated,
- email=getattr(request.user, 'email', None) if request.user.is_authenticated else None,
- suite_user_id=str(request.user.sub) if request.user.is_authenticated else None,
- ephemeral_public_key=ephemeral_public_key,
)
elif participant.status == LobbyParticipantStatus.WAITING:
self.refresh_waiting_status(room.id, participant_id)
elif participant.status == LobbyParticipantStatus.ACCEPTED:
- # If the joiner comes back with a different ephemeral key (e.g. browser
- # closed and reopened), they can no longer decrypt the encrypted symmetric
- # key. Reset them to WAITING so the admin re-accepts with the new key.
- if (
- ephemeral_public_key
- and participant.ephemeral_public_key
- and ephemeral_public_key != participant.ephemeral_public_key
- ):
- participant = self.enter(
- room.id, participant_id, username,
- is_authenticated=request.user.is_authenticated,
- email=getattr(request.user, 'email', None) if request.user.is_authenticated else None,
- suite_user_id=str(request.user.sub) if request.user.is_authenticated else None,
- ephemeral_public_key=ephemeral_public_key,
- )
- return participant, None
-
livekit_config = utils.generate_livekit_config(
room_id=room_id,
user=request.user,
@@ -242,7 +195,6 @@ class LobbyService:
configuration=room.configuration,
is_admin_or_owner=False,
participant_id=participant_id,
- encryption_mode=room.encryption_mode,
)
return participant, livekit_config
@@ -259,11 +211,11 @@ class LobbyService:
)
def enter(
- self, room_id: UUID, participant_id: str, username: str,
+ self,
+ room_id: UUID,
+ participant_id: str,
+ username: str,
is_authenticated: bool = False,
- email: Optional[str] = None,
- suite_user_id: Optional[str] = None,
- ephemeral_public_key: str = '',
) -> LobbyParticipant:
"""Add participant to waiting lobby.
@@ -279,9 +231,6 @@ class LobbyService:
id=participant_id,
color=color,
is_authenticated=is_authenticated,
- email=email,
- suite_user_id=suite_user_id,
- ephemeral_public_key=ephemeral_public_key,
)
try:
@@ -350,9 +299,6 @@ class LobbyService:
room_id: UUID,
participant_id: str,
allow_entry: bool,
- encrypted_key: str = '',
- admin_ephemeral_public_key: str = '',
- encrypted_vault_key: str = '',
) -> None:
"""Handle decision on participant entry.
@@ -371,13 +317,7 @@ class LobbyService:
"timeout": settings.LOBBY_DENIED_TIMEOUT,
}
- self._update_participant_status(
- room_id, participant_id,
- encrypted_key=encrypted_key,
- admin_ephemeral_public_key=admin_ephemeral_public_key,
- encrypted_vault_key=encrypted_vault_key,
- **decision,
- )
+ self._update_participant_status(room_id, participant_id, **decision)
def _update_participant_status(
self,
@@ -385,9 +325,6 @@ class LobbyService:
participant_id: str,
status: LobbyParticipantStatus,
timeout: int,
- encrypted_key: str = '',
- admin_ephemeral_public_key: str = '',
- encrypted_vault_key: str = '',
) -> None:
"""Update participant status with appropriate timeout."""
@@ -408,12 +345,6 @@ class LobbyService:
raise
participant.status = status
- if encrypted_key:
- participant.encrypted_key = encrypted_key
- if admin_ephemeral_public_key:
- participant.admin_ephemeral_public_key = admin_ephemeral_public_key
- if encrypted_vault_key:
- participant.encrypted_vault_key = encrypted_vault_key
cache.set(cache_key, participant.to_dict(), timeout=timeout)
def clear_room_cache(self, room_id: UUID) -> None:
diff --git a/src/backend/core/utils.py b/src/backend/core/utils.py
index 1b15f8eb..c1809b23 100644
--- a/src/backend/core/utils.py
+++ b/src/backend/core/utils.py
@@ -66,7 +66,6 @@ def generate_token(
sources: Optional[List[str]] = None,
is_admin_or_owner: bool = False,
participant_id: Optional[str] = None,
- encryption_mode: str = 'none',
) -> str:
"""Generate a LiveKit access token for a user in a specific room.
@@ -93,15 +92,11 @@ def generate_token(
if sources is None:
sources = settings.LIVEKIT_DEFAULT_SOURCES
- # In encrypted rooms, no one can change their name/metadata to prevent
- # identity spoofing — the admin accepted them based on their declared identity.
- can_update_metadata = encryption_mode == 'none'
-
video_grants = VideoGrants(
room=room,
room_join=True,
room_admin=is_admin_or_owner,
- can_update_own_metadata=can_update_metadata,
+ can_update_own_metadata=True,
can_publish=bool(sources),
can_publish_sources=sources,
can_subscribe=True,
@@ -117,42 +112,12 @@ def generate_token(
if color is None:
color = generate_color(identity)
- # Build participant attributes — these are server-signed in the JWT
- # and visible to all participants in the room.
attributes = {
"color": color,
"room_admin": "true" if is_admin_or_owner else "false",
"is_authenticated": "true" if not user.is_anonymous else "false",
}
- # Add identity info for authenticated users in encrypted rooms only.
- #
- # Email and suite_user_id are included in the JWT attributes for encrypted
- # rooms because:
- # - Email: allows admins to verify participant identity in the lobby and
- # participant list (important for trust decisions in encrypted meetings)
- # - suite_user_id: required for vault key exchange in advanced encryption
- # (vaultClient.shareKeys needs the recipient's user ID)
- #
- # These attributes are NOT included in non-encrypted rooms because:
- # - Non-encrypted rooms have no waiting room, so anonymous users can join
- # freely and would see everyone's email via LiveKit signaling
- # - LiveKit JWT attributes are immutable and broadcast to ALL participants
- # equally — there is no way to show them only to authenticated users
- # at the protocol level
- # - The frontend additionally hides email from anonymous users in the UI,
- # but this is defense-in-depth, not the primary protection
- #
- # Future improvement: serve email via a Django API endpoint that checks
- # the requester's authentication, removing it from the JWT entirely.
- # This would require the backend to call LiveKit's ListParticipants API
- # to cross-reference identities with the user database.
- if not user.is_anonymous and encryption_mode != 'none':
- if user.email:
- attributes["email"] = user.email
- if user.sub:
- attributes["suite_user_id"] = str(user.sub)
-
token = (
AccessToken(
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
@@ -175,7 +140,6 @@ def generate_livekit_config(
color: Optional[str] = None,
configuration: Optional[dict] = None,
participant_id: Optional[str] = None,
- encryption_mode: str = 'none',
) -> dict:
"""Generate LiveKit configuration for room access.
@@ -208,7 +172,6 @@ def generate_livekit_config(
sources=sources,
is_admin_or_owner=is_admin_or_owner,
participant_id=participant_id,
- encryption_mode=encryption_mode,
),
}
diff --git a/src/backend/meet/settings.py b/src/backend/meet/settings.py
index 47540621..d2c25adc 100755
--- a/src/backend/meet/settings.py
+++ b/src/backend/meet/settings.py
@@ -808,16 +808,12 @@ class Base(Configuration):
environ_prefix=None,
)
- # End-to-end encryption settings
+ # End-to-end encryption (passphrase-in-URL-hash mode).
+ # When True, users may opt in (account preference) to have their meetings
+ # created as end-to-end encrypted by default.
ENCRYPTION_ENABLED = values.BooleanValue(
False, environ_name="ENCRYPTION_ENABLED", environ_prefix=None
)
- ENCRYPTION_VAULT_URL = values.Value(
- None, environ_name="ENCRYPTION_VAULT_URL", environ_prefix=None
- )
- ENCRYPTION_INTERFACE_URL = values.Value(
- None, environ_name="ENCRYPTION_INTERFACE_URL", environ_prefix=None
- )
# External Applications
APPLICATION_CLIENT_ID_LENGTH = values.PositiveIntegerValue(
diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx
index 4da1c8a6..7921a2c8 100644
--- a/src/frontend/src/App.tsx
+++ b/src/frontend/src/App.tsx
@@ -14,7 +14,6 @@ import './i18n/init'
import { queryClient } from '@/api/queryClient'
import { AppInitialization } from '@/components/AppInitialization'
import { useIsSdkContext } from '@/features/sdk/hooks/useIsSdkContext'
-import { VaultClientProvider } from '@/features/encryption'
function App() {
const { i18n } = useTranslation()
@@ -26,22 +25,20 @@ function App() {
{!isSDKContext && }
-
-
-
+
+
{Object.entries(routes).map(([, route], i) => (
))}
-
-
-
-
+
+
+
)
diff --git a/src/frontend/src/api/useConfig.ts b/src/frontend/src/api/useConfig.ts
index b621cfa7..81041489 100644
--- a/src/frontend/src/api/useConfig.ts
+++ b/src/frontend/src/api/useConfig.ts
@@ -54,8 +54,6 @@ export interface ApiConfig {
}
encryption?: {
enabled: boolean
- vault_url: string
- interface_url: string
}
transcription_destination?: string
}
diff --git a/src/frontend/src/features/auth/api/ApiUser.ts b/src/frontend/src/features/auth/api/ApiUser.ts
index f7dcc62a..65012aa2 100644
--- a/src/frontend/src/features/auth/api/ApiUser.ts
+++ b/src/frontend/src/features/auth/api/ApiUser.ts
@@ -8,4 +8,5 @@ export type ApiUser = {
last_name: string
language: BackendLanguage
timezone: string
+ default_encryption: boolean
}
diff --git a/src/frontend/src/features/auth/api/updateUserPreferences.ts b/src/frontend/src/features/auth/api/updateUserPreferences.ts
index 09d13863..c0d09dbc 100644
--- a/src/frontend/src/features/auth/api/updateUserPreferences.ts
+++ b/src/frontend/src/features/auth/api/updateUserPreferences.ts
@@ -1,15 +1,18 @@
import { type ApiUser } from './ApiUser'
import { fetchApi } from '@/api/fetchApi'
-export type ApiUserPreferences = Pick
+export type ApiUserPreferences = Partial<
+ Pick
+> & { id: string }
export const updateUserPreferences = async ({
user,
}: {
user: ApiUserPreferences
}): Promise => {
- return await fetchApi(`/users/${user.id}/`, {
- method: 'PUT',
- body: JSON.stringify({ timezone: user.timezone, language: user.language }),
+ const { id, ...payload } = user
+ return await fetchApi(`/users/${id}/`, {
+ method: 'PATCH',
+ body: JSON.stringify(payload),
})
}
diff --git a/src/frontend/src/features/encryption/EncryptedMeetingBanner.tsx b/src/frontend/src/features/encryption/EncryptedMeetingBanner.tsx
deleted file mode 100644
index d5d1048c..00000000
--- a/src/frontend/src/features/encryption/EncryptedMeetingBanner.tsx
+++ /dev/null
@@ -1,181 +0,0 @@
-/**
- * Indicator shown at the top-left of an encrypted meeting.
- *
- * Initially shows the full label "End-to-end encrypted" with a lock icon.
- * After a few seconds, collapses to just the lock icon.
- * On hover, expands back with a smooth animation.
- * Clicking opens a modal explaining what E2EE means and its limitations.
- */
-import { css } from '@/styled-system/css'
-import { VStack } from '@/styled-system/jsx'
-import { RiLockFill, RiShieldCheckFill } from '@remixicon/react'
-import { useTranslation } from 'react-i18next'
-import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
-import { isEncryptedRoom, ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
-import { useEffect, useState } from 'react'
-import { Dialog, Text } from '@/primitives'
-
-const COLLAPSE_DELAY = 4000
-
-export function EncryptedMeetingBanner() {
- const roomData = useRoomData()
- const { t } = useTranslation('rooms', { keyPrefix: 'encryption' })
- const [isCollapsed, setIsCollapsed] = useState(false)
- const [isModalOpen, setIsModalOpen] = useState(false)
-
- const isStrongEncryption = roomData?.encryption_mode === ApiEncryptionMode.ADVANCED
-
- useEffect(() => {
- const timer = setTimeout(() => setIsCollapsed(true), COLLAPSE_DELAY)
- return () => clearTimeout(timer)
- }, [])
-
- if (!isEncryptedRoom(roomData)) return null
-
- const bgColor = isStrongEncryption ? '#166534' : '#1e3a5f'
- const hoverBgColor = isStrongEncryption ? '#15803d' : '#2563eb'
- const icon = isStrongEncryption
- ?
- :
- const label = isStrongEncryption ? t('bannerStrong') : t('banner')
-
- return (
- <>
-
+ )
+}
+
+function useRecordingStatus() {
+ const room = useRoomContext()
+ const [isRecording, setIsRecording] = useState(!!room.isRecording)
+
+ useEffect(() => {
+ const handler = () => setIsRecording(!!room.isRecording)
+ room.on(RoomEvent.RecordingStatusChanged, handler)
+ return () => {
+ room.off(RoomEvent.RecordingStatusChanged, handler)
+ }
+ }, [room])
+
+ return isRecording
+}
+
+export function RoomStatusBanner() {
+ const { t } = useTranslation('rooms', { keyPrefix: 'roomStatus' })
+ const { phase, pauseReason } = useEncryptionStatus()
+ const isRecording = useRecordingStatus()
+
+ if (
+ phase === EncryptionPhase.UNENCRYPTED &&
+ !isRecording &&
+ pauseReason !== 'transcript'
+ ) {
+ return null
+ }
+
+ return (
+
+ {phase === EncryptionPhase.ENCRYPTED && (
+ }
+ label={t('encrypted')}
+ background="#1e3a5f"
+ />
+ )}
+ {phase === EncryptionPhase.PAUSED && (
+ }
+ label={t('paused')}
+ background="#b45309"
+ />
+ )}
+ {pauseReason === 'transcript' && (
+ }
+ label={t('transcribing')}
+ background="#7c2d12"
+ />
+ )}
+ {isRecording && (
+ }
+ label={t('recording')}
+ background="#b91c1c"
+ pulse
+ />
+ )}
+
+ )
+}
diff --git a/src/frontend/src/features/encryption/SECURITY.md b/src/frontend/src/features/encryption/SECURITY.md
deleted file mode 100644
index 58dd270f..00000000
--- a/src/frontend/src/features/encryption/SECURITY.md
+++ /dev/null
@@ -1,117 +0,0 @@
-# Encryption Security Architecture
-
-## Threat model
-
-### What E2EE protects against
-- **Server-side data access**: The LiveKit SFU and Meet backend cannot read audio/video content
-- **Network interception**: Media frames are encrypted before leaving the client
-- **Unauthorized participants**: Restricted access + lobby ensures only admin-approved users join
-
-### Known limitations and mitigations
-
-#### Compromised LiveKit server (MITM on key exchange)
-
-**Threat**: If the LiveKit server is compromised, it could perform a Man-in-the-Middle attack on the ephemeral DH key exchange, intercepting the symmetric key.
-
-**Current mitigation**: KEY_RESPONSE is only accepted from participants with `room_admin: "true"` in their server-signed JWT attributes. This prevents non-admin participants from injecting fake keys, but does not protect against a compromised server that can forge JWT attributes.
-
-**Planned mitigations (3 levels):**
-
-##### Level 1 — Signed key exchange (requires encryption onboarding)
-
-When the admin has completed encryption onboarding via `data.encryption`:
-1. Admin signs the KEY_RESPONSE with their permanent private key (stored in IndexedDB)
-2. Receiving participant fetches admin's public key from `data.encryption` registry
-3. Verifies the signature before accepting the symmetric key
-4. If signature is invalid → **reject the key, show error, cut video**
-
-This protects against server compromise because the server cannot forge the admin's private key signature.
-
-**Requirement**: Admin must have completed encryption onboarding. If not, falls back to Level 2.
-
-##### Level 2 — SAS (Short Authentication String) verification
-
-After the ephemeral DH key exchange:
-1. Both parties compute SAS = hash(DH_shared_secret) → displayed as 4 emojis or a 6-digit code
-2. Each participant sees the SAS on their own screen (local rendering)
-3. They read it aloud to each other during the call
-4. If the SAS matches → the key exchange was not intercepted
-5. If the SAS doesn't match → MITM detected → reject the key
-
-This works because:
-- A MITM results in different DH shared secrets → different SAS codes
-- The SAS is rendered locally — the server cannot change what appears on screen
-- Real-time audio manipulation to fake the spoken SAS is extremely difficult
-
-**Requirement**: Participants must verbally compare the SAS. Optional but recommended.
-
-##### Level 3 — Trust the server (current default)
-
-Relies on the LiveKit server's integrity (JWT-signed attributes). Suitable when:
-- The server infrastructure is self-hosted and trusted
-- The threat model does not include server compromise
-- Quick, frictionless meetings are prioritized over maximum security
-
-#### Key propagation without admin
-
-**Current behavior**: Any participant who has the symmetric key can relay it to new joiners.
-
-**Risk**: If the server is compromised, it could inject a fake participant who relays a compromised key.
-
-**Planned fix**: Only accept KEY_RESPONSE from participants whose identity can be:
-- Cryptographically verified (Level 1 — signature from registered public key), or
-- Manually verified (Level 2 — SAS comparison)
-
-Non-verified key relays should show a clear warning.
-
-## Trust levels
-
-| Level | Badge | Identity verification | Key exchange | Server compromise protection |
-|-------|-------|----------------------|-------------|------------------------------|
-| Verified | 🟢 Green shield | Public key registered in `data.encryption` | Signed with permanent private key | Yes — signature cannot be forged |
-| Authenticated | 🔵 Blue shield | OIDC/ProConnect login | Ephemeral DH (unsigned) | No — relies on server integrity |
-| Anonymous | 🟡 Orange warning | None (self-declared name) | Ephemeral DH (unsigned) | No — relies on server integrity |
-
-#### Basic mode: unencrypted frame window on connection
-
-**Behavior**: LiveKit's built-in Worker passes frames through unencrypted when `!isEnabled()`.
-
-**Mitigation**: `setE2EEEnabled(true)` is called BEFORE the room connects (in Conference.tsx),
-ensuring the 'enable' message reaches the Worker before any frames flow. This eliminates the
-unencrypted window in normal operation. However, edge cases (Worker message queue delays,
-race conditions during reconnection) could theoretically still allow a few unencrypted frames.
-
-**Advanced mode**: VaultE2EEManager drops frames when the key isn't ready — no pass-through.
-
-#### Basic mode: "Decryption failed" overlay may not appear with wrong passphrase
-
-**Behavior**: When a participant joins with a wrong passphrase, the receiver may not show the
-"Decryption failed" overlay. The LiveKit Worker's error throttling (`MAX_ERRORS_PER_MINUTE = 5`)
-stops emitting `EncryptionError` events after 5 failures. Additionally, when a participant
-reconnects, the new `ParticipantTile` mounts fresh and may not receive errors referencing
-the new participant identity.
-
-**Impact**: The user sees a black tile but no error message explaining why.
-
-**Advanced mode**: VaultE2EEManager emits `EncryptionError` for each failure and signals
-`ParticipantEncryptionStatusChanged(true)` on first successful decrypt, ensuring the overlay
-appears and clears correctly.
-
-## Implementation status
-
-- [x] Basic E2EE with LiveKit Worker + passphrase in URL hash
-- [x] Advanced E2EE with VaultClient iframe (XChaCha20-Poly1305)
-- [x] Preserved codec header bytes for RTP compatibility
-- [x] Admin as key authority
-- [x] Server-signed trust attributes in JWT
-- [x] Trust badges (verified/unknown/refused/authenticated/anonymous)
-- [x] Encryption identity dialog with fingerprint verification
-- [x] Encryption settings in account menu (VaultClient onboarding)
-- [x] Fingerprint accept/refuse with `fingerprint-changed` event
-- [x] Disable recording/transcription in encrypted rooms (backend + frontend)
-- [x] Lobby bypass disabled for encrypted rooms
-- [x] Backend blocks encrypted room creation when `ENCRYPTION_ENABLED=false`
-- [ ] Signed KEY_RESPONSE (Level 1)
-- [ ] SAS verification (Level 2)
-- [ ] Restrict key propagation to verified participants only
-- [x] Mitigate unencrypted frame window (setE2EEEnabled before connection)
diff --git a/src/frontend/src/features/encryption/VaultClientProvider.tsx b/src/frontend/src/features/encryption/VaultClientProvider.tsx
deleted file mode 100644
index 9ca9d807..00000000
--- a/src/frontend/src/features/encryption/VaultClientProvider.tsx
+++ /dev/null
@@ -1,231 +0,0 @@
-/**
- * React context provider for the centralized encryption VaultClient SDK.
- *
- * The client SDK is loaded at runtime via a