mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-26 20:08:24 +00:00
wip base
This commit is contained in:
@@ -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).
|
||||
|
||||
We’re 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, France’s 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
|
||||
|
||||
We’re 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. We’re 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).
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
Generated
+16
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
+7
-2
@@ -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
|
||||
|
||||
+48
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+102
-14
@@ -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={{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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:"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 :"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user