This commit is contained in:
Thomas Ramé
2026-03-30 17:37:05 +02:00
parent 15133f9d6b
commit 58bc6398eb
49 changed files with 2672 additions and 80 deletions
+52 -21
View File
@@ -2,7 +2,6 @@
<img alt="meet logo" src="./docs/assets/banner-meet-fr.png" maxWidth="100%">
</p>
<p align="center">
<a href="https://github.com/suitenumerique/meet/stargazers/">
<img src="https://img.shields.io/github/stars/suitenumerique/meet" alt="">
@@ -12,11 +11,11 @@
<img alt="GitHub closed issues" src="https://img.shields.io/github/issues-closed/suitenumerique/meet"/>
<a href="https://github.com/suitenumerique/meet/blob/main/LICENSE">
<img alt="GitHub closed issues" src="https://img.shields.io/github/license/suitenumerique/meet"/>
</a>
</a>
</p>
<p align="center">
<a href="https://livekit.io/">LiveKit</a> - <a href="https://matrix.to/#/#meet-official:matrix.org">Chat with us</a> - <a href="https://github.com/orgs/suitenumerique/projects/3/views/2">Roadmap</a> - <a href="https://github.com/suitenumerique/meet/blob/main/CHANGELOG.md">Changelog</a> - <a href="https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md">Bug reports</a>
<a href="https://livekit.io/">LiveKit</a> - <a href="https://matrix.to/#/#meet-official:matrix.org">Chat with us</a> - <a href="https://github.com/orgs/suitenumerique/projects/3/views/2">Roadmap</a> - <a href="https://github.com/suitenumerique/meet/blob/main/CHANGELOG.md">Changelog</a> - <a href="https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md">Bug reports</a>
</p>
<p align="center">
@@ -28,25 +27,62 @@
## La Suite Meet: Simple Video Conferencing
Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level performance with high-quality video and audio. No installation required—simply join calls directly from your browser. Check out LiveKit's impressive optimizations in their [blog post](https://blog.livekit.io/livekit-one-dot-zero/).
### Features
- Optimized for stability in large meetings (+100 p.)
- Support for multiple screen sharing streams
- Non-persistent, secure chat
- End-to-end encryption (coming soon)
- End-to-end encryption with hybrid key distribution
- Meeting recording
- Meeting transcription & Summary (currently in beta)
- Telephony integration
- Secure participation with robust authentication and access control
- Customizable frontend style
- LiveKit Advances features including :
- speaker detection
- simulcast
- end-to-end optimizations
- speaker detection
- simulcast
- end-to-end optimizations
- selective subscription
- SVC codecs (VP9, AV1)
### End-to-end encryption
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).
La Suite Meet supports end-to-end encryption (E2EE) for meetings, ensuring that the media server (LiveKit SFU) cannot access audio/video content.
**Architecture:**
- Uses LiveKit's built-in Insertable Streams API for frame-level encryption
- Symmetric key (XChaCha20-Poly1305) encrypts all media frames
- Key exchange uses ephemeral X25519 Diffie-Hellman over LiveKit data channel (libsodium)
- The room admin is the key authority — generates and distributes the symmetric key
**Trust levels:**
| Badge | Level | Description |
|-------|-------|-------------|
| 🟢 Green shield | Verified | User completed encryption onboarding (public key registered in the encryption library). Identity cryptographically verified. |
| 🔵 Blue shield | Authenticated | User signed in via ProConnect/OIDC. Identity server-verified. Ephemeral key exchange. |
| 🟡 Orange warning | Anonymous | User not signed in. Self-declared name. Admin should verify identity before accepting. |
**Security guarantees:**
- Encrypted rooms enforce restricted access (lobby approval required)
- Trust information (`is_authenticated`, `email`) comes from server-signed JWT tokens — cannot be spoofed
- Only admin participants can respond to key exchange requests
- Recording and transcription are not available in encrypted rooms (server cannot decrypt media)
**Configuration:**
```env
ENCRYPTION_ENABLED=true
ENCRYPTION_VAULT_URL=https://data.encryption.example.fr
ENCRYPTION_INTERFACE_URL=https://encryption.example.fr
```
**Integration with the encryption library:**
When the [encryption library](https://github.com/suitenumerique/encryption) is deployed, users who complete encryption onboarding get the "verified" green shield badge. The admin can verify participants' public key fingerprints via the participants list.
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).
Were continuously adding new features to enhance your experience, with the latest updates coming soon!
@@ -63,7 +99,6 @@ On the 25th of January 2026, David Amiel, Frances Minister for Civil Service
- [Philosophy](#philosophy)
- [Open source](#open-source)
## Get started
## Docs
@@ -82,15 +117,15 @@ We use Kubernetes for our [production instance](https://visio.numerique.gouv.fr/
> Some advanced features (ex: recording, transcription) lack detailed documentation. We're working hard to provide comprehensive guides soon.
#### Known instances
We hope to see many more, here is an incomplete list of public La Suite Meet instances. Feel free to make a PR to add ones that are not listed below🙏
| Url | Org | Access |
|---------------------------------------------------------------| --- | ------- |
| [visio.numerique.gouv.fr](https://visio.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up|
| [visio.suite.anct.gouv.fr](https://visio.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up|
| [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
| [mosacloud.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
| Url | Org | Access |
| ------------------------------------------------------------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------- |
| [visio.numerique.gouv.fr](https://visio.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up |
| [visio.suite.anct.gouv.fr](https://visio.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up |
| [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
| [mosacloud.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
## Contributing
@@ -100,7 +135,6 @@ We <3 contributions of any kind, big and small:
- Open a PR (see our instructions on [developing La Suite Meet locally](https://github.com/suitenumerique/meet/blob/main/docs/developping_locally.md))
- Submit a [feature request](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=enhancement&template=Feature_request.md) or [bug report](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md)
## Philosophy
Were relentlessly focused on building the best open-source video conferencing product—La Suite Meet. Growth comes from creating something people truly need, not just from chasing metrics.
@@ -109,7 +143,6 @@ Our users come first. Were committed to making La Suite Meet as accessible an
Most of the heavy engineering is handled by the incredible LiveKit team, allowing us to focus on delivering a top-tier product. We follow extreme programming practices, favoring pair programming and quick, iterative releases. Challenge our tech and architecture—simplicity is always our top priority.
## Open-source
Gov 🇫🇷 supports open source! This project is available under [MIT license](https://github.com/suitenumerique/meet/blob/0cc2a7b7b4f4821e2c4d9d790efa739622bb6601/LICENSE).
@@ -121,14 +154,13 @@ To learn more, don't hesitate to [reach out](mailto:visio@numerique.gouv.fr).
Come help us make La Suite Meet even better. We're growing fast and [would love some help](mailto:visio@numerique.gouv.fr).
## Contributors 🧞
<a href="https://github.com/suitenumerique/meet/graphs/contributors">
<img src="https://contrib.rocks/image?repo=suitenumerique/meet" />
</a>
## Credits
## Credits
We're using the awesome [LiveKit](https://livekit.io/) implementation. We're also thankful to the teams behind [Django Rest Framework](https://www.django-rest-framework.org/), [Vite.js](https://vite.dev/), and [React Aria](https://github.com/adobe/react-spectrum) — Thanks for your amazing work!
This project is tested with BrowserStack.
@@ -137,4 +169,3 @@ This project is tested with BrowserStack.
Code in this repository is published under the MIT license by DINUM (Direction interministériel du numérique).
Documentation (in the docs/) directory is released under the [Etalab-2.0 license](https://spdx.org/licenses/etalab-2.0.html).
+6
View File
@@ -73,5 +73,11 @@ 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.update(settings.FRONTEND_CONFIGURATION)
return Response(frontend_configuration)
+25 -1
View File
@@ -128,9 +128,27 @@ class RoomSerializer(serializers.ModelSerializer):
class Meta:
model = models.Room
fields = ["id", "name", "slug", "configuration", "access_level", "pin_code"]
fields = ["id", "name", "slug", "configuration", "access_level", "pin_code", "encryption_enabled"]
read_only_fields = ["id", "slug", "pin_code"]
def validate_access_level(self, value):
"""Encrypted rooms must stay restricted — prevent downgrading access level."""
instance = self.instance
if instance and instance.encryption_enabled and value != models.RoomAccessLevel.RESTRICTED:
raise serializers.ValidationError(
"Encrypted rooms require restricted access level to enforce lobby approval."
)
return value
def validate_encryption_enabled(self, value):
"""Once encryption is enabled on a room, it cannot be disabled."""
instance = self.instance
if instance and instance.encryption_enabled and not value:
raise serializers.ValidationError(
"Encryption cannot be disabled once enabled on a room."
)
return value
def to_representation(self, instance):
"""
Add users only for administrator users.
@@ -172,6 +190,12 @@ class RoomSerializer(serializers.ModelSerializer):
if should_access_room:
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,
+22 -1
View File
@@ -281,6 +281,11 @@ class RoomViewSet(
def perform_create(self, serializer):
"""Set the current user as owner of the newly created room."""
# Encrypted rooms must use restricted access to enforce lobby approval
# before the encryption key is shared with participants.
if serializer.validated_data.get("encryption_enabled"):
serializer.validated_data["access_level"] = models.RoomAccessLevel.RESTRICTED
room = serializer.save()
models.ResourceAccess.objects.create(
resource=room,
@@ -396,12 +401,21 @@ class RoomViewSet(
serializer.is_valid(raise_exception=True)
room = self.get_object()
validated_data = serializer.validated_data
# 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(
room=room,
request=request,
**serializer.validated_data,
**validated_data,
)
response = drf_response.Response({**participant.to_dict(), "livekit": livekit})
lobby_service.prepare_response(response, participant.id)
@@ -464,6 +478,13 @@ class RoomViewSet(
lobby_service = LobbyService()
participants = lobby_service.list_waiting_participants(room.id)
# Only expose email in encrypted rooms (needed for admin identity verification).
# Strip it otherwise to avoid leaking personal data.
if not room.encryption_enabled:
for p in participants:
p.pop("email", None)
return drf_response.Response({"participants": participants})
@decorators.action(
@@ -0,0 +1,20 @@
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",
),
),
]
+5
View File
@@ -388,6 +388,11 @@ class Room(Resource):
choices=RoomAccessLevel.choices,
default=settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
)
encryption_enabled = 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,
default=dict,
+19 -3
View File
@@ -46,15 +46,21 @@ class LobbyParticipant:
username: str
color: str
id: str
is_authenticated: bool = False
email: Optional[str] = None
def to_dict(self) -> Dict[str, str]:
"""Serialize the participant object to a dict representation."""
return {
result = {
"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
return result
@classmethod
def from_dict(cls, data: dict) -> "LobbyParticipant":
@@ -68,6 +74,8 @@ class LobbyParticipant:
username=data["username"],
id=data["id"],
color=data["color"],
is_authenticated=data.get("is_authenticated", False),
email=data.get("email"),
)
except (KeyError, ValueError) as e:
logger.exception("Error creating Participant from dict:")
@@ -170,7 +178,11 @@ class LobbyService:
livekit_config = None
if participant is None:
participant = self.enter(room.id, participant_id, username)
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,
)
elif participant.status == LobbyParticipantStatus.WAITING:
self.refresh_waiting_status(room.id, participant_id)
@@ -201,7 +213,9 @@ 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,
) -> LobbyParticipant:
"""Add participant to waiting lobby.
@@ -216,6 +230,8 @@ class LobbyService:
username=username,
id=participant_id,
color=color,
is_authenticated=is_authenticated,
email=email,
)
try:
+17 -3
View File
@@ -112,6 +112,22 @@ 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 (visible to other participants
# for identity verification in encrypted rooms)
if not user.is_anonymous:
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"],
@@ -120,9 +136,7 @@ def generate_token(
.with_grants(video_grants)
.with_identity(identity)
.with_name(username or default_username)
.with_attributes(
{"color": color, "room_admin": "true" if is_admin_or_owner else "false"}
)
.with_attributes(attributes)
)
return token.to_jwt()
+11
View File
@@ -808,6 +808,17 @@ class Base(Configuration):
environ_prefix=None,
)
# End-to-end encryption settings
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(
40,
+16
View File
@@ -28,6 +28,7 @@
"i18next-parser": "9.3.0",
"i18next-resources-to-backend": "1.2.1",
"libphonenumber-js": "1.12.10",
"libsodium-wrappers-sumo": "0.8.2",
"livekit-client": "2.17.1",
"posthog-js": "1.342.1",
"react": "18.3.1",
@@ -8422,6 +8423,21 @@
"integrity": "sha512-E91vHJD61jekHHR/RF/E83T/CMoaLXT7cwYA75T4gim4FZjnM6hbJjVIGg7chqlSqRsSvQ3izGmOjHy1SQzcGQ==",
"license": "MIT"
},
"node_modules/libsodium-sumo": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/libsodium-sumo/-/libsodium-sumo-0.8.2.tgz",
"integrity": "sha512-uMgnjphJ717jLN+jFG1HUgNrK/gOVVfaO1DGZ1Ig/fKLKLVhvaH/sM1I1v784JFvmkJDaczDpi7xSYC4Jvdo1Q==",
"license": "ISC"
},
"node_modules/libsodium-wrappers-sumo": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/libsodium-wrappers-sumo/-/libsodium-wrappers-sumo-0.8.2.tgz",
"integrity": "sha512-wd1xAY++Kr6VMikSaa4EPRAHJmFvNlGWiiwU3Jh3GR1zRYF3/I3vy/wYsr4k3LVsNzwb9sqfEQ4LdVQ6zEebyQ==",
"license": "ISC",
"dependencies": {
"libsodium-sumo": "^0.8.0"
}
},
"node_modules/lightningcss": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
+1
View File
@@ -33,6 +33,7 @@
"i18next-parser": "9.3.0",
"i18next-resources-to-backend": "1.2.1",
"libphonenumber-js": "1.12.10",
"libsodium-wrappers-sumo": "0.8.2",
"livekit-client": "2.17.1",
"posthog-js": "1.342.1",
"react": "18.3.1",
+11 -8
View File
@@ -14,6 +14,7 @@ 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()
@@ -25,20 +26,22 @@ function App() {
<QueryClientProvider client={queryClient}>
{!isSDKContext && <AppInitialization />}
<Suspense fallback={null}>
<I18nProvider locale={i18n.language}>
<Layout>
<VaultClientProvider>
<I18nProvider locale={i18n.language}>
<Layout>
<Switch>
{Object.entries(routes).map(([, route], i) => (
<Route key={i} path={route.path} component={route.Component} />
))}
<Route component={NotFoundScreen} />
</Switch>
</Layout>
<ReactQueryDevtools
initialIsOpen={false}
buttonPosition="bottom-left"
/>
</I18nProvider>
</Layout>
<ReactQueryDevtools
initialIsOpen={false}
buttonPosition="bottom-left"
/>
</I18nProvider>
</VaultClientProvider>
</Suspense>
</QueryClientProvider>
)
+5
View File
@@ -52,6 +52,11 @@ export interface ApiConfig {
enable_firefox_proxy_workaround: boolean
default_sources: string[]
}
encryption?: {
enabled: boolean
vault_url: string
interface_url: string
}
transcription_destination?: string
}
@@ -0,0 +1,177 @@
/**
* 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 { useVaultClient } from './VaultClientProvider'
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 { hasKeys } = useVaultClient()
const [isCollapsed, setIsCollapsed] = useState(false)
const [isModalOpen, setIsModalOpen] = useState(false)
// Strong = admin has encryption onboarding (can sign key exchange)
// Standard = admin is only ProConnect (ephemeral key exchange, trusts server)
const isStrongEncryption = hasKeys === true
useEffect(() => {
const timer = setTimeout(() => setIsCollapsed(true), COLLAPSE_DELAY)
return () => clearTimeout(timer)
}, [])
if (!roomData?.encryption_enabled) return null
const bgColor = isStrongEncryption ? '#166534' : '#1e3a5f'
const hoverBgColor = isStrongEncryption ? '#15803d' : '#2563eb'
const icon = isStrongEncryption
? <RiShieldCheckFill size={13} color="white" className={css({ flexShrink: 0 })} />
: <RiLockFill size={13} color="white" className={css({ flexShrink: 0 })} />
const label = isStrongEncryption ? t('bannerStrong') : t('banner')
return (
<>
<div
onMouseEnter={() => setIsCollapsed(false)}
onMouseLeave={() => setIsCollapsed(true)}
onClick={() => setIsModalOpen(true)}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && setIsModalOpen(true)}
aria-label={label}
className={css({
position: 'absolute',
top: '0.5rem',
left: '0.5rem',
zIndex: 10,
display: 'flex',
alignItems: 'center',
gap: '0.35rem',
padding: '0.3rem 0.6rem',
borderRadius: '1rem',
border: '2px solid rgba(0, 0, 0, 0.3)',
cursor: 'pointer',
overflow: 'hidden',
transition: 'all 300ms ease',
maxWidth: isCollapsed ? '2.2rem' : '16rem',
whiteSpace: 'nowrap',
})}
style={{
backgroundColor: bgColor,
paddingRight: isCollapsed ? '0.3rem' : '0.6rem',
}}
onMouseOver={(e) => { (e.currentTarget as HTMLElement).style.backgroundColor = hoverBgColor }}
onMouseOut={(e) => { (e.currentTarget as HTMLElement).style.backgroundColor = bgColor }}
>
{icon}
<span
className={css({
fontSize: '0.7rem',
fontWeight: 600,
color: 'white',
letterSpacing: '0.02em',
transition: 'opacity 200ms ease',
})}
style={{
opacity: isCollapsed ? 0 : 1,
}}
>
{label}
</span>
</div>
<Dialog
isOpen={isModalOpen}
onOpenChange={setIsModalOpen}
role="dialog"
type="flex"
title={t('bannerModal.title')}
>
<VStack
gap="1rem"
alignItems="start"
className={css({ maxWidth: '24rem' })}
>
<Text variant="sm">{t('bannerModal.description')}</Text>
<VStack gap="0.5rem" alignItems="start" className={css({ width: '100%' })}>
<Text variant="sm" className={css({ fontWeight: 600 })}>
{t('bannerModal.guarantees')}
</Text>
<ul
className={css({
paddingLeft: '1.5rem',
fontSize: '0.85rem',
listStyleType: 'disc',
display: 'flex',
flexDirection: 'column',
gap: '0.4rem',
'& li': {
paddingLeft: '0.25rem',
},
'& li::marker': {
color: '#22c55e',
},
})}
>
<li>{t('bannerModal.guarantee1')}</li>
<li>{t('bannerModal.guarantee2')}</li>
<li>{t('bannerModal.guarantee3')}</li>
</ul>
</VStack>
<VStack gap="0.5rem" alignItems="start" className={css({ width: '100%' })}>
<Text variant="sm" className={css({ fontWeight: 600 })}>
{t('bannerModal.limitations')}
</Text>
<ul
className={css({
paddingLeft: '1.5rem',
fontSize: '0.85rem',
listStyleType: 'disc',
display: 'flex',
flexDirection: 'column',
gap: '0.4rem',
'& li': {
paddingLeft: '0.25rem',
},
'& li::marker': {
color: '#f59e0b',
},
})}
>
<li>{t('bannerModal.limitation1')}</li>
<li>{t('bannerModal.limitation2')}</li>
</ul>
</VStack>
<Text
variant="note"
className={css({
fontSize: '0.75rem',
borderTop: '1px solid',
borderColor: 'greyscale.200',
paddingTop: '0.75rem',
width: '100%',
})}
>
{t('bannerModal.note')}
</Text>
</VStack>
</Dialog>
</>
)
}
@@ -0,0 +1,65 @@
/**
* Per-participant encryption trust badge.
*
* Single icon per trust level, consistent with lobby badges:
* - "verified": Green shield — identity confirmed via PKI
* - "authenticated": Blue shield — ProConnect-authenticated, ephemeral key
* - "anonymous": Orange warning — identity not verified, ephemeral key
* - default (no trust level): Lock icon — encrypted, trust level unknown
*/
import { RiShieldCheckFill, RiAlertFill, RiLockFill } from '@remixicon/react'
import type { TrustLevel } from './types'
import { css } from '@/styled-system/css'
import { TooltipWrapper } from '@/primitives/TooltipWrapper'
interface EncryptionBadgeProps {
trustLevel: TrustLevel | null
isEncrypted: boolean
}
export function EncryptionBadge({
trustLevel,
isEncrypted,
}: EncryptionBadgeProps) {
if (!isEncrypted) return null
let icon: React.ReactNode
let tooltip: string
switch (trustLevel) {
case 'verified':
icon = <RiShieldCheckFill size={14} color="#22c55e" />
tooltip = 'Verified encryption'
break
case 'authenticated':
icon = <RiShieldCheckFill size={14} color="#3b82f6" />
tooltip = 'Encrypted (authenticated)'
break
case 'anonymous':
icon = <RiAlertFill size={14} color="#f59e0b" />
tooltip = 'Encrypted (anonymous)'
break
default:
icon = <RiLockFill size={14} />
tooltip = 'Encrypted'
break
}
return (
<TooltipWrapper tooltip={tooltip} tooltipType="instant">
<span
role="img"
aria-label={tooltip}
tabIndex={0}
className={css({
display: 'inline-flex',
alignItems: 'center',
marginRight: '0.25rem',
cursor: 'default',
})}
>
{icon}
</span>
</TooltipWrapper>
)
}
@@ -0,0 +1,102 @@
/**
* Overlay shown during encryption key exchange.
*
* When a participant joins an encrypted room, there's a brief period
* between connection and receiving the symmetric key where media
* cannot be decrypted. This overlay provides feedback during that time.
*/
import { css } from '@/styled-system/css'
import { VStack } from '@/styled-system/jsx'
import { Text } from '@/primitives'
import { Spinner } from '@/primitives/Spinner'
import { RiLockFill, RiAlertFill } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
export function EncryptionSetupOverlay({
isSettingUp,
error,
}: {
isSettingUp: boolean
error: string | null
}) {
const { t } = useTranslation('rooms', { keyPrefix: 'encryption' })
if (!isSettingUp && !error) return null
return (
<div
className={css({
position: 'absolute',
inset: 0,
zIndex: 100,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.85)',
})}
>
<VStack gap="1rem" alignItems="center">
<RiLockFill size={32} color="white" />
{error ? (
<>
<RiAlertFill size={32} color="#f87171" />
<Text
className={css({
color: '#f87171',
fontSize: '1.1rem',
fontWeight: 500,
textAlign: 'center',
})}
>
{t('error.title')}
</Text>
<Text
className={css({
color: 'greyscale.300',
fontSize: '0.85rem',
textAlign: 'center',
maxWidth: '20rem',
})}
>
{error}
</Text>
<Text
className={css({
color: 'greyscale.400',
fontSize: '0.75rem',
textAlign: 'center',
maxWidth: '20rem',
})}
>
{t('error.hint')}
</Text>
</>
) : (
<>
<Text
className={css({
color: 'white',
fontSize: '1.1rem',
fontWeight: 500,
textAlign: 'center',
})}
>
{t('settingUp.title')}
</Text>
<Text
className={css({
color: 'greyscale.300',
fontSize: '0.85rem',
textAlign: 'center',
maxWidth: '20rem',
})}
>
{t('settingUp.description')}
</Text>
<Spinner />
</>
)}
</VStack>
</div>
)
}
@@ -0,0 +1,143 @@
/**
* Modal explaining encryption trust levels.
* Shown when admin clicks the trust badge in the waiting room.
*/
import { css } from '@/styled-system/css'
import { VStack, HStack } from '@/styled-system/jsx'
import { Dialog, Text } from '@/primitives'
import { RiShieldCheckFill, RiShieldCheckLine, RiAlertLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
interface EncryptionTrustModalProps {
isOpen: boolean
onOpenChange: (open: boolean) => void
participantName: string
isAuthenticated: boolean
}
export function EncryptionTrustModal({
isOpen,
onOpenChange,
participantName,
isAuthenticated,
}: EncryptionTrustModalProps) {
const { t } = useTranslation('rooms', { keyPrefix: 'encryption.trustModal' })
return (
<Dialog
isOpen={isOpen}
onOpenChange={onOpenChange}
role="dialog"
type="flex"
title={t('title')}
>
<VStack
gap="1rem"
alignItems="start"
className={css({ maxWidth: '22rem' })}
>
<Text variant="sm">{t('intro', { name: participantName })}</Text>
{isAuthenticated ? (
<HStack
gap="0.75rem"
className={css({
backgroundColor: '#eff6ff',
padding: '0.75rem',
borderRadius: '0.5rem',
width: '100%',
border: '1px solid #bfdbfe',
})}
>
<RiShieldCheckLine
size={24}
color="#3b82f6"
className={css({ flexShrink: 0 })}
/>
<VStack gap="0.25rem" alignItems="start">
<Text className={css({ fontWeight: 600, fontSize: '0.85rem' })}>
{t('authenticated.title')}
</Text>
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
{t('authenticated.description')}
</Text>
</VStack>
</HStack>
) : (
<HStack
gap="0.75rem"
className={css({
backgroundColor: '#fffbeb',
padding: '0.75rem',
borderRadius: '0.5rem',
width: '100%',
border: '1px solid #fde68a',
})}
>
<RiAlertLine
size={24}
color="#f59e0b"
className={css({ flexShrink: 0 })}
/>
<VStack gap="0.25rem" alignItems="start">
<Text className={css({ fontWeight: 600, fontSize: '0.85rem' })}>
{t('anonymous.title')}
</Text>
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
{t('anonymous.description')}
</Text>
</VStack>
</HStack>
)}
<VStack
gap="0.5rem"
alignItems="start"
className={css({
borderTop: '1px solid',
borderColor: 'greyscale.200',
paddingTop: '0.75rem',
width: '100%',
})}
>
<Text
variant="note"
className={css({ fontWeight: 600, fontSize: '0.8rem' })}
>
{t('levels.title')}
</Text>
<HStack gap="0.5rem" alignItems="start">
<RiShieldCheckFill
size={16}
color="#22c55e"
className={css({ flexShrink: 0, marginTop: '2px' })}
/>
<Text variant="note" className={css({ fontSize: '0.75rem' })}>
{t('levels.verified')}
</Text>
</HStack>
<HStack gap="0.5rem" alignItems="start">
<RiShieldCheckLine
size={16}
color="#3b82f6"
className={css({ flexShrink: 0, marginTop: '2px' })}
/>
<Text variant="note" className={css({ fontSize: '0.75rem' })}>
{t('levels.authenticated')}
</Text>
</HStack>
<HStack gap="0.5rem" alignItems="start">
<RiAlertLine
size={16}
color="#f59e0b"
className={css({ flexShrink: 0, marginTop: '2px' })}
/>
<Text variant="note" className={css({ fontSize: '0.75rem' })}>
{t('levels.anonymous')}
</Text>
</HStack>
</VStack>
</VStack>
</Dialog>
)
}
@@ -0,0 +1,230 @@
/**
* Dialog showing a participant's encryption fingerprint.
* Allows the admin to verify, accept, or refuse the fingerprint.
*
* This connects to the encryption library's VaultClient to check/accept/refuse
* fingerprints from the TOFU (Trust On First Use) registry.
*/
import { css } from '@/styled-system/css'
import { VStack, HStack } from '@/styled-system/jsx'
import { Dialog, Text, Button } from '@/primitives'
import {
RiShieldCheckFill,
RiShieldCheckLine,
RiAlertLine,
RiCheckLine,
RiCloseLine,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useVaultClient } from './VaultClientProvider'
import { useEffect, useState } from 'react'
interface FingerprintDialogProps {
isOpen: boolean
onOpenChange: (open: boolean) => void
participantName: string
participantEmail?: string
suiteUserId?: string
isAuthenticated: boolean
}
type FingerprintStatus = 'loading' | 'no-key' | 'trusted' | 'refused' | 'unknown' | 'error'
export function FingerprintDialog({
isOpen,
onOpenChange,
participantName,
participantEmail,
suiteUserId,
isAuthenticated,
}: FingerprintDialogProps) {
const { t } = useTranslation('rooms', { keyPrefix: 'encryption.fingerprint' })
const { client: vaultClient } = useVaultClient()
const [status, setStatus] = useState<FingerprintStatus>('loading')
const [fingerprint, setFingerprint] = useState<string | null>(null)
useEffect(() => {
if (!isOpen || !vaultClient || !suiteUserId) {
setStatus(isAuthenticated ? 'loading' : 'no-key')
return
}
let cancelled = false
async function checkFingerprint() {
try {
// Timeout after 3 seconds — the encryption server may not be available
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('timeout')), 3000)
)
const fetchResult = await Promise.race([
vaultClient!.fetchPublicKeys([suiteUserId!]),
timeout,
])
const publicKey = fetchResult.publicKeys[suiteUserId!]
if (!publicKey || cancelled) {
setStatus('no-key')
return
}
const { results } = await Promise.race([
vaultClient!.checkFingerprints(
{ [suiteUserId!]: '' },
undefined
),
timeout,
])
if (cancelled) return
const result = results.find((r) => r.userId === suiteUserId)
if (result) {
setFingerprint(result.providedFingerprint)
setStatus(result.status)
} else {
setStatus('no-key')
}
} catch {
if (!cancelled) setStatus('no-key')
}
}
checkFingerprint()
return () => { cancelled = true }
}, [isOpen, vaultClient, suiteUserId, isAuthenticated])
const handleAccept = async () => {
if (!vaultClient || !suiteUserId || !fingerprint) return
try {
await vaultClient.acceptFingerprint(suiteUserId, fingerprint)
setStatus('trusted')
} catch {
// Failed to accept
}
}
const handleRefuse = async () => {
if (!vaultClient || !suiteUserId || !fingerprint) return
try {
await vaultClient.refuseFingerprint(suiteUserId, fingerprint)
setStatus('refused')
} catch {
// Failed to refuse
}
}
return (
<Dialog
isOpen={isOpen}
onOpenChange={onOpenChange}
role="dialog"
type="flex"
title={t('title')}
>
<VStack
gap="0.75rem"
alignItems="start"
className={css({ maxWidth: '22rem' })}
>
<HStack gap="0.5rem">
<Text className={css({ fontWeight: 600 })}>{participantName}</Text>
{participantEmail && (
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
{participantEmail}
</Text>
)}
</HStack>
{status === 'loading' && (
<Text variant="note">{t('loading')}</Text>
)}
{status === 'no-key' && (
<HStack
gap="0.5rem"
className={css({
backgroundColor: '#fffbeb',
padding: '0.75rem',
borderRadius: '0.5rem',
width: '100%',
border: '1px solid #fde68a',
})}
>
<RiAlertLine size={20} color="#f59e0b" className={css({ flexShrink: 0 })} />
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
{t('noKey')}
</Text>
</HStack>
)}
{status === 'error' && (
<Text variant="note" className={css({ color: '#ef4444' })}>
{t('error')}
</Text>
)}
{(status === 'trusted' || status === 'refused' || status === 'unknown') && fingerprint && (
<>
<VStack
gap="0.25rem"
className={css({
backgroundColor: 'greyscale.50',
padding: '0.75rem',
borderRadius: '0.5rem',
width: '100%',
fontFamily: 'monospace',
fontSize: '0.85rem',
letterSpacing: '0.05em',
wordBreak: 'break-all',
})}
>
<Text variant="note" className={css({ fontSize: '0.7rem', fontFamily: 'inherit' })}>
{t('fingerprintLabel')}
</Text>
{fingerprint}
</VStack>
{status === 'trusted' && (
<HStack gap="0.5rem" className={css({ color: '#22c55e' })}>
<RiShieldCheckFill size={18} />
<Text className={css({ fontSize: '0.85rem', fontWeight: 600, color: 'inherit' })}>
{t('trusted')}
</Text>
</HStack>
)}
{status === 'refused' && (
<HStack gap="0.5rem" className={css({ color: '#ef4444' })}>
<RiCloseLine size={18} />
<Text className={css({ fontSize: '0.85rem', fontWeight: 600, color: 'inherit' })}>
{t('refused')}
</Text>
</HStack>
)}
{status === 'unknown' && (
<VStack gap="0.5rem" className={css({ width: '100%' })}>
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
{t('unknownDescription')}
</Text>
<HStack gap="0.5rem">
<Button size="sm" variant="primary" onPress={handleAccept}>
<RiCheckLine size={16} />
{t('accept')}
</Button>
<Button size="sm" variant="secondaryText" onPress={handleRefuse}>
<RiCloseLine size={16} />
{t('refuse')}
</Button>
</HStack>
</VStack>
)}
</>
)}
</VStack>
</Dialog>
)
}
@@ -0,0 +1,112 @@
/**
* Hybrid key distributor: determines the best key distribution method per participant.
*
* For each participant joining an encrypted call:
* 1. Check if they have a registered public key (via VaultClient/encryption library)
* → If YES: wrap symmetric key with their public key (PKI path) → trust level "verified"
* 2. Check if they are authenticated via ProConnect
* → If YES but no public key: use ephemeral DH → trust level "authenticated"
* 3. Otherwise: use ephemeral DH → trust level "anonymous"
*
* The symmetric key is always the same for everyone — only the distribution channel varies.
*/
import type { TrustLevel } from './types'
import { PARTICIPANT_TRUST_ATTR } from './types'
export interface ParticipantEncryptionInfo {
identity: string
trustLevel: TrustLevel
hasPublicKey: boolean
isAuthenticated: boolean
}
/**
* Determine the trust level for a participant based on their encryption capabilities.
*/
export function determineTrustLevel(
hasPublicKey: boolean,
isAuthenticated: boolean
): TrustLevel {
if (hasPublicKey) return 'verified'
if (isAuthenticated) return 'authenticated'
return 'anonymous'
}
/**
* Derive trust level from participant's server-signed attributes.
*
* The `is_authenticated` attribute is set by the backend in the LiveKit JWT token
* and cannot be spoofed by clients. It indicates whether the participant
* authenticated via OIDC (ProConnect/Keycloak).
*
* TODO: Once VaultClient integration is complete, also check for registered
* public keys to distinguish "verified" (PKI) from "authenticated" (ephemeral).
*/
export function getTrustLevelFromAttributes(
attributes: Record<string, string> | undefined
): TrustLevel | null {
if (!attributes) return null
// Check for explicit trust level (set by future PKI integration)
const explicitLevel = attributes[PARTICIPANT_TRUST_ATTR]
if (explicitLevel === 'verified' || explicitLevel === 'authenticated' || explicitLevel === 'anonymous') {
return explicitLevel
}
// Derive from server-signed is_authenticated attribute
if (attributes.is_authenticated === 'true') {
return 'authenticated'
}
return 'anonymous'
}
/**
* Try to distribute the symmetric key via PKI (encryption library).
* Returns true if successful, false if the participant doesn't have a public key.
*/
export async function distributeKeyViaPKI(
vaultClient: VaultClient,
symmetricKey: Uint8Array,
participantUserId: string
): Promise<{ success: boolean; encryptedKey?: ArrayBuffer }> {
try {
const { publicKeys } = await vaultClient.fetchPublicKeys([
participantUserId,
])
const publicKey = publicKeys[participantUserId]
if (!publicKey) {
return { success: false }
}
// Use encryptWithoutKey to wrap the symmetric key for this user
const { encryptedKeys } = await vaultClient.shareKeys(
symmetricKey.buffer as ArrayBuffer,
{ [participantUserId]: publicKey }
)
const encryptedKey = encryptedKeys[participantUserId]
if (!encryptedKey) {
return { success: false }
}
return { success: true, encryptedKey }
} catch (err) {
console.warn(
'[Encryption] PKI key distribution failed for participant:',
participantUserId,
err
)
return { success: false }
}
}
/**
* Encode trust level into participant attributes for badge display.
*/
export function encodeTrustLevelAttribute(
trustLevel: TrustLevel
): Record<string, string> {
return { [PARTICIPANT_TRUST_ATTR]: trustLevel }
}
@@ -0,0 +1,382 @@
/**
* In-call key exchange protocol using ephemeral X25519 Diffie-Hellman
* over LiveKit's reliable data channel.
*
* Uses libsodium (same as the encryption library) for algorithmic consistency:
* - X25519 for ephemeral key exchange (crypto_scalarmult)
* - XChaCha20-Poly1305 for encrypting the symmetric key (crypto_secretbox)
* - BLAKE2b for deriving a shared key from the ECDH shared secret (crypto_generichash)
*
* Protocol:
* 1. New participant generates ephemeral X25519 key pair
* 2. Sends KEY_REQUEST with their ephemeral public key to the room
* 3. An admin (room_admin=true) who has the symmetric key responds with KEY_RESPONSE:
* - Generates their own ephemeral X25519 key pair
* - Derives shared secret via X25519(their ephemeral secret, requester's public key)
* - Derives encryption key via BLAKE2b(shared secret)
* - Encrypts the symmetric key with XChaCha20-Poly1305 using the derived key
* 4. Requester derives the same shared secret and decrypts the symmetric key
* 5. Requester sends KEY_ACK to confirm receipt
*
* Security:
* - Only KEY_RESPONSE from participants with room_admin=true are accepted
* - LiveKit's participant identity is server-verified (JWT-signed), so admin status cannot be spoofed
* - Each exchange uses unique ephemeral key pairs (forward secrecy per exchange)
* - Waiting room participants cannot send data channel messages (they're not in the LiveKit room)
*/
import _sodium from 'libsodium-wrappers-sumo'
import {
KeyExchangeMessage,
KeyExchangeMessageType,
KEY_EXCHANGE_TOPIC,
} from './types'
import type { Room, RemoteParticipant } from 'livekit-client'
// Ensure libsodium is initialized
let sodiumReady: Promise<void> | null = null
async function ensureSodium(): Promise<typeof _sodium> {
if (!sodiumReady) {
sodiumReady = _sodium.ready
}
await sodiumReady
return _sodium
}
// Helpers for base64 encoding/decoding
function toBase64(bytes: Uint8Array): string {
const sodium = _sodium
return sodium.to_base64(bytes, sodium.base64_variants.URLSAFE_NO_PADDING)
}
function fromBase64(base64: string): Uint8Array {
const sodium = _sodium
return sodium.from_base64(base64, sodium.base64_variants.URLSAFE_NO_PADDING)
}
/**
* Generate an ephemeral X25519 key pair using libsodium.
*/
async function generateEphemeralKeyPair(): Promise<{
publicKey: Uint8Array
secretKey: Uint8Array
}> {
const sodium = await ensureSodium()
const secretKey = sodium.randombytes_buf(sodium.crypto_scalarmult_SCALARBYTES)
const publicKey = sodium.crypto_scalarmult_base(secretKey)
return { publicKey, secretKey }
}
/**
* Derive a shared secret from X25519 ECDH, then derive an encryption key via BLAKE2b.
* Uses the same pattern as the encryption library's hybridEncapsulate/hybridDecapsulate.
*/
async function deriveSharedKey(
mySecretKey: Uint8Array,
theirPublicKey: Uint8Array
): Promise<Uint8Array> {
const sodium = await ensureSodium()
// X25519 scalar multiplication to get raw shared secret
const rawSharedSecret = sodium.crypto_scalarmult(mySecretKey, theirPublicKey)
// Derive a 32-byte key using BLAKE2b with a domain-separation tag
return sodium.crypto_generichash(
32,
rawSharedSecret,
sodium.from_string('meet-key-exchange')
)
}
/**
* Encrypt the symmetric key using XChaCha20-Poly1305 (same as encryption library).
* Returns nonce + ciphertext.
*/
async function encryptWithSharedKey(
sharedKey: Uint8Array,
symmetricKey: Uint8Array
): Promise<Uint8Array> {
const sodium = await ensureSodium()
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES)
const ciphertext = sodium.crypto_secretbox_easy(symmetricKey, nonce, sharedKey)
// Prepend nonce to ciphertext (same format as encryption library)
const result = new Uint8Array(nonce.length + ciphertext.length)
result.set(nonce, 0)
result.set(ciphertext, nonce.length)
return result
}
/**
* Decrypt the symmetric key using XChaCha20-Poly1305.
*/
async function decryptWithSharedKey(
sharedKey: Uint8Array,
encryptedData: Uint8Array
): Promise<Uint8Array> {
const sodium = await ensureSodium()
const nonce = encryptedData.slice(0, sodium.crypto_secretbox_NONCEBYTES)
const ciphertext = encryptedData.slice(sodium.crypto_secretbox_NONCEBYTES)
return sodium.crypto_secretbox_open_easy(ciphertext, nonce, sharedKey)
}
/**
* Check if a participant is a room admin (server-verified via JWT).
*/
function isParticipantAdmin(
participant: { attributes?: Record<string, string> } | undefined
): boolean {
return participant?.attributes?.room_admin === 'true'
}
/**
* Manages the in-call key exchange protocol for a single participant.
*/
export class InCallKeyExchange {
private room: Room
private symmetricKey: Uint8Array | null = null
private ephemeralKeyPair: {
publicKey: Uint8Array
secretKey: Uint8Array
} | null = null
private onKeyReceived: ((key: Uint8Array) => void) | null = null
private boundHandleMessage: (
payload: Uint8Array,
participant: RemoteParticipant | undefined,
kind: unknown,
topic: string | undefined
) => void
constructor(room: Room) {
this.room = room
this.boundHandleMessage = this.handleDataMessage.bind(this)
}
/**
* Start listening for key exchange messages on the data channel.
*/
startListening(): void {
this.room.on('dataReceived', this.boundHandleMessage)
}
/**
* Stop listening and clean up.
*/
stopListening(): void {
this.room.off('dataReceived', this.boundHandleMessage)
this.ephemeralKeyPair = null
}
/**
* Set the symmetric key (for admins who generate or receive it).
*/
setSymmetricKey(key: Uint8Array): void {
this.symmetricKey = key
}
/**
* Check if we already have the symmetric key.
*/
hasKey(): boolean {
return this.symmetricKey !== null
}
/**
* Request the symmetric key from admin participants in the room.
* Returns a promise that resolves when the key is received from a verified admin.
*/
async requestKey(timeoutMs = 15000): Promise<Uint8Array> {
await ensureSodium()
// Generate ephemeral key pair for this exchange
this.ephemeralKeyPair = await generateEphemeralKeyPair()
return new Promise<Uint8Array>((resolve, reject) => {
const timeout = setTimeout(() => {
this.onKeyReceived = null
reject(new Error('Key exchange timeout: no admin responded'))
}, timeoutMs)
this.onKeyReceived = (key: Uint8Array) => {
clearTimeout(timeout)
this.symmetricKey = key
resolve(key)
}
// Send KEY_REQUEST with our ephemeral public key
const message: KeyExchangeMessage = {
type: KeyExchangeMessageType.KEY_REQUEST,
senderIdentity: this.room.localParticipant.identity,
payload: toBase64(this.ephemeralKeyPair!.publicKey),
}
this.sendMessage(message)
})
}
/**
* Handle incoming data channel messages.
* The `participant` parameter is provided by LiveKit and contains
* the server-verified identity and attributes (from JWT).
*/
private async handleDataMessage(
payload: Uint8Array,
participant: RemoteParticipant | undefined,
_kind: unknown,
topic: string | undefined
): Promise<void> {
if (topic !== KEY_EXCHANGE_TOPIC) return
try {
const text = new TextDecoder().decode(payload)
const message: KeyExchangeMessage = JSON.parse(text)
// Ignore our own messages
if (message.senderIdentity === this.room.localParticipant.identity) return
switch (message.type) {
case KeyExchangeMessageType.KEY_REQUEST:
await this.handleKeyRequest(message, participant)
break
case KeyExchangeMessageType.KEY_RESPONSE:
await this.handleKeyResponse(message, participant)
break
case KeyExchangeMessageType.KEY_ACK:
break
}
} catch (err) {
console.error('[Encryption] Error handling key exchange message:', err)
}
}
/**
* Handle a KEY_REQUEST: if we're an admin with the symmetric key,
* respond with it encrypted using the requester's ephemeral public key.
*
* Only admins respond to key requests, ensuring the key authority model.
*/
private async handleKeyRequest(
message: KeyExchangeMessage,
_participant: RemoteParticipant | undefined
): Promise<void> {
if (!this.symmetricKey) return
// Only admins distribute the symmetric key
if (!isParticipantAdmin({ attributes: this.room.localParticipant.attributes })) {
return
}
// Only respond if the message is for us or broadcast
if (
message.targetIdentity &&
message.targetIdentity !== this.room.localParticipant.identity
) {
return
}
try {
// Generate our own ephemeral key pair for this exchange
const responderKeyPair = await generateEphemeralKeyPair()
// Import the requester's public key
const requesterPublicKey = fromBase64(message.payload)
// Derive shared key using X25519 + BLAKE2b
const sharedKey = await deriveSharedKey(
responderKeyPair.secretKey,
requesterPublicKey
)
// Encrypt the symmetric key with XChaCha20-Poly1305
const encryptedKey = await encryptWithSharedKey(sharedKey, this.symmetricKey)
// Send response with our public key + encrypted symmetric key
const responsePayload = JSON.stringify({
responderPublicKey: toBase64(responderKeyPair.publicKey),
encryptedKey: toBase64(encryptedKey),
})
const response: KeyExchangeMessage = {
type: KeyExchangeMessageType.KEY_RESPONSE,
senderIdentity: this.room.localParticipant.identity,
targetIdentity: message.senderIdentity,
payload: btoa(responsePayload),
}
this.sendMessage(response)
} catch (err) {
console.error('[Encryption] Error responding to key request:', err)
}
}
/**
* Handle a KEY_RESPONSE: decrypt the symmetric key using our ephemeral private key.
*
* SECURITY: Only accept KEY_RESPONSE from participants with room_admin=true.
* The admin attribute is set by the server (via JWT) and cannot be spoofed by clients.
*/
private async handleKeyResponse(
message: KeyExchangeMessage,
participant: RemoteParticipant | undefined
): Promise<void> {
// Only process if directed at us
if (message.targetIdentity !== this.room.localParticipant.identity) return
// SECURITY: Verify the sender is a room admin
// The `participant` object comes from LiveKit with server-verified attributes (JWT-signed)
if (!isParticipantAdmin(participant)) {
console.warn(
'[Encryption] Rejected KEY_RESPONSE from non-admin participant:',
message.senderIdentity
)
return
}
if (!this.ephemeralKeyPair) {
console.warn('[Encryption] Received key response but no ephemeral key pair')
return
}
try {
const responsePayload = JSON.parse(atob(message.payload))
const responderPublicKey = fromBase64(responsePayload.responderPublicKey)
// Derive shared key using our ephemeral secret + responder's public key
const sharedKey = await deriveSharedKey(
this.ephemeralKeyPair.secretKey,
responderPublicKey
)
// Decrypt the symmetric key with XChaCha20-Poly1305
const encryptedKey = fromBase64(responsePayload.encryptedKey)
const symmetricKey = await decryptWithSharedKey(sharedKey, encryptedKey)
// Send ACK
const ack: KeyExchangeMessage = {
type: KeyExchangeMessageType.KEY_ACK,
senderIdentity: this.room.localParticipant.identity,
targetIdentity: message.senderIdentity,
payload: '',
}
this.sendMessage(ack)
// Clean up ephemeral key pair
this.ephemeralKeyPair = null
// Notify that we received the key
if (this.onKeyReceived) {
this.onKeyReceived(symmetricKey)
this.onKeyReceived = null
}
} catch (err) {
console.error('[Encryption] Error handling key response:', err)
}
}
/**
* Send a key exchange message via LiveKit data channel.
*/
private sendMessage(message: KeyExchangeMessage): void {
const data = new TextEncoder().encode(JSON.stringify(message))
this.room.localParticipant.publishData(data, {
reliable: true,
topic: KEY_EXCHANGE_TOPIC,
})
}
}
@@ -0,0 +1,87 @@
# 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 |
## Implementation status
- [x] LiveKit E2EE with insertable streams
- [x] Ephemeral X25519 DH key exchange (libsodium)
- [x] Admin as key authority
- [x] Server-signed trust attributes in JWT
- [x] Trust badges (verified/authenticated/anonymous)
- [x] Fingerprint verification dialog
- [x] Encryption settings in account menu (VaultClient onboarding)
- [ ] Signed KEY_RESPONSE (Level 1)
- [ ] SAS verification (Level 2)
- [ ] Restrict key propagation to verified participants only
- [ ] Disable recording/transcription UI for encrypted rooms
@@ -0,0 +1,216 @@
/**
* React context provider for the centralized encryption VaultClient SDK.
*
* The client SDK is loaded at runtime via a <script> tag from the vault domain
* (data.encryption). This provider:
* - Loads the client.js script from the vault URL
* - Creates and initializes the VaultClient instance
* - Sets auth context when the user logs in
* - Tracks key state (hasKeys, publicKey)
* - Provides the client to all downstream components
*/
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { useConfig } from '@/api/useConfig'
export interface VaultClientContextValue {
client: VaultClient | null
isReady: boolean
isLoading: boolean
error: string | null
hasKeys: boolean | null
publicKey: ArrayBuffer | null
refreshKeyState: () => Promise<void>
}
const VaultClientContext = createContext<VaultClientContextValue>({
client: null,
isReady: false,
isLoading: true,
error: null,
hasKeys: null,
publicKey: null,
refreshKeyState: async () => {},
})
function loadClientScript(vaultUrl: string): Promise<void> {
return new Promise((resolve, reject) => {
if (window.EncryptionClient?.VaultClient) {
resolve()
return
}
const scriptSrc = `${vaultUrl}/client.js`
const existing = document.querySelector(`script[src="${scriptSrc}"]`)
if (existing) {
existing.addEventListener('load', () => resolve())
existing.addEventListener('error', () =>
reject(new Error('Failed to load encryption client SDK'))
)
return
}
const script = document.createElement('script')
script.src = scriptSrc
script.async = true
script.onload = () => resolve()
script.onerror = () =>
reject(new Error('Failed to load encryption client SDK'))
document.head.appendChild(script)
})
}
export function VaultClientProvider({
children,
}: {
children: React.ReactNode
}) {
const { data: config } = useConfig()
const { i18n } = useTranslation()
const clientRef = useRef<VaultClient | null>(null)
const [clientInitialized, setClientInitialized] = useState(false)
const [isReady, setIsReady] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [hasKeys, setHasKeys] = useState<boolean | null>(null)
const [publicKey, setPublicKey] = useState<ArrayBuffer | null>(null)
const initRef = useRef(false)
const vaultUrl = config?.encryption?.vault_url
const interfaceUrl = config?.encryption?.interface_url
// Load script + initialize VaultClient once
useEffect(() => {
if (initRef.current || !vaultUrl || !interfaceUrl) return
initRef.current = true
let destroyed = false
async function init() {
try {
await loadClientScript(vaultUrl!)
if (destroyed) return
const client = new window.EncryptionClient.VaultClient({
vaultUrl: vaultUrl!,
interfaceUrl: interfaceUrl!,
lang: i18n.language,
})
clientRef.current = client
client.on('onboarding:complete', () => {
setHasKeys(true)
client
.getPublicKey()
.then(({ publicKey: pk }) => setPublicKey(pk))
.catch(() => {})
})
client.on('keys-changed', () => {
client
.hasKeys()
.then(({ hasKeys: exists }) => {
setHasKeys(exists)
if (exists) {
client
.getPublicKey()
.then(({ publicKey: pk }) => setPublicKey(pk))
.catch(() => {})
}
})
.catch(() => {})
})
client.on('keys-destroyed', () => {
setHasKeys(false)
setPublicKey(null)
})
await client.init()
if (destroyed) {
client.destroy()
} else {
setClientInitialized(true)
}
} catch (err) {
if (!destroyed) {
setError((err as Error).message)
setIsLoading(false)
}
}
}
void init()
return () => {
destroyed = true
if (clientRef.current) {
clientRef.current.destroy()
clientRef.current = null
}
}
}, [vaultUrl, interfaceUrl, i18n.language])
// Set auth context when user is available
// Note: Meet may have anonymous users — VaultClient only works for authenticated users
// with a suite_user_id. For anonymous users, isReady stays false.
useEffect(() => {
const client = clientRef.current
if (!client || !clientInitialized) {
return
}
// For now, mark as ready without auth context.
// Auth context will be set when we have a suite_user_id.
setIsReady(true)
setIsLoading(false)
}, [clientInitialized])
const refreshKeyState = useCallback(async () => {
const client = clientRef.current
if (!client) return
try {
const { hasKeys: exists } = await client.hasKeys()
setHasKeys(exists)
if (exists) {
const { publicKey: pk } = await client.getPublicKey()
setPublicKey(pk)
} else {
setPublicKey(null)
}
} catch {
// Vault not available
}
}, [])
return (
<VaultClientContext.Provider
value={{
client: isReady ? clientRef.current : null,
isReady,
isLoading,
error,
hasKeys,
publicKey,
refreshKeyState,
}}
>
{children}
</VaultClientContext.Provider>
)
}
export const useVaultClient = (): VaultClientContextValue =>
useContext(VaultClientContext)
+73
View File
@@ -0,0 +1,73 @@
export {}
declare global {
interface VaultClient {
init(): Promise<void>
destroy(): void
setTheme(theme: string): void
setAuthContext(context: { suiteUserId: string }): void
hasKeys(): Promise<{ hasKeys: boolean }>
getPublicKey(): Promise<{ publicKey: ArrayBuffer }>
encryptWithoutKey(
data: ArrayBuffer,
userPublicKeys: Record<string, ArrayBuffer>
): Promise<{
encryptedContent: ArrayBuffer
encryptedKeys: Record<string, ArrayBuffer>
}>
encryptWithKey(
data: ArrayBuffer,
encryptedSymmetricKey: ArrayBuffer
): Promise<{ encryptedData: ArrayBuffer }>
decryptWithKey(
encryptedData: ArrayBuffer,
encryptedSymmetricKey: ArrayBuffer
): Promise<{ data: ArrayBuffer }>
shareKeys(
encryptedSymmetricKey: ArrayBuffer,
userPublicKeys: Record<string, ArrayBuffer>
): Promise<{ encryptedKeys: Record<string, ArrayBuffer> }>
fetchPublicKeys(
userIds: string[]
): Promise<{ publicKeys: Record<string, ArrayBuffer> }>
checkFingerprints(
userFingerprints: Record<string, string>,
currentUserId?: string
): Promise<{
results: Array<{
userId: string
knownFingerprint: string | null
providedFingerprint: string
status: 'trusted' | 'refused' | 'unknown'
}>
}>
acceptFingerprint(userId: string, fingerprint: string): Promise<void>
refuseFingerprint(userId: string, fingerprint: string): Promise<void>
getKnownFingerprints(): Promise<{
fingerprints: Record<
string,
{ fingerprint: string; status: 'trusted' | 'refused' | 'unknown' }
>
}>
openOnboarding(container: HTMLElement): void
openBackup(container: HTMLElement): void
openRestore(container: HTMLElement): void
openDeviceTransfer(container: HTMLElement): void
openSettings(container: HTMLElement): void
closeInterface(): void
on<K extends string>(event: K, listener: (data: unknown) => void): void
off<K extends string>(event: K, listener: (data: unknown) => void): void
}
interface Window {
EncryptionClient: {
VaultClient: new (options: {
vaultUrl: string
interfaceUrl: string
timeout?: number
theme?: string
lang?: string
}) => VaultClient
}
}
}
@@ -0,0 +1,20 @@
export { VaultClientProvider, useVaultClient } from './VaultClientProvider'
export type { VaultClientContextValue } from './VaultClientProvider'
export { useEncryption } from './useEncryption'
export type { EncryptionState } from './useEncryption'
export { InCallKeyExchange } from './InCallKeyExchange'
export {
determineTrustLevel,
getTrustLevelFromAttributes,
distributeKeyViaPKI,
encodeTrustLevelAttribute,
} from './HybridKeyDistributor'
export type { ParticipantEncryptionInfo } from './HybridKeyDistributor'
export { EncryptionBadge } from './EncryptionBadge'
export { EncryptionSetupOverlay } from './EncryptionSetupOverlay'
export { EncryptedMeetingBanner } from './EncryptedMeetingBanner'
export { EncryptionTrustModal } from './EncryptionTrustModal'
export { FingerprintDialog } from './FingerprintDialog'
export { useParticipantTrustLevel } from './useParticipantTrustLevel'
export { PARTICIPANT_TRUST_ATTR, KEY_EXCHANGE_TOPIC } from './types'
export type { TrustLevel } from './types'
@@ -0,0 +1,43 @@
/**
* Trust level for a participant's encryption key distribution.
*
* - 'verified': Key was distributed via PKI (public key registered in encryption library).
* Identity is cryptographically verified.
* - 'authenticated': Key was distributed via ephemeral DH, but participant is authenticated
* via ProConnect. Identity is server-verified, not cryptographically.
* - 'anonymous': Key was distributed via ephemeral DH, participant is not authenticated.
* Identity is self-declared.
*/
export type TrustLevel = 'verified' | 'authenticated' | 'anonymous'
/**
* Metadata attached to participant attributes for encryption trust level.
*/
export const PARTICIPANT_TRUST_ATTR = 'encryption.trustLevel'
/**
* Data channel topic for encryption key exchange protocol.
*/
export const KEY_EXCHANGE_TOPIC = 'encryption-key-exchange'
/**
* Message types for the in-call key exchange protocol.
*/
export enum KeyExchangeMessageType {
/** New participant sends their ephemeral public key to request the symmetric key */
KEY_REQUEST = 'KEY_REQUEST',
/** Existing participant responds with the symmetric key encrypted for the requester */
KEY_RESPONSE = 'KEY_RESPONSE',
/** Requester confirms receipt of the key */
KEY_ACK = 'KEY_ACK',
}
export interface KeyExchangeMessage {
type: KeyExchangeMessageType
/** Sender's participant identity */
senderIdentity: string
/** Target participant identity (for directed messages) */
targetIdentity?: string
/** Base64-encoded payload */
payload: string
}
@@ -0,0 +1,213 @@
/**
* Hook that orchestrates end-to-end encryption for a LiveKit room.
*
* Architecture:
* - The room admin/owner is the KEY AUTHORITY — they generate the symmetric key
* - Other participants wait in the lobby until the admin accepts them
* - When accepted and connected, non-admin participants request the key from the admin
* - Key distribution is hybrid:
* - If participant has a registered public key (encryption onboarding) → PKI → "verified" trust
* - If participant is ProConnect-authenticated → ephemeral DH → "authenticated" trust
* - If participant is anonymous → ephemeral DH → "anonymous" trust
*
* The admin can be any participant with room_admin=true (OWNER or ADMIN role).
* If multiple admins exist, any of them can distribute the key.
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import { ExternalE2EEKeyProvider, Room, E2EEOptions } from 'livekit-client'
import { InCallKeyExchange } from './InCallKeyExchange'
export interface EncryptionState {
isEnabled: boolean
isSettingUp: boolean
error: string | null
/** E2EE options to pass to RoomOptions.e2ee at Room construction time */
encryptionOptions: E2EEOptions | undefined
}
/**
* Generate a random passphrase for LiveKit E2EE.
* LiveKit's ExternalE2EEKeyProvider.setKey() works best with string passphrases
* (as proven in the PoC PR #296). The worker derives the actual AES key internally.
*/
function generatePassphrase(): string {
const bytes = crypto.getRandomValues(new Uint8Array(32))
// Convert to base64url string — LiveKit derives the actual encryption key from this
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '')
}
/**
* Check if the local participant is a room admin/owner.
*/
function isLocalParticipantAdmin(room: Room): boolean {
const attributes = room.localParticipant.attributes
return attributes?.room_admin === 'true'
}
/**
* Hook to manage encryption lifecycle for a LiveKit room.
*
* Usage in Conference.tsx:
* 1. Call useEncryption(room, encryptionEnabled) — returns encryptionOptions
* 2. Pass encryptionOptions to RoomOptions.e2ee when creating the Room
* 3. The hook handles key exchange automatically once the room is connected
*
* @param room - The LiveKit Room instance (can be undefined initially)
* @param encryptionEnabled - Whether encryption is enabled for this room
*/
export function useEncryption(
room: Room | undefined,
encryptionEnabled: boolean
): EncryptionState {
const [isSettingUp, setIsSettingUp] = useState(false)
const [error, setError] = useState<string | null>(null)
// Create keyProvider and worker as refs so they're available synchronously
// for RoomOptions.e2ee. They persist across renders.
const keyProviderRef = useRef<ExternalE2EEKeyProvider | null>(null)
const workerRef = useRef<Worker | null>(null)
const keyExchangeRef = useRef<InCallKeyExchange | null>(null)
const setupDoneRef = useRef(false)
if (encryptionEnabled && !keyProviderRef.current) {
keyProviderRef.current = new ExternalE2EEKeyProvider()
}
if (encryptionEnabled && !workerRef.current && typeof window !== 'undefined') {
workerRef.current = new Worker(
new URL('livekit-client/e2ee-worker', import.meta.url)
)
}
// Cleanup worker on unmount
useEffect(() => {
return () => {
if (workerRef.current) {
workerRef.current.terminate()
workerRef.current = null
}
keyProviderRef.current = null
}
}, [])
// Build the encryption options for RoomOptions.e2ee
const encryptionOptions: E2EEOptions | undefined =
encryptionEnabled && keyProviderRef.current && workerRef.current
? { keyProvider: keyProviderRef.current, worker: workerRef.current }
: undefined
// Key exchange: once room is connected, the admin generates or distributes the key
const setupKeyExchange = useCallback(async () => {
if (!room || !keyProviderRef.current || !encryptionEnabled || setupDoneRef.current) {
return
}
if (room.state !== 'connected') return
setupDoneRef.current = true
setIsSettingUp(true)
setError(null)
try {
const keyExchange = new InCallKeyExchange(room)
keyExchangeRef.current = keyExchange
keyExchange.startListening()
const isAdmin = isLocalParticipantAdmin(room)
let passphrase: string
if (isAdmin) {
const existingAdmins = Array.from(
room.remoteParticipants.values()
).filter((p) => p.attributes?.room_admin === 'true')
if (existingAdmins.length > 0) {
console.info(
'[Encryption] Another admin is present, requesting passphrase...'
)
const keyBytes = await keyExchange.requestKey()
keyExchange.setSymmetricKey(keyBytes)
passphrase = new TextDecoder().decode(keyBytes)
console.info('[Encryption] Received passphrase from existing admin')
} else {
passphrase = generatePassphrase()
const keyBytes = new TextEncoder().encode(passphrase)
keyExchange.setSymmetricKey(keyBytes)
console.info(
'[Encryption] Generated passphrase as room admin (key authority)'
)
}
} else {
console.info(
'[Encryption] Requesting passphrase from room admin...'
)
const keyBytes = await keyExchange.requestKey()
keyExchange.setSymmetricKey(keyBytes)
passphrase = new TextDecoder().decode(keyBytes)
console.info('[Encryption] Received passphrase from admin')
}
// Feed the passphrase to LiveKit's encryption worker.
// Using a string passphrase (as in the PoC PR #296) — LiveKit derives
// the actual AES encryption key internally from this passphrase.
console.info('[Encryption] Setting passphrase, length:', passphrase.length)
await keyProviderRef.current!.setKey(passphrase)
await room.setE2EEEnabled(true)
console.info('[Encryption] End-to-end encryption enabled')
} catch (err) {
console.error('[Encryption] Failed to set up encryption:', err)
setError((err as Error).message)
} finally {
setIsSettingUp(false)
}
}, [room, encryptionEnabled])
// Listen for LiveKit encryption errors (decryption failures, key mismatches)
useEffect(() => {
if (!room || !encryptionEnabled) return
const handleEncryptionError = (err: Error) => {
console.error('[Encryption] Decryption error:', err)
setError(err.message || 'Decryption failed')
}
room.on('encryptionError', handleEncryptionError)
return () => {
room.off('encryptionError', handleEncryptionError)
}
}, [room, encryptionEnabled])
useEffect(() => {
if (!room || !encryptionEnabled) return
const handleConnected = () => {
setupKeyExchange()
}
if (room.state === 'connected') {
setupKeyExchange()
} else {
room.on('connected', handleConnected)
}
return () => {
room.off('connected', handleConnected)
if (keyExchangeRef.current) {
keyExchangeRef.current.stopListening()
keyExchangeRef.current = null
}
setupDoneRef.current = false
}
}, [room, encryptionEnabled, setupKeyExchange])
return {
isEnabled: encryptionEnabled,
isSettingUp,
error,
encryptionOptions,
}
}
@@ -0,0 +1,49 @@
/**
* Hook that determines a participant's trust level by checking:
* 1. If they have a registered public key in the encryption library (via VaultClient)
* 2. If they are authenticated via OIDC
*
* Returns "verified" if they have a public key, "authenticated" if OIDC-authenticated,
* "anonymous" otherwise.
*/
import { useEffect, useState } from 'react'
import { useVaultClient } from './VaultClientProvider'
import type { TrustLevel } from './types'
export function useParticipantTrustLevel(
attributes: Record<string, string> | undefined
): TrustLevel {
const { client: vaultClient } = useVaultClient()
const [hasPublicKey, setHasPublicKey] = useState(false)
const isAuthenticated = attributes?.is_authenticated === 'true'
const suiteUserId = attributes?.suite_user_id
useEffect(() => {
if (!vaultClient || !suiteUserId || !isAuthenticated) {
setHasPublicKey(false)
return
}
let cancelled = false
vaultClient
.fetchPublicKeys([suiteUserId])
.then(({ publicKeys }) => {
if (!cancelled && publicKeys[suiteUserId]) {
setHasPublicKey(true)
}
})
.catch(() => {
// VaultClient not available or user has no public key
})
return () => {
cancelled = true
}
}, [vaultClient, suiteUserId, isAuthenticated])
if (hasPublicKey) return 'verified'
if (isAuthenticated) return 'authenticated'
return 'anonymous'
}
+47 -2
View File
@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'
import { DialogTrigger, MenuItem, Menu as RACMenu } from 'react-aria-components'
import { DialogTrigger, MenuItem, Menu as RACMenu, Separator as RACSeparator } from 'react-aria-components'
import { Button, Menu } from '@/primitives'
import { styled } from '@/styled-system/jsx'
import { navigateTo } from '@/navigation/navigateTo'
@@ -7,7 +7,7 @@ import { Screen } from '@/layout/Screen'
import { generateRoomId, useCreateRoom } from '@/features/rooms'
import { useUser, UserAware } from '@/features/auth'
import { JoinMeetingDialog } from '../components/JoinMeetingDialog'
import { RiAddLine, RiLink } from '@remixicon/react'
import { RiAddLine, RiLink, RiLockLine } from '@remixicon/react'
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
import { IntroSlider } from '@/features/home/components/IntroSlider'
import { MoreLink } from '@/features/home/components/MoreLink'
@@ -237,6 +237,51 @@ export const Home = () => {
<RiLink size={18} />
{t('createMenu.laterOption')}
</MenuItem>
<RACSeparator
className={css({
borderTop: '1px solid',
borderColor: 'greyscale.200',
margin: '0.25rem 0',
})}
/>
<MenuItem
className={
menuRecipe({ icon: true, variant: 'light' }).item
}
onAction={async () => {
const slug = generateRoomId()
createRoom({
slug,
username,
encryptionEnabled: true,
}).then((data) =>
navigateTo('room', data.slug, {
state: { create: true, initialRoomData: data },
})
)
}}
data-attr="create-option-encrypted-instant"
>
<RiLockLine size={18} />
{t('createMenu.encryptedInstantOption')}
</MenuItem>
<MenuItem
className={
menuRecipe({ icon: true, variant: 'light' }).item
}
onAction={() => {
const slug = generateRoomId()
createRoom({
slug,
username,
encryptionEnabled: true,
}).then((data) => setLaterRoom(data))
}}
data-attr="create-option-encrypted-later"
>
<RiLockLine size={18} />
{t('createMenu.encryptedLaterOption')}
</MenuItem>
</RACMenu>
</Menu>
) : (
@@ -3,7 +3,7 @@ import { HStack, VStack } from '@/styled-system/jsx'
import { Avatar } from '@/components/Avatar'
import { Button, Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { RiInfinityLine } from '@remixicon/react'
import { RiInfinityLine, RiShieldCheckLine, RiAlertLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useEffect, useRef, useState } from 'react'
import { usePrevious } from '@/hooks/usePrevious'
@@ -100,13 +100,18 @@ export const WaitingParticipantNotification = () => {
>
{t('one')}
</Text>
<HStack gap="1rem">
<HStack gap="0.5rem">
<Avatar
name={waitingParticipants[0].username}
bgColor={waitingParticipants[0].color}
context="list"
notification
/>
{waitingParticipants[0].is_authenticated ? (
<RiShieldCheckLine size={16} color="#3b82f6" />
) : (
<RiAlertLine size={16} color="#f59e0b" />
)}
<Text
variant="sm"
margin={false}
@@ -17,6 +17,7 @@ export type ApiRoom = {
pin_code: string
is_administrable: boolean
access_level: ApiAccessLevel
encryption_enabled: boolean
livekit?: ApiLiveKit
configuration?: {
[key: string]: string | number | boolean | string[]
@@ -7,18 +7,21 @@ export interface CreateRoomParams {
slug: string
callbackId?: string
username?: string
encryptionEnabled?: boolean
}
const createRoom = ({
slug,
callbackId,
username = '',
encryptionEnabled = false,
}: CreateRoomParams): Promise<ApiRoom> => {
return fetchApi(`rooms/?username=${encodeURIComponent(username)}`, {
method: 'POST',
body: JSON.stringify({
name: slug,
callback_id: callbackId,
encryption_enabled: encryptionEnabled,
}),
})
}
@@ -8,6 +8,8 @@ export type WaitingParticipant = {
status: string
username: string
color: string
is_authenticated: boolean
email?: string
}
export type WaitingParticipantsResponse = {
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import {
@@ -12,6 +12,7 @@ import {
RoomOptions,
VideoPresets,
} from 'livekit-client'
import { useEncryption, EncryptionSetupOverlay } from '@/features/encryption'
import { keys } from '@/api/queryKeys'
import { queryClient } from '@/api/queryClient'
import { Screen } from '@/layout/Screen'
@@ -86,12 +87,26 @@ export const Conference = ({
retry: false,
})
const encryptionEnabled = data?.encryption_enabled ?? false
// Encryption: encryptionOptions (keyProvider + worker) are created synchronously via refs
// inside the hook. The room is passed via state so the hook re-runs when it's created.
const [roomInstance, setRoomInstance] = useState<Room | undefined>(undefined)
const { encryptionOptions, isSettingUp: isEncryptionSettingUp, error: encryptionError } = useEncryption(roomInstance, encryptionEnabled)
// Stabilize encryptionOptions reference — only recalculate roomOptions when
// encryptionEnabled changes, not when encryptionOptions object reference changes.
const encryptionOptionsRef = useRef(encryptionOptions)
encryptionOptionsRef.current = encryptionOptions
const roomOptions = useMemo((): RoomOptions => {
return {
adaptiveStream: true,
dynacast: true,
publishDefaults: {
videoCodec: 'vp9',
// Encryption requires VP8 codec — VP9 and RED are not compatible with insertable streams
videoCodec: encryptionEnabled ? 'vp8' : 'vp9',
red: !encryptionEnabled,
},
videoCaptureDefaults: {
deviceId: userConfig.videoDeviceId ?? undefined,
@@ -105,9 +120,11 @@ export const Conference = ({
audioOutput: {
deviceId: userConfig.audioOutputDeviceId ?? undefined,
},
e2ee: encryptionOptionsRef.current,
}
// do not rely on the userConfig object directly as its reference may change on every render
}, [
encryptionEnabled,
userConfig.videoDeviceId,
userConfig.videoPublishResolution,
userConfig.audioDeviceId,
@@ -116,6 +133,11 @@ export const Conference = ({
const room = useMemo(() => new Room(roomOptions), [roomOptions])
// Pass the room to the encryption hook via state (triggers re-render so the hook sees it)
useEffect(() => {
setRoomInstance(room)
}, [room])
useEffect(() => {
/**
* Warm up connection to LiveKit server before joining room
@@ -250,6 +272,12 @@ export const Conference = ({
}
}}
>
{encryptionEnabled && (
<EncryptionSetupOverlay
isSettingUp={isEncryptionSettingUp}
error={encryptionError}
/>
)}
<VideoConference />
{showInviteDialog && !isMobile && (
<InviteDialog
@@ -34,6 +34,8 @@ import { ApiLobbyStatus, ApiRequestEntry } from '../api/requestEntry'
import { Spinner } from '@/primitives/Spinner'
import { ApiAccessLevel } from '../api/ApiRoom'
import { useLoginHint } from '@/hooks/useLoginHint'
import { useUser } from '@/features/auth'
import { RiInformationLine, RiLockLine } from '@remixicon/react'
import { openPermissionsDialog } from '@/stores/permissions'
import { useResolveInitiallyDefaultDeviceId } from '../livekit/hooks/useResolveInitiallyDefaultDeviceId'
import { isSafari } from '@/utils/livekit'
@@ -102,6 +104,20 @@ export const Join = ({
roomId: string
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const { isLoggedIn, user } = useUser()
// Early fetch to check if the room is encrypted (needed before form submission)
const { data: roomInfo } = useQuery({
queryKey: [keys.room, roomId, 'info'],
queryFn: () => fetchRoom({ roomId }),
staleTime: 6 * 60 * 60 * 1000,
retry: false,
})
const isEncryptedRoom = roomInfo?.encryption_enabled ?? false
// In encrypted rooms, authenticated users must use their OIDC name
const isNameLocked = isEncryptedRoom && !!isLoggedIn
const lockedName = user?.full_name || user?.email || ''
const {
userChoices: {
@@ -438,20 +454,81 @@ export const Join = ({
<H lvl={1} margin="sm" centered>
{t('heading')}
</H>
<Field
type="text"
onChange={saveUsername}
label={t('usernameLabel')}
id="input-name"
defaultValue={username}
validate={(value) => !value && t('errors.usernameEmpty')}
wrapperProps={{
noMargin: true,
fullWidth: true,
}}
autoComplete="name"
maxLength={50}
/>
{isNameLocked ? (
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.25rem',
width: '100%',
})}
>
<Text
variant="sm"
className={css({
color: 'greyscale.500',
fontSize: '0.8rem',
})}
>
{t('usernameLabel')}
</Text>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.5rem 0.75rem',
backgroundColor: 'greyscale.100',
borderRadius: '0.375rem',
border: '1px solid',
borderColor: 'greyscale.200',
})}
>
<RiLockLine size={14} color="#6b7280" />
<Text
variant="sm"
className={css({
fontWeight: 500,
})}
>
{lockedName}
</Text>
</div>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
})}
>
<RiInformationLine size={12} color="#9ca3af" />
<Text
variant="note"
className={css({
fontSize: '0.7rem',
color: 'greyscale.400',
})}
>
{t('encryptedNameLocked')}
</Text>
</div>
</div>
) : (
<Field
type="text"
onChange={saveUsername}
label={t('usernameLabel')}
id="input-name"
defaultValue={username}
validate={(value) => !value && t('errors.usernameEmpty')}
wrapperProps={{
noMargin: true,
fullWidth: true,
}}
autoComplete="name"
maxLength={50}
/>
)}
</VStack>
</Form>
)
@@ -10,6 +10,8 @@ import { keys } from '@/api/queryKeys'
import { useQuery } from '@tanstack/react-query'
import { useParams } from 'wouter'
import { usePublishSourcesManager } from '@/features/rooms/livekit/hooks/usePublishSourcesManager'
import { RiLockFill } from '@remixicon/react'
import { HStack } from '@/styled-system/jsx'
export const Admin = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'admin' })
@@ -166,6 +168,25 @@ export const Admin = () => {
>
{t('access.description')}
</Text>
{readOnlyData?.encryption_enabled && (
<HStack
gap="0.5rem"
className={css({
backgroundColor: 'primary.100',
borderRadius: '0.5rem',
padding: '0.75rem',
marginBottom: '0.75rem',
})}
>
<RiLockFill size={16} className={css({ flexShrink: 0 })} />
<Text
variant="note"
className={css({ textStyle: 'sm' })}
>
{t('access.encryptionWarning')}
</Text>
</HStack>
)}
<Field
type="radioGroup"
label={t('access.type')}
@@ -192,11 +213,13 @@ export const Admin = () => {
value: ApiAccessLevel.PUBLIC,
label: t('access.levels.public.label'),
description: t('access.levels.public.description'),
isDisabled: readOnlyData?.encryption_enabled,
},
{
value: ApiAccessLevel.TRUSTED,
label: t('access.levels.trusted.label'),
description: t('access.levels.trusted.description'),
isDisabled: readOnlyData?.encryption_enabled,
},
{
value: ApiAccessLevel.RESTRICTED,
@@ -1,7 +1,6 @@
import {
AudioTrack,
ConnectionQualityIndicator,
LockLockedIcon,
ParticipantTileProps,
ScreenShareIcon,
useEnsureTrackRef,
@@ -23,6 +22,8 @@ import {
import { Track } from 'livekit-client'
import { RiHand } from '@remixicon/react'
import { useRaisedHand, useRaisedHandPosition } from '../hooks/useRaisedHand'
import { EncryptionBadge, getTrustLevelFromAttributes } from '@/features/encryption'
import { useRoomData } from '../hooks/useRoomData'
import { HStack } from '@/styled-system/jsx'
import { MutedMicIndicator } from './MutedMicIndicator'
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
@@ -79,6 +80,8 @@ export const ParticipantTile: (
trackRef: trackReference,
})
const isEncrypted = useIsEncrypted(trackReference.participant)
const roomData = useRoomData()
const isEncryptedRoom = roomData?.encryption_enabled ?? false
const layoutContext = useMaybeLayoutContext()
const autoManageSubscription = useFeatureContext()?.autoSubscription
@@ -215,8 +218,13 @@ export const ParticipantTile: (
}}
/>
)}
{isEncrypted && !isScreenShare && (
<LockLockedIcon style={{ marginRight: '0.25rem' }} />
{(isEncrypted || isEncryptedRoom) && !isScreenShare && (
<EncryptionBadge
isEncrypted={true}
trustLevel={getTrustLevelFromAttributes(
trackReference.participant.attributes as Record<string, string> | undefined
)}
/>
)}
<div className="lk-participant-name-wrapper">
<ParticipantName
@@ -21,6 +21,9 @@ import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
import { ParticipantMenuButton } from '../../ParticipantMenu/ParticipantMenuButton'
import { PinBadge } from './PinBadge'
import { EncryptionBadge, getTrustLevelFromAttributes, FingerprintDialog } from '@/features/encryption'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
type MicIndicatorProps = {
participant: Participant
@@ -97,7 +100,12 @@ export const ParticipantListItem = ({
participant,
}: ParticipantListItemProps) => {
const { t } = useTranslation('rooms')
const roomData = useRoomData()
const isEncryptedRoom = roomData?.encryption_enabled ?? false
const isAdmin = useIsAdminOrOwner()
const [isFingerprintOpen, setIsFingerprintOpen] = useState(false)
const name = participant.name || participant.identity
const attrs = participant.attributes as Record<string, string> | undefined
return (
<HStack
role="listitem"
@@ -124,8 +132,22 @@ export const ParticipantListItem = ({
userSelect: 'none',
cursor: 'default',
display: 'flex',
alignItems: 'center',
})}
>
{isEncryptedRoom && (
<span
onClick={isAdmin ? () => setIsFingerprintOpen(true) : undefined}
className={css({
cursor: isAdmin ? 'pointer' : 'default',
})}
>
<EncryptionBadge
isEncrypted={true}
trustLevel={getTrustLevelFromAttributes(attrs)}
/>
</span>
)}
<span
className={css({
whiteSpace: 'nowrap',
@@ -151,12 +173,38 @@ export const ParticipantListItem = ({
{getParticipantIsRoomAdmin(participant) && (
<Text variant="xsNote">{t('participants.host')}</Text>
)}
{isEncryptedRoom &&
participant.attributes?.is_authenticated === 'true' &&
participant.attributes?.email && (
<Text
variant="xsNote"
className={css({
color: 'greyscale.500',
maxWidth: '120px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
})}
>
{participant.attributes.email}
</Text>
)}
</VStack>
</HStack>
<HStack>
<MicIndicator participant={participant} />
<ParticipantMenuButton participant={participant} />
</HStack>
{isEncryptedRoom && isAdmin && (
<FingerprintDialog
isOpen={isFingerprintOpen}
onOpenChange={setIsFingerprintOpen}
participantName={name}
participantEmail={attrs?.email}
suiteUserId={attrs?.suite_user_id}
isAuthenticated={attrs?.is_authenticated === 'true'}
/>
)}
</HStack>
)
}
@@ -4,7 +4,65 @@ import { css } from '@/styled-system/css'
import { Avatar } from '@/components/Avatar'
import { useTranslation } from 'react-i18next'
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
import { RiCloseLine } from '@remixicon/react'
import { RiCloseLine, RiShieldCheckLine, RiAlertLine } from '@remixicon/react'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { EncryptionTrustModal } from '@/features/encryption'
import { useState } from 'react'
const EncryptionTrustIndicator = ({
isAuthenticated,
participantName,
}: {
isAuthenticated: boolean
participantName: string
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participants.waiting' })
const [isModalOpen, setIsModalOpen] = useState(false)
return (
<>
<Button
variant="tertiaryText"
size="sm"
square
tooltip={
isAuthenticated
? t('trust.authenticated')
: t('trust.anonymous')
}
aria-label={
isAuthenticated
? t('trust.authenticated')
: t('trust.anonymous')
}
onPress={() => setIsModalOpen(true)}
className={css({
padding: '0.15rem !important',
minWidth: 'auto !important',
width: '1.5rem !important',
height: '1.5rem !important',
borderRadius: '50% !important',
backgroundColor: isAuthenticated
? '#eff6ff !important'
: '#fffbeb !important',
flexShrink: 0,
})}
>
{isAuthenticated ? (
<RiShieldCheckLine size={16} color="#3b82f6" />
) : (
<RiAlertLine size={16} color="#f59e0b" />
)}
</Button>
<EncryptionTrustModal
isOpen={isModalOpen}
onOpenChange={setIsModalOpen}
participantName={participantName}
isAuthenticated={isAuthenticated}
/>
</>
)
}
export const WaitingParticipantListItem = ({
participant,
@@ -14,6 +72,8 @@ export const WaitingParticipantListItem = ({
onAction: (participant: WaitingParticipant, allowEntry: boolean) => void
}) => {
const { t } = useTranslation('rooms')
const roomData = useRoomData()
const isEncryptedRoom = roomData?.encryption_enabled ?? false
return (
<HStack
@@ -30,31 +90,59 @@ export const WaitingParticipantListItem = ({
className={css({
flex: '1',
minWidth: '0',
gap: '0.35rem',
})}
>
<Avatar name={participant.username} bgColor={participant.color} />
<Text
variant={'sm'}
{isEncryptedRoom && (
<EncryptionTrustIndicator
isAuthenticated={participant.is_authenticated}
participantName={participant.username}
/>
)}
<div
className={css({
userSelect: 'none',
cursor: 'default',
display: 'flex',
flexDirection: 'column',
flex: '1',
minWidth: '0',
})}
>
<span
<Text
variant={'sm'}
className={css({
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
width: '100%',
display: 'block',
userSelect: 'none',
cursor: 'default',
display: 'flex',
})}
>
{participant.username}
</span>
</Text>
<span
className={css({
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
width: '100%',
display: 'block',
})}
>
{participant.username}
</span>
</Text>
{isEncryptedRoom && participant.email && (
<Text
variant={'sm'}
className={css({
fontSize: '0.7rem',
color: 'greyscale.500',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
})}
>
{participant.email}
</Text>
)}
</div>
</HStack>
<HStack
gap="0.25rem"
@@ -42,6 +42,7 @@ import { Subtitles } from '@/features/subtitle/component/Subtitles'
import { CarouselLayout } from '../components/layout/CarouselLayout'
import { GridLayout } from '../components/layout/GridLayout'
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
import { EncryptedMeetingBanner } from '@/features/encryption/EncryptedMeetingBanner'
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
@@ -276,6 +277,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
onClose={() => setIsShareErrorVisible(false)}
/>
<IsIdleDisconnectModal />
<EncryptedMeetingBanner />
<div
// todo - extract these magic values into constant
style={{
+45 -2
View File
@@ -11,8 +11,10 @@ import { Menu } from '@/primitives/Menu'
import { MenuList } from '@/primitives/MenuList'
import { LoginButton } from '@/components/LoginButton'
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
import { useLoginHint } from '@/hooks/useLoginHint'
import { useVaultClient } from '@/features/encryption'
import { useConfig } from '@/api/useConfig'
import { useRef } from 'react'
const Logo = () => (
<img
@@ -91,6 +93,10 @@ export const Header = () => {
const isTermsOfService = useMatchesRoute('termsOfService')
const isRoom = useMatchesRoute('room')
const { user, isLoggedIn, logout } = useUser()
const { data: config } = useConfig()
const { client: vaultClient, hasKeys } = useVaultClient()
const encryptionContainerRef = useRef<HTMLDivElement | null>(null)
const isEncryptionAvailable = !!config?.encryption?.enabled && !!vaultClient
const userLabel = user?.full_name || user?.email
const loggedInTooltip = t('loggedInUserTooltip')
const loggedInAriaLabel = userLabel
@@ -180,11 +186,48 @@ export const Header = () => {
</Button>
<MenuList
variant={'light'}
items={[{ value: 'logout', label: t('logout') }]}
items={[
...(isEncryptionAvailable
? [
{
value: 'encryption',
label: hasKeys
? t('encryptionSettings')
: t('encryptionSetup'),
},
]
: []),
{ value: 'logout', label: t('logout') },
]}
onAction={(value) => {
if (value === 'logout') {
logout()
}
if (value === 'encryption' && vaultClient) {
// Create a container for the VaultClient interface
const container = document.createElement('div')
container.style.position = 'fixed'
container.style.inset = '0'
container.style.zIndex = '9999'
document.body.appendChild(container)
encryptionContainerRef.current = container
if (hasKeys) {
vaultClient.openSettings(container)
} else {
vaultClient.openOnboarding(container)
}
// Listen for close
vaultClient.on('interface:closed', () => {
if (encryptionContainerRef.current) {
document.body.removeChild(
encryptionContainerRef.current
)
encryptionContainerRef.current = null
}
})
}
}}
/>
</Menu>
+2
View File
@@ -21,6 +21,8 @@
"proconnectLink": "What is ProConnect?"
},
"logout": "Logout",
"encryptionSetup": "Set up encryption",
"encryptionSettings": "Encryption settings",
"notFound": {
"heading": "Verify your meeting code",
"body": "Check that you have entered the correct meeting code in the URL. Example:"
+3 -1
View File
@@ -15,7 +15,9 @@
"moreAbout": "about {{appTitle}}",
"createMenu": {
"laterOption": "Create a meeting for a later date",
"instantOption": "Start an instant meeting"
"instantOption": "Start an instant meeting",
"encryptedInstantOption": "Start an encrypted meeting",
"encryptedLaterOption": "Create an encrypted meeting for later"
},
"laterMeetingDialog": {
"heading": "Your connection details",
+60
View File
@@ -54,6 +54,7 @@
"toggleOn": "Click to turn on",
"usernameHint": "Shown to other participants",
"usernameLabel": "Your name",
"encryptedNameLocked": "Encrypted meeting — name from your account.",
"errors": {
"usernameEmpty": "Your name cannot be empty"
},
@@ -480,6 +481,7 @@
"access": {
"title": "Room access",
"description": "These settings will also apply to future occurrences of this meeting.",
"encryptionWarning": "This meeting is encrypted. Access must remain restricted so that each participant is approved before receiving the encryption key.",
"type": "Meeting access types",
"levels": {
"public": {
@@ -570,6 +572,10 @@
"button": "Deny",
"label": "Deny {{name}} from the meeting",
"all": "Deny all"
},
"trust": {
"authenticated": "Authenticated identity (ProConnect). Encryption key will be exchanged securely.",
"anonymous": "Anonymous user — identity not verified. Verify their identity before accepting."
}
},
"moreOptions": "More options"
@@ -670,5 +676,59 @@
},
"participantTile": {
"screenShare": "{{name}}'s screen"
},
"encryption": {
"banner": "Encrypted",
"bannerStrong": "Verified encryption",
"bannerModal": {
"title": "End-to-end encrypted meeting",
"description": "This meeting uses end-to-end encryption. Audio and video are encrypted on your device before being sent. The server only sees encrypted data it cannot read.",
"guarantees": "What this protects:",
"guarantee1": "The server cannot access audio, video, or screen sharing content",
"guarantee2": "Only approved participants with the encryption key can view the content",
"guarantee3": "Each participant's trust level is visible (verified, authenticated, or anonymous)",
"limitations": "Limitations:",
"limitation1": "Recording and transcription are not available (the server cannot decrypt content)",
"limitation2": "Maximum security requires all participants to complete encryption onboarding and verify fingerprints",
"note": "For the highest level of trust, ask participants to set up encryption in their account settings. This registers their public key and enables fingerprint verification."
},
"settingUp": {
"title": "Securing your connection",
"description": "Exchanging encryption keys with the room administrator..."
},
"error": {
"title": "Encryption error",
"hint": "The encrypted data could not be decoded. Try leaving and rejoining the meeting, or ask the host to restart the call."
},
"fingerprint": {
"title": "Encryption identity",
"loading": "Checking encryption keys...",
"noKey": "This participant has not set up encryption keys. Their identity is verified by the authentication server only, not by a public key.",
"error": "Unable to check encryption keys.",
"fingerprintLabel": "Public key fingerprint",
"trusted": "Fingerprint verified and trusted",
"refused": "Fingerprint refused",
"unknownDescription": "This fingerprint has not been verified yet. Compare it with the participant to ensure their identity.",
"accept": "Trust",
"refuse": "Refuse"
},
"trustModal": {
"title": "Encryption trust level",
"intro": "This indicates how \"{{name}}\" was identified before joining the encrypted meeting.",
"authenticated": {
"title": "Authenticated identity",
"description": "This person signed in via ProConnect. Their identity is verified by the authentication server. The encryption key will be exchanged securely via ephemeral key exchange."
},
"anonymous": {
"title": "Anonymous user",
"description": "This person is not signed in. Their displayed name is self-declared and could be impersonated. Verify their identity verbally before accepting them. The encryption key will be exchanged via ephemeral key exchange."
},
"levels": {
"title": "Trust levels in encrypted meetings",
"verified": "Verified — completed encryption onboarding, identity confirmed by public key.",
"authenticated": "Authenticated — signed in via ProConnect, identity verified by server.",
"anonymous": "Anonymous — not signed in, name is self-declared. Verify before accepting."
}
}
}
}
+2
View File
@@ -21,6 +21,8 @@
"proconnectLink": "Qu'est-ce que ProConnect ?"
},
"logout": "Se déconnecter",
"encryptionSetup": "Configurer le chiffrement",
"encryptionSettings": "Paramètres de chiffrement",
"notFound": {
"heading": "Vérifier votre code de réunion",
"body": "Vérifiez que vous avez saisi le code de réunion correct dans l'URL. Exemple :"
+3 -1
View File
@@ -15,7 +15,9 @@
"moreAbout": "sur {{appTitle}}",
"createMenu": {
"laterOption": "Créer une réunion pour une date ultérieure",
"instantOption": "Démarrer une réunion instantanée"
"instantOption": "Démarrer une réunion instantanée",
"encryptedInstantOption": "Démarrer une réunion chiffrée",
"encryptedLaterOption": "Créer une réunion chiffrée pour plus tard"
},
"laterMeetingDialog": {
"heading": "Vos informations de connexion",
+60
View File
@@ -54,6 +54,7 @@
"toggleOn": "Cliquez pour activer",
"usernameHint": "Affiché aux autres participants",
"usernameLabel": "Votre nom",
"encryptedNameLocked": "Réunion chiffrée — nom issu de votre compte.",
"errors": {
"usernameEmpty": "Votre nom ne peut pas être vide"
},
@@ -480,6 +481,7 @@
"access": {
"title": "Accès à la réunion",
"description": "Ces paramètres s'appliqueront également aux futures occurrences de cette réunion.",
"encryptionWarning": "Cette réunion est chiffrée. L'accès doit rester restreint afin que chaque participant soit approuvé avant de recevoir la clé de chiffrement.",
"type": "Type d'accès à la réunion",
"levels": {
"public": {
@@ -570,6 +572,10 @@
"button": "Refuser",
"label": "Refuser {{name}} dans la réunion",
"all": "Tout rejeter"
},
"trust": {
"authenticated": "Identité authentifiée (ProConnect). La clé de chiffrement sera échangée de manière sécurisée.",
"anonymous": "Utilisateur anonyme — identité non vérifiée. Vérifiez son identité avant d'accepter."
}
},
"moreOptions": "Plus d'options"
@@ -670,5 +676,59 @@
},
"participantTile": {
"screenShare": "Écran de {{name}}"
},
"encryption": {
"banner": "Chiffré",
"bannerStrong": "Chiffrement vérifié",
"bannerModal": {
"title": "Réunion chiffrée de bout en bout",
"description": "Cette réunion utilise le chiffrement de bout en bout. L'audio et la vidéo sont chiffrés sur votre appareil avant d'être envoyés. Le serveur ne voit que des données chiffrées qu'il ne peut pas lire.",
"guarantees": "Ce que cela protège :",
"guarantee1": "Le serveur ne peut pas accéder au contenu audio, vidéo ou de partage d'écran",
"guarantee2": "Seuls les participants approuvés disposant de la clé de chiffrement peuvent voir le contenu",
"guarantee3": "Le niveau de confiance de chaque participant est visible (vérifié, authentifié ou anonyme)",
"limitations": "Limitations :",
"limitation1": "L'enregistrement et la transcription ne sont pas disponibles (le serveur ne peut pas déchiffrer le contenu)",
"limitation2": "La sécurité maximale nécessite que tous les participants effectuent l'onboarding chiffrement et vérifient les empreintes",
"note": "Pour le plus haut niveau de confiance, demandez aux participants de configurer le chiffrement dans les paramètres de leur compte. Cela enregistre leur clé publique et permet la vérification des empreintes."
},
"settingUp": {
"title": "Sécurisation de votre connexion",
"description": "Échange des clés de chiffrement avec l'administrateur de la réunion..."
},
"error": {
"title": "Erreur de chiffrement",
"hint": "Les données chiffrées n'ont pas pu être décodées. Essayez de quitter et de rejoindre la réunion, ou demandez à l'hôte de relancer l'appel."
},
"fingerprint": {
"title": "Identité chiffrée",
"loading": "Vérification des clés de chiffrement...",
"noKey": "Ce participant n'a pas configuré de clés de chiffrement. Son identité est vérifiée par le serveur d'authentification uniquement, pas par une clé publique.",
"error": "Impossible de vérifier les clés de chiffrement.",
"fingerprintLabel": "Empreinte de la clé publique",
"trusted": "Empreinte vérifiée et approuvée",
"refused": "Empreinte refusée",
"unknownDescription": "Cette empreinte n'a pas encore été vérifiée. Comparez-la avec le participant pour confirmer son identité.",
"accept": "Approuver",
"refuse": "Refuser"
},
"trustModal": {
"title": "Niveau de confiance du chiffrement",
"intro": "Ceci indique comment « {{name}} » a été identifié avant de rejoindre la réunion chiffrée.",
"authenticated": {
"title": "Identité authentifiée",
"description": "Cette personne s'est connectée via ProConnect. Son identité est vérifiée par le serveur d'authentification. La clé de chiffrement sera échangée de manière sécurisée."
},
"anonymous": {
"title": "Utilisateur anonyme",
"description": "Cette personne n'est pas connectée. Son nom affiché est déclaratif et pourrait être usurpé. Vérifiez son identité de vive voix avant de l'accepter. La clé de chiffrement sera échangée via un échange éphémère."
},
"levels": {
"title": "Niveaux de confiance en réunion chiffrée",
"verified": "Vérifié — onboarding chiffrement effectué, identité confirmée par clé publique.",
"authenticated": "Authentifié — connecté via ProConnect, identité vérifiée par le serveur.",
"anonymous": "Anonyme — non connecté, nom auto-déclaré. Vérifiez avant d'accepter."
}
}
}
}
+2 -1
View File
@@ -58,7 +58,7 @@ const StyledLabel = styled(Label, {
type OmittedRACProps = 'type' | 'label' | 'items' | 'description' | 'validate'
type Items<T = ReactNode> = {
items: Array<{ value: string; description?: string; label: T }>
items: Array<{ value: string; description?: string; label: T; isDisabled?: boolean }>
}
type PartialTextFieldProps = Omit<TextFieldProps, OmittedRACProps>
type PartialCheckboxProps = Omit<CheckboxProps, OmittedRACProps>
@@ -216,6 +216,7 @@ export const Field = <T extends object>({
<Radio
value={item.value}
alignment={item.description ? 'top' : undefined}
isDisabled={item.isDisabled}
>
<div
className={css({
+5
View File
@@ -48,6 +48,11 @@ export const StyledRadio = styled(RACRadio, {
'&[data-selected][data-pressed] .mt-Radio-check': {
backgroundColor: 'primary.active',
},
'&[data-disabled]': {
opacity: 0.4,
cursor: 'not-allowed',
pointerEvents: 'none',
},
},
variants: {
size: {
+1 -1
View File
@@ -12,7 +12,7 @@ export default defineConfig(({ mode }) => {
},
server: {
port: parseInt(env.VITE_PORT) || 3000,
host: env.VITE_HOST ?? 'localhost',
host: env.VITE_HOST ?? '0.0.0.0',
allowedHosts: ['.nip.io'],
// In a local dev setup, we proxy the media server ourselves to avoid CORS issues
proxy: {