Compare commits

..

4 Commits

98 changed files with 6481 additions and 4843 deletions
+6
View File
@@ -125,10 +125,16 @@ run-summary: ## start only the summary application and all needed services
@$(COMPOSE) up --force-recreate -d celery-summary-summarize
.PHONY: run-summary
run-sip: ## start the SIP gateway and the browser-based softphone (Janus + nginx)
@$(COMPOSE) up --force-recreate -d sip
@$(COMPOSE) up --force-recreate -d sip-web-janus sip-web
.PHONY: run-sip
run:
run: ## start the wsgi (production) and development server
@$(MAKE) run-backend
@$(MAKE) run-summary
@$(MAKE) run-sip
@$(COMPOSE) up --force-recreate -d frontend
.PHONY: run
+22 -32
View File
@@ -33,7 +33,7 @@ Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level perfo
- Optimized for stability in large meetings (+100 p.)
- Support for multiple screen sharing streams
- Non-persistent, secure chat
- End-to-end encryption with hybrid key distribution
- End-to-end encryption with passphrase-in-link key distribution
- Meeting recording
- Meeting transcription & Summary (currently in beta)
- Telephony integration
@@ -48,52 +48,42 @@ Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level perfo
### End-to-end encryption
La Suite Meet supports end-to-end encryption (E2EE) for meetings, ensuring that the media server (LiveKit SFU) cannot access audio/video content. Two encryption modes are available:
La Suite Meet supports end-to-end encryption (E2EE) for meetings, so the media server (LiveKit SFU) cannot read audio, video or screen-share content.
#### Basic encryption
#### How it works
- Passphrase-based the encryption key is embedded in the meeting URL hash (`#passphrase`)
- Uses LiveKit's built-in Worker + `crypto.subtle` (AES-GCM) for frame encryption
- Sharing the meeting link shares the encryption key
- No account or onboarding required
- Security depends on keeping the link private
- Each encrypted meeting carries a 48-character random passphrase appended to the URL hash (`#…`). The server never sees it; sharing the meeting link shares the key.
- Frames are encrypted in the browser via LiveKit's Worker + `crypto.subtle` (AES-GCM); only the media payload is encrypted, codec headers stay clear so the SFU can still packetize RTP.
- The runtime "is this call encrypted?" decision keys off the URL hash, not the database flag — a compromised server cannot fabricate a passphrase that all participants happen to share.
- The DB flag (`Room.is_encrypted`) is a hint used at room creation time only (so the Create button knows to generate a hash) and to detect link/server inconsistencies.
#### Advanced encryption
#### Opt-in by user
- Key managed by [La Suite Encryption](https://github.com/suitenumerique/encryption)the symmetric key never leaves the vault iframe
- Uses XChaCha20-Poly1305 (libsodium) via the VaultClient iframe for frame encryption
- Key distribution uses `vaultClient.shareKeys()` (hybrid PKI with X25519 + post-quantum slot)
- All participants must complete encryption onboarding (key generation + backup) before joining
- Requires a Chromium-based browser (Chrome, Edge, Brave) — uses the Insertable Streams API
End-to-end encryption is a per-user preference. In **Settings → Security**, signed-in users can enable "End-to-end encryption"from then on every meeting they create is encrypted by default. Joining is unaffected: if a meeting URL has a passphrase, the joining client uses it.
**Frame encryption (both modes):**
#### Pause / resume for recording and transcription
- Codec header bytes (VP8 payload descriptor) are preserved unencrypted — required for proper RTP packetization
- Only the media payload is encrypted, with a per-frame random nonce
- The server (LiveKit SFU) only forwards encrypted data it cannot read
While encryption is on, the SFU cannot record or transcribe (it has nothing to read). When an admin (or, if no admin is present, the longest-present participant — provided a pause has already been observed in the session) starts a recording or transcription:
**Trust levels (advanced mode):**
| Badge | Level | Description |
|-------|-------|-------------|
| 🟢 Green shield | Verified | User completed encryption onboarding (public key registered). Identity cryptographically verified. |
| 🔵 Blue shield | Authenticated | User signed in via ProConnect/OIDC. Identity server-verified. |
| 🟡 Orange warning | Anonymous | User not signed in. Self-declared name. Admin should verify identity before accepting. |
1. A confirmation dialog warns that encryption will be paused.
2. On confirm, an `ENCRYPTION_PAUSED` message is broadcast over a LiveKit reliable data channel. While the sender hasn't yet flipped its own state, that message is itself encrypted — which is the trust anchor: only callers holding the passphrase can produce frames everyone can decrypt.
3. Each receiver disables E2EE locally and republishes its tracks unencrypted.
4. Late joiners send an `ENCRYPTION_STATUS_PROBE` so the leader can re-emit the announcement to them.
5. When **both** recording and transcription stop, the participant who paused broadcasts `ENCRYPTION_RESUMED` and everyone re-enables E2EE with the same URL passphrase.
**Security guarantees:**
The pause state is intentionally session-only and never persisted — `Room.is_encrypted` does not flip.
- Encrypted rooms enforce restricted access (lobby approval required)
- Trust information (`is_authenticated`, `email`) comes from server-signed JWT tokens — cannot be spoofed
- Recording and transcription are not available in encrypted rooms (server cannot decrypt media)
#### Phone / SIP participants
**Configuration:**
Phone and other external devices can't decrypt our frames. When one joins an encrypted room, the backend webhook detects them, broadcasts a system notice (admins see a snackbar with an "Open settings" CTA), and removes the external participant. The admin can then disable encryption from the Security settings and the user can dial in again.
#### Configuration
```env
ENCRYPTION_ENABLED=true
ENCRYPTION_VAULT_URL=https://data.encryption.example.fr
ENCRYPTION_INTERFACE_URL=https://encryption.example.fr
```
When the encryption service is deployed and configured, rooms can use advanced encryption. Without it, only basic (passphrase) encryption is available.
Setting `ENCRYPTION_ENABLED=false` disables the user preference toggle entirely; existing encrypted rooms stay encrypted but no new ones can be created.
La Suite Meet is fully self-hostable and released under the MIT License, ensuring complete control and flexibility. It's simple to [get started](https://visio.numerique.gouv.fr/) or [request a demo](mailto:visio@numerique.gouv.fr).
+55 -2
View File
@@ -168,8 +168,7 @@ services:
- "3000:8080"
dockerize:
image: jwilder/dockerize
platform: linux/x86_64
image: powerman/dockerize:0.19.0
crowdin:
image: crowdin/cli:4.0.0
@@ -246,6 +245,60 @@ services:
depends_on:
- redis
# SIP gateway. Built from the sibling livekit-sip checkout so any dev
# cloning both repos gets a working `docker compose up`. TODO: replace
# the build: block with `image: livekit/sip` once upstream ships an
# image with the team's video bridging.
sip:
build:
context: ../livekit-sip
dockerfile: build/sip/Dockerfile
ports:
- "5060:5060/udp"
- "5060:5060/tcp"
- "10000-10020:10000-10020/udp"
environment:
SIP_CONFIG_BODY: |
api_key: 'devkey'
api_secret: 'secret'
ws_url: 'ws://livekit:7880'
redis:
address: 'redis:6379'
sip_port: 5060
rtp_port: 10000-10020
use_external_ip: false
logging:
level: debug
depends_on:
- livekit
- redis
# Janus WebRTC gateway with the SIP plugin. Bridges a browser SIP demo
# tab to the SIP gateway above. ICE-TCP enabled (janus.jcfg) so media
# works under Lima port-forwarding without vmnet.
sip-web-janus:
build: ./docker/janus
ports:
- "10100-10120:10100-10120/tcp"
volumes:
- ./docker/janus/conf/janus.jcfg:/usr/local/etc/janus/janus.jcfg:ro
- ./docker/janus/conf/janus.transport.http.jcfg:/usr/local/etc/janus/janus.transport.http.jcfg:ro
- ./docker/janus/conf/janus.plugin.sip.jcfg:/usr/local/etc/janus/janus.plugin.sip.jcfg:ro
depends_on:
- sip
# Serves docker/janus/web/{index.html,janus.js} and proxies /janus to
# the Janus container so the demo is single-origin.
sip-web:
image: nginx:alpine
ports:
- "8088:80"
volumes:
- ./docker/janus/web:/usr/share/nginx/html:ro
- ./docker/janus/conf/nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- sip-web-janus
redis-summary:
image: redis
ports:
+58
View File
@@ -0,0 +1,58 @@
# Build Janus from source so ICE-TCP support is present (the canyan image
# was compiled against libnice 0.1.16, which lacks the runtime ICE-TCP
# capability Janus 1.1.x feature-detects at startup). Debian 13 ships
# libnice 0.1.21+ which has ICE-TCP. Also: builds natively on arm64, so
# no QEMU emulation overhead.
FROM debian:13-slim AS builder
ARG JANUS_VERSION=v1.1.4
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential ca-certificates git pkg-config \
autoconf automake libtool gengetopt \
libssl-dev libsrtp2-dev libglib2.0-dev libopus-dev libogg-dev \
libcurl4-openssl-dev libconfig-dev libnice-dev \
libmicrohttpd-dev libjansson-dev libsofia-sip-ua-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /src
RUN git clone --depth 1 --branch ${JANUS_VERSION} \
https://github.com/meetecho/janus-gateway.git janus
WORKDIR /src/janus
RUN ./autogen.sh && \
./configure \
--prefix=/opt/janus \
--disable-rabbitmq --disable-mqtt --disable-nanomsg --disable-unix-sockets \
--disable-data-channels \
--disable-all-plugins \
--enable-plugin-sip \
--disable-all-handlers \
--disable-all-transports --enable-rest && \
make -j$(nproc) && \
make install && \
make configs
FROM debian:13-slim
# Debian 13 (trixie) renamed several libs in the time_t-64 transition:
# libglib2.0-0t64, libcurl4t64. libconfig9 → libconfig11.
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates libssl3 libsrtp2-1 libglib2.0-0t64 libopus0 libogg0 \
libcurl4t64 libconfig11 libnice10 libmicrohttpd12 libjansson4 \
libsofia-sip-ua0 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /opt/janus /opt/janus
# Make paths match canyan's so the rest of the compose mounts (configs, demos)
# Just-Work without changes. The configs we mount in docker/janus/conf/ assume
# /usr/local/etc/janus and /usr/local/lib/janus.
RUN ln -s /opt/janus/bin/janus /usr/local/bin/janus && \
ln -s /opt/janus/etc/janus /usr/local/etc/janus && \
ln -s /opt/janus/lib/janus /usr/local/lib/janus && \
ln -s /opt/janus/share/janus /usr/local/share/janus
EXPOSE 8088 10100-10120/tcp 10100-10120/udp
CMD ["/usr/local/bin/janus"]
+42
View File
@@ -0,0 +1,42 @@
// Minimal Janus config for browser-↔-SIP demo. Trimmed from the upstream
// sample to the bits we actually need.
general: {
configs_folder = "/usr/local/etc/janus"
plugins_folder = "/usr/local/lib/janus/plugins"
transports_folder = "/usr/local/lib/janus/transports"
events_folder = "/usr/local/lib/janus/events"
log_to_stdout = true
debug_level = 4
server_name = "meet-sip-demo"
}
media: {
// Janus ↔ Browser media (WebRTC over TCP — Lima only forwards TCP).
rtp_port_range = "10100-10120"
ipv6 = false
}
nat: {
// The whole reason this works without vmnet — Janus offers TCP ICE
// candidates the Mac browser can actually connect to.
ice_tcp = true
// libnice quirk: ICE-TCP needs ICE Lite mode on the server side or
// the connectivity check state machine deadlocks. Janus warns about
// this at startup if Lite is off while TCP is on.
ice_lite = true
// Janus advertises this IP in ICE candidates. From the Mac browser's
// perspective, the Janus daemon lives at 127.0.0.1:<port> (Lima
// forwards Mac:127.0.0.1:<port> → VM → docker port-map → container).
nat_1_1_mapping = "127.0.0.1"
keep_private_host = true
}
plugins: {
// Only keep the SIP plugin. Everything else trimmed for boot time.
disable = "libjanus_audiobridge.so,libjanus_videoroom.so,libjanus_streaming.so,libjanus_textroom.so,libjanus_recordplay.so,libjanus_voicemail.so,libjanus_echotest.so,libjanus_videocall.so,libjanus_nosip.so,libjanus_duktape.so,libjanus_lua.so"
}
transports: {
// Only HTTP. WS is overkill for the demo and adds another port.
disable = "libjanus_websockets.so,libjanus_mqtt.so,libjanus_nanomsg.so,libjanus_rabbitmq.so,libjanus_pfunix.so"
}
+8
View File
@@ -0,0 +1,8 @@
// SIP plugin defaults. RTP range for the SIP leg (Janus ↔ livekit/sip)
// is kept separate from WebRTC range — both legs are inside the docker
// bridge network, so any UDP range works.
general: {
local_ip = "0.0.0.0"
rtp_port_range = "20000-20100"
events = true
}
@@ -0,0 +1,24 @@
// HTTP transport: bind 0.0.0.0:8088 inside the container, served to the
// browser through the nginx sidecar that also hosts the demo HTML.
general: {
json = "indented"
base_path = "/janus"
http = true
port = 8088
https = false
mhd_connection_limit = 1020
}
admin: {
admin_base_path = "/admin"
admin_http = false
admin_https = false
}
certificates: {
}
cors: {
// Same-origin via nginx proxy; demo page and API share a host.
# allow_origin = "*"
}
+33
View File
@@ -0,0 +1,33 @@
server {
listen 80;
server_name _;
# Docker's embedded DNS (127.0.0.11). Without this, nginx resolves
# upstreams once at startup and breaks the moment a peer container is
# recreated with a new bridge IP. The `valid=10s` plus a variable in
# proxy_pass forces per-request re-resolution.
resolver 127.0.0.11 valid=10s ipv6=off;
# Serve the Janus demo HTML/JS extracted from the canyan image.
root /usr/share/nginx/html;
index index.html;
# Janus HTTP API — same origin as the demo pages so the default
# settings.js URL ("http://<host>:8088/janus") just works. Regex so
# /janus and /janus/<session>/... proxy, but /janus.js still hits
# the static file root.
location ~ ^/janus(/|$) {
# Variable upstream + the resolver above = re-DNS on each request.
# Plain `proxy_pass http://sip-web-janus:8088` would cache the IP
# for the lifetime of the worker process.
set $janus_upstream sip-web-janus;
proxy_pass http://$janus_upstream:8088;
proxy_http_version 1.1;
proxy_buffering off;
proxy_read_timeout 120s;
}
location / {
try_files $uri $uri/ =404;
}
}
+405
View File
@@ -0,0 +1,405 @@
<!DOCTYPE html>
<!--
Minimal SIP-via-Janus demo. Replaces the upstream siptest.html bundle with
a single self-contained page. Everything is auto-driven: page load creates
the Janus session, attaches to the SIP plugin, registers as guest, places
the call. The user just needs to type the room PIN on the dialpad.
Co-located only with janus.js (vendor lib, ~113KB) — no settings.js, no
navbar/footer, no shared CSS, no logos. nginx serves these two files.
-->
<html lang="en">
<head>
<meta charset="utf-8">
<title>SIP Demo</title>
<style>
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: 100%; background: #1b1b1f; color: #e8e8e8;
font: 14px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
#app { display: flex; flex-direction: column; height: 100vh; max-width: 720px; margin: 0 auto; padding: 16px; gap: 12px; }
h1 { margin: 0; font-size: 18px; font-weight: 600; }
#status { padding: 8px 12px; border-radius: 6px; background: #2c2c33; font-family: ui-monospace, monospace; font-size: 13px; }
#video-wrap { position: relative; flex: 1; background: #000; border-radius: 8px; overflow: hidden; min-height: 240px; }
#remoteVideo { width: 100%; height: 100%; object-fit: contain; }
#noVideo { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: #555; font-size: 14px; }
.row { display: flex; gap: 8px; flex-wrap: wrap; }
button { font: inherit; padding: 8px 14px; border: 0; border-radius: 6px; background: #4a90e2; color: #fff; cursor: pointer; }
button:hover:not(:disabled) { background: #357ec0; }
button:disabled { opacity: 0.4; cursor: not-allowed; }
button.danger { background: #dc3545; }
button.danger:hover:not(:disabled) { background: #b62733; }
button.success { background: #28a745; }
button.success:hover:not(:disabled) { background: #1f8035; }
.dialpad { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; }
.dialpad button { padding: 14px 0; font-size: 16px; background: #3a3a44; }
.dialpad button:hover:not(:disabled) { background: #4a4a55; }
#pin-row input { flex: 1; padding: 8px 12px; border: 1px solid #444; border-radius: 6px; background: #2c2c33; color: #fff; font: inherit; font-family: ui-monospace, monospace; letter-spacing: 0.2em; }
#log { font-family: ui-monospace, monospace; font-size: 11px; max-height: 120px; overflow-y: auto; background: #15151a; padding: 8px; border-radius: 6px; color: #888; }
.log-line { white-space: pre-wrap; word-break: break-word; }
.log-err { color: #ff7373; }
.disabled { opacity: 0.35; pointer-events: none; transition: opacity 0.2s; }
</style>
</head>
<body>
<div id="app">
<h1>SIP demo (browser → Janus → livekit-sip → room)</h1>
<div id="status">Loading…</div>
<div id="video-wrap">
<video id="remoteVideo" autoplay playsinline muted></video>
<audio id="remoteAudio" autoplay></audio>
<div id="noVideo">No remote video yet</div>
</div>
<div id="pin-row" class="row" style="display:none">
<input type="text" id="pin" placeholder="Enter PIN then press # on the pad" inputmode="numeric" pattern="[0-9]*">
<button id="sendPin" class="success">Send PIN</button>
</div>
<div id="dialpad" class="dialpad" style="display:none">
<button data-d="1">1</button><button data-d="2">2</button><button data-d="3">3</button><button data-d="A">A</button>
<button data-d="4">4</button><button data-d="5">5</button><button data-d="6">6</button><button data-d="B">B</button>
<button data-d="7">7</button><button data-d="8">8</button><button data-d="9">9</button><button data-d="C">C</button>
<button data-d="*">*</button><button data-d="0">0</button><button data-d="#">#</button><button data-d="D">D</button>
</div>
<div id="controls" class="row">
<button id="muteBtn" class="danger">🔇 Mic muted</button>
<button id="cameraBtn" class="success">📷 Camera on</button>
<button id="speakerBtn" class="success">🔊 Speaker on</button>
<button id="hangupBtn" class="danger" disabled>Hangup</button>
<button id="rejoinBtn" style="display:none">Re-join</button>
</div>
<details><summary style="cursor:pointer; opacity:0.7; font-size:12px">debug log</summary>
<div id="log"></div>
</details>
</div>
<!-- webrtc-adapter is required by janus.js (cross-browser WebRTC API
normalizer). The upstream siptest pulls it from CDN; we do the same.
Without it Janus.init() silently fails. -->
<script src="https://cdn.jsdelivr.net/npm/webrtc-adapter@9.0.1/out/adapter.min.js"></script>
<script src="janus.js"></script>
<script>
// ============================================================================
// Configuration (hard-coded; this is a dev demo)
// ============================================================================
const JANUS_SERVER = location.origin + "/janus"; // nginx proxies to janus
const SIP_PROXY = "sip:sip:5060"; // docker service name
const SIP_IDENTITY = "sip:demo@example.invalid"; // no registrar, guest
const SIP_PEER = "sip:phone@sip:5060"; // user-part irrelevant
// (Direct+PIN rule)
const DISPLAY_NAME = "test-user";
const ICE_SERVERS = []; // ICE-TCP via janus.jcfg
// ============================================================================
// UI helpers
// ============================================================================
const $ = id => document.getElementById(id);
function setStatus(text) { $("status").textContent = text; log(text); }
function log(msg, isErr = false) {
const line = document.createElement("div");
line.className = "log-line" + (isErr ? " log-err" : "");
line.textContent = "[" + new Date().toLocaleTimeString() + "] " + msg;
$("log").appendChild(line);
$("log").scrollTop = $("log").scrollHeight;
}
// ============================================================================
// Janus state
// ============================================================================
let janus = null;
let sipcall = null;
let inCall = false;
// ============================================================================
// Init: create Janus → session → SIP handle → register → call
// ============================================================================
Janus.init({
debug: false,
callback: () => {
if (!Janus.isWebrtcSupported()) {
setStatus("WebRTC not supported in this browser.");
return;
}
createSession();
}
});
function createSession() {
setStatus("Connecting to Janus…");
janus = new Janus({
server: JANUS_SERVER,
iceServers: ICE_SERVERS,
success: () => {
setStatus("Connected. Attaching SIP plugin…");
attachSip();
},
error: (err) => setStatus("Janus error: " + err),
destroyed: () => setStatus("Janus session destroyed."),
});
}
function attachSip() {
janus.attach({
plugin: "janus.plugin.sip",
opaqueId: "sip-demo-" + Janus.randomString(8),
success: (handle) => {
sipcall = handle;
window.sipcall = handle; // for ad-hoc console debugging
registerAsGuest();
},
error: (err) => setStatus("Plugin attach error: " + err),
onmessage: handleMessage,
onlocaltrack: (track, on) => {
// We don't render local preview — Meet tab is already showing it.
},
onremotetrack: (track, mid, on, meta) => {
if (!on) return;
const stream = new MediaStream([track]);
if (track.kind === "audio") {
$("remoteAudio").srcObject = stream;
} else if (track.kind === "video") {
$("remoteVideo").srcObject = stream;
$("noVideo").style.display = "none";
}
},
oncleanup: () => { setStatus("Call cleaned up."); endCallUi(); },
});
}
function registerAsGuest() {
setStatus("Registering as guest…");
sipcall.send({
message: {
request: "register",
type: "guest",
proxy: SIP_PROXY,
username: SIP_IDENTITY,
display_name: DISPLAY_NAME,
},
});
}
function placeCall() {
setStatus("Calling " + SIP_PEER + "…");
const tracks = [
{ type: "audio", capture: true, recv: true },
{ type: "video", capture: true, recv: true },
];
sipcall.createOffer({
tracks: tracks,
success: (jsep) => {
sipcall.send({
message: { request: "call", uri: SIP_PEER, autoaccept_reinvites: false },
jsep: jsep,
});
},
error: (err) => setStatus("createOffer failed: " + err),
});
}
// ============================================================================
// Plugin event router
// ============================================================================
function handleMessage(msg, jsep) {
const result = msg && msg.result;
const event = result && result.event;
if (event) log("event: " + event);
if (event === "registered") {
setStatus("Registered. Placing call…");
// Small delay to settle the registration
setTimeout(placeCall, 100);
}
else if (event === "calling") {
setStatus("Calling…");
}
else if (event === "accepted") {
setStatus("Call accepted. You should hear the IVR — enter PIN on the dialpad.");
startCallUi();
if (jsep) sipcall.handleRemoteJsep({ jsep: jsep });
}
else if (event === "hangup") {
setStatus("Call ended: " + (result.reason || result.code || ""));
endCallUi();
}
else if (event === "missed_call") {
setStatus("Missed call.");
endCallUi();
}
else if (event === "registration_failed") {
setStatus("Registration failed: " + result.code + " " + result.reason, true);
}
else if (msg && msg.error) {
setStatus("Plugin error: " + msg.error, true);
}
if (jsep && event !== "accepted") {
sipcall.handleRemoteJsep({ jsep: jsep });
}
}
// ============================================================================
// In-call UI: mute, hangup, dialpad, PIN entry
// ============================================================================
function startCallUi() {
inCall = true;
$("pin-row").style.display = "flex";
$("pin-row").classList.remove("disabled");
$("dialpad").style.display = "grid";
$("dialpad").classList.remove("disabled");
$("hangupBtn").disabled = false;
$("rejoinBtn").style.display = "none";
// Auto-mute mic so two tabs on the same Mac don't feedback-howl.
if (sipcall.isAudioMuted && !sipcall.isAudioMuted()) {
sipcall.muteAudio();
}
updateMuteButton();
updateCameraButton();
updateSpeakerButton();
}
function endCallUi() {
inCall = false;
$("pin-row").style.display = "none";
$("pin-row").classList.remove("disabled");
$("dialpad").style.display = "none";
$("dialpad").classList.remove("disabled");
$("hangupBtn").disabled = true;
$("rejoinBtn").style.display = "inline-block";
$("remoteVideo").srcObject = null;
$("remoteAudio").srcObject = null;
$("noVideo").style.display = "flex";
}
function updateMuteButton() {
const b = $("muteBtn");
if (!sipcall || !sipcall.isAudioMuted) return;
if (sipcall.isAudioMuted()) {
b.textContent = "🔇 Mic muted";
b.className = "danger";
} else {
b.textContent = "🎤 Mic on";
b.className = "success";
}
}
function updateCameraButton() {
const b = $("cameraBtn");
if (!sipcall || !sipcall.isVideoMuted) return;
if (sipcall.isVideoMuted()) {
b.textContent = "📷 Camera off";
b.className = "danger";
} else {
b.textContent = "📷 Camera on";
b.className = "success";
}
}
function updateSpeakerButton() {
const b = $("speakerBtn");
const audio = $("remoteAudio");
if (audio.muted) {
b.textContent = "🔇 Speaker off";
b.className = "danger";
} else {
b.textContent = "🔊 Speaker on";
b.className = "success";
}
}
$("muteBtn").addEventListener("click", () => {
if (!sipcall) return;
if (sipcall.isAudioMuted()) sipcall.unmuteAudio();
else sipcall.muteAudio();
setTimeout(updateMuteButton, 50);
});
$("cameraBtn").addEventListener("click", () => {
if (!sipcall || !sipcall.muteVideo) return;
if (sipcall.isVideoMuted()) sipcall.unmuteVideo();
else sipcall.muteVideo();
setTimeout(updateCameraButton, 50);
});
$("speakerBtn").addEventListener("click", () => {
const audio = $("remoteAudio");
audio.muted = !audio.muted;
updateSpeakerButton();
});
$("hangupBtn").addEventListener("click", () => {
if (!sipcall) return;
sipcall.send({ message: { request: "hangup" } });
sipcall.hangup();
});
$("rejoinBtn").addEventListener("click", () => location.reload());
// ============================================================================
// DTMF — try the in-band RTP-event path first (RFC 4733 via the handle's
// dtmf() method), which is what livekit-sip's IVR actually listens for.
// Fall back to SIP INFO if RTP DTMF isn't available (older Janus, no
// telephone-event in SDP, etc.).
// ============================================================================
function sendDtmf(digit) {
if (!sipcall || !inCall) return;
log("DTMF → " + digit);
let sent = false;
try {
if (typeof sipcall.dtmf === "function") {
sipcall.dtmf({
dtmf: { tones: String(digit), duration: 200, gap: 70 },
success: () => {},
error: (err) => log("dtmf() error: " + err, true),
});
sent = true;
}
} catch (e) {
log("dtmf() threw: " + e, true);
}
if (!sent) {
// Fallback: SIP INFO method
sipcall.send({
message: { request: "dtmf_info", digit: String(digit) },
});
}
}
document.querySelectorAll(".dialpad button").forEach(b => {
b.addEventListener("click", () => sendDtmf(b.dataset.d));
});
$("sendPin").addEventListener("click", () => {
const pin = $("pin").value.replace(/[^\d#*]/g, "");
if (!pin) return;
const tones = (pin.endsWith("#") ? pin : pin + "#");
log("DTMF batch → " + tones);
// Send all tones in ONE call. Per-digit looping with setTimeout races
// the WebRTC DTMF sender's internal queue and drops digits (we lost
// the trailing "0" before "#" doing it digit-by-digit). The browser's
// RTCDTMFSender paces them itself given duration/gap.
try {
sipcall.dtmf({
dtmf: { tones: tones, duration: 400, gap: 100 },
success: () => log("DTMF batch sent"),
error: (err) => log("dtmf() error: " + err, true),
});
} catch (e) {
log("dtmf() threw, falling back to SIP INFO per digit: " + e, true);
for (const ch of tones) {
sipcall.send({ message: { request: "dtmf_info", digit: String(ch) } });
}
}
$("pin").value = "";
// PIN + dialpad are no longer needed once you've submitted. Gateway
// either bridges you in (correct PIN) or hangs up with wrong-pin
// (the hangup event re-resets everything via endCallUi).
$("pin-row").classList.add("disabled");
$("dialpad").classList.add("disabled");
});
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+3 -6
View File
@@ -73,11 +73,8 @@ def get_frontend_configuration(request):
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
},
}
if settings.ENCRYPTION_ENABLED and settings.ENCRYPTION_VAULT_URL:
frontend_configuration["encryption"] = {
"enabled": True,
"vault_url": settings.ENCRYPTION_VAULT_URL,
"interface_url": settings.ENCRYPTION_INTERFACE_URL,
}
frontend_configuration["encryption"] = {
"enabled": settings.ENCRYPTION_ENABLED,
}
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
return Response(frontend_configuration)
+39 -47
View File
@@ -30,7 +30,16 @@ class UserSerializer(serializers.ModelSerializer):
class Meta:
model = models.User
fields = ["id", "sub", "email", "full_name", "short_name", "timezone", "language"]
fields = [
"id",
"sub",
"email",
"full_name",
"short_name",
"timezone",
"language",
"default_encryption",
]
read_only_fields = ["id", "sub", "email", "full_name", "short_name"]
@@ -75,22 +84,6 @@ class ResourceAccessSerializerMixin:
"Only owners of a room can assign other users as owners."
)
# In advanced encrypted rooms, new accesses require an encrypted_symmetric_key
# so the new member can decrypt the room's streams. Without it, they'd have
# access but no key — which is useless and confusing.
# Future: a sharing UI (like Docs) could provide the key via vault shareKeys.
if not self.instance and "resource" in data:
resource = data["resource"]
if (
hasattr(resource, 'encryption_mode')
and resource.encryption_mode == models.EncryptionMode.ADVANCED
and not data.get("encrypted_symmetric_key")
):
raise serializers.ValidationError(
"Adding members to advanced encrypted rooms requires "
"an encrypted_symmetric_key for the new user."
)
return data
def validate_resource(self, resource):
@@ -115,7 +108,7 @@ class ResourceAccessSerializer(
class Meta:
model = models.ResourceAccess
fields = ["id", "user", "resource", "role", "encrypted_symmetric_key"]
fields = ["id", "user", "resource", "role"]
read_only_fields = ["id"]
def update(self, instance, validated_data):
@@ -145,24 +138,42 @@ class RoomSerializer(serializers.ModelSerializer):
class Meta:
model = models.Room
fields = ["id", "name", "slug", "configuration", "access_level", "pin_code", "encryption_mode"]
fields = [
"id",
"name",
"slug",
"configuration",
"access_level",
"pin_code",
"is_encrypted",
"encryption_paused",
]
read_only_fields = ["id", "slug", "pin_code"]
def validate_access_level(self, value):
"""Encrypted rooms must stay restricted — prevent downgrading access level."""
def validate_is_encrypted(self, value):
"""is_encrypted is set at creation and is part of the link's identity
(copy-link always carries the hash for an encrypted room). Mid-call
toggling is done via encryption_paused, not by mutating this flag."""
instance = self.instance
if instance and instance.encryption_enabled and value != models.RoomAccessLevel.RESTRICTED:
if instance and instance.is_encrypted != value:
raise serializers.ValidationError(
"Encrypted rooms require restricted access level to enforce lobby approval."
"Encryption mode cannot be changed after room creation. "
"Use encryption_paused to temporarily suspend encryption."
)
return value
def validate_encryption_mode(self, value):
"""Once encryption is enabled on a room, it cannot be disabled or downgraded."""
instance = self.instance
if instance and instance.encryption_enabled and value == models.EncryptionMode.NONE:
def validate_encryption_paused(self, value):
"""encryption_paused only makes sense on encrypted rooms. Allow either
direction (True ↔ False) post-creation: admins suspend E2EE so a
SIP/device caller can join, then resume once they hang up."""
is_encrypted = (
self.instance.is_encrypted
if self.instance is not None
else self.initial_data.get("is_encrypted", False)
)
if value and not is_encrypted:
raise serializers.ValidationError(
"Encryption cannot be disabled once enabled on a room."
"Cannot pause encryption on a non-encrypted room."
)
return value
@@ -208,33 +219,18 @@ class RoomSerializer(serializers.ModelSerializer):
room_id = f"{instance.id!s}"
username = request.query_params.get("username", None)
# In encrypted rooms, authenticated users must use their real name from
# the OIDC profile (ProConnect) — they cannot choose an arbitrary name.
if instance.encryption_enabled and request.user.is_authenticated:
username = request.user.full_name or request.user.email
output["livekit"] = utils.generate_livekit_config(
room_id=room_id,
user=request.user,
username=username,
configuration=configuration,
is_admin_or_owner=is_admin_or_owner,
encryption_mode=instance.encryption_mode,
)
else:
del output["pin_code"]
output["is_administrable"] = is_admin_or_owner
# Include the current user's encrypted symmetric key for advanced E2EE
if request.user.is_authenticated and instance.encryption_mode == models.EncryptionMode.ADVANCED:
try:
access = instance.accesses.get(user=request.user)
if access.encrypted_symmetric_key:
output["encrypted_symmetric_key"] = access.encrypted_symmetric_key
except models.ResourceAccess.DoesNotExist:
pass
return output
@@ -317,7 +313,6 @@ class RequestEntrySerializer(BaseValidationOnlySerializer):
"""Validate request entry data."""
username = serializers.CharField(required=True, allow_blank=True)
ephemeral_public_key = serializers.CharField(required=False, allow_blank=True, default='')
class ParticipantEntrySerializer(BaseValidationOnlySerializer):
@@ -325,9 +320,6 @@ class ParticipantEntrySerializer(BaseValidationOnlySerializer):
participant_id = serializers.UUIDField(required=True)
allow_entry = serializers.BooleanField(required=True)
encrypted_key = serializers.CharField(required=False, allow_blank=True, default='')
admin_ephemeral_public_key = serializers.CharField(required=False, allow_blank=True, default='')
encrypted_vault_key = serializers.CharField(required=False, allow_blank=True, default='')
class CreationCallbackSerializer(BaseValidationOnlySerializer):
+12 -53
View File
@@ -281,32 +281,19 @@ class RoomViewSet(
def perform_create(self, serializer):
"""Set the current user as owner of the newly created room."""
encryption_mode = serializer.validated_data.get("encryption_mode", models.EncryptionMode.NONE)
is_encrypted = serializer.validated_data.get("is_encrypted", False)
# Block encrypted room creation if encryption is not enabled on this instance
if encryption_mode != models.EncryptionMode.NONE and not settings.ENCRYPTION_ENABLED:
if is_encrypted and not settings.ENCRYPTION_ENABLED:
raise drf_exceptions.ValidationError(
{"encryption_mode": "Encryption is not enabled on this server."}
{"is_encrypted": "Encryption is not enabled on this server."}
)
# Advanced encryption requires the vault service to be configured
if encryption_mode == models.EncryptionMode.ADVANCED and not getattr(settings, 'ENCRYPTION_VAULT_URL', ''):
raise drf_exceptions.ValidationError(
{"encryption_mode": "Advanced encryption requires the encryption service to be configured."}
)
# Encrypted rooms must use restricted access to enforce lobby approval
# before the encryption key is shared with participants.
if encryption_mode != models.EncryptionMode.NONE:
serializer.validated_data["access_level"] = models.RoomAccessLevel.RESTRICTED
room = serializer.save()
encrypted_symmetric_key = self.request.data.get("encrypted_symmetric_key", "")
models.ResourceAccess.objects.create(
resource=room,
user=self.request.user,
role=models.RoleChoices.OWNER,
encrypted_symmetric_key=encrypted_symmetric_key,
)
if callback_id := self.request.data.get("callback_id"):
@@ -327,20 +314,22 @@ class RoomViewSet(
serializer = serializers.StartRecordingSerializer(data=request.data)
if not serializer.is_valid():
print(
"[start-recording] body=",
request.data,
"errors=",
serializer.errors,
flush=True,
)
return drf_response.Response(
{"detail": "Invalid request."}, status=drf_status.HTTP_400_BAD_REQUEST
{"detail": "Invalid request.", "errors": serializer.errors},
status=drf_status.HTTP_400_BAD_REQUEST,
)
mode = serializer.validated_data["mode"]
options = serializer.validated_data.get("options")
room = self.get_object()
if room.encryption_enabled:
return drf_response.Response(
{"detail": "Recording is not available in encrypted rooms."},
status=drf_status.HTTP_403_FORBIDDEN,
)
# May raise exception if an active or initiated recording already exist for the room
recording = models.Recording.objects.create(
room=room,
@@ -425,20 +414,6 @@ class RoomViewSet(
room = self.get_object()
validated_data = serializer.validated_data
# Advanced encrypted rooms require authentication
if room.encryption_mode == models.EncryptionMode.ADVANCED and not request.user.is_authenticated:
return drf_response.Response(
{"detail": "This meeting requires authentication to join."},
status=drf_status.HTTP_403_FORBIDDEN,
)
# In encrypted rooms, authenticated users must use their real name
# from the OIDC profile — they cannot choose an arbitrary name.
if room.encryption_enabled and request.user.is_authenticated:
validated_data["username"] = (
request.user.full_name or request.user.email
)
lobby_service = LobbyService()
participant, livekit = lobby_service.request_entry(
@@ -480,9 +455,6 @@ class RoomViewSet(
room_id=room.id,
participant_id=str(serializer.validated_data.get("participant_id")),
allow_entry=serializer.validated_data.get("allow_entry"),
encrypted_key=serializer.validated_data.get("encrypted_key", ''),
admin_ephemeral_public_key=serializer.validated_data.get("admin_ephemeral_public_key", ''),
encrypted_vault_key=serializer.validated_data.get("encrypted_vault_key", ''),
)
return drf_response.Response({"message": "Participant was updated."})
@@ -511,13 +483,6 @@ class RoomViewSet(
participants = lobby_service.list_waiting_participants(room.id)
# Only expose email and ephemeral keys in encrypted rooms.
# Strip them otherwise to avoid leaking personal data.
if not room.encryption_enabled:
for p in participants:
p.pop("email", None)
p.pop("ephemeral_public_key", None)
return drf_response.Response({"participants": participants})
@decorators.action(
@@ -620,12 +585,6 @@ class RoomViewSet(
room = self.get_object()
if room.encryption_enabled:
return drf_response.Response(
{"error": "Transcription is not available in encrypted rooms."},
status=drf_status.HTTP_403_FORBIDDEN,
)
try:
SubtitleService().start_subtitle(room)
except SubtitleException:
@@ -1,20 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0018_rename_active_application_is_active"),
]
operations = [
migrations.AddField(
model_name="room",
name="encryption_enabled",
field=models.BooleanField(
default=False,
help_text="Whether end-to-end encryption is enabled for this room.",
verbose_name="Encryption enabled",
),
),
]
@@ -0,0 +1,36 @@
"""Add Room.is_encrypted and User.default_encryption.
`is_encrypted` is a boolean for now because passphrase-in-URL is the only
supported mode. A future migration may turn it into a CharField/enum if a
local-keys (vault) mode is reintroduced.
"""
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0018_rename_active_application_is_active"),
]
operations = [
migrations.AddField(
model_name="room",
name="is_encrypted",
field=models.BooleanField(
default=False,
help_text="Whether end-to-end encryption is enabled for this room.",
verbose_name="Encryption enabled",
),
),
migrations.AddField(
model_name="user",
name="default_encryption",
field=models.BooleanField(
default=False,
help_text="Whether new meetings created by this user are end-to-end encrypted by default.",
verbose_name="Default to end-to-end encryption",
),
),
]
@@ -1,51 +0,0 @@
"""Replace encryption_enabled boolean with encryption_mode enum."""
from django.db import migrations, models
def migrate_encryption_enabled_to_mode(apps, schema_editor):
"""Convert existing encryption_enabled=True rooms to encryption_mode='basic'."""
Room = apps.get_model("core", "Room")
Room.objects.filter(encryption_enabled=True).update(encryption_mode="basic")
def migrate_mode_to_encryption_enabled(apps, schema_editor):
"""Reverse: set encryption_enabled=True for any non-'none' encryption_mode."""
Room = apps.get_model("core", "Room")
Room.objects.exclude(encryption_mode="none").update(encryption_enabled=True)
class Migration(migrations.Migration):
dependencies = [
("core", "0019_room_encryption_enabled"),
]
operations = [
# 1. Add the new encryption_mode field
migrations.AddField(
model_name="room",
name="encryption_mode",
field=models.CharField(
choices=[
("none", "No encryption"),
("basic", "Basic encryption"),
("advanced", "Advanced encryption"),
],
default="none",
help_text="End-to-end encryption mode for this room.",
max_length=20,
verbose_name="Encryption mode",
),
),
# 2. Migrate existing data
migrations.RunPython(
migrate_encryption_enabled_to_mode,
migrate_mode_to_encryption_enabled,
),
# 3. Remove the old boolean field
migrations.RemoveField(
model_name="room",
name="encryption_enabled",
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.2.12 on 2026-05-12 08:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0019_room_is_encrypted_user_default_encryption'),
]
operations = [
migrations.AddField(
model_name='room',
name='encryption_paused',
field=models.BooleanField(default=False, help_text='Temporarily suspend E2EE so external devices can join.', verbose_name='Encryption paused'),
),
]
@@ -1,23 +0,0 @@
"""Add encrypted_symmetric_key to ResourceAccess for advanced E2EE mode."""
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0020_room_encryption_mode"),
]
operations = [
migrations.AddField(
model_name="resourceaccess",
name="encrypted_symmetric_key",
field=models.TextField(
blank=True,
default="",
help_text="Vault-wrapped symmetric encryption key for advanced E2EE mode. Each user's copy is encrypted for their own vault public key.",
verbose_name="Encrypted symmetric key",
),
),
]
+58 -29
View File
@@ -98,14 +98,6 @@ class RoomAccessLevel(models.TextChoices):
RESTRICTED = "restricted", _("Restricted Access")
class EncryptionMode(models.TextChoices):
"""Encryption mode choices for rooms."""
NONE = "none", _("No encryption")
BASIC = "basic", _("Basic encryption")
ADVANCED = "advanced", _("Advanced encryption")
class BaseModel(models.Model):
"""
Serves as an abstract base model for other models, ensuring that records are validated
@@ -208,6 +200,14 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
"Unselect this instead of deleting accounts."
),
)
default_encryption = models.BooleanField(
_("Default to end-to-end encryption"),
default=False,
help_text=_(
"Whether new meetings created by this user are "
"end-to-end encrypted by default."
),
)
objects = auth_models.UserManager()
@@ -332,15 +332,6 @@ class ResourceAccess(BaseModel):
role = models.CharField(
max_length=20, choices=RoleChoices.choices, default=RoleChoices.MEMBER
)
encrypted_symmetric_key = models.TextField(
blank=True,
default='',
verbose_name=_("Encrypted symmetric key"),
help_text=_(
"Vault-wrapped symmetric encryption key for advanced E2EE mode. "
"Each user's copy is encrypted for their own vault public key."
),
)
class Meta:
db_table = "meet_resource_access"
@@ -405,12 +396,24 @@ class Room(Resource):
choices=RoomAccessLevel.choices,
default=settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
)
encryption_mode = models.CharField(
max_length=20,
choices=EncryptionMode.choices,
default=EncryptionMode.NONE,
verbose_name=_("Encryption mode"),
help_text=_("End-to-end encryption mode for this room."),
# Boolean for now: today the only encryption mode is the passphrase-in-URL
# one. If a follow-up adds a stronger "local-keys" mode (private keys held
# in a vault iframe), this can grow into a CharField with choices like
# `none / passphrase / local_keys`. Set at creation; never mutated after —
# changing it would change the link's semantics (copy-link / hash carry).
is_encrypted = models.BooleanField(
default=False,
verbose_name=_("Encryption enabled"),
help_text=_("Whether end-to-end encryption is enabled for this room."),
)
# Mid-call admin override: when True on an `is_encrypted` room, the active
# encryption is suspended so a phone/SIP/device caller can join in plaintext.
# The link still carries the hash; toggling back to False resumes E2EE.
# Always False on non-encrypted rooms (enforced by the serializer).
encryption_paused = models.BooleanField(
default=False,
verbose_name=_("Encryption paused"),
help_text=_("Temporarily suspend E2EE so external devices can join."),
)
configuration = models.JSONField(
blank=True,
@@ -437,13 +440,44 @@ class Room(Resource):
return capfirst(self.name)
def save(self, *args, **kwargs):
"""Generate a unique n-digit pin code for new rooms."""
if settings.ROOM_TELEPHONY_ENABLED and not self.pk and not self.pin_code:
self.pin_code = self.generate_unique_pin_code(
length=settings.ROOM_TELEPHONY_PIN_LENGTH
)
previous = None
if self.pk:
try:
previous = Room.objects.only(
"is_encrypted", "encryption_paused"
).get(pk=self.pk)
except Room.DoesNotExist:
previous = None
super().save(*args, **kwargs)
# Sync encryption state to LiveKit room metadata so the SIP gateway can
# decide between bridge mode and placeholder mode. Best-effort: if no
# LiveKit room exists yet, the call is a no-op.
encryption_changed = previous is None or (
previous.is_encrypted != self.is_encrypted
or previous.encryption_paused != self.encryption_paused
)
if encryption_changed:
try:
from core import utils as core_utils # local import: avoid cycle
core_utils.update_room_metadata(
room_name=str(self.pk),
metadata={
"is_encrypted": bool(self.is_encrypted),
"encryption_paused": bool(self.encryption_paused),
},
)
except Exception: # pylint: disable=broad-except
# Metadata sync is advisory — never break Room.save() over it.
pass
def clean_fields(self, exclude=None):
"""
Automatically generate the slug from the name and make sure it does not look like a UUID.
@@ -466,11 +500,6 @@ class Room(Resource):
"""Check if a room is public"""
return self.access_level == RoomAccessLevel.PUBLIC
@property
def encryption_enabled(self):
"""Check if any encryption mode is active."""
return self.encryption_mode != EncryptionMode.NONE
@staticmethod
def generate_unique_pin_code(length):
"""Generate a unique n-digit PIN code"""
@@ -18,6 +18,10 @@ from core.recording.services.recording_events import (
)
from .lobby import LobbyService
from .participants_management import (
ParticipantsManagement,
ParticipantsManagementException,
)
from .telephony import TelephonyException, TelephonyService
logger = getLogger(__name__)
@@ -185,6 +189,72 @@ class LiveKitEventsService:
f"Failed to process limit reached event for recording {recording}"
) from e
def _handle_participant_joined(self, data):
"""Handle 'participant_joined' event.
When a SIP/phone participant joins an end-to-end encrypted room they
cannot decrypt anything. We:
1. Send an in-band notification so admins see a snackbar with an
"Open settings" CTA.
2. Leave the participant connected — the gateway is responsible for
holding them on a placeholder prompt loop ("this meeting is
encrypted, ask the host to disable it") until either the admin
turns encryption off (we update LiveKit room metadata, gateway
reacts) or the user hangs up.
See README "Phone / SIP participants" for the full flow.
"""
participant = getattr(data, "participant", None)
if participant is None:
return
# LiveKit ParticipantInfo.Kind: 0 = STANDARD, 1 = INGRESS, 2 = EGRESS,
# 3 = SIP, 4 = AGENT. We treat both SIP and INGRESS as "external
# device that can't run our E2EE code".
kind = getattr(participant, "kind", 0)
is_external_device = kind in (1, 3)
if not is_external_device:
return
try:
room_id = uuid.UUID(data.room.name)
except ValueError:
return
try:
room = models.Room.objects.get(id=room_id)
except models.Room.DoesNotExist:
return
# Live encryption: is_encrypted set AND not currently paused.
# If the admin has already paused encryption, the gateway will bridge
# the call normally — no need to surface a blocked notification.
if not room.is_encrypted or room.encryption_paused:
return
# 1. Broadcast a system notice — frontends decode this on the
# "encryption-state" or notifications channel and show a snackbar.
try:
utils.notify_participants(
room_name=str(room_id),
notification_data={
"type": "external_device_blocked",
"participant_identity": participant.identity,
"participant_name": participant.name or participant.identity,
},
)
except utils.NotificationError:
logger.exception(
"Failed to notify room about blocked external device"
)
# No eject. The gateway reads LiveKit room metadata
# (`{is_encrypted, encryption_paused}` — see `Room.save()`) and stays
# in placeholder-prompt mode for the SIP leg as long as encryption is
# live. When the admin sets encryption_paused=true, the gateway
# transitions to a normal bridge without dropping the call.
def _handle_room_started(self, data):
"""Handle 'room_started' event."""
@@ -202,6 +272,22 @@ class LiveKitEventsService:
except models.Room.DoesNotExist as err:
raise ActionFailedError(f"Room with ID {room_id} does not exist") from err
# Now that the LiveKit room object exists, push the encryption flags
# into its metadata so the SIP gateway can read them as soon as a SIP
# caller joins. Room.save() also tries this on every change, but at
# *creation* time the LiveKit room didn't exist yet — this is the
# first reliable opportunity.
try:
utils.update_room_metadata(
str(room_id),
{
"is_encrypted": bool(room.is_encrypted),
"encryption_paused": bool(room.encryption_paused),
},
)
except utils.MetadataUpdateException as e:
logger.exception("Failed to seed encryption metadata: %s", e)
if settings.ROOM_TELEPHONY_ENABLED:
try:
self.telephony_service.create_dispatch_rule(room)
+14 -83
View File
@@ -46,36 +46,19 @@ class LobbyParticipant:
username: str
color: str
id: str
# Whether the user signed in (e.g. via ProConnect). Surfaced to admins so
# they can decide whether to accept self-declared identities.
is_authenticated: bool = False
email: Optional[str] = None
suite_user_id: Optional[str] = None
ephemeral_public_key: str = ''
encrypted_key: str = ''
admin_ephemeral_public_key: str = ''
encrypted_vault_key: str = ''
def to_dict(self) -> Dict[str, str]:
def to_dict(self) -> Dict[str, object]:
"""Serialize the participant object to a dict representation."""
result = {
return {
"status": self.status.value,
"username": self.username,
"id": self.id,
"color": self.color,
"is_authenticated": self.is_authenticated,
}
if self.email:
result["email"] = self.email
if self.suite_user_id:
result["suite_user_id"] = self.suite_user_id
if self.ephemeral_public_key:
result["ephemeral_public_key"] = self.ephemeral_public_key
if self.encrypted_key:
result["encrypted_key"] = self.encrypted_key
if self.admin_ephemeral_public_key:
result["admin_ephemeral_public_key"] = self.admin_ephemeral_public_key
if self.encrypted_vault_key:
result["encrypted_vault_key"] = self.encrypted_vault_key
return result
@classmethod
def from_dict(cls, data: dict) -> "LobbyParticipant":
@@ -89,13 +72,7 @@ class LobbyParticipant:
username=data["username"],
id=data["id"],
color=data["color"],
is_authenticated=data.get("is_authenticated", False),
email=data.get("email"),
suite_user_id=data.get("suite_user_id"),
ephemeral_public_key=data.get("ephemeral_public_key", ''),
encrypted_key=data.get("encrypted_key", ''),
admin_ephemeral_public_key=data.get("admin_ephemeral_public_key", ''),
encrypted_vault_key=data.get("encrypted_vault_key", ''),
is_authenticated=bool(data.get("is_authenticated", False)),
)
except (KeyError, ValueError) as e:
logger.exception("Error creating Participant from dict:")
@@ -139,16 +116,11 @@ class LobbyService:
1. The room is public (open to everyone)
2. The room has TRUSTED access level and the user is authenticated
Encrypted rooms never bypass the lobby — participants must go through
the lobby key exchange to receive the encryption key.
Note: Room access levels can change while participants are waiting in the lobby.
This function only checks the current state and should be called each time
a participant requests entry to ensure consistent access control, even for
participants who have already begun waiting.
"""
if hasattr(room, 'encryption_mode') and room.encryption_mode != 'none':
return False
return room.is_public or (
room.access_level == models.RoomAccessLevel.TRUSTED
and user.is_authenticated
@@ -159,7 +131,6 @@ class LobbyService:
room,
request,
username: str,
ephemeral_public_key: str = '',
) -> Tuple[LobbyParticipant, Optional[Dict]]:
"""Request entry to a room for a participant.
@@ -186,6 +157,7 @@ class LobbyService:
username=username,
id=participant_id,
color=utils.generate_color(participant_id),
is_authenticated=request.user.is_authenticated,
)
else:
participant.status = LobbyParticipantStatus.ACCEPTED
@@ -198,7 +170,6 @@ class LobbyService:
configuration=room.configuration,
is_admin_or_owner=False,
participant_id=participant_id,
encryption_mode=room.encryption_mode,
)
return participant, livekit_config
@@ -206,34 +177,16 @@ class LobbyService:
if participant is None:
participant = self.enter(
room.id, participant_id, username,
room.id,
participant_id,
username,
is_authenticated=request.user.is_authenticated,
email=getattr(request.user, 'email', None) if request.user.is_authenticated else None,
suite_user_id=str(request.user.sub) if request.user.is_authenticated else None,
ephemeral_public_key=ephemeral_public_key,
)
elif participant.status == LobbyParticipantStatus.WAITING:
self.refresh_waiting_status(room.id, participant_id)
elif participant.status == LobbyParticipantStatus.ACCEPTED:
# If the joiner comes back with a different ephemeral key (e.g. browser
# closed and reopened), they can no longer decrypt the encrypted symmetric
# key. Reset them to WAITING so the admin re-accepts with the new key.
if (
ephemeral_public_key
and participant.ephemeral_public_key
and ephemeral_public_key != participant.ephemeral_public_key
):
participant = self.enter(
room.id, participant_id, username,
is_authenticated=request.user.is_authenticated,
email=getattr(request.user, 'email', None) if request.user.is_authenticated else None,
suite_user_id=str(request.user.sub) if request.user.is_authenticated else None,
ephemeral_public_key=ephemeral_public_key,
)
return participant, None
livekit_config = utils.generate_livekit_config(
room_id=room_id,
user=request.user,
@@ -242,7 +195,6 @@ class LobbyService:
configuration=room.configuration,
is_admin_or_owner=False,
participant_id=participant_id,
encryption_mode=room.encryption_mode,
)
return participant, livekit_config
@@ -259,11 +211,11 @@ class LobbyService:
)
def enter(
self, room_id: UUID, participant_id: str, username: str,
self,
room_id: UUID,
participant_id: str,
username: str,
is_authenticated: bool = False,
email: Optional[str] = None,
suite_user_id: Optional[str] = None,
ephemeral_public_key: str = '',
) -> LobbyParticipant:
"""Add participant to waiting lobby.
@@ -279,9 +231,6 @@ class LobbyService:
id=participant_id,
color=color,
is_authenticated=is_authenticated,
email=email,
suite_user_id=suite_user_id,
ephemeral_public_key=ephemeral_public_key,
)
try:
@@ -350,9 +299,6 @@ class LobbyService:
room_id: UUID,
participant_id: str,
allow_entry: bool,
encrypted_key: str = '',
admin_ephemeral_public_key: str = '',
encrypted_vault_key: str = '',
) -> None:
"""Handle decision on participant entry.
@@ -371,13 +317,7 @@ class LobbyService:
"timeout": settings.LOBBY_DENIED_TIMEOUT,
}
self._update_participant_status(
room_id, participant_id,
encrypted_key=encrypted_key,
admin_ephemeral_public_key=admin_ephemeral_public_key,
encrypted_vault_key=encrypted_vault_key,
**decision,
)
self._update_participant_status(room_id, participant_id, **decision)
def _update_participant_status(
self,
@@ -385,9 +325,6 @@ class LobbyService:
participant_id: str,
status: LobbyParticipantStatus,
timeout: int,
encrypted_key: str = '',
admin_ephemeral_public_key: str = '',
encrypted_vault_key: str = '',
) -> None:
"""Update participant status with appropriate timeout."""
@@ -408,12 +345,6 @@ class LobbyService:
raise
participant.status = status
if encrypted_key:
participant.encrypted_key = encrypted_key
if admin_ephemeral_public_key:
participant.admin_ephemeral_public_key = admin_ephemeral_public_key
if encrypted_vault_key:
participant.encrypted_vault_key = encrypted_vault_key
cache.set(cache_key, participant.to_dict(), timeout=timeout)
def clear_room_cache(self, room_id: UUID) -> None:
+1 -38
View File
@@ -66,7 +66,6 @@ def generate_token(
sources: Optional[List[str]] = None,
is_admin_or_owner: bool = False,
participant_id: Optional[str] = None,
encryption_mode: str = 'none',
) -> str:
"""Generate a LiveKit access token for a user in a specific room.
@@ -93,15 +92,11 @@ def generate_token(
if sources is None:
sources = settings.LIVEKIT_DEFAULT_SOURCES
# In encrypted rooms, no one can change their name/metadata to prevent
# identity spoofing — the admin accepted them based on their declared identity.
can_update_metadata = encryption_mode == 'none'
video_grants = VideoGrants(
room=room,
room_join=True,
room_admin=is_admin_or_owner,
can_update_own_metadata=can_update_metadata,
can_update_own_metadata=True,
can_publish=bool(sources),
can_publish_sources=sources,
can_subscribe=True,
@@ -117,42 +112,12 @@ def generate_token(
if color is None:
color = generate_color(identity)
# Build participant attributes — these are server-signed in the JWT
# and visible to all participants in the room.
attributes = {
"color": color,
"room_admin": "true" if is_admin_or_owner else "false",
"is_authenticated": "true" if not user.is_anonymous else "false",
}
# Add identity info for authenticated users in encrypted rooms only.
#
# Email and suite_user_id are included in the JWT attributes for encrypted
# rooms because:
# - Email: allows admins to verify participant identity in the lobby and
# participant list (important for trust decisions in encrypted meetings)
# - suite_user_id: required for vault key exchange in advanced encryption
# (vaultClient.shareKeys needs the recipient's user ID)
#
# These attributes are NOT included in non-encrypted rooms because:
# - Non-encrypted rooms have no waiting room, so anonymous users can join
# freely and would see everyone's email via LiveKit signaling
# - LiveKit JWT attributes are immutable and broadcast to ALL participants
# equally — there is no way to show them only to authenticated users
# at the protocol level
# - The frontend additionally hides email from anonymous users in the UI,
# but this is defense-in-depth, not the primary protection
#
# Future improvement: serve email via a Django API endpoint that checks
# the requester's authentication, removing it from the JWT entirely.
# This would require the backend to call LiveKit's ListParticipants API
# to cross-reference identities with the user database.
if not user.is_anonymous and encryption_mode != 'none':
if user.email:
attributes["email"] = user.email
if user.sub:
attributes["suite_user_id"] = str(user.sub)
token = (
AccessToken(
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
@@ -175,7 +140,6 @@ def generate_livekit_config(
color: Optional[str] = None,
configuration: Optional[dict] = None,
participant_id: Optional[str] = None,
encryption_mode: str = 'none',
) -> dict:
"""Generate LiveKit configuration for room access.
@@ -208,7 +172,6 @@ def generate_livekit_config(
sources=sources,
is_admin_or_owner=is_admin_or_owner,
participant_id=participant_id,
encryption_mode=encryption_mode,
),
}
+3 -7
View File
@@ -808,16 +808,12 @@ class Base(Configuration):
environ_prefix=None,
)
# End-to-end encryption settings
# End-to-end encryption (passphrase-in-URL-hash mode).
# When True, users may opt in (account preference) to have their meetings
# created as end-to-end encrypted by default.
ENCRYPTION_ENABLED = values.BooleanValue(
False, environ_name="ENCRYPTION_ENABLED", environ_prefix=None
)
ENCRYPTION_VAULT_URL = values.Value(
None, environ_name="ENCRYPTION_VAULT_URL", environ_prefix=None
)
ENCRYPTION_INTERFACE_URL = values.Value(
None, environ_name="ENCRYPTION_INTERFACE_URL", environ_prefix=None
)
# External Applications
APPLICATION_CLIENT_ID_LENGTH = values.PositiveIntegerValue(
+8 -11
View File
@@ -14,7 +14,6 @@ import './i18n/init'
import { queryClient } from '@/api/queryClient'
import { AppInitialization } from '@/components/AppInitialization'
import { useIsSdkContext } from '@/features/sdk/hooks/useIsSdkContext'
import { VaultClientProvider } from '@/features/encryption'
function App() {
const { i18n } = useTranslation()
@@ -26,22 +25,20 @@ function App() {
<QueryClientProvider client={queryClient}>
{!isSDKContext && <AppInitialization />}
<Suspense fallback={null}>
<VaultClientProvider>
<I18nProvider locale={i18n.language}>
<Layout>
<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>
</VaultClientProvider>
</Layout>
<ReactQueryDevtools
initialIsOpen={false}
buttonPosition="bottom-left"
/>
</I18nProvider>
</Suspense>
</QueryClientProvider>
)
-2
View File
@@ -54,8 +54,6 @@ export interface ApiConfig {
}
encryption?: {
enabled: boolean
vault_url: string
interface_url: string
}
transcription_destination?: string
}
@@ -8,4 +8,5 @@ export type ApiUser = {
last_name: string
language: BackendLanguage
timezone: string
default_encryption: boolean
}
@@ -1,15 +1,18 @@
import { type ApiUser } from './ApiUser'
import { fetchApi } from '@/api/fetchApi'
export type ApiUserPreferences = Pick<ApiUser, 'id' | 'timezone' | 'language'>
export type ApiUserPreferences = Partial<
Pick<ApiUser, 'timezone' | 'language' | 'default_encryption'>
> & { id: string }
export const updateUserPreferences = async ({
user,
}: {
user: ApiUserPreferences
}): Promise<ApiUser> => {
return await fetchApi(`/users/${user.id}/`, {
method: 'PUT',
body: JSON.stringify({ timezone: user.timezone, language: user.language }),
const { id, ...payload } = user
return await fetchApi(`/users/${id}/`, {
method: 'PATCH',
body: JSON.stringify(payload),
})
}
@@ -0,0 +1,100 @@
/**
* Small banner shown on a participant tile when LiveKit raises an
* EncryptionError for that participant — typically a passphrase/key
* mismatch ("you and they don't share the same encryption key"). The
* avatar stays visible behind the banner.
*
* Cleared automatically once frames decrypt again
* (ParticipantEncryptionStatusChanged with encrypted=true).
*/
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Participant, RoomEvent } from 'livekit-client'
import { useRoomContext } from '@livekit/components-react'
import { RiLockFill } from '@remixicon/react'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
interface Props {
participant: Participant
}
export function DecryptionFailedTileOverlay({ participant }: Props) {
const { t } = useTranslation('rooms', {
keyPrefix: 'encryption.decryptionFailed',
})
const room = useRoomContext()
const roomData = useRoomData()
const [failed, setFailed] = useState(false)
useEffect(() => {
if (!roomData?.is_encrypted) return
if (participant.isLocal) return
const identity = participant.identity
const onError = (_err: Error, p?: Participant) => {
if (p?.identity === identity) setFailed(true)
}
const onStatus = (encrypted: boolean, p?: Participant) => {
if (p?.identity === identity && encrypted) setFailed(false)
}
room.on(RoomEvent.EncryptionError, onError)
room.on(RoomEvent.ParticipantEncryptionStatusChanged, onStatus)
return () => {
room.off(RoomEvent.EncryptionError, onError)
room.off(RoomEvent.ParticipantEncryptionStatusChanged, onStatus)
}
}, [room, roomData?.is_encrypted, participant])
if (!failed) return null
return (
<div
style={{
position: 'absolute',
bottom: '2.5rem',
left: '50%',
transform: 'translateX(-50%)',
backgroundColor: 'rgba(0, 0, 0, 0.75)',
borderRadius: '0.5rem',
padding: '0.6rem 1rem',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '0.3rem',
maxWidth: '85%',
zIndex: 4,
pointerEvents: 'none',
}}
role="status"
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.4rem',
color: '#f87171',
fontSize: '0.85rem',
fontWeight: 600,
}}
>
<RiLockFill size={14} />
<span>{t('title')}</span>
</div>
<div
style={{
color: '#d1d5db',
fontSize: '0.75rem',
textAlign: 'center',
lineHeight: 1.4,
maxWidth: '22rem',
}}
>
{t('body')}
</div>
</div>
)
}
@@ -1,181 +0,0 @@
/**
* Indicator shown at the top-left of an encrypted meeting.
*
* Initially shows the full label "End-to-end encrypted" with a lock icon.
* After a few seconds, collapses to just the lock icon.
* On hover, expands back with a smooth animation.
* Clicking opens a modal explaining what E2EE means and its limitations.
*/
import { css } from '@/styled-system/css'
import { VStack } from '@/styled-system/jsx'
import { RiLockFill, RiShieldCheckFill } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { isEncryptedRoom, ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
import { useEffect, useState } from 'react'
import { Dialog, Text } from '@/primitives'
const COLLAPSE_DELAY = 4000
export function EncryptedMeetingBanner() {
const roomData = useRoomData()
const { t } = useTranslation('rooms', { keyPrefix: 'encryption' })
const [isCollapsed, setIsCollapsed] = useState(false)
const [isModalOpen, setIsModalOpen] = useState(false)
const isStrongEncryption = roomData?.encryption_mode === ApiEncryptionMode.ADVANCED
useEffect(() => {
const timer = setTimeout(() => setIsCollapsed(true), COLLAPSE_DELAY)
return () => clearTimeout(timer)
}, [])
if (!isEncryptedRoom(roomData)) return null
const bgColor = isStrongEncryption ? '#166534' : '#1e3a5f'
const hoverBgColor = isStrongEncryption ? '#15803d' : '#2563eb'
const icon = isStrongEncryption
? <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">
{isStrongEncryption
? t('bannerModal.descriptionAdvanced')
: t('bannerModal.descriptionBasic')}
</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>{isStrongEncryption
? t('bannerModal.limitation2Advanced')
: t('bannerModal.limitation2Basic')}
</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,46 @@
/**
* When the participant who paused encryption sees that *both* recording and
* transcription have stopped, automatically broadcast `ENCRYPTION_RESUMED`.
*
* Other participants don't run this watcher: only the pauser can resume
* (they're the one with `pausedByMe=true`). If they leave the room, the next
* leader/admin can manually resume from the Settings panel — or in v1 the
* room simply stays paused for the rest of the session.
*/
import { useEffect, useRef } from 'react'
import { useIsRecording } from '@livekit/components-react'
import { RecordingMode, useRecordingStatuses } from '@/features/recording'
import { EncryptionPhase } from './encryptionStatusTypes'
import { useEncryptionStatus } from './useEncryptionStatus'
export function EncryptionAutoResumeWatcher() {
const { phase, pausedByMe, resumeEncryption } = useEncryptionStatus()
const isLiveKitRecording = useIsRecording()
const transcriptStatuses = useRecordingStatuses(RecordingMode.Transcript)
const screenRecStatuses = useRecordingStatuses(RecordingMode.ScreenRecording)
// Edge guard: avoid resuming on the initial render before anything has
// actually started. We only resume after we've observed an active state.
const wasActiveRef = useRef(false)
const isAnyActive =
isLiveKitRecording ||
transcriptStatuses.isActive ||
screenRecStatuses.isActive
useEffect(() => {
if (isAnyActive) {
wasActiveRef.current = true
}
}, [isAnyActive])
useEffect(() => {
if (phase !== EncryptionPhase.PAUSED) return
if (!pausedByMe) return
if (!wasActiveRef.current) return
if (isAnyActive) return
void resumeEncryption()
}, [phase, pausedByMe, isAnyActive, resumeEncryption])
return null
}
@@ -1,82 +0,0 @@
/**
* Per-participant encryption trust badge.
*
* In advanced mode:
* - "verified": Green shield — fingerprint explicitly trusted
* - "unknown": Grey shield — has public key, not yet verified
* - "refused": Red shield — fingerprint previously refused
* - "authenticated": Blue shield — ProConnect, no vault keys
* - "anonymous": Orange warning — not signed in
*
* In basic mode:
* - "authenticated": Blue shield — ProConnect
* - "anonymous": Orange warning — not signed in
*/
import {
RiShieldCheckFill,
RiShieldFill,
RiShieldCrossFill,
RiErrorWarningFill,
RiLockFill,
} from '@remixicon/react'
import type { TrustLevel } from './types'
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
interface EncryptionBadgeProps {
trustLevel: TrustLevel | null
isEncrypted: boolean
}
export function EncryptionBadge({
trustLevel,
isEncrypted,
}: EncryptionBadgeProps) {
const { t } = useTranslation('rooms', { keyPrefix: 'encryption.badge' })
if (!isEncrypted) return null
let icon: React.ReactNode
let label: string
switch (trustLevel) {
case 'verified':
icon = <RiShieldCheckFill size={14} color="#22c55e" />
label = t('verified')
break
case 'unknown':
icon = <RiShieldFill size={14} color="#9ca3af" />
label = t('unknown')
break
case 'refused':
icon = <RiShieldCrossFill size={14} color="#ef4444" />
label = t('refused')
break
case 'authenticated':
icon = <RiShieldCheckFill size={14} color="#3b82f6" />
label = t('authenticated')
break
case 'anonymous':
icon = <RiErrorWarningFill size={15} color="#d97706" />
label = t('anonymous')
break
default:
icon = <RiLockFill size={14} />
label = t('default')
break
}
return (
<span
aria-label={label}
className={css({
display: 'inline-flex',
alignItems: 'center',
marginRight: '0.15rem',
cursor: 'inherit',
})}
>
{icon}
</span>
)
}
@@ -1,10 +0,0 @@
import { createContext, useContext } from 'react'
interface EncryptionContextValue {
symmetricKey?: Uint8Array
}
const EncryptionContext = createContext<EncryptionContextValue>({})
export const EncryptionProvider = EncryptionContext.Provider
export const useEncryptionContext = () => useContext(EncryptionContext)
@@ -1,326 +0,0 @@
/**
* 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 { Avatar } from '@/components/Avatar'
import { useUser } from '@/features/auth'
import {
RiShieldCheckFill,
RiShieldCheckLine,
RiAlertLine,
RiCheckLine,
RiCloseLine,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useVaultClient } from './VaultClientProvider'
import { formatFingerprint } from './useParticipantTrustLevel'
import { useEffect, useState } from 'react'
interface EncryptionIdentityDialogProps {
isOpen: boolean
onOpenChange: (open: boolean) => void
participantName: string
participantEmail?: string
suiteUserId?: string
isAuthenticated: boolean
encryptionMode?: 'basic' | 'advanced' | 'none'
isSelf?: boolean
preloadedFingerprint?: string | null
preloadedFingerprintStatus?: string | null
}
type FingerprintStatus = 'loading' | 'no-key' | 'trusted' | 'refused' | 'unknown' | 'error'
export function EncryptionIdentityDialog({
isOpen,
onOpenChange,
participantName,
participantEmail,
suiteUserId,
isAuthenticated,
encryptionMode,
isSelf,
preloadedFingerprint,
preloadedFingerprintStatus,
}: EncryptionIdentityDialogProps) {
const { t } = useTranslation('rooms', { keyPrefix: 'encryption.fingerprint' })
const { client: vaultClient } = useVaultClient()
const { isLoggedIn } = useUser()
const [status, setStatus] = useState<FingerprintStatus>(
(preloadedFingerprintStatus as FingerprintStatus) || 'loading'
)
const [fingerprint, setFingerprint] = useState<string | null>(preloadedFingerprint || null)
// Sync preloaded data when it becomes available (hook resolves after mount)
useEffect(() => {
if (preloadedFingerprintStatus) setStatus(preloadedFingerprintStatus as FingerprintStatus)
if (preloadedFingerprint) setFingerprint(preloadedFingerprint)
}, [preloadedFingerprint, preloadedFingerprintStatus])
const isBasicMode = encryptionMode !== 'advanced'
useEffect(() => {
if (!isOpen) return
// In basic mode, no fingerprint check — identity is from ProConnect only
if (isBasicMode) {
setStatus(isAuthenticated ? 'no-key' : 'no-key')
return
}
if (!vaultClient) {
setStatus('error')
return
}
if (!suiteUserId) {
setStatus(isAuthenticated ? 'no-key' : 'no-key')
return
}
let cancelled = false
async function checkFingerprint() {
try {
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
}
// Compute fingerprint from the public key (SHA-256, first 16 hex chars)
const hash = await crypto.subtle.digest('SHA-256', publicKey)
const fp = Array.from(new Uint8Array(hash))
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
.slice(0, 16)
if (cancelled) return
setFingerprint(fp)
// Check local registry without triggering TOFU auto-trust
const { fingerprints: known } = await Promise.race([
vaultClient!.getKnownFingerprints(),
timeout,
])
if (cancelled) return
const knownEntry = known[suiteUserId!]
if (!knownEntry) {
setStatus('unknown')
} else if (knownEntry.fingerprint === fp) {
setStatus(knownEntry.status)
} else {
// Fingerprint changed — needs re-verification
setStatus('unknown')
}
} catch {
if (!cancelled) setStatus('error')
}
}
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.65rem" className={css({ width: '100%' })}>
<div className={css({ flexShrink: 0, transform: 'scale(0.85)' })}>
<Avatar name={participantName} bgColor="rgb(87, 44, 216)" />
</div>
<VStack gap="0" alignItems="start">
<Text className={css({ fontWeight: 600, fontSize: '0.9rem' })}>{participantName}</Text>
<Text variant="note" className={css({ fontSize: '0.8rem', color: 'greyscale.500' })}>
{isLoggedIn && participantEmail ? participantEmail : (!isAuthenticated ? t('anonymous') : '')}
</Text>
</VStack>
</HStack>
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
{isSelf
? (isAuthenticated ? t('descriptionSelf') : t('descriptionSelfAnonymous'))
: t('description')}
</Text>
{status === 'loading' && (
<Text variant="note">{t('loading')}</Text>
)}
{status === 'no-key' && isBasicMode && isAuthenticated && (
<HStack
gap="0.5rem"
className={css({
backgroundColor: '#eff6ff',
padding: '0.75rem',
borderRadius: '0.5rem',
width: '100%',
border: '1px solid #bfdbfe',
})}
>
<RiShieldCheckLine size={20} color="#3b82f6" className={css({ flexShrink: 0 })} />
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
{t('noKeyBasicAuthenticated')}
</Text>
</HStack>
)}
{status === 'no-key' && !(isBasicMode && isAuthenticated) && !isSelf && (
<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' })}>
{isAuthenticated ? t('noKey') : t('noKeyAnonymous')}
</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>
{formatFingerprint(fingerprint)}
</VStack>
{status === 'trusted' && (
<VStack gap="0.25rem" alignItems="start">
<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>
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
{isSelf ? t('descriptionSelf') : t('trustedDescription')}
</Text>
{!isSelf && (
<Text
variant="note"
className={css({ fontSize: '0.75rem', color: 'greyscale.500', cursor: 'pointer', _hover: { textDecoration: 'underline' } })}
onClick={() => setStatus('unknown')}
>
{t('changeDecision')}
</Text>
)}
</VStack>
)}
{status === 'refused' && (
<VStack gap="0.25rem" alignItems="start">
<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>
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
{t('refusedDescription')}
</Text>
<Text
variant="note"
className={css({ fontSize: '0.75rem', color: 'greyscale.500', cursor: 'pointer', _hover: { textDecoration: 'underline' } })}
onClick={() => setStatus('unknown')}
>
{t('changeDecision')}
</Text>
</VStack>
)}
{status === 'unknown' && !isSelf && (
<VStack gap="0.5rem" className={css({ width: '100%' })}>
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
{t('unknownDescription')}
</Text>
<Text variant="note" className={css({ fontSize: '0.75rem', fontStyle: 'italic' })}>
{t('fingerprintHint')}
</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,104 @@
/**
* Shown when the URL hash and the room's `is_encrypted` flag disagree.
*
* - missingPassphrase: room is encrypted on the server, but the URL has no
* (or an invalid) passphrase. The user opened the wrong link.
* - unexpectedPassphrase: the URL has a passphrase, but the server says the
* room is not encrypted. Either the room was created differently or the
* link looks tampered with — either way, joining as "encrypted" would
* leave the user alone in an encrypted bubble. Better to bail.
*/
import { css } from '@/styled-system/css'
import { Center } from '@/styled-system/jsx'
import { useTranslation } from 'react-i18next'
import { RiAlertLine, RiLockUnlockLine } from '@remixicon/react'
import { Button, Text } from '@/primitives'
import { Screen } from '@/layout/Screen'
import { CenteredContent } from '@/layout/CenteredContent'
import { navigateTo } from '@/navigation/navigateTo'
import {
generateRoomId,
useCreateRoom,
} from '@/features/rooms'
import { generatePassphrase } from './passphrase'
interface Props {
reason: 'missingPassphrase' | 'unexpectedPassphrase'
}
export function EncryptionMismatchScreen({ reason }: Props) {
const { t } = useTranslation('rooms', { keyPrefix: 'encryption.mismatch' })
const { mutateAsync: createRoom } = useCreateRoom()
const handleCreateFresh = async () => {
const slug = generateRoomId()
const hash = generatePassphrase()
const room = await createRoom({ slug, isEncrypted: true })
navigateTo('room', room.slug, {
state: { create: true, initialRoomData: room },
})
window.history.replaceState(
window.history.state,
'',
`${window.location.pathname}#${hash}`
)
}
return (
<Screen layout="centered">
<CenteredContent withBackButton>
<Center>
<div
className={css({
maxWidth: '420px',
padding: '2rem',
borderRadius: '1rem',
backgroundColor: 'white',
border: '1px solid',
borderColor: 'greyscale.200',
boxShadow: '0 4px 16px rgba(0,0,0,0.06)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '1rem',
textAlign: 'center',
})}
>
<div
className={css({
width: '3.5rem',
height: '3.5rem',
borderRadius: '50%',
backgroundColor: '#fffbeb',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
})}
>
{reason === 'missingPassphrase' ? (
<RiLockUnlockLine size={28} color="#b45309" />
) : (
<RiAlertLine size={28} color="#b45309" />
)}
</div>
<Text
as="h2"
className={css({ fontWeight: 700, fontSize: '1.15rem' })}
>
{t(`${reason}.title`)}
</Text>
<Text
as="p"
className={css({ fontSize: '0.9rem', color: 'greyscale.700' })}
>
{t(`${reason}.body`)}
</Text>
<Button variant="primary" onPress={handleCreateFresh}>
{t('createFresh')}
</Button>
</div>
</Center>
</CenteredContent>
</Screen>
)
}
@@ -1,118 +0,0 @@
/**
* 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.
*
* After 20 seconds without the key, shows an error with a refresh button.
*/
import { css } from '@/styled-system/css'
import { VStack } from '@/styled-system/jsx'
import { Text, Button } from '@/primitives'
import { Spinner } from '@/primitives/Spinner'
import { RiLockFill, RiAlertFill, RiRefreshLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useEffect, useState } from 'react'
const KEY_EXCHANGE_TIMEOUT = 20000
export function EncryptionSetupOverlay({
isSettingUp,
error,
}: {
isSettingUp: boolean
error: string | null
}) {
const { t } = useTranslation('rooms', { keyPrefix: 'encryption' })
const [timedOut, setTimedOut] = useState(false)
useEffect(() => {
if (!isSettingUp) {
setTimedOut(false)
return
}
const timer = setTimeout(() => setTimedOut(true), KEY_EXCHANGE_TIMEOUT)
return () => clearTimeout(timer)
}, [isSettingUp])
if (!isSettingUp && !error) return null
const showError = error || timedOut
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">
{showError ? (
<>
<RiAlertFill size={36} color="#f87171" />
<Text
className={css({
color: '#f87171',
fontSize: '1.1rem',
fontWeight: 500,
textAlign: 'center',
})}
>
{timedOut ? t('error.timeout') : t('error.title')}
</Text>
<Text
className={css({
color: 'greyscale.300',
fontSize: '0.85rem',
textAlign: 'center',
maxWidth: '20rem',
})}
>
{error || t('error.timeoutHint')}
</Text>
<Button
variant="primary"
size="sm"
onPress={() => window.location.reload()}
>
<RiRefreshLine size={16} />
{t('error.refresh')}
</Button>
</>
) : (
<>
<RiLockFill size={32} color="white" />
<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,384 @@
/**
* In-call encryption state machine and pause protocol.
*
* Three phases:
* - UNENCRYPTED — the room is not end-to-end encrypted.
* - ENCRYPTED — E2EE is active; frames are encrypted with the URL passphrase.
* - PAUSED — encryption is temporarily paused for this session, typically
* so the SFU can record / transcribe.
*
* The "paused" state is intentionally ephemeral: it is never persisted in the
* database. The truth source for "this call is encrypted" is the presence of
* the passphrase in the URL hash, not a server-side flag — a hacked server
* cannot fabricate a passphrase that all participants happen to share.
*
* Pause is broadcast over a LiveKit reliable data channel. While the sender
* has not yet flipped its own state, the message itself travels encrypted,
* which is the trust anchor: only callers who hold the passphrase can produce
* frames everyone can decrypt.
*
* Pause is reversible: when both recording and transcription have stopped,
* the participant who initiated the pause broadcasts ENCRYPTION_RESUMED and
* everyone re-enables E2EE with the same URL passphrase.
*
* Initiation: admins can always pause/resume. If no admin is in the room and
* a pause has already been observed in this session (everSeenPause), the
* leader (oldest non-SIP participant) may also pause/resume — this covers
* the "the admin left mid-call" edge case without granting unsolicited
* pause power to non-admins.
*/
import {
ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { useRoomContext } from '@livekit/components-react'
import {
DataPacket_Kind,
Participant,
ParticipantKind,
RemoteParticipant,
RoomEvent,
} from 'livekit-client'
import { EncryptionStatusContext } from './encryptionStatusContextValue'
import { EncryptionPhase, PauseReason } from './encryptionStatusTypes'
const ENCRYPTION_TOPIC = 'encryption-state'
const PROBE_RESPONSE_GRACE_MS = 2500
const textEncoder = new TextEncoder()
const textDecoder = new TextDecoder()
interface ProtocolMessage {
type:
| 'ENCRYPTION_PAUSED'
| 'ENCRYPTION_RESUMED'
| 'ENCRYPTION_STATUS_PROBE'
reason?: PauseReason
/** Sender's `joinedAt` timestamp; used in leader election. */
senderJoinedAt?: number
/** Whether the sender is a room admin/owner. */
senderIsAdmin?: boolean
}
function encodeMessage(msg: ProtocolMessage): Uint8Array {
return textEncoder.encode(JSON.stringify(msg))
}
function decodeMessage(payload: Uint8Array): ProtocolMessage | null {
try {
return JSON.parse(textDecoder.decode(payload)) as ProtocolMessage
} catch {
return null
}
}
function isParticipantAdmin(participant: Participant | undefined): boolean {
return participant?.attributes?.room_admin === 'true'
}
function isParticipantPhoneOrSip(p: Participant): boolean {
return p.kind === ParticipantKind.SIP
}
interface EncryptionStatusProviderProps {
children: ReactNode
/** Whether this room is end-to-end encrypted. */
isEncrypted: boolean
/** Called when the local client should toggle E2EE on/off. */
onPhaseChange?: (phase: EncryptionPhase) => void
/**
* Optional hook to mirror local pause/resume to the room's
* server-side encryption_paused field. When provided, recording and
* transcription pauses also propagate to the SIP gateway (so phone
* callers transition out of placeholder mode automatically).
*/
setServerEncryptionPaused?: (paused: boolean) => Promise<void> | void
}
export function EncryptionStatusProvider({
children,
isEncrypted,
onPhaseChange,
setServerEncryptionPaused,
}: EncryptionStatusProviderProps) {
const room = useRoomContext()
const initialPhase = isEncrypted
? EncryptionPhase.ENCRYPTED
: EncryptionPhase.UNENCRYPTED
const [phase, setPhase] = useState<EncryptionPhase>(initialPhase)
const [pauseReason, setPauseReason] = useState<PauseReason | undefined>()
const [pausedByMe, setPausedByMe] = useState(false)
const everSeenPauseRef = useRef(false)
const phaseRef = useRef(phase)
phaseRef.current = phase
// When the encrypted flag changes (e.g. on initial room data load), align
// the local phase. The pause path keeps phase=PAUSED across updates.
useEffect(() => {
if (!isEncrypted && phaseRef.current !== EncryptionPhase.UNENCRYPTED) {
setPhase(EncryptionPhase.UNENCRYPTED)
setPauseReason(undefined)
setPausedByMe(false)
} else if (
isEncrypted &&
phaseRef.current === EncryptionPhase.UNENCRYPTED
) {
setPhase(EncryptionPhase.ENCRYPTED)
}
}, [isEncrypted])
// Push phase transitions to LiveKit (E2EE on/off + republish).
useEffect(() => {
onPhaseChange?.(phase)
}, [phase, onPhaseChange])
const sendProtocolMessage = useCallback(
async (msg: ProtocolMessage, destination?: string[]) => {
try {
await room.localParticipant.publishData(encodeMessage(msg), {
reliable: true,
topic: ENCRYPTION_TOPIC,
destinationIdentities: destination,
})
} catch (err) {
console.error('[encryption] failed to publish protocol message', err)
}
},
[room]
)
/**
* Determine whether we (locally) consider `sender` legitimate to issue
* pause/resume messages.
*
* Always true for admins. For non-admins, true only if there is no admin
* currently in the room AND the sender is the oldest non-SIP participant
* we know of (deterministic across peers via joinedAt+identity).
*/
const isLegitimatePauseSender = useCallback(
(sender: RemoteParticipant | undefined): boolean => {
if (!sender) return false
if (isParticipantAdmin(sender)) return true
const everyone: Participant[] = [
room.localParticipant,
...Array.from(room.remoteParticipants.values()),
]
const adminPresent = everyone.some(isParticipantAdmin)
if (adminPresent) return false
const eligible = everyone.filter((p) => !isParticipantPhoneOrSip(p))
const sorted = eligible.sort((a, b) => {
const aJ = a.joinedAt?.getTime() ?? Number.MAX_SAFE_INTEGER
const bJ = b.joinedAt?.getTime() ?? Number.MAX_SAFE_INTEGER
if (aJ !== bJ) return aJ - bJ
return a.identity.localeCompare(b.identity)
})
const leader = sorted[0]
return !!leader && leader.identity === sender.identity
},
[room]
)
/** Same logic but applied to the local participant (am I allowed to act?). */
const localCanInitiate = useCallback((): boolean => {
if (isParticipantAdmin(room.localParticipant)) return true
if (!everSeenPauseRef.current) return false
const everyone: Participant[] = [
room.localParticipant,
...Array.from(room.remoteParticipants.values()),
]
if (everyone.some(isParticipantAdmin)) return false
const eligible = everyone.filter((p) => !isParticipantPhoneOrSip(p))
const sorted = eligible.sort((a, b) => {
const aJ = a.joinedAt?.getTime() ?? Number.MAX_SAFE_INTEGER
const bJ = b.joinedAt?.getTime() ?? Number.MAX_SAFE_INTEGER
if (aJ !== bJ) return aJ - bJ
return a.identity.localeCompare(b.identity)
})
return sorted[0]?.identity === room.localParticipant.identity
}, [room])
const handlePauseAnnouncement = useCallback(
(msg: ProtocolMessage, sender?: RemoteParticipant) => {
if (!isLegitimatePauseSender(sender)) return
everSeenPauseRef.current = true
if (phaseRef.current !== EncryptionPhase.ENCRYPTED) return
setPhase(EncryptionPhase.PAUSED)
setPauseReason(msg.reason)
setPausedByMe(false)
},
[isLegitimatePauseSender]
)
const handleResumeAnnouncement = useCallback(
(sender?: RemoteParticipant) => {
if (!isLegitimatePauseSender(sender)) return
if (phaseRef.current !== EncryptionPhase.PAUSED) return
setPhase(EncryptionPhase.ENCRYPTED)
setPauseReason(undefined)
setPausedByMe(false)
},
[isLegitimatePauseSender]
)
const handleProbe = useCallback(
(sender: RemoteParticipant) => {
if (phaseRef.current !== EncryptionPhase.PAUSED) return
// We respond if we ourselves are a legitimate sender for this room.
if (!localCanInitiate()) return
void sendProtocolMessage(
{
type: 'ENCRYPTION_PAUSED',
reason: pauseReason,
senderIsAdmin: isParticipantAdmin(room.localParticipant),
senderJoinedAt:
room.localParticipant.joinedAt?.getTime() ?? Date.now(),
},
[sender.identity]
)
},
[room, pauseReason, sendProtocolMessage, localCanInitiate]
)
// Subscribe to encryption-channel data messages.
useEffect(() => {
if (!isEncrypted) return
const handler = (
payload: Uint8Array,
participant?: RemoteParticipant,
_kind?: DataPacket_Kind,
topic?: string
) => {
if (topic !== ENCRYPTION_TOPIC) return
const msg = decodeMessage(payload)
if (!msg) return
if (msg.type === 'ENCRYPTION_PAUSED') {
handlePauseAnnouncement(msg, participant)
} else if (msg.type === 'ENCRYPTION_RESUMED') {
handleResumeAnnouncement(participant)
} else if (msg.type === 'ENCRYPTION_STATUS_PROBE' && participant) {
handleProbe(participant)
}
}
room.on(RoomEvent.DataReceived, handler)
return () => {
room.off(RoomEvent.DataReceived, handler)
}
}, [
room,
isEncrypted,
handlePauseAnnouncement,
handleResumeAnnouncement,
handleProbe,
])
// On join, ask the room whether encryption is currently paused.
useEffect(() => {
if (!isEncrypted) return
if (phase !== EncryptionPhase.ENCRYPTED) return
let cancelled = false
const timer = setTimeout(() => {
if (cancelled) return
void sendProtocolMessage({ type: 'ENCRYPTION_STATUS_PROBE' })
}, 0)
const cleanup = setTimeout(() => {
// Nothing to do — if no answer arrived, we stay encrypted.
}, PROBE_RESPONSE_GRACE_MS)
return () => {
cancelled = true
clearTimeout(timer)
clearTimeout(cleanup)
}
// we intentionally only run this when joining the encrypted state
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEncrypted])
const pauseEncryption = useCallback(
async (reason: PauseReason) => {
if (phaseRef.current !== EncryptionPhase.ENCRYPTED) return false
if (!localCanInitiate()) return false
everSeenPauseRef.current = true
setPhase(EncryptionPhase.PAUSED)
setPauseReason(reason)
setPausedByMe(true)
// Mirror to the server's encryption_paused field if a setter was
// wired through. This is what makes the SIP gateway transition out
// of placeholder mode for recording/transcription too, not just
// for an explicit admin pause from the Admin panel.
if (setServerEncryptionPaused) {
try {
await setServerEncryptionPaused(true)
} catch (err) {
console.error('[encryption] server-side pause failed', err)
}
}
await sendProtocolMessage({
type: 'ENCRYPTION_PAUSED',
reason,
senderIsAdmin: isParticipantAdmin(room.localParticipant),
senderJoinedAt:
room.localParticipant.joinedAt?.getTime() ?? Date.now(),
})
return true
},
[room, sendProtocolMessage, localCanInitiate, setServerEncryptionPaused]
)
const resumeEncryption = useCallback(async () => {
if (phaseRef.current !== EncryptionPhase.PAUSED) return false
if (!localCanInitiate()) return false
setPhase(EncryptionPhase.ENCRYPTED)
setPauseReason(undefined)
setPausedByMe(false)
if (setServerEncryptionPaused) {
try {
await setServerEncryptionPaused(false)
} catch (err) {
console.error('[encryption] server-side resume failed', err)
}
}
await sendProtocolMessage({
type: 'ENCRYPTION_RESUMED',
senderIsAdmin: isParticipantAdmin(room.localParticipant),
senderJoinedAt: room.localParticipant.joinedAt?.getTime() ?? Date.now(),
})
return true
}, [room, sendProtocolMessage, localCanInitiate, setServerEncryptionPaused])
const value = useMemo(
() => ({
phase,
pauseReason,
pausedByMe,
pauseEncryption,
resumeEncryption,
}),
[phase, pauseReason, pausedByMe, pauseEncryption, resumeEncryption]
)
return (
<EncryptionStatusContext.Provider value={value}>
{children}
</EncryptionStatusContext.Provider>
)
}
@@ -0,0 +1,212 @@
/**
* Transient bottom snackbars announcing encryption state changes:
* - "Encryption paused while transcription is on"
* - "Encryption was turned off for this meeting"
* - "A participant can't decrypt this meeting" (admin only, with CTA)
*/
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { css } from '@/styled-system/css'
import { HStack, VStack } from '@/styled-system/jsx'
import { Button, Text } from '@/primitives'
import { ParticipantKind, RemoteParticipant } from 'livekit-client'
import { useRemoteParticipants } from '@livekit/components-react'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { EncryptionPhase, PauseReason } from './encryptionStatusTypes'
import { useEncryptionStatus } from './useEncryptionStatus'
const DISPLAY_DURATION_MS = 8000
const SnackbarShell = ({ children }: { children: React.ReactNode }) => (
<div
className={css({
position: 'fixed',
bottom: '5rem',
right: '1rem',
zIndex: 1500,
maxWidth: '24rem',
padding: '0.85rem 1rem',
backgroundColor: '#1e3a5f',
borderRadius: '0.5rem',
boxShadow: '0 10px 30px rgba(0,0,0,0.25)',
})}
role="status"
>
{children}
</div>
)
function useTransient<T>(value: T, displayMs: number) {
const [shown, setShown] = useState<T | null>(null)
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (!value) return
setShown(value)
if (timer.current) clearTimeout(timer.current)
timer.current = setTimeout(() => setShown(null), displayMs)
return () => {
if (timer.current) clearTimeout(timer.current)
}
}, [value, displayMs])
return [shown, () => setShown(null)] as const
}
export function EncryptionStatusSnackbars() {
const { t } = useTranslation('rooms', { keyPrefix: 'encryption.snackbar' })
const { phase, pauseReason, pausedByMe } = useEncryptionStatus()
const remoteParticipants = useRemoteParticipants()
const isAdmin = useIsAdminOrOwner()
const { toggleAdmin, isAdminOpen } = useSidePanel()
const [pauseSignal, setPauseSignal] = useState<{
reason?: PauseReason
pausedByMe: boolean
} | null>(null)
const previousPhase = useRef(phase)
useEffect(() => {
if (
previousPhase.current !== EncryptionPhase.PAUSED &&
phase === EncryptionPhase.PAUSED
) {
setPauseSignal({ reason: pauseReason, pausedByMe })
}
previousPhase.current = phase
}, [phase, pauseReason, pausedByMe])
const [pauseToast, dismissPauseToast] = useTransient(
pauseSignal,
DISPLAY_DURATION_MS
)
// The SIP-blocked snackbar persists as long as a SIP participant is
// present in the encrypted room — admin needs to either pause encryption
// (Settings → Security → This meeting) or wait for the SIP user to hang
// up. Manual dismiss hides it until a different SIP participant arrives.
//
// useRemoteParticipants re-renders on participant join/leave/state — that
// gives us a reactive participant list without manual event listeners,
// which is more reliable than the prior subscribe-on-mount approach.
const [sipDismissedIdentity, setSipDismissedIdentity] = useState<string | null>(null)
const isSip = (p: RemoteParticipant) =>
p.kind === ParticipantKind.SIP || p.identity.startsWith('sip_')
const sipParticipant =
isAdmin && phase === EncryptionPhase.ENCRYPTED
? remoteParticipants.find(isSip) ?? null
: null
const sipLabel = sipParticipant
? sipParticipant.name || sipParticipant.identity
: null
const showSipSnack =
sipParticipant !== null &&
sipDismissedIdentity !== sipParticipant.identity
return (
<>
{pauseToast && (
<SnackbarShell>
<HStack
gap="1rem"
justify="space-between"
alignItems="center"
className={css({ width: '100%' })}
>
<VStack gap="0.15rem" alignItems="start">
<Text
variant="sm"
margin={false}
className={css({ color: 'white', fontWeight: 600 })}
>
{pauseToast.pausedByMe
? t('pausedByMeTitle')
: t('pausedTitle')}
</Text>
<Text
variant="note"
margin={false}
className={css({
color: 'rgba(255,255,255,0.85)',
fontSize: '0.8rem',
})}
>
{pauseToast.reason === 'transcript'
? t('reasonTranscript')
: pauseToast.reason === 'recording'
? t('reasonRecording')
: pauseToast.reason === 'sip_participant'
? t('reasonSip')
: t('reasonManual')}
</Text>
</VStack>
<Button
size="sm"
variant="text"
onPress={dismissPauseToast}
className={css({ color: 'white !important' })}
>
{t('dismiss')}
</Button>
</HStack>
</SnackbarShell>
)}
{showSipSnack && phase === EncryptionPhase.ENCRYPTED && (
<SnackbarShell>
<HStack
gap="1rem"
justify="space-between"
alignItems="center"
className={css({ width: '100%' })}
>
<VStack gap="0.15rem" alignItems="start">
<Text
variant="sm"
margin={false}
className={css({ color: 'white', fontWeight: 600 })}
>
{t('sipTitle')}
</Text>
<Text
variant="note"
margin={false}
className={css({
color: 'rgba(255,255,255,0.85)',
fontSize: '0.8rem',
})}
>
{t('sipBody', { name: sipLabel })}
</Text>
</VStack>
{!isAdminOpen && (
<Button
size="sm"
variant="text"
className={css({ color: 'white !important' })}
onPress={toggleAdmin}
>
{t('openAdmin')}
</Button>
)}
<Button
size="sm"
variant="text"
className={css({ color: 'white !important' })}
onPress={() =>
sipParticipant &&
setSipDismissedIdentity(sipParticipant.identity)
}
>
{t('dismiss')}
</Button>
</HStack>
</SnackbarShell>
)}
</>
)
}
@@ -1,143 +0,0 @@
/**
* 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>
)
}
@@ -1,122 +0,0 @@
/**
* 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).
*
* In basic encryption mode, the "verified" level is never returned because
* PKI keys are not used — encryption relies on a shared passphrase, not on
* per-user public keys. The green shield would be misleading.
*
* In advanced encryption mode, "verified" means the participant has completed
* encryption onboarding and their public key is used to encrypt the symmetric key.
*/
export function getTrustLevelFromAttributes(
attributes: Record<string, string> | undefined,
encryptionMode?: 'basic' | 'advanced' | 'none',
): TrustLevel | null {
if (!attributes) return null
const isAdvanced = encryptionMode === 'advanced'
// Check for explicit trust level (set by PKI integration)
const explicitLevel = attributes[PARTICIPANT_TRUST_ATTR]
if (explicitLevel === 'verified' && isAdvanced) {
return 'verified'
}
if (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,67 @@
/**
* Tiny per-participant identity confidence pill, shown in encrypted meetings.
*
* - "ProConnect" → server-verified identity (the participant signed in).
* - "Anonymous" → self-declared name; treat with caution.
*
* Sourced from the `is_authenticated` JWT attribute set in
* `core/utils.py::generate_token` (or the equivalent flag on a lobby
* participant). No fingerprints, no email — just a one-glance signal.
*/
import { css } from '@/styled-system/css'
import { RiShieldCheckFill, RiUserLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import type { Participant } from 'livekit-client'
interface BadgeProps {
size?: 'sm' | 'md'
}
interface FromParticipantProps extends BadgeProps {
participant: Participant
isAuthenticated?: never
}
interface FromFlagProps extends BadgeProps {
isAuthenticated: boolean
participant?: never
}
export function IdentityBadge(props: FromParticipantProps | FromFlagProps) {
const { t } = useTranslation('rooms', { keyPrefix: 'identity' })
const isAuthenticated =
props.participant !== undefined
? props.participant.attributes?.is_authenticated === 'true'
: props.isAuthenticated
const px = props.size === 'md' ? 14 : 12
const label = isAuthenticated ? t('proconnect') : t('anonymous')
const color = isAuthenticated ? '#1e40af' : '#b45309'
const bg = isAuthenticated ? 'rgba(30,64,175,0.10)' : 'rgba(180,83,9,0.10)'
return (
<span
title={label}
aria-label={label}
className={css({
display: 'inline-flex',
alignItems: 'center',
gap: '0.2rem',
padding: '0 0.3rem',
borderRadius: '0.25rem',
fontSize: '0.65rem',
fontWeight: 600,
letterSpacing: '0.02em',
whiteSpace: 'nowrap',
})}
style={{ backgroundColor: bg, color }}
>
{isAuthenticated ? (
<RiShieldCheckFill size={px} color={color} />
) : (
<RiUserLine size={px} color={color} />
)}
{label}
</span>
)
}
@@ -0,0 +1,67 @@
/**
* Modal asking the admin to confirm that they accept pausing encryption
* to start recording or transcription.
*/
import { Button, Dialog, Text } from '@/primitives'
import { HStack, VStack } from '@/styled-system/jsx'
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
interface Props {
isOpen: boolean
onOpenChange: (open: boolean) => void
reason: 'recording' | 'transcript'
onConfirm: () => void | Promise<void>
}
export function PauseEncryptionConfirmDialog({
isOpen,
onOpenChange,
reason,
onConfirm,
}: Props) {
const { t } = useTranslation('rooms', {
keyPrefix: 'encryption.pauseConfirm',
})
return (
<Dialog
isOpen={isOpen}
onOpenChange={onOpenChange}
role="dialog"
type="flex"
title={t(`title.${reason}`)}
>
<VStack
alignItems="start"
gap="0.75rem"
className={css({ maxWidth: '24rem' })}
>
<Text variant="sm">{t('description')}</Text>
<Text
variant="note"
className={css({
fontSize: '0.8rem',
color: 'greyscale.500',
})}
>
{t('learnMore')}
</Text>
<HStack gap="0.5rem" justify="end" className={css({ width: '100%' })}>
<Button variant="secondary" onPress={() => onOpenChange(false)}>
{t('cancel')}
</Button>
<Button
variant="primary"
onPress={async () => {
await onConfirm()
onOpenChange(false)
}}
>
{t(`confirm.${reason}`)}
</Button>
</HStack>
</VStack>
</Dialog>
)
}
@@ -0,0 +1,157 @@
/**
* Top-left status banner shown during a meeting.
*
* Renders a horizontal stack of pills, one per active state:
* - "End-to-end encrypted" / "Encryption paused"
* - "Recording in progress"
* - "Transcription in progress"
*
* Each pill auto-collapses to its icon a few seconds after appearing,
* and expands back on hover.
*/
import { css } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx'
import {
RiFileTextFill,
RiLockFill,
RiLockUnlockFill,
RiRecordCircleFill,
} from '@remixicon/react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import {
RecordingMode,
useRecordingStatuses,
} from '@/features/recording'
const COLLAPSE_DELAY_MS = 4000
interface PillProps {
icon: React.ReactNode
label: string
background: string
pulse?: boolean
}
function StatusPill({ icon, label, background, pulse }: PillProps) {
const [collapsed, setCollapsed] = useState(false)
useEffect(() => {
const t = setTimeout(() => setCollapsed(true), COLLAPSE_DELAY_MS)
return () => clearTimeout(t)
}, [])
return (
<div
onMouseEnter={() => setCollapsed(false)}
onMouseLeave={() => setCollapsed(true)}
role="status"
aria-label={label}
className={css({
display: 'inline-flex',
alignItems: 'center',
gap: '0.35rem',
padding: '0.3rem 0.6rem',
borderRadius: '1rem',
border: '2px solid rgba(0, 0, 0, 0.3)',
cursor: 'default',
overflow: 'hidden',
transition: 'max-width 300ms ease, padding-right 200ms ease',
whiteSpace: 'nowrap',
})}
style={{
backgroundColor: background,
maxWidth: collapsed ? '2.2rem' : '20rem',
paddingRight: collapsed ? '0.3rem' : '0.6rem',
animation: pulse ? 'pulse_background 1.6s infinite' : undefined,
}}
>
<span className={css({ flexShrink: 0, display: 'inline-flex' })}>
{icon}
</span>
<span
className={css({
fontSize: '0.7rem',
fontWeight: 600,
color: 'white',
letterSpacing: '0.02em',
transition: 'opacity 200ms ease',
})}
style={{ opacity: collapsed ? 0 : 1 }}
>
{label}
</span>
</div>
)
}
export function RoomStatusBanner() {
const { t } = useTranslation('rooms', { keyPrefix: 'roomStatus' })
const roomData = useRoomData()
// Use the metadata-driven `isStarted` for both pills — it flips to
// false the moment the user clicks stop (recording_status moves to
// Saving), so the pill disappears immediately instead of lingering
// through LK's 1-2s post-stop callback delay.
const screenRec = useRecordingStatuses(RecordingMode.ScreenRecording)
const transcript = useRecordingStatuses(RecordingMode.Transcript)
const isRecording = screenRec.isStarted
const isTranscribing = transcript.isStarted
// The encryption pill reflects the room's nature plus its current paused
// state — never disappears just because encryption is temporarily off
// mid-call.
const encryptionCapable = !!roomData?.is_encrypted
const encryptionPaused = !!roomData?.encryption_paused
if (!encryptionCapable && !isRecording && !isTranscribing) {
return null
}
return (
<HStack
gap="0.4rem"
className={css({
position: 'absolute',
top: '0.5rem',
left: '0.5rem',
zIndex: 10,
})}
>
{encryptionCapable && !encryptionPaused && (
<StatusPill
key="encrypted"
icon={<RiLockFill size={13} color="white" />}
label={t('encrypted')}
background="#1e3a5f"
/>
)}
{encryptionCapable && encryptionPaused && (
<StatusPill
key="paused"
icon={<RiLockUnlockFill size={13} color="white" />}
label={t('paused')}
background="#b45309"
/>
)}
{isTranscribing && (
<StatusPill
key="transcript"
icon={<RiFileTextFill size={13} color="white" />}
label={t('transcribing')}
background="#7c2d12"
/>
)}
{isRecording && (
<StatusPill
key="recording"
icon={<RiRecordCircleFill size={13} color="white" />}
label={t('recording')}
background="#b91c1c"
pulse
/>
)}
</HStack>
)
}
@@ -1,117 +0,0 @@
# Encryption Security Architecture
## Threat model
### What E2EE protects against
- **Server-side data access**: The LiveKit SFU and Meet backend cannot read audio/video content
- **Network interception**: Media frames are encrypted before leaving the client
- **Unauthorized participants**: Restricted access + lobby ensures only admin-approved users join
### Known limitations and mitigations
#### Compromised LiveKit server (MITM on key exchange)
**Threat**: If the LiveKit server is compromised, it could perform a Man-in-the-Middle attack on the ephemeral DH key exchange, intercepting the symmetric key.
**Current mitigation**: KEY_RESPONSE is only accepted from participants with `room_admin: "true"` in their server-signed JWT attributes. This prevents non-admin participants from injecting fake keys, but does not protect against a compromised server that can forge JWT attributes.
**Planned mitigations (3 levels):**
##### Level 1 — Signed key exchange (requires encryption onboarding)
When the admin has completed encryption onboarding via `data.encryption`:
1. Admin signs the KEY_RESPONSE with their permanent private key (stored in IndexedDB)
2. Receiving participant fetches admin's public key from `data.encryption` registry
3. Verifies the signature before accepting the symmetric key
4. If signature is invalid → **reject the key, show error, cut video**
This protects against server compromise because the server cannot forge the admin's private key signature.
**Requirement**: Admin must have completed encryption onboarding. If not, falls back to Level 2.
##### Level 2 — SAS (Short Authentication String) verification
After the ephemeral DH key exchange:
1. Both parties compute SAS = hash(DH_shared_secret) → displayed as 4 emojis or a 6-digit code
2. Each participant sees the SAS on their own screen (local rendering)
3. They read it aloud to each other during the call
4. If the SAS matches → the key exchange was not intercepted
5. If the SAS doesn't match → MITM detected → reject the key
This works because:
- A MITM results in different DH shared secrets → different SAS codes
- The SAS is rendered locally — the server cannot change what appears on screen
- Real-time audio manipulation to fake the spoken SAS is extremely difficult
**Requirement**: Participants must verbally compare the SAS. Optional but recommended.
##### Level 3 — Trust the server (current default)
Relies on the LiveKit server's integrity (JWT-signed attributes). Suitable when:
- The server infrastructure is self-hosted and trusted
- The threat model does not include server compromise
- Quick, frictionless meetings are prioritized over maximum security
#### Key propagation without admin
**Current behavior**: Any participant who has the symmetric key can relay it to new joiners.
**Risk**: If the server is compromised, it could inject a fake participant who relays a compromised key.
**Planned fix**: Only accept KEY_RESPONSE from participants whose identity can be:
- Cryptographically verified (Level 1 — signature from registered public key), or
- Manually verified (Level 2 — SAS comparison)
Non-verified key relays should show a clear warning.
## Trust levels
| Level | Badge | Identity verification | Key exchange | Server compromise protection |
|-------|-------|----------------------|-------------|------------------------------|
| Verified | 🟢 Green shield | Public key registered in `data.encryption` | Signed with permanent private key | Yes — signature cannot be forged |
| Authenticated | 🔵 Blue shield | OIDC/ProConnect login | Ephemeral DH (unsigned) | No — relies on server integrity |
| Anonymous | 🟡 Orange warning | None (self-declared name) | Ephemeral DH (unsigned) | No — relies on server integrity |
#### Basic mode: unencrypted frame window on connection
**Behavior**: LiveKit's built-in Worker passes frames through unencrypted when `!isEnabled()`.
**Mitigation**: `setE2EEEnabled(true)` is called BEFORE the room connects (in Conference.tsx),
ensuring the 'enable' message reaches the Worker before any frames flow. This eliminates the
unencrypted window in normal operation. However, edge cases (Worker message queue delays,
race conditions during reconnection) could theoretically still allow a few unencrypted frames.
**Advanced mode**: VaultE2EEManager drops frames when the key isn't ready — no pass-through.
#### Basic mode: "Decryption failed" overlay may not appear with wrong passphrase
**Behavior**: When a participant joins with a wrong passphrase, the receiver may not show the
"Decryption failed" overlay. The LiveKit Worker's error throttling (`MAX_ERRORS_PER_MINUTE = 5`)
stops emitting `EncryptionError` events after 5 failures. Additionally, when a participant
reconnects, the new `ParticipantTile` mounts fresh and may not receive errors referencing
the new participant identity.
**Impact**: The user sees a black tile but no error message explaining why.
**Advanced mode**: VaultE2EEManager emits `EncryptionError` for each failure and signals
`ParticipantEncryptionStatusChanged(true)` on first successful decrypt, ensuring the overlay
appears and clears correctly.
## Implementation status
- [x] Basic E2EE with LiveKit Worker + passphrase in URL hash
- [x] Advanced E2EE with VaultClient iframe (XChaCha20-Poly1305)
- [x] Preserved codec header bytes for RTP compatibility
- [x] Admin as key authority
- [x] Server-signed trust attributes in JWT
- [x] Trust badges (verified/unknown/refused/authenticated/anonymous)
- [x] Encryption identity dialog with fingerprint verification
- [x] Encryption settings in account menu (VaultClient onboarding)
- [x] Fingerprint accept/refuse with `fingerprint-changed` event
- [x] Disable recording/transcription in encrypted rooms (backend + frontend)
- [x] Lobby bypass disabled for encrypted rooms
- [x] Backend blocks encrypted room creation when `ENCRYPTION_ENABLED=false`
- [ ] Signed KEY_RESPONSE (Level 1)
- [ ] SAS verification (Level 2)
- [ ] Restrict key propagation to verified participants only
- [x] Mitigate unencrypted frame window (setE2EEEnabled before connection)
@@ -0,0 +1,84 @@
/**
* Small banner shown on top of a SIP/phone participant's tile when the room
* is live-encrypted (encrypted AND not paused). The avatar (LiveKit's
* ParticipantPlaceholder) stays visible behind it; this is just a callout
* near the bottom of the tile so everyone in the room knows why the caller
* isn't producing audio/video. Admins get a CTA to pause encryption.
*
* Detection is kind-then-identity: ParticipantKind.SIP is the canonical
* signal but we also accept identities prefixed `sip_` so the UI keeps
* working if a gateway revision forgets to set the kind enum.
*/
import { useTranslation } from 'react-i18next'
import { Participant, ParticipantKind } from 'livekit-client'
import { RiLockFill } from '@remixicon/react'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
function isSipParticipant(p: Participant): boolean {
if (p.kind === ParticipantKind.SIP) return true
return p.identity.startsWith('sip_')
}
interface Props {
participant: Participant
}
export function SipBlockedTileOverlay({ participant }: Props) {
const { t } = useTranslation('rooms', { keyPrefix: 'encryption.sipBlocked' })
const room = useRoomData()
const isAdmin = useIsAdminOrOwner()
const liveEncryption =
!!room && !!room.is_encrypted && !room.encryption_paused
if (!liveEncryption) return null
if (!isSipParticipant(participant)) return null
return (
<div
style={{
position: 'absolute',
bottom: '2.5rem',
left: '50%',
transform: 'translateX(-50%)',
backgroundColor: 'rgba(0, 0, 0, 0.75)',
borderRadius: '0.5rem',
padding: '0.6rem 1rem',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '0.4rem',
maxWidth: '85%',
zIndex: 4,
pointerEvents: 'auto',
}}
role="status"
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.4rem',
color: '#fbbf24',
fontSize: '0.85rem',
fontWeight: 600,
}}
>
<RiLockFill size={14} />
<span>{t('title')}</span>
</div>
<div
style={{
color: '#d1d5db',
fontSize: '0.75rem',
textAlign: 'center',
lineHeight: 1.4,
maxWidth: '22rem',
}}
>
{isAdmin ? t('bodyAdmin') : t('bodyParticipant')}
</div>
</div>
)
}
@@ -1,231 +0,0 @@
/**
* 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 { useUser } from '@/features/auth'
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 { user } = useUser()
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
}
const suiteUserId = (user as Record<string, unknown>)?.sub as string | undefined
if (suiteUserId) {
client.setAuthContext({ suiteUserId })
setIsReady(true)
// Check key state now that auth context is set
client.hasKeys()
.then(({ hasKeys: exists }) => {
setHasKeys(exists)
if (exists) {
client.getPublicKey()
.then(({ publicKey: pk }) => setPublicKey(pk))
.catch(() => {})
}
})
.catch(() => {})
}
setIsLoading(false)
}, [clientInitialized, (user as Record<string, unknown>)?.sub])
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: clientInitialized ? clientRef.current : null,
isReady,
isLoading,
error,
hasKeys,
publicKey,
refreshKeyState,
}}
>
{children}
</VaultClientContext.Provider>
)
}
export const useVaultClient = (): VaultClientContextValue =>
useContext(VaultClientContext)
@@ -1,396 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
VaultE2EEManager,
getUnencryptedBytes,
UNENCRYPTED_BYTES,
} from './VaultE2EEManager'
// ── getUnencryptedBytes ───────────────────────────────────────────────
describe('getUnencryptedBytes', () => {
it('returns 10 for VP8 keyframes', () => {
const frame = { type: 'key', data: new ArrayBuffer(100) }
expect(getUnencryptedBytes(frame as unknown as RTCEncodedVideoFrame)).toBe(
UNENCRYPTED_BYTES.key
)
})
it('returns 3 for VP8 delta frames', () => {
const frame = { type: 'delta', data: new ArrayBuffer(100) }
expect(getUnencryptedBytes(frame as unknown as RTCEncodedVideoFrame)).toBe(
UNENCRYPTED_BYTES.delta
)
})
it('returns 1 for audio frames (no type property)', () => {
const frame = { data: new ArrayBuffer(100) }
expect(getUnencryptedBytes(frame as unknown as RTCEncodedAudioFrame)).toBe(
UNENCRYPTED_BYTES.audio
)
})
})
// ── Mock VaultClient ──────────────────────────────────────────────────
function createMockVaultClient() {
// Simulates vault crypto: prepends 24-byte nonce + appends 16-byte MAC
const NONCE_LEN = 24
const MAC_LEN = 16
return {
encryptWithKey: vi.fn(async (data: ArrayBuffer, _key: ArrayBuffer) => {
const input = new Uint8Array(data)
const nonce = new Uint8Array(NONCE_LEN).fill(0xaa) // deterministic for tests
const ciphertext = new Uint8Array(input.length + MAC_LEN)
ciphertext.set(input) // "encrypt" = copy (for testing)
ciphertext.set(new Uint8Array(MAC_LEN).fill(0xbb), input.length) // fake MAC
const result = new Uint8Array(NONCE_LEN + ciphertext.length)
result.set(nonce)
result.set(ciphertext, NONCE_LEN)
return { encryptedData: result.buffer }
}),
decryptWithKey: vi.fn(
async (encryptedData: ArrayBuffer, _key: ArrayBuffer) => {
const input = new Uint8Array(encryptedData)
// Strip nonce (24B) and MAC (16B)
const plaintext = input.slice(NONCE_LEN, input.length - MAC_LEN)
return { data: plaintext.buffer }
}
),
} as unknown as VaultClient
}
// ── Key management ────────────────────────────────────────────────────
describe('VaultE2EEManager key management', () => {
it('stores an independent copy of the key', () => {
const vaultClient = createMockVaultClient()
const manager = new VaultE2EEManager(vaultClient)
const original = new Uint8Array([1, 2, 3, 4])
manager.setEncryptedSymmetricKey(original.buffer)
// Mutate original — should not affect stored key
original[0] = 99
// Access internal state via encryptData (which uses freshKeyBuffer)
// If the key was a view on the original, this would reflect the mutation
expect(manager.isDataChannelEncryptionEnabled).toBe(false) // _isDataChannelEncryptionEnabled not set
manager.isDataChannelEncryptionEnabled = true
expect(manager.isDataChannelEncryptionEnabled).toBe(true) // key is set
})
it('isDataChannelEncryptionEnabled is false without key', () => {
const manager = new VaultE2EEManager(createMockVaultClient())
manager.isDataChannelEncryptionEnabled = true
expect(manager.isDataChannelEncryptionEnabled).toBe(false)
})
it('isDataChannelEncryptionEnabled is true with key + flag', () => {
const manager = new VaultE2EEManager(createMockVaultClient())
manager.setEncryptedSymmetricKey(new ArrayBuffer(32))
manager.isDataChannelEncryptionEnabled = true
expect(manager.isDataChannelEncryptionEnabled).toBe(true)
})
})
// ── Data channel encrypt/decrypt round-trip ───────────────────────────
describe('VaultE2EEManager data channel encryption', () => {
let manager: VaultE2EEManager
let vaultClient: ReturnType<typeof createMockVaultClient>
beforeEach(() => {
vaultClient = createMockVaultClient()
manager = new VaultE2EEManager(vaultClient as unknown as VaultClient)
manager.setEncryptedSymmetricKey(new ArrayBuffer(32))
})
it('encryptData calls vaultClient.encryptWithKey', async () => {
const data = new Uint8Array([10, 20, 30])
const result = await manager.encryptData(data)
expect(vaultClient.encryptWithKey).toHaveBeenCalledOnce()
expect(result.payload).toBeInstanceOf(Uint8Array)
expect(result.payload.length).toBeGreaterThan(data.length) // overhead from nonce+MAC
})
it('handleEncryptedData calls vaultClient.decryptWithKey', async () => {
const data = new Uint8Array([10, 20, 30])
const encrypted = await manager.encryptData(data)
const decrypted = await manager.handleEncryptedData(
encrypted.payload,
new Uint8Array(0),
'participant-1',
0
)
expect(vaultClient.decryptWithKey).toHaveBeenCalledOnce()
expect(new Uint8Array(decrypted.payload)).toEqual(data)
})
it('encryptData throws without key', async () => {
const noKeyManager = new VaultE2EEManager(
vaultClient as unknown as VaultClient
)
await expect(noKeyManager.encryptData(new Uint8Array([1]))).rejects.toThrow(
'No encrypted symmetric key set'
)
})
it('handleEncryptedData throws without key', async () => {
const noKeyManager = new VaultE2EEManager(
vaultClient as unknown as VaultClient
)
await expect(
noKeyManager.handleEncryptedData(
new Uint8Array([1]),
new Uint8Array(0),
'p',
0
)
).rejects.toThrow('No encrypted symmetric key set')
})
})
// ── Frame format (header preservation) ────────────────────────────────
describe('Frame format — header preservation', () => {
let vaultClient: ReturnType<typeof createMockVaultClient>
beforeEach(() => {
vaultClient = createMockVaultClient()
})
it('encrypt preserves VP8 keyframe header (10 bytes)', async () => {
// Simulate what the sender transform does
const frameData = new Uint8Array(100)
// Fill with recognizable pattern: header = 0x01-0x0A, payload = 0xFF
for (let i = 0; i < 10; i++) frameData[i] = i + 1
frameData.fill(0xff, 10)
const unencryptedBytes = UNENCRYPTED_BYTES.key // 10
const header = frameData.slice(0, unencryptedBytes)
const payload = frameData.slice(unencryptedBytes)
const { encryptedData } = await vaultClient.encryptWithKey(
payload.buffer,
new ArrayBuffer(32)
)
const encrypted = new Uint8Array(encryptedData)
// Reconstruct frame: [header][encrypted payload]
const newFrame = new Uint8Array(header.length + encrypted.length)
newFrame.set(header)
newFrame.set(encrypted, header.length)
// Verify header is preserved unencrypted
expect(newFrame.slice(0, 10)).toEqual(header)
// Verify the rest is different (encrypted)
expect(newFrame.length).toBeGreaterThan(frameData.length) // overhead
})
it('encrypt + decrypt round-trip preserves original frame', async () => {
const frameData = new Uint8Array(50)
for (let i = 0; i < 50; i++) frameData[i] = i
const unencryptedBytes = UNENCRYPTED_BYTES.delta // 3
const header = frameData.slice(0, unencryptedBytes)
const payload = frameData.slice(unencryptedBytes)
// Encrypt
const { encryptedData } = await vaultClient.encryptWithKey(
payload.slice().buffer,
new ArrayBuffer(32)
)
const encrypted = new Uint8Array(encryptedData)
const encryptedFrame = new Uint8Array(header.length + encrypted.length)
encryptedFrame.set(header)
encryptedFrame.set(encrypted, header.length)
// Decrypt (receiver side)
const rxHeader = encryptedFrame.slice(0, unencryptedBytes)
const rxEncrypted = encryptedFrame.slice(unencryptedBytes)
const { data } = await vaultClient.decryptWithKey(
rxEncrypted.slice().buffer,
new ArrayBuffer(32)
)
const plaintext = new Uint8Array(data)
const decryptedFrame = new Uint8Array(rxHeader.length + plaintext.length)
decryptedFrame.set(rxHeader)
decryptedFrame.set(plaintext, rxHeader.length)
// Original frame should be recovered exactly
expect(decryptedFrame).toEqual(frameData)
})
it('audio frames preserve 1 byte header', async () => {
const frameData = new Uint8Array(20)
frameData[0] = 0xfc // Opus TOC byte
frameData.fill(0xab, 1)
const unencryptedBytes = UNENCRYPTED_BYTES.audio // 1
const header = frameData.slice(0, unencryptedBytes)
const payload = frameData.slice(unencryptedBytes)
const { encryptedData } = await vaultClient.encryptWithKey(
payload.slice().buffer,
new ArrayBuffer(32)
)
const encrypted = new Uint8Array(encryptedData)
const encryptedFrame = new Uint8Array(header.length + encrypted.length)
encryptedFrame.set(header)
encryptedFrame.set(encrypted, header.length)
// First byte (Opus TOC) must be preserved
expect(encryptedFrame[0]).toBe(0xfc)
})
})
// ── Proof: data transiting through LiveKit SFU is not decipherable ────
describe('SFU sees only encrypted data', () => {
let vaultClient: ReturnType<typeof createMockVaultClient>
beforeEach(() => {
vaultClient = createMockVaultClient()
})
it('encrypted frame payload does NOT match original payload', async () => {
// Simulate a VP8 keyframe with recognizable pixel data
const frameSize = 5000 // typical small video frame
const originalFrame = new Uint8Array(frameSize)
for (let i = 0; i < frameSize; i++) originalFrame[i] = i % 256
const headerSize = UNENCRYPTED_BYTES.key // 10
const header = originalFrame.slice(0, headerSize)
const payload = originalFrame.slice(headerSize)
// Encrypt (what the sender does before sending to SFU)
const { encryptedData } = await vaultClient.encryptWithKey(
payload.slice().buffer,
new ArrayBuffer(32)
)
const encrypted = new Uint8Array(encryptedData)
// This is what the SFU sees: [header][encrypted payload]
const sfuFrame = new Uint8Array(header.length + encrypted.length)
sfuFrame.set(header)
sfuFrame.set(encrypted, header.length)
// The SFU frame is LARGER than original (nonce + MAC overhead)
expect(sfuFrame.length).toBe(originalFrame.length + 24 + 16) // +40B
// The header bytes are the same (unencrypted, needed for RTP)
expect(sfuFrame.slice(0, headerSize)).toEqual(header)
// The payload bytes are COMPLETELY DIFFERENT from the original
const sfuPayload = sfuFrame.slice(headerSize)
const originalPayload = originalFrame.slice(headerSize)
expect(sfuPayload.length).not.toBe(originalPayload.length)
expect(sfuPayload).not.toEqual(originalPayload)
})
it('encrypted payload cannot be reversed without vault decryption', async () => {
const originalPayload = new Uint8Array([72, 101, 108, 108, 111]) // "Hello"
const { encryptedData } = await vaultClient.encryptWithKey(
originalPayload.slice().buffer,
new ArrayBuffer(32)
)
const encrypted = new Uint8Array(encryptedData)
// The encrypted data is 40 bytes larger (24B nonce + 16B MAC)
expect(encrypted.length).toBe(originalPayload.length + 24 + 16)
// No substring of the encrypted data matches the original payload
// (the nonce prepended and MAC appended obscure everything)
for (let i = 0; i <= encrypted.length - originalPayload.length; i++) {
const slice = encrypted.slice(i, i + originalPayload.length)
if (i === 24) {
// At offset 24 (after nonce), our mock "encrypts" by copying,
// so in a real vault this would NOT match. Skip this offset for
// the mock — the real test is the overhead structure.
continue
}
expect(slice).not.toEqual(originalPayload)
}
})
it('overhead is exactly 40 bytes (24B nonce + 16B MAC) per frame', async () => {
const testSizes = [10, 100, 1000, 5000, 20000]
for (const size of testSizes) {
const payload = new Uint8Array(size)
const { encryptedData } = await vaultClient.encryptWithKey(
payload.buffer,
new ArrayBuffer(32)
)
const overhead = new Uint8Array(encryptedData).length - size
expect(overhead).toBe(40) // 24B nonce + 16B MAC = XChaCha20-Poly1305
}
})
it('only codec header bytes leak — they contain no media content', () => {
// VP8 keyframe header is 10 bytes of codec metadata (not pixels)
// VP8 delta header is 3 bytes
// Opus audio header is 1 byte (TOC byte = codec config, not audio samples)
//
// These bytes tell the RTP packetizer how to split the frame into packets.
// They do NOT contain visual or audio content.
expect(UNENCRYPTED_BYTES.key).toBe(10) // VP8 payload descriptor
expect(UNENCRYPTED_BYTES.delta).toBe(3) // VP8 payload descriptor
expect(UNENCRYPTED_BYTES.audio).toBe(1) // Opus TOC byte
// Maximum leak per frame is 10 bytes out of typically 1000-50000 byte frames
// = 0.02% to 1% of frame data, and it's codec metadata, not content
const typicalKeyframeSize = 50000
const leakRatio = UNENCRYPTED_BYTES.key / typicalKeyframeSize
expect(leakRatio).toBeLessThan(0.001) // less than 0.1%
})
it('full sender→SFU→receiver pipeline: receiver recovers original, SFU cannot', async () => {
// Original video frame (sender side)
const originalFrame = new Uint8Array(200)
for (let i = 0; i < 200; i++) originalFrame[i] = (i * 7 + 13) % 256
const headerSize = UNENCRYPTED_BYTES.delta // 3
// ── SENDER: encrypt and send ──
const header = originalFrame.slice(0, headerSize)
const payload = originalFrame.slice(headerSize)
const { encryptedData } = await vaultClient.encryptWithKey(
payload.slice().buffer,
new ArrayBuffer(32)
)
const encrypted = new Uint8Array(encryptedData)
const wireFrame = new Uint8Array(header.length + encrypted.length)
wireFrame.set(header)
wireFrame.set(encrypted, header.length)
// ── SFU: can only see wireFrame — cannot recover original ──
// The SFU would need to strip the nonce and decrypt the ciphertext,
// but it doesn't have the symmetric key (it's in the vault iframe).
expect(wireFrame).not.toEqual(originalFrame)
expect(wireFrame.length).not.toBe(originalFrame.length)
// ── RECEIVER: decrypt and recover ──
const rxHeader = wireFrame.slice(0, headerSize)
const rxEncrypted = wireFrame.slice(headerSize)
const { data } = await vaultClient.decryptWithKey(
rxEncrypted.slice().buffer,
new ArrayBuffer(32)
)
const decryptedPayload = new Uint8Array(data)
const recoveredFrame = new Uint8Array(rxHeader.length + decryptedPayload.length)
recoveredFrame.set(rxHeader)
recoveredFrame.set(decryptedPayload, rxHeader.length)
// Receiver gets the EXACT original frame
expect(recoveredFrame).toEqual(originalFrame)
})
})
@@ -1,328 +0,0 @@
/**
* Custom E2EE Manager that delegates crypto to the VaultClient iframe.
*
* Uses XChaCha20-Poly1305 (libsodium) via the vault — the symmetric key
* never leaves the iframe. Preserves codec header bytes unencrypted so
* the WebRTC RTP packetizer can construct valid packets.
*
* Frame format (sender output / receiver input):
* [unencrypted codec header][vault-encrypted payload]
*
* Where vault-encrypted payload = [24B nonce][ciphertext + 16B Poly1305 MAC]
*
* Unencrypted header sizes (VP8):
* - keyframe: 10 bytes (VP8 payload descriptor)
* - delta: 3 bytes
* - audio: 1 byte (Opus TOC)
*/
import { EventEmitter } from 'events'
import { Encryption_Type } from '@livekit/protocol'
import type { Room, RemoteTrack, Track } from 'livekit-client'
import { RoomEvent, ParticipantEvent, ConnectionState } from 'livekit-client'
import type { RTCEngine } from 'livekit-client/src/room/RTCEngine'
const E2EE_FLAG = Symbol('e2ee')
enum EncryptionEvent {
ParticipantEncryptionStatusChanged = 'participantEncryptionStatusChanged',
EncryptionError = 'encryptionError',
}
function isInsertableStreamSupported(): boolean {
return (
typeof window.RTCRtpSender !== 'undefined' &&
// @ts-expect-error — createEncodedStreams not in TS types
typeof window.RTCRtpSender.prototype.createEncodedStreams !== 'undefined'
)
}
export const UNENCRYPTED_BYTES = { key: 10, delta: 3, audio: 1 }
export function getUnencryptedBytes(
frame: RTCEncodedVideoFrame | RTCEncodedAudioFrame
): number {
if (!('type' in frame)) return UNENCRYPTED_BYTES.audio
return frame.type === 'key' ? UNENCRYPTED_BYTES.key : UNENCRYPTED_BYTES.delta
}
export class VaultE2EEManager extends EventEmitter {
private vaultClient: VaultClient
private room?: Room
private encryptionEnabled = false
private _isDataChannelEncryptionEnabled = false
/**
* Encrypted symmetric key (wrapped for the user's vault public key).
* Stored as an independent copy so the original ArrayBuffer can't be detached.
*/
private encryptedKeyBytes: Uint8Array | null = null
constructor(vaultClient: VaultClient) {
super()
this.vaultClient = vaultClient
}
get isEnabled() {
return this.encryptionEnabled
}
get isDataChannelEncryptionEnabled() {
return this._isDataChannelEncryptionEnabled && !!this.encryptedKeyBytes
}
set isDataChannelEncryptionEnabled(enabled: boolean) {
this._isDataChannelEncryptionEnabled = enabled
}
/** Fresh ArrayBuffer copy of the key for each vault call (avoids postMessage detachment). */
private freshKeyBuffer(): ArrayBuffer {
return new Uint8Array(this.encryptedKeyBytes!).buffer
}
setEncryptedSymmetricKey(key: ArrayBuffer): void {
this.encryptedKeyBytes = new Uint8Array(new Uint8Array(key))
}
// ── Lifecycle (mirrors built-in E2EEManager) ────────────────────────
setup(room: Room): void {
if (!isInsertableStreamSupported()) {
throw new Error(
'End-to-end encryption is not supported in this browser. ' +
'Please use a Chromium-based browser (Chrome, Edge, Brave).'
)
}
if (room !== this.room) {
this.room = room
this.setupEventListeners(room)
}
}
setupEngine(_engine: RTCEngine): void {}
setParticipantCryptorEnabled(
enabled: boolean,
participantIdentity: string
): void {
if (
participantIdentity === this.room?.localParticipant.identity &&
this.encryptionEnabled !== enabled
) {
this.encryptionEnabled = enabled
this.emit(
EncryptionEvent.ParticipantEncryptionStatusChanged,
enabled,
this.room!.localParticipant
)
} else if (participantIdentity !== this.room?.localParticipant.identity) {
const p = this.room?.getParticipantByIdentity(participantIdentity)
if (p)
this.emit(
EncryptionEvent.ParticipantEncryptionStatusChanged,
enabled,
p
)
}
}
setSifTrailer(_trailer: Uint8Array): void {}
async encryptData(data: Uint8Array) {
if (!this.encryptedKeyBytes)
throw new Error('No encrypted symmetric key set')
const r = await this.vaultClient.encryptWithKey(
data.slice().buffer,
this.freshKeyBuffer()
)
return {
uuid: crypto.randomUUID(),
payload: new Uint8Array(r.encryptedData).slice(),
iv: new Uint8Array(0),
keyIndex: 0,
}
}
async handleEncryptedData(
payload: Uint8Array,
_iv: Uint8Array,
_participantIdentity: string,
_keyIndex: number
) {
if (!this.encryptedKeyBytes)
throw new Error('No encrypted symmetric key set')
const r = await this.vaultClient.decryptWithKey(
payload.slice().buffer,
this.freshKeyBuffer()
)
return {
uuid: crypto.randomUUID(),
payload: new Uint8Array(r.data).slice(),
}
}
// ── Event listeners ─────────────────────────────────────────────────
private setupEventListeners(room: Room): void {
room.on(RoomEvent.TrackPublished, (pub, participant) => {
this.setParticipantCryptorEnabled(
pub.trackInfo!.encryption !== Encryption_Type.NONE,
participant.identity
)
})
room.on(RoomEvent.ConnectionStateChanged, (state) => {
if (state === ConnectionState.Connected) {
room.remoteParticipants.forEach((p) => {
p.trackPublications.forEach((pub) => {
this.setParticipantCryptorEnabled(
pub.trackInfo!.encryption !== Encryption_Type.NONE,
p.identity
)
})
})
}
})
room.on(RoomEvent.TrackSubscribed, (track, _pub, participant) => {
this.setupReceiver(track, participant.identity)
})
room.on(RoomEvent.SignalConnected, () => {
this.setParticipantCryptorEnabled(
room.localParticipant.isE2EEEnabled,
room.localParticipant.identity
)
})
room.localParticipant.on(
ParticipantEvent.LocalSenderCreated,
(sender: RTCRtpSender, track: Track) => {
this.setupSender(sender, track.mediaStreamID)
}
)
}
// ── Sender (encrypt outgoing frames) ────────────────────────────────
private setupSender(sender: RTCRtpSender, _trackId: string): void {
if (E2EE_FLAG in sender) return
if (!this.room?.localParticipant.identity) return
// @ts-expect-error — createEncodedStreams not in TS types
const streams = sender.createEncodedStreams()
const transformStream = new TransformStream({
transform: async (
frame: RTCEncodedVideoFrame | RTCEncodedAudioFrame,
controller: TransformStreamDefaultController
) => {
try {
if (!this.encryptedKeyBytes) return // drop — never send unencrypted
if (!frame.data || frame.data.byteLength === 0)
return controller.enqueue(frame)
const unencryptedBytes = getUnencryptedBytes(frame)
const header = new Uint8Array(frame.data, 0, unencryptedBytes)
const payload = new Uint8Array(frame.data, unencryptedBytes)
const { encryptedData } = await this.vaultClient.encryptWithKey(
payload.slice().buffer,
this.freshKeyBuffer()
)
const encrypted = new Uint8Array(encryptedData)
const newData = new Uint8Array(
header.byteLength + encrypted.byteLength
)
newData.set(header)
newData.set(encrypted, header.byteLength)
frame.data = newData.buffer
controller.enqueue(frame)
} catch {
// Drop frame on error — never send unencrypted
}
},
})
streams.readable.pipeThrough(transformStream).pipeTo(streams.writable)
// @ts-expect-error
sender[E2EE_FLAG] = true
}
// ── Receiver (decrypt incoming frames) ──────────────────────────────
private setupReceiver(track: RemoteTrack, participantIdentity: string): void {
if (!track.receiver) return
const receiver = track.receiver
if (E2EE_FLAG in receiver) return
// @ts-expect-error
let writable: WritableStream = receiver.writableStream
// @ts-expect-error
let readable: ReadableStream = receiver.readableStream
if (!writable || !readable) {
// @ts-expect-error
const streams = receiver.createEncodedStreams()
// @ts-expect-error
receiver.writableStream = streams.writable
writable = streams.writable
// @ts-expect-error
receiver.readableStream = streams.readable
readable = streams.readable
}
let successEmitted = false
const transformStream = new TransformStream({
transform: async (
frame: RTCEncodedVideoFrame | RTCEncodedAudioFrame,
controller: TransformStreamDefaultController
) => {
try {
if (!this.encryptedKeyBytes) return // drop — can't decrypt without key
if (!frame.data || frame.data.byteLength === 0)
return controller.enqueue(frame)
const unencryptedBytes = getUnencryptedBytes(frame)
const header = new Uint8Array(frame.data, 0, unencryptedBytes)
const encryptedPayload = new Uint8Array(frame.data, unencryptedBytes)
const { data } = await this.vaultClient.decryptWithKey(
encryptedPayload.slice().buffer,
this.freshKeyBuffer()
)
const plaintext = new Uint8Array(data)
const newData = new Uint8Array(
header.byteLength + plaintext.byteLength
)
newData.set(header)
newData.set(plaintext, header.byteLength)
frame.data = newData.buffer
controller.enqueue(frame)
if (!successEmitted) {
successEmitted = true
const p = this.room?.getParticipantByIdentity(participantIdentity)
if (p)
this.emit(
EncryptionEvent.ParticipantEncryptionStatusChanged,
true,
p
)
}
} catch {
// Drop frame — keeps pipe alive, avoids sending corrupt data to decoder
}
},
})
readable
.pipeThrough(transformStream)
.pipeTo(writable)
.catch(() => {})
// @ts-expect-error
receiver[E2EE_FLAG] = true
}
}
@@ -0,0 +1,15 @@
import { createContext } from 'react'
import {
EncryptionPhase,
EncryptionStatusContextValue,
} from './encryptionStatusTypes'
const noopContext: EncryptionStatusContextValue = {
phase: EncryptionPhase.UNENCRYPTED,
pausedByMe: false,
pauseEncryption: async () => false,
resumeEncryption: async () => false,
}
export const EncryptionStatusContext =
createContext<EncryptionStatusContextValue>(noopContext)
@@ -0,0 +1,32 @@
export enum EncryptionPhase {
UNENCRYPTED = 'unencrypted',
ENCRYPTED = 'encrypted',
PAUSED = 'paused',
}
export type PauseReason =
| 'recording'
| 'transcript'
| 'manual'
| 'sip_participant'
export interface EncryptionStatus {
phase: EncryptionPhase
pauseReason?: PauseReason
/** True when the local participant initiated the current pause. */
pausedByMe: boolean
}
export interface EncryptionStatusContextValue extends EncryptionStatus {
/**
* Pause encryption for this session and notify the rest of the room.
* Returns true on success.
*/
pauseEncryption: (reason: PauseReason) => Promise<boolean>
/**
* Resume encryption after a pause. Returns true on success. Only the
* participant who initiated the pause (or anyone meeting the legitimacy
* rules) can resume.
*/
resumeEncryption: () => Promise<boolean>
}
-104
View File
@@ -1,104 +0,0 @@
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>,
options?: { optimizeMemory?: boolean }
): Promise<{
encryptedContent: ArrayBuffer
encryptedKeys: Record<string, ArrayBuffer>
}>
encryptWithKey(
data: ArrayBuffer,
encryptedSymmetricKey: ArrayBuffer,
encryptedKeyChain?: ArrayBuffer[],
options?: { optimizeMemory?: boolean }
): Promise<{ encryptedData: ArrayBuffer }>
decryptWithKey(
encryptedData: ArrayBuffer,
encryptedSymmetricKey: ArrayBuffer,
encryptedKeyChain?: ArrayBuffer[],
options?: { optimizeMemory?: boolean }
): 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
}
/**
* Stable error codes carried by `VaultError`. Sourced from the
* encryption SDK (re-exported on `window.EncryptionClient.VaultErrorCode`)
* — meet consumers match on these via `(err as VaultError).code` rather
* than regexing message text. Keep in sync with the SDK definition.
*/
type VaultErrorCode =
| 'MISSING_KEYS'
| 'WRONG_SECRET_KEY'
| 'INVALID_BACKUP'
| 'INVALID_MNEMONIC'
| 'NOT_INITIALIZED'
| 'AUTH_REQUIRED'
| 'PRIVILEGED_ORIGIN_REQUIRED'
| 'TIMEOUT'
| 'IFRAME_REQUIRED'
| 'CIPHERTEXT_TOO_SHORT'
| 'UNKNOWN'
interface VaultError extends Error {
readonly code: VaultErrorCode
}
interface Window {
EncryptionClient: {
VaultClient: new (options: {
vaultUrl: string
interfaceUrl: string
timeout?: number
theme?: string
lang?: string
}) => VaultClient
VaultError: new (code: VaultErrorCode, message: string) => VaultError
VaultErrorCode: { readonly [K in VaultErrorCode]: K }
isVaultError: (err: unknown) => err is VaultError
}
}
}
+17 -16
View File
@@ -1,17 +1,18 @@
export { VaultClientProvider, useVaultClient } from './VaultClientProvider'
export type { VaultClientContextValue } from './VaultClientProvider'
export {
determineTrustLevel,
getTrustLevelFromAttributes,
distributeKeyViaPKI,
encodeTrustLevelAttribute,
} from './HybridKeyDistributor'
export type { ParticipantEncryptionInfo } from './HybridKeyDistributor'
export { EncryptionBadge } from './EncryptionBadge'
export { EncryptedMeetingBanner } from './EncryptedMeetingBanner'
export { EncryptionTrustModal } from './EncryptionTrustModal'
export { EncryptionIdentityDialog } from './EncryptionIdentityDialog'
export { useParticipantTrustLevel } from './useParticipantTrustLevel'
export { PARTICIPANT_TRUST_ATTR } from './types'
export type { TrustLevel } from './types'
generatePassphrase,
isValidPassphrase,
getPassphraseFromHash,
PASSPHRASE_LENGTH,
} from './passphrase'
export { EncryptionStatusProvider } from './EncryptionStatusContext'
export { useEncryptionStatus } from './useEncryptionStatus'
export { EncryptionPhase } from './encryptionStatusTypes'
export type { EncryptionStatus, PauseReason } from './encryptionStatusTypes'
export { RoomStatusBanner } from './RoomStatusBanner'
export { EncryptionStatusSnackbars } from './EncryptionStatusSnackbars'
export { PauseEncryptionConfirmDialog } from './PauseEncryptionConfirmDialog'
export { IdentityBadge } from './IdentityBadge'
export { EncryptionMismatchScreen } from './EncryptionMismatchScreen'
export { EncryptionAutoResumeWatcher } from './EncryptionAutoResumeWatcher'
export { SipBlockedTileOverlay } from './SipBlockedTileOverlay'
export { DecryptionFailedTileOverlay } from './DecryptionFailedTileOverlay'
@@ -1,49 +0,0 @@
/**
* Key storage and passphrase utilities for E2EE lobby flow.
*
* Basic mode: passphrase is in the URL hash — shared by sharing the link.
* Advanced mode: vault-wrapped symmetric key exchanged via lobby REST API.
*/
// ── Module-level symmetric key (basic mode) ───────────────────────────
let _symmetricKey: Uint8Array | null = null
export function setSymmetricKey(key: Uint8Array): void {
_symmetricKey = key
}
export function getSymmetricKey(): Uint8Array | null {
return _symmetricKey
}
export function clearSymmetricKey(): void {
_symmetricKey = null
}
// ── Module-level encrypted vault key (advanced mode) ──────────────────
let _encryptedVaultKey: ArrayBuffer | null = null
export function setEncryptedVaultKey(key: ArrayBuffer): void {
_encryptedVaultKey = key
}
export function getEncryptedVaultKey(): ArrayBuffer | null {
return _encryptedVaultKey
}
// ── Passphrase generation (basic mode) ────────────────────────────────
/**
* Generate a random passphrase for basic mode encryption.
* 24 random bytes encoded in base36 = 48 alphanumeric characters.
*/
export function generatePassphrase(): string {
return Array.from(crypto.getRandomValues(new Uint8Array(24)))
.map((b) => b.toString(36).padStart(2, '0'))
.join('')
}
/** Expected length of a basic mode passphrase */
export const BASIC_KEY_LENGTH = 48
@@ -0,0 +1,34 @@
/**
* Passphrase utilities for end-to-end encryption.
*
* The passphrase is appended to a room URL as the hash fragment
* (e.g. `https://meet.example.com/abc-defg-hij#<passphrase>`). The
* server never sees it; participants share it by sharing the link.
*/
/**
* Number of random bytes used to seed a passphrase.
* Each byte is rendered as 2 base36 characters, so the resulting
* passphrase is 48 characters long.
*/
const PASSPHRASE_BYTES = 24
/** Length, in characters, of a generated passphrase. */
export const PASSPHRASE_LENGTH = PASSPHRASE_BYTES * 2
/** Generate a random passphrase suitable for room E2E encryption. */
export function generatePassphrase(): string {
return Array.from(crypto.getRandomValues(new Uint8Array(PASSPHRASE_BYTES)))
.map((b) => b.toString(36).padStart(2, '0'))
.join('')
}
/** Whether a string looks like a valid passphrase. */
export function isValidPassphrase(value: string): boolean {
return value.length === PASSPHRASE_LENGTH && /^[a-z0-9]+$/.test(value)
}
/** Read the current URL hash (without the leading `#`). */
export function getPassphraseFromHash(): string {
return window.location.hash.replace(/^#/, '')
}
@@ -1,43 +0,0 @@
/**
* 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' | 'refused' | 'unknown'
/**
* 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,4 @@
import { useContext } from 'react'
import { EncryptionStatusContext } from './encryptionStatusContextValue'
export const useEncryptionStatus = () => useContext(EncryptionStatusContext)
@@ -1,133 +0,0 @@
/**
* Hook that determines a participant's trust level and fingerprint status
* by checking the vault (encryption library) via VaultClient.
*
* In advanced mode:
* - Checks if the participant has a registered public key
* - Checks the fingerprint status (trusted/refused/unknown)
* - Returns "verified" only if they have a public key
*
* In basic mode:
* - Only uses authentication status (no vault check)
*/
import { useEffect, useState } from 'react'
import { useVaultClient } from './VaultClientProvider'
import type { TrustLevel } from './types'
/** Compute a fingerprint from a public key (same as encryption repo: SHA-256, first 16 hex chars) */
async function computeFingerprint(publicKey: ArrayBuffer): Promise<string> {
const hash = await crypto.subtle.digest('SHA-256', publicKey)
return Array.from(new Uint8Array(hash))
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
.slice(0, 16)
}
/** Format for display: "a1b2c3d4e5f67890" → "A1B2 C3D4 E5F6 7890" */
export function formatFingerprint(fp: string): string {
return fp.replace(/(.{4})/g, '$1 ').trim().toUpperCase()
}
export type FingerprintStatus = 'loading' | 'trusted' | 'refused' | 'unknown' | 'no-key' | 'error'
export function useParticipantTrustLevel(
attributes: Record<string, string> | undefined,
encryptionMode?: string,
isSelf?: boolean,
): { trustLevel: TrustLevel; fingerprintStatus: FingerprintStatus; fingerprint: string | null } {
const { client: vaultClient } = useVaultClient()
const [fingerprintStatus, setFingerprintStatus] = useState<FingerprintStatus>('loading')
const [fingerprint, setFingerprint] = useState<string | null>(null)
const isAuthenticated = attributes?.is_authenticated === 'true'
const suiteUserId = attributes?.suite_user_id
const isAdvanced = encryptionMode === 'advanced'
// Re-check when a fingerprint is accepted/refused via VaultClient
const [revision, setRevision] = useState(0)
useEffect(() => {
if (!vaultClient) return
const handler = () => setRevision((r) => r + 1)
vaultClient.on('fingerprint-changed', handler)
return () => { vaultClient.off('fingerprint-changed', handler) }
}, [vaultClient])
useEffect(() => {
if (!isAdvanced || !isAuthenticated) {
setFingerprintStatus('no-key')
return
}
if (!vaultClient || !suiteUserId) {
setFingerprintStatus(vaultClient ? 'no-key' : 'error')
return
}
let cancelled = false
async function check() {
try {
const { publicKeys } = await vaultClient!.fetchPublicKeys([suiteUserId!])
if (cancelled) return
const publicKey = publicKeys[suiteUserId!]
if (!publicKey) {
setFingerprintStatus('no-key')
return
}
// Compute the fingerprint from the public key (SHA-256, first 16 hex chars)
const fp = await computeFingerprint(publicKey)
if (cancelled) return
setFingerprint(fp)
// Own fingerprint is always trusted — we hold the private key
if (isSelf) {
setFingerprintStatus('trusted')
return
}
// Check if we have a known fingerprint in the local registry
const { fingerprints: known } = await vaultClient!.getKnownFingerprints()
if (cancelled) return
const knownEntry = known[suiteUserId!]
if (!knownEntry) {
// Never seen — unknown, needs explicit acceptance
setFingerprintStatus('unknown')
} else if (knownEntry.fingerprint === fp) {
// Same fingerprint — use stored status
setFingerprintStatus(knownEntry.status as FingerprintStatus)
} else {
// Different fingerprint — key changed, needs re-verification
setFingerprintStatus('unknown')
}
} catch {
if (!cancelled) setFingerprintStatus('error')
}
}
check()
return () => { cancelled = true }
}, [vaultClient, suiteUserId, isAuthenticated, isAdvanced, isSelf, revision])
// Derive trust level from fingerprint status
let trustLevel: TrustLevel
if (!isAuthenticated) {
trustLevel = 'anonymous'
} else if (!isAdvanced) {
// Basic mode: only authentication matters
trustLevel = 'authenticated'
} else if (fingerprintStatus === 'trusted') {
trustLevel = 'verified'
} else if (fingerprintStatus === 'refused') {
trustLevel = 'refused'
} else if (fingerprintStatus === 'no-key' || fingerprintStatus === 'error') {
// Authenticated but no vault keys — show as authenticated (blue)
trustLevel = 'authenticated'
} else {
// 'unknown' or 'loading' — has key but not yet verified
trustLevel = 'unknown'
}
return { trustLevel, fingerprintStatus, fingerprint }
}
@@ -1,142 +0,0 @@
import { Button, Dialog, type DialogProps, Text } from '@/primitives'
import { VStack, HStack } from '@/styled-system/jsx'
import { css } from '@/styled-system/css'
import { RiLockFill, RiShieldCheckFill, RiAlertLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
import { useVaultClient } from '@/features/encryption'
export const EncryptionModeDialog = ({
onSelect,
isForLater = false,
...dialogProps
}: {
onSelect: (mode: ApiEncryptionMode) => void
isForLater?: boolean
} & Omit<DialogProps, 'title'>) => {
const { t } = useTranslation('home', { keyPrefix: 'encryptionModeDialog' })
const { hasKeys, client: vaultClient, error: vaultError, isLoading: vaultLoading } = useVaultClient()
const vaultUnavailable = !vaultClient && !vaultLoading
const canUseAdvanced = !!hasKeys && !vaultUnavailable
return (
<Dialog title={t('title')} isOpen {...dialogProps}>
<VStack gap="1rem" alignItems="stretch">
<Text variant="sm" className={css({ color: 'greyscale.700' })}>
{t('description')}
</Text>
<button
className={css({
display: 'flex',
gap: '0.75rem',
padding: '1rem',
borderRadius: '0.5rem',
border: '1px solid',
borderColor: 'greyscale.200',
backgroundColor: 'white',
cursor: 'pointer',
textAlign: 'left',
transition: 'border-color 150ms ease, background-color 150ms ease',
_hover: {
borderColor: 'primary.500',
backgroundColor: 'primary.50',
},
})}
onClick={() => onSelect(ApiEncryptionMode.BASIC)}
>
<div className={css({ flexShrink: 0, paddingTop: '0.15rem' })}>
<RiLockFill size={20} color="#2563eb" />
</div>
<VStack gap="0.25rem" alignItems="flex-start">
<Text
variant="sm"
bold
className={css({ color: 'greyscale.900' })}
>
{t('basic.title')}
</Text>
<Text variant="sm" className={css({ color: 'greyscale.600' })}>
{t('basic.description')}
</Text>
</VStack>
</button>
<div style={{ position: 'relative' }}>
<button
className={css({
display: 'flex',
gap: '0.75rem',
padding: '1rem',
borderRadius: '0.5rem',
border: '1px solid',
borderColor: 'greyscale.200',
backgroundColor: 'white',
cursor: canUseAdvanced ? 'pointer' : 'not-allowed',
textAlign: 'left',
opacity: canUseAdvanced ? 1 : 0.5,
transition:
'border-color 150ms ease, background-color 150ms ease',
_hover: canUseAdvanced
? {
borderColor: 'green.500',
backgroundColor: 'green.50',
}
: {},
})}
onClick={() => canUseAdvanced && onSelect(ApiEncryptionMode.ADVANCED)}
disabled={!canUseAdvanced}
>
<div className={css({ flexShrink: 0, paddingTop: '0.15rem' })}>
<RiShieldCheckFill
size={20}
color={canUseAdvanced ? '#166534' : '#9ca3af'}
/>
</div>
<VStack gap="0.25rem" alignItems="flex-start">
<Text
variant="sm"
bold
className={css({
color: canUseAdvanced ? 'greyscale.900' : 'greyscale.400',
})}
>
{t('advanced.title')}
</Text>
<Text
variant="sm"
className={css({
color: canUseAdvanced ? 'greyscale.600' : 'greyscale.400',
})}
>
{t('advanced.description')}
</Text>
</VStack>
</button>
{!canUseAdvanced && (
<HStack
gap="0.4rem"
className={css({
marginTop: '0.5rem',
padding: '0.5rem 0.75rem',
backgroundColor: vaultUnavailable ? 'red.50' : 'orange.50',
borderRadius: '0.375rem',
})}
>
<RiAlertLine
size={14}
color={vaultUnavailable ? '#dc2626' : '#d97706'}
className={css({ flexShrink: 0 })}
/>
<Text variant="note" className={css({ color: vaultUnavailable ? 'red.800' : 'orange.800' })}>
{vaultUnavailable
? t('advanced.serviceUnavailable')
: t('advanced.onboardingRequired')}
</Text>
</HStack>
)}
</div>
</VStack>
</Dialog>
)
}
@@ -6,7 +6,6 @@ import { navigateTo } from '@/navigation/navigateTo'
import { isRoomValid } from '@/features/rooms'
import { normalizeRoomId } from '@/features/rooms/utils/isRoomValid'
import { fetchRoom } from '@/features/rooms/api/fetchRoom'
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
export const JoinMeetingDialog = () => {
const { t } = useTranslation('home')
@@ -31,18 +30,16 @@ export const JoinMeetingDialog = () => {
const input = data.roomId as string
const parsed = parseInput(input)
// If URL already has a hash, navigate directly with it
if (parsed.hash) {
navigateTo('room', parsed.roomId)
window.location.hash = parsed.hash
return
}
// Check if the room uses basic encryption (needs passphrase)
setIsLoading(true)
try {
const room = await fetchRoom({ roomId: parsed.roomId })
if (room.encryption_mode === ApiEncryptionMode.BASIC) {
if (room.is_encrypted) {
setRoomId(parsed.roomId)
setStep('passphrase')
return
@@ -122,7 +119,9 @@ export const JoinMeetingDialog = () => {
isRequired
name="passphrase"
label={t('joinPassphraseLabel')}
errorMessage={t('joinPassphraseError')}
validate={(value: string) =>
!value ? t('joinPassphraseError') : null
}
/>
<P
+28 -99
View File
@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'
import { DialogTrigger, MenuItem, Menu as RACMenu, Separator as RACSeparator } from 'react-aria-components'
import { DialogTrigger, MenuItem, Menu as RACMenu } from 'react-aria-components'
import { Button, Menu } from '@/primitives'
import { styled } from '@/styled-system/jsx'
import { navigateTo } from '@/navigation/navigateTo'
@@ -7,12 +7,9 @@ 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, RiLockLine, RiShieldKeyholeLine } from '@remixicon/react'
import { RiAddLine, RiLink } from '@remixicon/react'
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
import { EncryptionModeDialog } from '@/features/home/components/EncryptionModeDialog'
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
import { generatePassphrase } from '@/features/encryption/lobbyKeyExchange'
import { useVaultClient } from '@/features/encryption'
import { generatePassphrase } from '@/features/encryption'
import { IntroSlider } from '@/features/home/components/IntroSlider'
import { MoreLink } from '@/features/home/components/MoreLink'
import { ReactNode, useEffect, useState } from 'react'
@@ -152,19 +149,27 @@ const IntroText = styled('div', {
export const Home = () => {
const { t } = useTranslation('home')
const { isLoggedIn } = useUser()
const { isLoggedIn, user } = useUser()
const {
userChoices: { username },
} = usePersistentUserChoices()
const { mutateAsync: createRoom } = useCreateRoom()
const { client: vaultClient } = useVaultClient()
const [laterRoom, setLaterRoom] = useState<null | { room: ApiRoom; hash?: string }>(null)
const [encryptionDialogMode, setEncryptionDialogMode] = useState<null | 'instant' | 'later'>(null)
const [redirectFailed, setRedirectFailed] = useState(false)
const { data } = useConfig()
const encryptionAvailable = !!data?.encryption?.enabled
const defaultEncryption = encryptionAvailable && !!user?.default_encryption
const buildRoomBundle = async () => {
const slug = generateRoomId()
const isEncrypted = defaultEncryption
const hash = isEncrypted ? generatePassphrase() : undefined
const room = await createRoom({ slug, username, isEncrypted })
return { room, hash }
}
useEffect(() => {
const checkSiteAndRedirect = async () => {
@@ -216,12 +221,17 @@ export const Home = () => {
menuRecipe({ icon: true, variant: 'light' }).item
}
onAction={async () => {
const slug = generateRoomId()
createRoom({ slug, username }).then((data) =>
navigateTo('room', data.slug, {
state: { create: true, initialRoomData: data },
})
)
const { room, hash } = await buildRoomBundle()
navigateTo('room', room.slug, {
state: { create: true, initialRoomData: room },
})
if (hash) {
window.history.replaceState(
window.history.state,
'',
`${window.location.pathname}#${hash}`
)
}
}}
data-attr="create-option-instant"
>
@@ -232,48 +242,15 @@ export const Home = () => {
className={
menuRecipe({ icon: true, variant: 'light' }).item
}
onAction={() => {
const slug = generateRoomId()
createRoom({ slug, username }).then((data) =>
setLaterRoom({ room: data })
)
onAction={async () => {
const { room, hash } = await buildRoomBundle()
setLaterRoom({ room, hash })
}}
data-attr="create-option-later"
>
<RiLink size={18} />
{t('createMenu.laterOption')}
</MenuItem>
{data?.encryption?.enabled && (
<>
<RACSeparator
className={css({
borderTop: '1px solid',
borderColor: 'greyscale.200',
margin: '0.25rem 0',
})}
/>
<MenuItem
className={
menuRecipe({ icon: true, variant: 'light' }).item
}
onAction={() => setEncryptionDialogMode('instant')}
data-attr="create-option-encrypted-instant"
>
<RiLockLine size={18} />
{t('createMenu.encryptedInstantOption')}
</MenuItem>
<MenuItem
className={
menuRecipe({ icon: true, variant: 'light' }).item
}
onAction={() => setEncryptionDialogMode('later')}
data-attr="create-option-encrypted-later"
>
<RiShieldKeyholeLine size={18} />
{t('createMenu.encryptedLaterOption')}
</MenuItem>
</>
)}
</RACMenu>
</Menu>
) : (
@@ -306,54 +283,6 @@ export const Home = () => {
hash={laterRoom?.hash}
onOpenChange={() => setLaterRoom(null)}
/>
{encryptionDialogMode && (
<EncryptionModeDialog
onSelect={async (mode) => {
const dialogMode = encryptionDialogMode
setEncryptionDialogMode(null)
const slug = generateRoomId()
const hash = mode === ApiEncryptionMode.BASIC ? generatePassphrase() : undefined
let encryptedSymmetricKey = ''
if (mode === ApiEncryptionMode.ADVANCED && vaultClient) {
// encryptWithoutKey requires data to encrypt, but we only care about
// the generated symmetric key (encryptedKeys), not the encrypted content.
// The same symmetric key will be used for all streams (video/audio/chat).
const dummyData = new Uint8Array(32).buffer
const { publicKey } = await vaultClient.getPublicKey()
const { encryptedKeys } = await vaultClient.encryptWithoutKey(
dummyData,
{ self: publicKey }
)
const keyBytes = new Uint8Array(encryptedKeys['self'])
encryptedSymmetricKey = btoa(String.fromCharCode(...keyBytes))
}
createRoom({
slug,
username,
encryptionMode: mode,
encryptedSymmetricKey,
}).then((data) => {
if (dialogMode === 'instant') {
navigateTo('room', data.slug, {
state: { create: true, initialRoomData: data },
})
if (hash) {
window.history.replaceState(
window.history.state,
'',
`${window.location.pathname}#${hash}`
)
}
} else {
setLaterRoom({ room: data, hash })
}
})
}}
onOpenChange={() => setEncryptionDialogMode(null)}
/>
)}
</Screen>
</UserAware>
)
@@ -12,112 +12,10 @@ import { useWaitingParticipants } from '@/features/rooms/hooks/useWaitingPartici
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { useNotificationSound } from '../hooks/useSoundNotification'
import { NotificationType } from '@/features/notifications'
import { EncryptionBadge, EncryptionIdentityDialog } from '@/features/encryption'
import { useParticipantTrustLevel, formatFingerprint } from '@/features/encryption/useParticipantTrustLevel'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { isEncryptedRoom } from '@/features/rooms/api/ApiRoom'
const WaitingParticipantIdentity = ({ participant }: { participant: WaitingParticipant }) => {
const { t: tBadge } = useTranslation('rooms', { keyPrefix: 'encryption.badge' })
const roomData = useRoomData()
const [isIdentityOpen, setIsIdentityOpen] = useState(false)
const attrs = {
is_authenticated: participant.is_authenticated ? 'true' : 'false',
suite_user_id: participant.suite_user_id || '',
}
const { trustLevel, fingerprintStatus, fingerprint } = useParticipantTrustLevel(attrs, roomData?.encryption_mode)
const badgeTooltip = tBadge(trustLevel)
return (
<>
<VStack gap="0" alignItems="start">
<Button
variant="greyscale"
size="sm"
tooltip={badgeTooltip}
aria-label={badgeTooltip}
onPress={() => setIsIdentityOpen(true)}
className={css({
padding: '0.1rem 0.25rem !important',
minWidth: 'auto !important',
height: 'auto !important',
gap: '0.15rem !important',
borderRadius: '0.25rem !important',
backgroundColor: 'transparent !important',
color: 'white !important',
cursor: 'pointer',
'&[data-hovered]': {
backgroundColor: 'rgba(255, 255, 255, 0.15) !important',
},
})}
>
<EncryptionBadge isEncrypted={true} trustLevel={trustLevel} />
<Text
variant="sm"
margin={false}
className={css({
maxWidth: '8rem',
wordBreak: 'break-word',
overflowWrap: 'break-word',
whiteSpace: 'normal',
})}
>
{participant.username}
</Text>
</Button>
{fingerprint && (
<Text
variant="sm"
margin={false}
className={css({
fontFamily: 'monospace',
fontSize: '0.6rem',
color: 'greyscale.100',
letterSpacing: '0.03em',
paddingLeft: '0.25rem',
})}
>
{formatFingerprint(fingerprint)}
</Text>
)}
<Text
variant="sm"
margin={false}
className={css({
fontSize: '0.7rem',
color: 'greyscale.200',
paddingLeft: '0.25rem',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '10rem',
})}
>
{participant.is_authenticated && participant.email
? participant.email
: tBadge('anonymous')}
</Text>
</VStack>
<EncryptionIdentityDialog
isOpen={isIdentityOpen}
onOpenChange={setIsIdentityOpen}
participantName={participant.username}
participantEmail={participant.email}
suiteUserId={participant.suite_user_id}
isAuthenticated={participant.is_authenticated}
encryptionMode={roomData?.encryption_mode}
preloadedFingerprint={fingerprint}
preloadedFingerprintStatus={fingerprintStatus}
/>
</>
)
}
export const NOTIFICATION_DISPLAY_DURATION = 10000
export const WaitingParticipantNotification = () => {
const roomData = useRoomData()
const encrypted = isEncryptedRoom(roomData)
const { triggerNotificationSound } = useNotificationSound()
const { t } = useTranslation('notifications', {
@@ -136,7 +34,6 @@ export const WaitingParticipantNotification = () => {
const isParticipantListEmpty = (p?: WaitingParticipant[]) => p?.length == 0
useEffect(() => {
// Show notification when the first participant enters the waiting room
if (
!isParticipantListEmpty(waitingParticipants) &&
isParticipantListEmpty(prevWaitingParticipant) &&
@@ -151,10 +48,9 @@ export const WaitingParticipantNotification = () => {
}
timerRef.current = setTimeout(() => {
setShowQuickActionsMessage(false)
timerRef.current = null // Clear the ref when timeout completes
timerRef.current = null
}, NOTIFICATION_DISPLAY_DURATION)
} else if (waitingParticipants.length !== prevWaitingParticipant?.length) {
// Hide notification when the participant count changes
setShowQuickActionsMessage(false)
}
}, [
@@ -165,7 +61,6 @@ export const WaitingParticipantNotification = () => {
])
useEffect(() => {
// This cleanup function will only run when the component unmounts
return () => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current)
@@ -174,7 +69,6 @@ export const WaitingParticipantNotification = () => {
}, [])
useEffect(() => {
// Hide notification when participants panel is opened
if (isParticipantsOpen) {
setShowQuickActionsMessage(false)
}
@@ -209,22 +103,18 @@ export const WaitingParticipantNotification = () => {
context="list"
notification
/>
{encrypted ? (
<WaitingParticipantIdentity participant={waitingParticipants[0]} />
) : (
<Text
variant="sm"
margin={false}
className={css({
maxWidth: '10rem',
wordBreak: 'break-word',
overflowWrap: 'break-word',
whiteSpace: 'normal',
})}
>
{waitingParticipants[0].username}
</Text>
)}
<Text
variant="sm"
margin={false}
className={css({
maxWidth: '10rem',
wordBreak: 'break-word',
overflowWrap: 'break-word',
whiteSpace: 'normal',
})}
>
{waitingParticipants[0].username}
</Text>
</HStack>
<HStack gap="0.25rem" marginLeft="auto">
<Button
@@ -1,11 +1,13 @@
import { LimitReachedAlertDialog } from './LimitReachedAlertDialog'
import { RecordingStateToast } from './RecordingStateToast'
import { ErrorAlertDialog } from './ErrorAlertDialog'
// RecordingStateToast removed — the RoomStatusBanner (top-left pill row)
// now shows "Recording in progress" and "Transcription in progress" in the
// same place, so the standalone toast was rendering behind the new pills.
export const RecordingProvider = () => {
return (
<>
<RecordingStateToast />
<LimitReachedAlertDialog />
<ErrorAlertDialog />
</>
+2 -17
View File
@@ -10,21 +10,6 @@ export enum ApiAccessLevel {
RESTRICTED = 'restricted',
}
export enum ApiEncryptionMode {
NONE = 'none',
BASIC = 'basic',
ADVANCED = 'advanced',
}
export function isEncryptedRoom(room?: { encryption_mode?: ApiEncryptionMode; encryption_enabled?: boolean } | null): boolean {
if (!room) return false
// Support both new encryption_mode and legacy encryption_enabled
if (room.encryption_mode !== undefined) {
return room.encryption_mode !== ApiEncryptionMode.NONE
}
return !!room.encryption_enabled
}
export type ApiRoom = {
id: string
name: string
@@ -32,8 +17,8 @@ export type ApiRoom = {
pin_code: string
is_administrable: boolean
access_level: ApiAccessLevel
encryption_mode: ApiEncryptionMode
encrypted_symmetric_key?: string
is_encrypted: boolean
encryption_paused: boolean
livekit?: ApiLiveKit
configuration?: {
[key: string]: string | number | boolean | string[]
@@ -1,22 +1,20 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query'
import { fetchApi } from '@/api/fetchApi'
import { ApiError } from '@/api/ApiError'
import { ApiRoom, ApiEncryptionMode } from './ApiRoom'
import { ApiRoom } from './ApiRoom'
export interface CreateRoomParams {
slug: string
callbackId?: string
username?: string
encryptionMode?: ApiEncryptionMode
encryptedSymmetricKey?: string
isEncrypted?: boolean
}
const createRoom = ({
slug,
callbackId,
username = '',
encryptionMode = ApiEncryptionMode.NONE,
encryptedSymmetricKey = '',
isEncrypted = false,
}: CreateRoomParams): Promise<ApiRoom> => {
const queryParams = username ? `?username=${encodeURIComponent(username)}` : ''
return fetchApi(`rooms/${queryParams}`, {
@@ -24,8 +22,7 @@ const createRoom = ({
body: JSON.stringify({
name: slug,
callback_id: callbackId,
encryption_mode: encryptionMode,
encrypted_symmetric_key: encryptedSymmetricKey,
is_encrypted: isEncrypted,
}),
})
}
@@ -6,9 +6,6 @@ export interface EnterRoomParams {
roomId: string
allowEntry: boolean
participantId: string
encryptedKey?: string
adminEphemeralPublicKey?: string
encryptedVaultKey?: string
}
export interface EnterRoomResponse {
@@ -19,18 +16,12 @@ export const enterRoom = async ({
roomId,
allowEntry,
participantId,
encryptedKey = '',
adminEphemeralPublicKey = '',
encryptedVaultKey = '',
}: EnterRoomParams): Promise<EnterRoomResponse> => {
return await fetchApi<EnterRoomResponse>(`/rooms/${roomId}/enter/`, {
method: 'POST',
body: JSON.stringify({
participant_id: participantId,
allow_entry: allowEntry,
encrypted_key: encryptedKey,
admin_ephemeral_public_key: adminEphemeralPublicKey,
encrypted_vault_key: encryptedVaultKey,
}),
})
}
@@ -9,9 +9,6 @@ export type WaitingParticipant = {
username: string
color: string
is_authenticated: boolean
email?: string
suite_user_id?: string
ephemeral_public_key?: string
}
export type WaitingParticipantsResponse = {
@@ -5,7 +5,9 @@ import { ApiError } from '@/api/ApiError'
export type PatchRoomParams = {
roomId: string
room: Partial<Pick<ApiRoom, 'configuration' | 'access_level'>>
room: Partial<
Pick<ApiRoom, 'configuration' | 'access_level' | 'encryption_paused'>
>
}
export const patchRoom = ({ roomId, room }: PatchRoomParams) => {
@@ -4,7 +4,6 @@ import { ApiLiveKit } from '@/features/rooms/api/ApiRoom'
export interface RequestEntryParams {
roomId: string
username?: string
ephemeralPublicKey?: string
}
export enum ApiLobbyStatus {
@@ -18,21 +17,16 @@ export enum ApiLobbyStatus {
export interface ApiRequestEntry {
status: ApiLobbyStatus
livekit?: ApiLiveKit
encrypted_key?: string
admin_ephemeral_public_key?: string
encrypted_vault_key?: string
}
export const requestEntry = async ({
roomId,
username = '',
ephemeralPublicKey = '',
}: RequestEntryParams) => {
return fetchApi<ApiRequestEntry>(`/rooms/${roomId}/request-entry/`, {
method: 'POST',
body: JSON.stringify({
username,
ephemeral_public_key: ephemeralPublicKey,
}),
})
}
@@ -10,25 +10,27 @@ import {
ExternalE2EEKeyProvider,
MediaDeviceFailure,
Room,
RoomEvent,
RoomOptions,
VideoPresets,
} from 'livekit-client'
import { setSymmetricKey, getSymmetricKey, getEncryptedVaultKey, generatePassphrase } from '@/features/encryption/lobbyKeyExchange'
import { isEncryptedRoom, ApiEncryptionMode } from '../api/ApiRoom'
import { VaultE2EEManager } from '@/features/encryption/VaultE2EEManager'
import { useVaultClient } from '@/features/encryption'
import {
generatePassphrase,
getPassphraseFromHash,
isValidPassphrase,
EncryptionStatusProvider,
EncryptionMismatchScreen,
EncryptionPhase,
} from '@/features/encryption'
import { keys } from '@/api/queryKeys'
import { queryClient } from '@/api/queryClient'
import { Screen } from '@/layout/Screen'
import { CenteredContent } from '@/layout/CenteredContent'
import { RiLockLine } from '@remixicon/react'
import { Center } from '@/styled-system/jsx'
import { Text } from '@/primitives'
import { QueryAware } from '@/components/QueryAware'
import { ErrorScreen } from '@/components/ErrorScreen'
import { fetchRoom } from '../api/fetchRoom'
import { ApiRoom } from '../api/ApiRoom'
import { useCreateRoom } from '../api/createRoom'
import { usePatchRoom } from '../api/patchRoom'
import { InviteDialog } from './InviteDialog'
import { VideoConference } from '../livekit/prefabs/VideoConference'
import { css } from '@/styled-system/css'
@@ -95,28 +97,66 @@ export const Conference = ({
retry: false,
})
const encryptionEnabled = isEncryptedRoom(data)
const { client: vaultClient, hasKeys: vaultHasKeys, error: vaultError, isLoading: vaultLoading } = useVaultClient()
// Trust the URL hash for the runtime "is encrypted" decision: it's the
// only signal a hacked server can't fabricate. The DB flag tells us
// whether the room creator *meant* this room to be encrypted — it's used
// to detect mismatches (see below) but never to enable encryption alone.
// encryption_paused is an admin override on an encrypted room: when set,
// E2EE is suspended for this call so external devices can join, but the
// link still carries the hash and the room can be resumed at any time.
//
// Two derived flags:
// encryptionCapable — this room has an encryption key; the Room object
// is constructed with the e2ee worker + key provider regardless of
// whether E2EE is currently active. Stable across mid-call pauses, so
// the Room instance never gets recreated mid-call.
// liveEncryption — encryption is currently active (capable AND not
// paused). Drives room.setE2EEEnabled() and the encryption phase UI.
const hashPassphrase = getPassphraseFromHash()
const dbSaysEncrypted = !!data?.is_encrypted
const isPaused = !!data?.encryption_paused
const hasValidHash = isValidPassphrase(hashPassphrase)
// Determine which E2EE backend to use based solely on the room's encryption_mode.
// Advanced mode always uses VaultClient, basic mode always uses LiveKit Worker+KeyProvider.
const useVaultE2EE = data?.encryption_mode === ApiEncryptionMode.ADVANCED
const encryptionMismatch:
| 'missingPassphrase'
| 'unexpectedPassphrase'
| null =
data === undefined
? null
: dbSaysEncrypted && !isPaused && !hasValidHash
? 'missingPassphrase'
: !dbSaysEncrypted && hashPassphrase.length > 0
? 'unexpectedPassphrase'
: null
const encryptionCapable = dbSaysEncrypted && hasValidHash
const liveEncryption = encryptionCapable && !isPaused
// Kept as `isEncrypted` so legacy reads below stay readable. Refers to
// the live state — flip-on-pause/flip-on-resume is wired below.
const isEncrypted = liveEncryption
// Refs for both approaches (only one is used per session)
const keyProviderRef = useRef<ExternalE2EEKeyProvider | null>(null)
const workerRef = useRef<Worker | null>(null)
const vaultManagerRef = useRef<VaultE2EEManager | null>(null)
const [encryptionSetupComplete, setEncryptionSetupComplete] = useState(!encryptionEnabled)
// Setup is complete once the key provider is wired up; it does NOT need
// to re-run when encryption_paused flips. We toggle the active state of
// E2EE separately via room.setE2EEEnabled.
const [encryptionSetupComplete, setEncryptionSetupComplete] = useState(
!encryptionCapable
)
const getKeyProvider = () => {
if (!keyProviderRef.current && encryptionEnabled && !useVaultE2EE) {
if (!keyProviderRef.current && encryptionCapable) {
keyProviderRef.current = new ExternalE2EEKeyProvider()
}
return keyProviderRef.current
}
const getWorker = () => {
if (!workerRef.current && encryptionEnabled && !useVaultE2EE && typeof window !== 'undefined') {
if (
!workerRef.current &&
encryptionCapable &&
typeof window !== 'undefined'
) {
workerRef.current = new Worker(
new URL('livekit-client/e2ee-worker', import.meta.url)
)
@@ -124,20 +164,18 @@ export const Conference = ({
return workerRef.current
}
const getVaultManager = () => {
if (!vaultManagerRef.current && useVaultE2EE && vaultClient) {
vaultManagerRef.current = new VaultE2EEManager(vaultClient)
}
return vaultManagerRef.current
}
const roomOptions = useMemo((): RoomOptions => {
const baseOptions: RoomOptions = {
adaptiveStream: true,
dynacast: true,
publishDefaults: {
videoCodec: encryptionEnabled ? undefined : 'vp9',
red: !encryptionEnabled,
// VP8 whenever the room is encryption-capable: encryption_paused
// can flip mid-call to let a SIP/phone caller bridge, and the
// gateway's room→SIP GStreamer path only handles VP8 today. Using
// VP8 unconditionally for encryption-capable rooms keeps the Room
// instance stable across pause/resume.
videoCodec: encryptionCapable ? 'vp8' : 'vp8',
red: !encryptionCapable,
},
videoCaptureDefaults: {
deviceId: userConfig.videoDeviceId ?? undefined,
@@ -153,12 +191,11 @@ export const Conference = ({
},
}
if (useVaultE2EE) {
const vaultManager = getVaultManager()
if (vaultManager) {
baseOptions.encryption = { e2eeManager: vaultManager }
}
} else if (encryptionEnabled) {
// Always wire up the E2EE worker + key provider for an encryption-
// capable room. We toggle whether encryption is *active* with
// room.setE2EEEnabled below; the worker stays around so resume is a
// single API call rather than a Room reconstruction.
if (encryptionCapable) {
const worker = getWorker()
const keyProvider = getKeyProvider()
if (keyProvider && worker) {
@@ -168,9 +205,10 @@ export const Conference = ({
return baseOptions
// do not rely on the userConfig object directly as its reference may change on every render
// getKeyProvider/getWorker are stable refs, intentionally not in deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
encryptionEnabled,
useVaultE2EE,
encryptionCapable,
userConfig.videoDeviceId,
userConfig.videoPublishResolution,
userConfig.audioDeviceId,
@@ -193,46 +231,12 @@ export const Conference = ({
return livekit_url
}, [apiConfig?.livekit])
// Encryption key setup:
// VaultE2EE: admin generates key via vaultClient.encryptWithoutKey(), joiner receives wrapped key
// Fallback: admin generates passphrase, joiner receives via lobby DH exchange
const isAdmin = mode === 'create' || data?.is_administrable === true
const adminPassphraseRef = useRef<string | null>(null)
useEffect(() => {
if (!encryptionEnabled || encryptionSetupComplete) return
if (!encryptionCapable || encryptionSetupComplete) return
if (useVaultE2EE) {
// Advanced mode: VaultE2EEManager delegates crypto to VaultClient iframe
const vaultManager = getVaultManager()
if (!vaultManager || !vaultClient) return
if (isAdmin) {
const existingKey = data?.encrypted_symmetric_key
if (existingKey) {
const binaryStr = atob(existingKey)
const bytes = new Uint8Array(binaryStr.length)
for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i)
vaultManager.setEncryptedSymmetricKey(bytes.buffer)
}
} else {
const vaultKey = getEncryptedVaultKey()
if (vaultKey) {
vaultManager.setEncryptedSymmetricKey(vaultKey)
}
}
// Enable E2EE BEFORE connecting — no tracks exist yet so
// republishAllTracks() is a no-op. Calling after connection
// triggers republish which times out.
room.setE2EEEnabled(true).catch((err) => {
console.error('[VaultE2EE] E2EE enable failed:', err)
})
setEncryptionSetupComplete(true)
return
}
// Basic mode: LiveKit Worker+KeyProvider with passphrase
const keyProvider = getKeyProvider()
if (!keyProvider) return
@@ -240,7 +244,7 @@ export const Conference = ({
if (isAdmin) {
if (!adminPassphraseRef.current) {
const existingHash = window.location.hash.slice(1)
const existingHash = getPassphraseFromHash()
if (existingHash) {
adminPassphraseRef.current = existingHash
} else {
@@ -253,18 +257,8 @@ export const Conference = ({
}
}
passphrase = adminPassphraseRef.current
setSymmetricKey(new TextEncoder().encode(passphrase))
} else {
const hashKey = window.location.hash.slice(1)
if (hashKey) {
passphrase = hashKey
setSymmetricKey(new TextEncoder().encode(passphrase))
} else {
const preExchangedKey = getSymmetricKey()
if (preExchangedKey) {
passphrase = new TextDecoder().decode(preExchangedKey)
}
}
passphrase = getPassphraseFromHash() || null
}
if (!passphrase) {
@@ -276,11 +270,11 @@ export const Conference = ({
.setKey(passphrase)
.then(async () => {
// Enable E2EE BEFORE connecting — sets encryptionType=GCM so tracks
// are published with encryption metadata from the start.
// Also sends 'enable' to the Worker before any frames flow,
// eliminating the unencrypted frame window.
// are published with encryption metadata from the start. If the
// room is currently paused, we set up the worker but leave E2EE
// disabled (resume flips it on without rebuilding the Room).
try {
await room.setE2EEEnabled(true)
await room.setE2EEEnabled(liveEncryption)
} catch (err) {
console.error('[Encryption] E2EE enable failed:', err)
}
@@ -290,28 +284,85 @@ export const Conference = ({
.catch((err) => {
console.error('[Encryption] Key setup failed:', err)
})
// getKeyProvider is a stable ref; not part of deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [room, encryptionCapable, encryptionSetupComplete, isAdmin])
}, [room, encryptionEnabled, encryptionSetupComplete, isAdmin, useVaultE2EE])
// Mid-call admin toggle: when the server flips encryption_paused, every
// browser client sees the change as `liveEncryption` flipping. A single
// setE2EEEnabled call is enough — livekit-client internally calls
// republishAllTracks so the SFU forwards tracks in the new mode (plain
// during pause so SIP can bridge; E2EE on resume).
//
// republishAllTracks occasionally drops a track silently when its
// renegotiation hits a transport-state race. We snapshot the local
// mic/camera enabled state before the toggle and re-assert them
// afterwards so the user doesn't lose their camera or microphone over a
// pause/resume cycle.
const prevLiveRef = useRef<boolean | null>(null)
useEffect(() => {
if (!encryptionCapable || !encryptionSetupComplete) return
if (prevLiveRef.current === null) {
prevLiveRef.current = liveEncryption
return // initial setup already covered this value
}
if (prevLiveRef.current === liveEncryption) return
prevLiveRef.current = liveEncryption
void (async () => {
const lp = room.localParticipant
const camWasOn = lp.isCameraEnabled
const micWasOn = lp.isMicrophoneEnabled
try {
await room.setE2EEEnabled(liveEncryption)
} catch (err) {
console.error('[Encryption] mid-call E2EE toggle failed', err)
return
}
// Re-assert track state — republishAllTracks may have silently
// dropped one of them mid-renegotiation.
try {
if (camWasOn && !lp.isCameraEnabled) {
await lp.setCameraEnabled(true)
}
if (micWasOn && !lp.isMicrophoneEnabled) {
await lp.setMicrophoneEnabled(true)
}
} catch (err) {
console.error('[Encryption] track re-assert failed', err)
}
})()
}, [room, encryptionCapable, encryptionSetupComplete, liveEncryption])
// In basic encrypted rooms, the passphrase is in the URL hash.
// If the user changes the hash (e.g. corrects a typo), reload the page
// Listen for server-driven metadata updates (admin pausing/resuming from
// a different client) and refresh the room query so liveEncryption above
// reflects the new state.
useEffect(() => {
if (!room) return
const onMetadataChanged = () => {
queryClient.invalidateQueries({ queryKey: fetchKey })
}
room.on(RoomEvent.RoomMetadataChanged, onMetadataChanged)
return () => {
room.off(RoomEvent.RoomMetadataChanged, onMetadataChanged)
}
// fetchKey is derived from roomId; both stable
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [room])
// If the user changes the hash mid-session (e.g. corrects a typo), reload
// so the new passphrase is picked up by the encryption setup.
useEffect(() => {
if (!encryptionEnabled || useVaultE2EE) return
if (!encryptionCapable) return
const handleHashChange = () => {
window.location.reload()
}
window.addEventListener('hashchange', handleHashChange)
return () => window.removeEventListener('hashchange', handleHashChange)
}, [encryptionEnabled, useVaultE2EE])
}, [encryptionCapable])
useEffect(() => {
/**
* Warm up connection to LiveKit server before joining room
* This prefetch helps reduce initial connection latency by establishing
* an early HTTP connection to the WebRTC signaling server
*
* It should cache DNS and TLS keys.
*/
const prepareConnection = async () => {
if (!apiConfig || isConnectionWarmedUp) return
@@ -325,20 +376,9 @@ export const Conference = ({
.replace(/\/$/, '') + '/rtc'
/**
* FIREFOX + PROXY WORKAROUND:
*
* Issue: On Firefox behind proxy configurations, WebSocket signaling fails to establish.
* Symptom: Client receives HTTP 200 instead of expected 101 (Switching Protocols).
* Root Cause: Certificate/security issue where the initial request is considered unsecure.
*
* Solution: Pre-establish a WebSocket connection to the signaling server, which fails.
* This "primes" the connection, allowing subsequent WebSocket establishments to work correctly.
*
* Note: This issue is reproducible on LiveKit's demo app.
* Reference: livekit-examples/meet/issues/466
* FIREFOX + PROXY WORKAROUND see livekit-examples/meet/issues/466
*/
const ws = new WebSocket(wssUrl)
// 401 unauthorized response is expected
ws.onerror = () => ws.readyState <= 1 && ws.close()
} catch (e) {
console.debug('Firefox WebSocket workaround failed.', e)
@@ -350,6 +390,26 @@ export const Conference = ({
prepareConnection()
}, [room, apiConfig, isConnectionWarmedUp])
const { mutateAsync: patchRoom } = usePatchRoom()
const setServerEncryptionPaused = async (paused: boolean) => {
await patchRoom({ roomId, room: { encryption_paused: paused } })
}
const handlePhaseChange = (phase: EncryptionPhase) => {
if (!encryptionCapable) return
if (phase === EncryptionPhase.PAUSED) {
void room.setE2EEEnabled(false).catch((err) => {
console.error('[Encryption] E2EE pause failed', err)
})
} else if (phase === EncryptionPhase.ENCRYPTED) {
// Resume path: re-enable E2EE with the same URL passphrase that's
// already loaded into the keyProvider.
void room.setE2EEEnabled(true).catch((err) => {
console.error('[Encryption] E2EE resume failed', err)
})
}
}
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
const [mediaDeviceError, setMediaDeviceError] = useState<{
error: MediaDeviceFailure | null
@@ -363,7 +423,6 @@ export const Conference = ({
const { t } = useTranslation('rooms')
if (isCreateError) {
// this error screen should be replaced by a proper waiting room for anonymous user.
return (
<ErrorScreen
title={t('error.createRoom.heading')}
@@ -372,72 +431,14 @@ export const Conference = ({
)
}
// Block entry to advanced encrypted rooms when vault service is unavailable
if (useVaultE2EE && !vaultLoading && !vaultClient) {
return (
<Screen layout="centered">
<CenteredContent withBackButton>
<Center>
<div
className={css({
maxWidth: '400px',
backgroundColor: 'white',
borderRadius: '1rem',
padding: '2rem',
boxShadow: '0 2px 12px rgba(0, 0, 0, 0.08)',
border: '1px solid',
borderColor: 'greyscale.200',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '1rem',
textAlign: 'center',
})}
>
<div
className={css({
width: '3.5rem',
height: '3.5rem',
borderRadius: '50%',
backgroundColor: '#fef2f2',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
})}
>
<RiLockLine size={28} color="#dc2626" />
</div>
<Text as="h2" className={css({ fontWeight: 700, fontSize: '1.15rem' })}>
{t('encryption.error.title')}
</Text>
<Text as="p" className={css({ fontSize: '0.9rem', color: 'greyscale.700' })}>
{t('encryption.error.vaultUnavailable')}
</Text>
<div
className={css({
backgroundColor: '#fffbeb',
border: '1px solid #fde68a',
borderRadius: '0.5rem',
padding: '0.75rem 1rem',
width: '100%',
})}
>
<Text as="p" className={css({ fontSize: '0.8rem', color: '#92400e' })}>
{t('encryption.error.vaultUnavailableHint')}
</Text>
</div>
</div>
</Center>
</CenteredContent>
</Screen>
)
if (encryptionMismatch) {
return <EncryptionMismatchScreen reason={encryptionMismatch} />
}
// Some clients (like DINUM) operate in bandwidth-constrained environments
// These settings help ensure successful connections in poor network conditions
const connectOptions = {
maxRetries: 5, // Default: 1. Only for unreachable server scenarios
peerConnectionTimeout: 60000, // Default: 15s. Extended for slow TURN/TLS negotiation
maxRetries: 5,
peerConnectionTimeout: 60000,
}
return (
@@ -486,7 +487,13 @@ export const Conference = ({
}
}}
>
<VideoConference />
<EncryptionStatusProvider
isEncrypted={isEncrypted}
onPhaseChange={handlePhaseChange}
setServerEncryptionPaused={setServerEncryptionPaused}
>
<VideoConference />
</EncryptionStatusProvider>
{showInviteDialog && !isMobile && (
<InviteDialog
isOpen={showInviteDialog}
@@ -32,122 +32,14 @@ import { useQuery } from '@tanstack/react-query'
import { queryClient } from '@/api/queryClient'
import { ApiLobbyStatus, ApiRequestEntry } from '../api/requestEntry'
import { Spinner } from '@/primitives/Spinner'
import { ApiAccessLevel, ApiEncryptionMode, isEncryptedRoom as checkEncryptedRoom } from '../api/ApiRoom'
import { useVaultClient } from '@/features/encryption'
import { LoginButton } from '@/components/LoginButton'
const AdvancedOnboardingScreen = ({
modalOpen,
onModalOpenChange,
}: {
modalOpen: boolean
onModalOpenChange: (open: boolean) => void
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const { client: vaultClient } = useVaultClient()
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!modalOpen || !vaultClient) return
const el = containerRef.current
if (!el) return
el.innerHTML = ''
vaultClient.openOnboarding(el)
const handleClosed = () => {
onModalOpenChange(false)
vaultClient.off('interface:closed', handleClosed)
}
vaultClient.on('interface:closed', handleClosed)
return () => {
vaultClient.off('interface:closed', handleClosed)
}
}, [modalOpen, vaultClient, onModalOpenChange])
return (
<>
<VStack alignItems="center" textAlign="center" gap="0.75rem">
<RiLockLine size={32} color="#d97706" />
<H lvl={1} margin={false} centered>
{t('advancedOnboarding.title')}
</H>
<Text as="p" variant="note">
{t('advancedOnboarding.body')}
</Text>
<Button
variant="primary"
onPress={() => onModalOpenChange(true)}
>
{t('advancedOnboarding.button')}
</Button>
</VStack>
{modalOpen && (
<div
className={css({
position: 'fixed',
inset: 0,
zIndex: 9999,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
})}
onClick={(e) => {
if (e.target === e.currentTarget) {
onModalOpenChange(false)
vaultClient?.closeInterface()
}
}}
>
<div
className={css({
backgroundColor: 'white',
borderRadius: '0.75rem',
width: '90%',
maxWidth: '550px',
maxHeight: '85vh',
overflow: 'auto',
position: 'relative',
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)',
})}
>
<button
onClick={() => {
onModalOpenChange(false)
vaultClient?.closeInterface()
}}
className={css({
position: 'absolute',
top: '0.75rem',
right: '0.75rem',
zIndex: 1,
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: '1.25rem',
color: 'greyscale.500',
_hover: { color: 'greyscale.900' },
})}
aria-label="Close"
>
</button>
<div
ref={containerRef}
className={css({ minHeight: '300px' })}
/>
</div>
</div>
)}
</>
)
}
import { ApiAccessLevel } from '../api/ApiRoom'
import {
isValidPassphrase,
getPassphraseFromHash,
EncryptionMismatchScreen,
} from '@/features/encryption'
import { useLoginHint } from '@/hooks/useLoginHint'
import { useUser } from '@/features/auth'
import { RiInformationLine, RiLockLine } from '@remixicon/react'
import { RiInformationLine } from '@remixicon/react'
import { openPermissionsDialog } from '@/stores/permissions'
import { useResolveInitiallyDefaultDeviceId } from '../livekit/hooks/useResolveInitiallyDefaultDeviceId'
import { isSafari } from '@/utils/livekit'
@@ -216,31 +108,29 @@ 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)
// Early fetch to inspect the room (encrypted? requires passphrase?)
const { data: roomInfo } = useQuery({
queryKey: [keys.room, roomId, 'info'],
queryFn: () => fetchRoom({ roomId }),
staleTime: 6 * 60 * 60 * 1000,
retry: false,
})
const isEncryptedRoom = checkEncryptedRoom(roomInfo)
const isBasicEncrypted = roomInfo?.encryption_mode === ApiEncryptionMode.BASIC
const isAdvancedEncrypted = roomInfo?.encryption_mode === ApiEncryptionMode.ADVANCED
// Basic mode: validate the passphrase in the URL hash
const hashKey = window.location.hash.slice(1)
const hasValidBasicKey = isBasicEncrypted ? (hashKey.length === 48 && /^[a-z0-9]+$/.test(hashKey)) : true
// Advanced mode: require auth + vault onboarding
const { hasKeys: vaultHasKeys, isReady: vaultReady } = useVaultClient()
const advancedRequiresLogin = isAdvancedEncrypted && !isLoggedIn
const advancedRequiresOnboarding = isAdvancedEncrypted && isLoggedIn && vaultReady && !vaultHasKeys
// In encrypted rooms, authenticated users must use their OIDC name
const isNameLocked = isEncryptedRoom && !!isLoggedIn
const lockedName = user?.full_name || user?.email || ''
const isEncryptedRoom = !!roomInfo?.is_encrypted
const isPaused = !!roomInfo?.encryption_paused
// Live encryption is what gates the "needs a passphrase" UX. A paused
// encrypted room accepts hashless joiners (they join in plaintext) and
// re-enforces the passphrase requirement once the admin resumes.
const liveEncryption = isEncryptedRoom && !isPaused
const passphrase = getPassphraseFromHash()
const hasValidPassphrase = liveEncryption
? isValidPassphrase(passphrase)
: true
// If the URL has a passphrase but the room itself is not encrypted, the
// link looks tampered with — refuse to join and offer a fresh room.
const unexpectedPassphrase =
!!roomInfo && !roomInfo.is_encrypted && passphrase.length > 0
const {
userChoices: {
@@ -312,11 +202,6 @@ export const Join = ({
[tracks]
)
/*
* Dynamic track creation strategy: Only create a dynamic track if the user initially disabled audio/video
* but now wants to enable it. This is a "just-in-time" acquisition pattern where we create the track
* on-demand. We avoid creating tracks when the user explicitly requested them to be disabled.
*/
useEffect(() => {
const createVideoTrack = async () => {
try {
@@ -384,8 +269,6 @@ export const Join = ({
const videoTrack = dynamicVideoTrack || previewVideoTrack
const audioTrack = dynamicAudioTrack || previewAudioTrack
// LiveKit by default populates device choices with "default" value.
// Instead, use the current device id used by the preview track as a default
useResolveInitiallyDefaultDeviceId(
audioDeviceId,
audioTrack,
@@ -425,12 +308,6 @@ export const Join = ({
}
}, [videoTrack, videoEnabled])
// Room data strategy:
// 1. Initial fetch is performed to check access and get LiveKit configuration
// 2. Data remains valid for 6 hours to avoid unnecessary refetches
// 3. State is manually updated via queryClient when a waiting participant is accepted
// 4. No automatic refetching or revalidation occurs during this period
// todo - refactor in a hook
const {
data: roomData,
error,
@@ -464,17 +341,14 @@ export const Join = ({
roomId,
username,
onAccepted: handleAccepted,
encryptionEnabled: isEncryptedRoom,
})
const [advancedOnboardingOpen, setAdvancedOnboardingOpen] = useState(false)
const { openLoginHint } = useLoginHint()
const handleSubmit = async () => {
const { data } = await refetchRoom()
if (!data?.livekit) {
// Display a message to inform the user that by logging in, they won't have to wait for room entry approval.
if (data?.access_level == ApiAccessLevel.TRUSTED) {
openLoginHint()
}
@@ -567,40 +441,11 @@ export const Join = ({
)
default:
if (advancedRequiresLogin) {
return (
<VStack alignItems="center" textAlign="center" gap="0.75rem">
<RiLockLine size={32} color="#2563eb" />
<H lvl={1} margin={false} centered>
{t('advancedAuth.title')}
</H>
<Text as="p" variant="note">
{t('advancedAuth.body')}
</Text>
<LoginButton proConnectHint={false} />
</VStack>
)
if (unexpectedPassphrase) {
return <EncryptionMismatchScreen reason="unexpectedPassphrase" />
}
if (advancedRequiresOnboarding || advancedOnboardingOpen) {
return (
<AdvancedOnboardingScreen
modalOpen={advancedOnboardingOpen}
onModalOpenChange={setAdvancedOnboardingOpen}
/>
)
}
if (isBasicEncrypted && !hasValidBasicKey) {
return (
<VStack alignItems="center" textAlign="center" gap="0.75rem">
<RiLockLine size={32} color="#dc2626" />
<H lvl={1} margin={false} centered>
{t('invalidKey.title')}
</H>
<Text as="p" variant="note">
{t('invalidKey.body')}
</Text>
</VStack>
)
if (liveEncryption && !hasValidPassphrase) {
return <EncryptionMismatchScreen reason="missingPassphrase" />
}
return (
<Form
@@ -614,80 +459,34 @@ export const Join = ({
<H lvl={1} margin="sm" centered>
{t('heading')}
</H>
{isNameLocked ? (
<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}
/>
{liveEncryption && (
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.25rem',
width: '100%',
alignItems: 'center',
gap: '0.4rem',
paddingTop: '0.5rem',
})}
>
<Text
variant="sm"
className={css({
color: 'greyscale.500',
fontSize: '0.8rem',
})}
>
{t('usernameLabel')}
<RiInformationLine size={14} color="#1e3a5f" />
<Text variant="note" className={css({ fontSize: '0.75rem' })}>
{t('encryptedHint')}
</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>
@@ -6,10 +6,6 @@ import {
ApiLobbyStatus,
ApiRequestEntry,
} from '../api/requestEntry'
import {
setSymmetricKey,
setEncryptedVaultKey,
} from '@/features/encryption/lobbyKeyExchange'
export const WAIT_TIMEOUT_MS = 600000 // 10 minutes
export const POLL_INTERVAL_MS = 1000
@@ -18,12 +14,10 @@ export const useLobby = ({
roomId,
username,
onAccepted,
encryptionEnabled = false,
}: {
roomId: string
username: string
onAccepted: (e: ApiRequestEntry) => void
encryptionEnabled?: boolean
}) => {
const [status, setStatus] = useState(ApiLobbyStatus.IDLE)
const waitingTimeoutRef = useRef<NodeJS.Timeout | null>(null)
@@ -45,27 +39,10 @@ export const useLobby = ({
/* eslint-disable @tanstack/query/exhaustive-deps */
queryKey: [keys.requestEntry, roomId],
queryFn: async () => {
const response = await requestEntry({
roomId,
username,
})
const response = await requestEntry({ roomId, username })
if (response.status === ApiLobbyStatus.ACCEPTED) {
clearWaitingTimeout()
setStatus(ApiLobbyStatus.ACCEPTED)
// Advanced mode: vault-wrapped key
if (encryptionEnabled && response.encrypted_vault_key) {
console.info('[VaultE2EE] Joiner: received encrypted_vault_key from lobby, length:', response.encrypted_vault_key.length)
const binaryStr = atob(response.encrypted_vault_key)
const bytes = new Uint8Array(binaryStr.length)
for (let i = 0; i < binaryStr.length; i++) {
bytes[i] = binaryStr.charCodeAt(i)
}
setEncryptedVaultKey(bytes.buffer)
} else if (encryptionEnabled) {
console.warn('[VaultE2EE] Joiner: ACCEPTED but no encrypted_vault_key in response', response)
}
onAccepted(response)
} else if (response.status === ApiLobbyStatus.DENIED) {
clearWaitingTimeout()
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { useRoomContext } from '@livekit/components-react'
import { RoomEvent } from 'livekit-client'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { isEncryptedRoom as checkEncryptedRoom, ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { useEnterRoom } from '../api/enterRoom'
import {
@@ -11,8 +10,6 @@ import {
} from '../api/listWaitingParticipants'
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
import { NotificationType } from '@/features/notifications/NotificationType'
import { useVaultClient } from '@/features/encryption'
import { toastQueue } from '@/features/notifications/components/ToastProvider'
export const POLL_INTERVAL_MS = 1000
@@ -21,12 +18,9 @@ export const useWaitingParticipants = () => {
const roomData = useRoomData()
const roomId = roomData?.id || '' // FIXME - bad practice
const encrypted = checkEncryptedRoom(roomData)
const isAdvancedMode = roomData?.encryption_mode === ApiEncryptionMode.ADVANCED
const room = useRoomContext()
const isAdminOrOwner = useIsAdminOrOwner()
const { client: vaultClient } = useVaultClient()
const handleDataReceived = useCallback((payload: Uint8Array) => {
const notification = decodeNotificationDataReceived(payload)
@@ -63,91 +57,14 @@ export const useWaitingParticipants = () => {
const { mutateAsync: enterRoom } = useEnterRoom()
const encryptKeyForAccept = async (participant: WaitingParticipant) => {
let encryptedKey = ''
let adminEphemeralPublicKey = ''
let encryptedVaultKey = ''
if (isAdvancedMode) {
// Advanced mode: re-wrap the existing symmetric key for the joiner.
// All steps are mandatory — if any fails, the participant must NOT be accepted
// (they would join without a key and see nothing).
if (!vaultClient) {
throw new Error('Encryption service is not available')
}
if (!participant.suite_user_id) {
throw new Error('Participant has no vault identity — they may not be authenticated')
}
const adminKeyBase64 = roomData?.encrypted_symmetric_key
if (!adminKeyBase64) {
throw new Error('Admin has no encrypted symmetric key for this room')
}
console.info('[VaultE2EE] Admin: wrapping key for joiner', participant.suite_user_id)
const adminKeyBinary = atob(adminKeyBase64)
const adminKeyBytes = new Uint8Array(adminKeyBinary.length)
for (let i = 0; i < adminKeyBinary.length; i++) adminKeyBytes[i] = adminKeyBinary.charCodeAt(i)
// Fetch joiner's vault public key
const { publicKeys } = await vaultClient.fetchPublicKeys([participant.suite_user_id])
const joinerPubKey = publicKeys[participant.suite_user_id]
if (!joinerPubKey) {
throw new Error(`Could not find encryption public key for participant "${participant.username}"`)
}
// Re-wrap the symmetric key for the joiner using shareKeys
const { encryptedKeys } = await vaultClient.shareKeys(
adminKeyBytes.buffer,
{ [participant.suite_user_id]: joinerPubKey }
)
const joinerKey = encryptedKeys[participant.suite_user_id]
if (!joinerKey) {
throw new Error('Key wrapping returned no result — shareKeys failed')
}
const bytes = new Uint8Array(joinerKey)
encryptedVaultKey = btoa(String.fromCharCode(...bytes))
console.info('[VaultE2EE] Admin: key wrapped successfully, length:', encryptedVaultKey.length)
}
return { encryptedKey, adminEphemeralPublicKey, encryptedVaultKey }
}
const handleParticipantEntry = async (
participant: WaitingParticipant,
allowEntry: boolean
) => {
let encryptedKey = ''
let adminEphemeralPublicKey = ''
let encryptedVaultKey = ''
if (allowEntry) {
try {
const keys = await encryptKeyForAccept(participant)
encryptedKey = keys.encryptedKey
adminEphemeralPublicKey = keys.adminEphemeralPublicKey
encryptedVaultKey = keys.encryptedVaultKey
} catch (err) {
console.error('[VaultE2EE] Cannot accept participant:', err)
toastQueue.add(
{
type: 'encryptionError' as NotificationType,
message: `Cannot accept ${participant.username}: ${(err as Error).message}`,
},
{ timeout: 8000 }
)
return
}
}
await enterRoom({
roomId: roomId,
roomId,
allowEntry,
participantId: participant.id,
encryptedKey,
adminEphemeralPublicKey,
encryptedVaultKey,
})
await refetchWaiting()
}
@@ -159,39 +76,13 @@ export const useWaitingParticipants = () => {
setListEnabled(false)
await Promise.all(
waitingParticipants.map(async (participant) => {
let encryptedKey = ''
let adminEphemeralPublicKey = ''
let encryptedVaultKey = ''
if (allowEntry) {
try {
const keys = await encryptKeyForAccept(participant)
encryptedKey = keys.encryptedKey
adminEphemeralPublicKey = keys.adminEphemeralPublicKey
encryptedVaultKey = keys.encryptedVaultKey
} catch (err) {
console.error('[VaultE2EE] Cannot accept participant:', err)
toastQueue.add(
{
type: 'encryptionError' as NotificationType,
message: `Cannot accept ${participant.username}: ${(err as Error).message}`,
},
{ timeout: 8000 }
)
return
}
}
return enterRoom({
roomId: roomId,
waitingParticipants.map((participant) =>
enterRoom({
roomId,
allowEntry,
participantId: participant.id,
encryptedKey,
adminEphemeralPublicKey,
encryptedVaultKey,
})
})
)
)
await refetchWaiting()
@@ -2,16 +2,16 @@ import { Div, Field, H, Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { Separator as RACSeparator } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { useRecordingStatuses } from '@/features/recording'
import { RecordingMode } from '@/features/recording'
import { usePatchRoom } from '@/features/rooms/api/patchRoom'
import { fetchRoom } from '@/features/rooms/api/fetchRoom'
import { ApiAccessLevel, isEncryptedRoom } from '@/features/rooms/api/ApiRoom'
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
import { queryClient } from '@/api/queryClient'
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' })
@@ -40,6 +40,25 @@ export const Admin = () => {
isScreenShareEnabled,
} = usePublishSourcesManager()
// Reasons we block the admin from re-enabling encryption mid-call:
// - a recording is in progress (the recording server needs plaintext)
// - a transcript is being captured (same reason)
// SIP participants aren't a blocker — re-enabling encryption while
// they're present moves them back into placeholder mode, and the admin
// will see the snackbar.
//
// We use `isStarted` from useRecordingStatuses (metadata-driven) rather
// than LK's `useIsRecording`. The metadata transitions through Saving
// immediately when the user clicks stop, so the alert clears right
// away rather than after the 12s LK round-trip.
const screenRec = useRecordingStatuses(RecordingMode.ScreenRecording)
const transcript = useRecordingStatuses(RecordingMode.Transcript)
const resumeBlockedReason = screenRec.isStarted
? t('encryption.blocked.recording')
: transcript.isStarted
? t('encryption.blocked.transcript')
: null
return (
<Div
display="flex"
@@ -134,6 +153,122 @@ export const Admin = () => {
/>
</div>
</div>
{readOnlyData?.is_encrypted && (
<div
className={css({
display: 'flex',
flexDirection: 'column',
marginTop: '1rem',
width: '100%',
})}
>
<RACSeparator
className={css({
border: 'none',
height: '1px',
width: '100%',
background: 'greyscale.250',
})}
/>
<H
lvl={2}
className={css({
fontWeight: 500,
})}
margin="sm"
>
{t('encryption.title')}
</H>
<Text
variant="note"
wrap="balance"
className={css({
textStyle: 'sm',
})}
margin={'md'}
>
{t('encryption.description')}
</Text>
{(() => {
const resumeBlocked =
!!readOnlyData?.encryption_paused && resumeBlockedReason != null
return (
<>
<div
className={css({
opacity: resumeBlocked ? 0.45 : 1,
pointerEvents: resumeBlocked ? 'none' : undefined,
transition: 'opacity 200ms ease',
})}
aria-disabled={resumeBlocked || undefined}
>
<Field
type="switch"
label={t('encryption.toggle.label')}
description={
readOnlyData?.encryption_paused
? t('encryption.toggle.descriptionPaused')
: t('encryption.toggle.descriptionLive')
}
isSelected={!!readOnlyData?.encryption_paused}
isDisabled={resumeBlocked}
onChange={(paused) =>
patchRoom({
roomId,
room: { encryption_paused: paused },
})
.then((room) => {
queryClient.setQueryData([keys.room, roomId], room)
})
.catch((e) => console.error(e))
}
wrapperProps={{
noMargin: true,
fullWidth: true,
}}
/>
</div>
{resumeBlocked && (
<div
role="alert"
className={css({
display: 'flex',
gap: '0.5rem',
marginTop: '0.75rem',
padding: '0.7rem 0.85rem',
borderRadius: '0.5rem',
backgroundColor: '#fff7ed',
border: '1px solid #fed7aa',
color: '#7c2d12',
})}
>
<span
aria-hidden
className={css({
fontSize: '1rem',
lineHeight: '1.2',
})}
>
</span>
<Text
variant="sm"
margin={false}
className={css({
color: '#7c2d12',
fontSize: '0.85rem',
lineHeight: 1.4,
})}
>
{resumeBlockedReason}
</Text>
</div>
)}
</>
)
})()}
</div>
)}
<div
className={css({
display: 'flex',
@@ -168,25 +303,6 @@ export const Admin = () => {
>
{t('access.description')}
</Text>
{isEncryptedRoom(readOnlyData) && (
<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')}
@@ -213,13 +329,11 @@ export const Admin = () => {
value: ApiAccessLevel.PUBLIC,
label: t('access.levels.public.label'),
description: t('access.levels.public.description'),
isDisabled: isEncryptedRoom(readOnlyData),
},
{
value: ApiAccessLevel.TRUSTED,
label: t('access.levels.trusted.label'),
description: t('access.levels.trusted.description'),
isDisabled: isEncryptedRoom(readOnlyData),
},
{
value: ApiAccessLevel.RESTRICTED,
@@ -5,7 +5,6 @@ import {
ScreenShareIcon,
useEnsureTrackRef,
useFeatureContext,
useIsEncrypted,
useMaybeLayoutContext,
useMaybeTrackRefContext,
useParticipantTile,
@@ -19,23 +18,16 @@ import {
isTrackReferencePinned,
TrackReferenceOrPlaceholder,
} from '@livekit/components-core'
import { Track, RoomEvent } from 'livekit-client'
import type { Participant } from 'livekit-client'
import { Track } from 'livekit-client'
import { RiHand } from '@remixicon/react'
import { useRoomContext } from '@livekit/components-react'
import { useRaisedHand, useRaisedHandPosition } from '../hooks/useRaisedHand'
import {
EncryptionBadge,
EncryptionIdentityDialog,
} from '@/features/encryption'
import { useParticipantTrustLevel } from '@/features/encryption/useParticipantTrustLevel'
import { useRoomData } from '../hooks/useRoomData'
import { isEncryptedRoom as checkEncryptedRoom } from '@/features/rooms/api/ApiRoom'
import { useIsAdminOrOwner } from '../hooks/useIsAdminOrOwner'
import { RiLockFill } from '@remixicon/react'
import { css } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx'
import { Button } from '@/primitives'
import {
IdentityBadge,
SipBlockedTileOverlay,
DecryptionFailedTileOverlay,
} from '@/features/encryption'
import { useRoomData } from '../hooks/useRoomData'
import { MutedMicIndicator } from './MutedMicIndicator'
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
import { ParticipantTileFocus } from './ParticipantTileFocus'
@@ -90,52 +82,6 @@ export const ParticipantTile: (
onParticipantClick,
trackRef: trackReference,
})
const isEncrypted = useIsEncrypted(trackReference.participant)
const roomData = useRoomData()
const isEncryptedRoom = checkEncryptedRoom(roomData)
const isAdmin = useIsAdminOrOwner()
const [isIdentityOpen, setIsFingerprintOpen] = React.useState(false)
const participantAttrs = trackReference.participant.attributes as Record<string, string> | undefined
const { trustLevel, fingerprintStatus, fingerprint: participantFingerprint } = useParticipantTrustLevel(participantAttrs, roomData?.encryption_mode, trackReference.participant.isLocal)
const { t: tBadge } = useTranslation('rooms', { keyPrefix: 'encryption.badge' })
const badgeTooltip = tBadge(trustLevel)
// Track decryption failures via EncryptionError events from LiveKit.
// useIsEncrypted returns true when E2EE is enabled, NOT when frames decrypt successfully.
// So we listen for actual decryption errors to know when to show the overlay.
const room = useRoomContext()
const [decryptionFailed, setDecryptionFailed] = React.useState(false)
React.useEffect(() => {
if (!isEncryptedRoom || trackReference.participant.isLocal) return
const participantIdentity = trackReference.participant.identity
const handleEncryptionError = (_error: Error, participant?: Participant) => {
if (participant?.identity === participantIdentity) {
setDecryptionFailed(true)
}
}
const handleEncryptionStatusChanged = (encrypted: boolean, participant?: Participant) => {
// Clear the error when encryption status confirms frames are decrypting
if (participant?.identity === participantIdentity && encrypted) {
setDecryptionFailed(false)
}
}
room.on(RoomEvent.EncryptionError, handleEncryptionError)
room.on(RoomEvent.ParticipantEncryptionStatusChanged, handleEncryptionStatusChanged)
return () => {
room.off(RoomEvent.EncryptionError, handleEncryptionError)
room.off(RoomEvent.ParticipantEncryptionStatusChanged, handleEncryptionStatusChanged)
}
}, [room, isEncryptedRoom, trackReference.participant])
const showDecryptionError =
!trackReference.participant.isLocal &&
isEncryptedRoom &&
decryptionFailed
const layoutContext = useMaybeLayoutContext()
const autoManageSubscription = useFeatureContext()?.autoSubscription
@@ -165,13 +111,19 @@ export const ParticipantTile: (
const isScreenShare = trackReference.source != Track.Source.Camera
const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false)
// Show the badge in any encryption-capable room — including while the
// room is paused. The badge reflects the *room's nature* (encrypted vs
// unencrypted-by-design), which doesn't change when an admin temporarily
// pauses encryption for SIP/recording/transcription.
const tileRoomData = useRoomData()
const showIdentityBadge =
!isScreenShare && !!tileRoomData?.is_encrypted
const participantName = getParticipantName(trackReference.participant)
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
const interactiveProps = {
...elementProps,
// Ensure the tile is focusable to expose contextual controls to keyboard users.
tabIndex: 0,
'aria-label': t('containerLabel', { name: participantName }),
onFocus: (event: React.FocusEvent<HTMLDivElement>) => {
@@ -218,65 +170,6 @@ export const ParticipantTile: (
participant={trackReference.participant}
/>
</div>
{showDecryptionError && !isScreenShare && (
<div
className={css({
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: '0 !important',
pointerEvents: 'none',
})}
>
<ParticipantPlaceholder
participant={trackReference.participant}
/>
<div
style={{
position: 'absolute',
bottom: '2.5rem',
left: '50%',
transform: 'translateX(-50%)',
backgroundColor: 'rgba(0, 0, 0, 0.75)',
borderRadius: '0.5rem',
padding: '0.6rem 1rem',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '0.3rem',
maxWidth: '85%',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.4rem',
color: '#f87171',
fontSize: '0.85rem',
fontWeight: 600,
}}
>
<RiLockFill size={14} />
<span>Decryption failed</span>
</div>
<div
style={{
color: '#d1d5db',
fontSize: '0.75rem',
textAlign: 'center',
lineHeight: 1.4,
}}
>
Check that you and this person are using the correct
meeting link. If they are the only one you can&apos;t see,
the issue is likely on their side.
</div>
</div>
</div>
)}
{!disableMetadata && (
<div className="lk-participant-metadata">
<HStack gap={0.25}>
@@ -331,58 +224,17 @@ export const ParticipantTile: (
}}
/>
)}
{isEncryptedRoom && !isScreenShare ? (
<Button
variant="greyscale"
<div className="lk-participant-name-wrapper">
<ParticipantName
isScreenShare={isScreenShare}
participant={trackReference.participant}
/>
</div>
{showIdentityBadge && (
<IdentityBadge
participant={trackReference.participant}
size="sm"
tooltip={badgeTooltip}
aria-label={badgeTooltip}
onPress={() => setIsFingerprintOpen(true)}
className={css({
display: 'inline-flex !important',
alignItems: 'center !important',
gap: '0.15rem !important',
padding: '0.1rem 0.15rem !important',
minWidth: 'auto !important',
height: 'auto !important',
position: 'relative',
zIndex: 10,
borderRadius: '0.25rem !important',
backgroundColor: 'transparent !important',
color: 'inherit !important',
'&[data-hovered]': {
backgroundColor: 'rgba(255, 255, 255, 0.15) !important',
},
})}
>
{(isEncrypted || isEncryptedRoom) && (
<EncryptionBadge
isEncrypted={true}
trustLevel={trustLevel}
/>
)}
<div className="lk-participant-name-wrapper">
<ParticipantName
isScreenShare={isScreenShare}
participant={trackReference.participant}
/>
</div>
</Button>
) : (
<>
{(isEncrypted || isEncryptedRoom) && !isScreenShare && (
<EncryptionBadge
isEncrypted={true}
trustLevel={trustLevel}
/>
)}
<div className="lk-participant-name-wrapper">
<ParticipantName
isScreenShare={isScreenShare}
participant={trackReference.participant}
/>
</div>
</>
/>
)}
</div>
</HStack>
@@ -397,6 +249,16 @@ export const ParticipantTile: (
hasKeyboardFocus={hasKeyboardFocus}
/>
)}
{!isScreenShare && (
<>
<SipBlockedTileOverlay
participant={trackReference.participant}
/>
<DecryptionFailedTileOverlay
participant={trackReference.participant}
/>
</>
)}
</ParticipantContextIfNeeded>
</TrackRefContextIfNeeded>
<KeyboardShortcutHint>
@@ -406,20 +268,6 @@ export const ParticipantTile: (
),
})}
</KeyboardShortcutHint>
{isEncryptedRoom && (
<EncryptionIdentityDialog
isOpen={isIdentityOpen}
onOpenChange={setIsFingerprintOpen}
participantName={trackReference.participant.name || trackReference.participant.identity}
participantEmail={participantAttrs?.email}
suiteUserId={participantAttrs?.suite_user_id}
isAuthenticated={participantAttrs?.is_authenticated === 'true'}
encryptionMode={roomData?.encryption_mode}
isSelf={trackReference.participant.isLocal}
preloadedFingerprint={participantFingerprint}
preloadedFingerprintStatus={fingerprintStatus}
/>
)}
</div>
)
})
@@ -2,7 +2,7 @@ import { A, Div, Icon, Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { Button as RACButton } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { ReactNode } from 'react'
import { ReactNode, useState } from 'react'
import { SubPanelId, useSidePanel } from '../hooks/useSidePanel'
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
import {
@@ -12,9 +12,12 @@ import {
ScreenRecordingSidePanel,
} from '@/features/recording'
import { useConfig } from '@/api/useConfig'
import { useRoomData } from '../hooks/useRoomData'
import { isEncryptedRoom } from '@/features/rooms/api/ApiRoom'
import { RiLockLine } from '@remixicon/react'
import {
EncryptionPhase,
PauseEncryptionConfirmDialog,
useEncryptionStatus,
} from '@/features/encryption'
export interface ToolsButtonProps {
icon: ReactNode
@@ -108,17 +111,17 @@ export const Tools = () => {
const { openTranscript, openScreenRecording, activeSubPanelId, isToolsOpen } =
useSidePanel()
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
const { phase, pauseEncryption } = useEncryptionStatus()
const [confirmReason, setConfirmReason] = useState<
'recording' | 'transcript' | null
>(null)
// Restore focus to the element that opened the Tools panel
// following the same pattern as Chat.
useRestoreFocus(isToolsOpen, {
// If the active element is a MenuItem (DIV) that will be unmounted when the menu closes,
// find the "more options" button ("Plus d'options") that opened the menu
resolveTrigger: (activeEl) => {
if (activeEl?.tagName === 'DIV') {
return document.querySelector<HTMLElement>('#room-options-trigger')
}
// For direct button clicks (e.g. "Plus d'outils"), use the active element as is
return activeEl
},
restoreFocusRaf: true,
@@ -142,8 +145,14 @@ export const Tools = () => {
break
}
const roomData = useRoomData()
const encrypted = isEncryptedRoom(roomData)
const handlePress = (reason: 'recording' | 'transcript') => {
if (phase === EncryptionPhase.ENCRYPTED) {
setConfirmReason(reason)
return
}
if (reason === 'recording') openScreenRecording()
else openTranscript()
}
return (
<Div
@@ -179,33 +188,12 @@ export const Tools = () => {
</A>
)}
</Text>
{encrypted && (
<div
className={css({
display: 'flex',
gap: '0.5rem',
alignItems: 'start',
padding: '0.6rem 0.75rem',
backgroundColor: '#fffbeb',
borderRadius: '0.5rem',
border: '1px solid #fde68a',
marginBottom: '0.5rem',
width: '100%',
})}
>
<RiLockLine size={16} color="#d97706" className={css({ flexShrink: 0, marginTop: '0.1rem' })} />
<Text variant="note" className={css({ fontSize: '0.8rem', color: '#92400e' })}>
{t('encryptedDisabled')}
</Text>
</div>
)}
{isTranscriptEnabled && (
<ToolButton
icon={<Icon type="symbols" name="speech_to_text" />}
title={t('tools.transcript.title')}
description={t('tools.transcript.body')}
onPress={() => openTranscript()}
isDisabled={encrypted}
onPress={() => handlePress('transcript')}
/>
)}
{isScreenRecordingEnabled && (
@@ -213,10 +201,22 @@ export const Tools = () => {
icon={<Icon type="symbols" name="mode_standby" />}
title={t('tools.screenRecording.title')}
description={t('tools.screenRecording.body')}
onPress={() => openScreenRecording()}
isDisabled={encrypted}
onPress={() => handlePress('recording')}
/>
)}
<PauseEncryptionConfirmDialog
isOpen={confirmReason !== null}
onOpenChange={(open) => !open && setConfirmReason(null)}
reason={confirmReason ?? 'recording'}
onConfirm={async () => {
if (!confirmReason) return
const ok = await pauseEncryption(confirmReason)
if (ok) {
if (confirmReason === 'recording') openScreenRecording()
else openTranscript()
}
}}
/>
</Div>
)
}
@@ -1,36 +1,58 @@
import { RiRecordCircleLine } from '@remixicon/react'
import { MenuItem } from 'react-aria-components'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { menuRecipe } from '@/primitives/menuRecipe'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { RecordingMode, useHasRecordingAccess } from '@/features/recording'
import { FeatureFlags } from '@/features/analytics/enums'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { isEncryptedRoom as checkEncryptedRoom } from '@/features/rooms/api/ApiRoom'
import {
EncryptionPhase,
PauseEncryptionConfirmDialog,
useEncryptionStatus,
} from '@/features/encryption'
export const ScreenRecordingMenuItem = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
const { isScreenRecordingOpen, openScreenRecording, toggleTools } =
useSidePanel()
const roomData = useRoomData()
const { phase, pauseEncryption } = useEncryptionStatus()
const [confirmOpen, setConfirmOpen] = useState(false)
const hasScreenRecordingAccess = useHasRecordingAccess(
RecordingMode.ScreenRecording,
FeatureFlags.ScreenRecording
)
// Recording not available in encrypted rooms
if (!hasScreenRecordingAccess || checkEncryptedRoom(roomData)) return null
if (!hasScreenRecordingAccess) return null
const handlePress = () => {
if (phase === EncryptionPhase.ENCRYPTED) {
setConfirmOpen(true)
return
}
if (!isScreenRecordingOpen) openScreenRecording()
else toggleTools()
}
return (
<MenuItem
className={menuRecipe({ icon: true, variant: 'dark' }).item}
onAction={() =>
!isScreenRecordingOpen ? openScreenRecording() : toggleTools()
}
>
<RiRecordCircleLine size={20} />
{t('screenRecording')}
</MenuItem>
<>
<MenuItem
className={menuRecipe({ icon: true, variant: 'dark' }).item}
onAction={handlePress}
>
<RiRecordCircleLine size={20} />
{t('screenRecording')}
</MenuItem>
<PauseEncryptionConfirmDialog
isOpen={confirmOpen}
onOpenChange={setConfirmOpen}
reason="recording"
onConfirm={async () => {
const ok = await pauseEncryption('recording')
if (ok) openScreenRecording()
}}
/>
</>
)
}
@@ -1,33 +1,57 @@
import { RiFileTextLine } from '@remixicon/react'
import { MenuItem } from 'react-aria-components'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { menuRecipe } from '@/primitives/menuRecipe'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { RecordingMode, useHasRecordingAccess } from '@/features/recording'
import { FeatureFlags } from '@/features/analytics/enums'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { isEncryptedRoom as checkEncryptedRoom } from '@/features/rooms/api/ApiRoom'
import {
EncryptionPhase,
PauseEncryptionConfirmDialog,
useEncryptionStatus,
} from '@/features/encryption'
export const TranscriptMenuItem = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
const { isTranscriptOpen, openTranscript, toggleTools } = useSidePanel()
const roomData = useRoomData()
const { phase, pauseEncryption } = useEncryptionStatus()
const [confirmOpen, setConfirmOpen] = useState(false)
const hasTranscriptAccess = useHasRecordingAccess(
RecordingMode.Transcript,
FeatureFlags.Transcript
)
// Recording/transcription not available in encrypted rooms
if (!hasTranscriptAccess || checkEncryptedRoom(roomData)) return null
if (!hasTranscriptAccess) return null
const handlePress = () => {
if (phase === EncryptionPhase.ENCRYPTED) {
setConfirmOpen(true)
return
}
if (!isTranscriptOpen) openTranscript()
else toggleTools()
}
return (
<MenuItem
className={menuRecipe({ icon: true, variant: 'dark' }).item}
onAction={() => (!isTranscriptOpen ? openTranscript() : toggleTools())}
>
<RiFileTextLine size={20} />
{t('transcript')}
</MenuItem>
<>
<MenuItem
className={menuRecipe({ icon: true, variant: 'dark' }).item}
onAction={handlePress}
>
<RiFileTextLine size={20} />
{t('transcript')}
</MenuItem>
<PauseEncryptionConfirmDialog
isOpen={confirmOpen}
onOpenChange={setConfirmOpen}
reason="transcript"
onConfirm={async () => {
const ok = await pauseEncryption('transcript')
if (ok) openTranscript()
}}
/>
</>
)
}
@@ -21,13 +21,7 @@ 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, EncryptionIdentityDialog } from '@/features/encryption'
import { useParticipantTrustLevel } from '@/features/encryption/useParticipantTrustLevel'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { isEncryptedRoom as isEncryptedRoomFn } from '@/features/rooms/api/ApiRoom'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { useUser } from '@/features/auth'
import { TooltipWrapper } from '@/primitives/TooltipWrapper'
import { IdentityBadge, useEncryptionStatus, EncryptionPhase } from '@/features/encryption'
type MicIndicatorProps = {
participant: Participant
@@ -104,16 +98,9 @@ export const ParticipantListItem = ({
participant,
}: ParticipantListItemProps) => {
const { t } = useTranslation('rooms')
const roomData = useRoomData()
const isEncryptedRoom = isEncryptedRoomFn(roomData)
const isAdmin = useIsAdminOrOwner()
const { isLoggedIn } = useUser()
const { t: tEncBadge } = useTranslation('rooms', { keyPrefix: 'encryption.badge' })
const [isIdentityOpen, setIsFingerprintOpen] = useState(false)
const { phase } = useEncryptionStatus()
const showIdentityBadge = phase !== EncryptionPhase.UNENCRYPTED
const name = participant.name || participant.identity
const attrs = participant.attributes as Record<string, string> | undefined
const { trustLevel, fingerprintStatus, fingerprint } = useParticipantTrustLevel(attrs, roomData?.encryption_mode, isLocal(participant))
const badgeTooltip = tEncBadge(trustLevel)
return (
<HStack
role="listitem"
@@ -134,131 +121,31 @@ export const ParticipantListItem = ({
<PinBadge participant={participant} />
</div>
<VStack gap={0} alignItems="start">
{isEncryptedRoom ? (
<Button
variant="greyscale"
size="sm"
tooltip={badgeTooltip}
aria-label={badgeTooltip}
onPress={() => setIsFingerprintOpen(true)}
className={css({
padding: '0.1rem 0.25rem !important',
minWidth: 'auto !important',
height: 'auto !important',
gap: '0.15rem !important',
borderRadius: '0.25rem !important',
backgroundColor: 'transparent !important',
color: 'greyscale.900 !important',
cursor: isEncryptedRoom ? 'pointer' : 'default',
'&[data-hovered]': {
backgroundColor: 'greyscale.100 !important',
},
})}
>
<EncryptionBadge
isEncrypted={true}
trustLevel={trustLevel}
/>
<Text
variant="sm"
className={css({
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '120px',
})}
>
{name}
</Text>
{isLocal(participant) && (
<Text
variant="sm"
className={css({ whiteSpace: 'nowrap', flexShrink: 0 })}
>
({t('participants.you')})
</Text>
)}
</Button>
) : (
<Text
variant="sm"
className={css({
userSelect: 'none',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '150px',
})}
>
{name}
{isLocal(participant) && ` (${t('participants.you')})`}
</Text>
)}
<Text
variant="sm"
className={css({
userSelect: 'none',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '150px',
})}
>
{name}
{isLocal(participant) && ` (${t('participants.you')})`}
</Text>
{getParticipantIsRoomAdmin(participant) && (
<Text variant="xsNote">{t('participants.host')}</Text>
)}
{/* Email is only in JWT for encrypted rooms (backend restriction).
Additionally, only show to authenticated users in the UI anonymous
users in encrypted rooms could still extract it from LiveKit signaling
but won't see it in the interface. See utils.py for details. */}
{isEncryptedRoom && isLoggedIn && (() => {
const email = participant.attributes?.is_authenticated === 'true' && participant.attributes?.email
? participant.attributes.email
: null
const label = email || t('participants.anonymous')
return (
<Button
variant="greyscale"
size="sm"
tooltip={email || undefined}
aria-label={label}
className={css({
padding: '0 !important',
minWidth: 'auto !important',
height: 'auto !important',
backgroundColor: 'transparent !important',
color: 'greyscale.500 !important',
fontSize: '0.7rem !important',
fontWeight: 'normal !important',
width: '100%',
minW: 0,
justifyContent: 'flex-start !important',
'&[data-hovered]': {
backgroundColor: 'transparent !important',
},
})}
>
<span className={css({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
minWidth: 0,
})}>
{label}
</span>
</Button>
)
})()}
{showIdentityBadge && (
<IdentityBadge participant={participant} size="sm" />
)}
</VStack>
</HStack>
<HStack>
<MicIndicator participant={participant} />
<ParticipantMenuButton participant={participant} />
</HStack>
{isEncryptedRoom && (
<EncryptionIdentityDialog
isOpen={isIdentityOpen}
onOpenChange={setIsFingerprintOpen}
participantName={name}
participantEmail={attrs?.email}
suiteUserId={attrs?.suite_user_id}
isAuthenticated={attrs?.is_authenticated === 'true'}
encryptionMode={roomData?.encryption_mode}
isSelf={isLocal(participant)}
preloadedFingerprint={fingerprint}
preloadedFingerprintStatus={fingerprintStatus}
/>
)}
</HStack>
)
}
@@ -5,11 +5,8 @@ import { Avatar } from '@/components/Avatar'
import { useTranslation } from 'react-i18next'
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
import { RiCloseLine } from '@remixicon/react'
import { IdentityBadge } from '@/features/encryption'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { isEncryptedRoom } from '@/features/rooms/api/ApiRoom'
import { EncryptionBadge, EncryptionIdentityDialog } from '@/features/encryption'
import { useParticipantTrustLevel, formatFingerprint } from '@/features/encryption/useParticipantTrustLevel'
import { useState } from 'react'
export const WaitingParticipantListItem = ({
participant,
@@ -20,16 +17,7 @@ export const WaitingParticipantListItem = ({
}) => {
const { t } = useTranslation('rooms')
const roomData = useRoomData()
const encryptedRoom = isEncryptedRoom(roomData)
const { t: tBadge } = useTranslation('rooms', { keyPrefix: 'encryption.badge' })
const [isIdentityOpen, setIsDialogOpen] = useState(false)
// Build attributes-like object for the hook (waiting participants aren't in LiveKit yet)
const waitingAttrs = {
is_authenticated: participant.is_authenticated ? 'true' : 'false',
suite_user_id: participant.suite_user_id || '',
}
const { trustLevel, fingerprintStatus, fingerprint } = useParticipantTrustLevel(waitingAttrs, roomData?.encryption_mode)
const badgeTooltip = encryptedRoom ? tBadge(trustLevel) : undefined
const showIdentityBadge = !!roomData?.is_encrypted
return (
<HStack
@@ -51,113 +39,21 @@ export const WaitingParticipantListItem = ({
>
<Avatar name={participant.username} bgColor={participant.color} />
<VStack gap={0} alignItems="start" className={css({ flex: 1, minWidth: 0 })}>
{encryptedRoom ? (
<Button
variant="greyscale"
size="sm"
tooltip={badgeTooltip}
aria-label={badgeTooltip}
onPress={() => setIsDialogOpen(true)}
className={css({
padding: '0.1rem 0.25rem !important',
minWidth: 'auto !important',
height: 'auto !important',
gap: '0.15rem !important',
borderRadius: '0.25rem !important',
backgroundColor: 'transparent !important',
color: 'greyscale.900 !important',
cursor: 'pointer',
'&[data-hovered]': {
backgroundColor: 'greyscale.100 !important',
},
})}
>
<EncryptionBadge
isEncrypted={true}
trustLevel={trustLevel}
/>
<Text
variant="sm"
className={css({
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
minWidth: 0,
})}
>
{participant.username}
</Text>
</Button>
) : (
<Text
variant="sm"
className={css({
userSelect: 'none',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
padding: '0.1rem 0.25rem',
})}
>
{participant.username}
</Text>
<Text
variant="sm"
className={css({
userSelect: 'none',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
padding: '0.1rem 0.25rem',
})}
>
{participant.username}
</Text>
{showIdentityBadge && (
<IdentityBadge isAuthenticated={participant.is_authenticated} />
)}
{encryptedRoom && fingerprint && (
<Text
variant="sm"
className={css({
fontSize: '0.6rem',
fontFamily: 'monospace',
color: 'greyscale.400',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
paddingLeft: '0.25rem',
width: '100%',
minWidth: 0,
})}
>
{formatFingerprint(fingerprint)}
</Text>
)}
{encryptedRoom && (() => {
const email = participant.is_authenticated && participant.email
? participant.email
: null
const label = email || t('participants.anonymous')
return (
<Button
variant="greyscale"
size="sm"
tooltip={email || undefined}
aria-label={label}
className={css({
padding: '0 0.25rem !important',
minWidth: 'auto !important',
height: 'auto !important',
backgroundColor: 'transparent !important',
color: 'greyscale.500 !important',
fontSize: '0.7rem !important',
fontWeight: 'normal !important',
width: '100%',
minW: 0,
justifyContent: 'flex-start !important',
'&[data-hovered]': {
backgroundColor: 'transparent !important',
},
})}
>
<span className={css({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
minWidth: 0,
})}>
{label}
</span>
</Button>
)
})()}
</VStack>
</HStack>
<HStack
@@ -185,19 +81,6 @@ export const WaitingParticipantListItem = ({
<RiCloseLine />
</Button>
</HStack>
{encryptedRoom && (
<EncryptionIdentityDialog
isOpen={isIdentityOpen}
onOpenChange={setIsDialogOpen}
participantName={participant.username}
participantEmail={participant.email}
suiteUserId={participant.suite_user_id}
isAuthenticated={participant.is_authenticated}
encryptionMode={roomData?.encryption_mode}
preloadedFingerprint={fingerprint}
preloadedFingerprintStatus={fingerprintStatus}
/>
)}
</HStack>
)
}
@@ -42,7 +42,11 @@ 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 {
RoomStatusBanner,
EncryptionStatusSnackbars,
EncryptionAutoResumeWatcher,
} from '@/features/encryption'
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
@@ -277,7 +281,9 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
onClose={() => setIsShareErrorVisible(false)}
/>
<IsIdleDisconnectModal />
<EncryptedMeetingBanner />
<RoomStatusBanner />
<EncryptionStatusSnackbars />
<EncryptionAutoResumeWatcher />
<div
// todo - extract these magic values into constant
style={{
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { css } from '@/styled-system/css'
import { generateRoomId, useCreateRoom } from '../../rooms'
@@ -8,66 +8,42 @@ import { Button, Text } from '@/primitives'
import { VStack } from '@/styled-system/jsx'
import { CallbackIdHandler } from '../utils/CallbackIdHandler'
import { PopupWindow } from '../utils/PopupWindow'
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
import { generatePassphrase } from '@/features/encryption/lobbyKeyExchange'
import { useVaultClient } from '@/features/encryption'
import {
RiVideoOnLine,
RiLockLine,
RiShieldCheckLine,
} from '@remixicon/react'
import { generatePassphrase } from '@/features/encryption'
import { useConfig } from '@/api/useConfig'
import { RiVideoOnLine } from '@remixicon/react'
const callbackIdHandler = new CallbackIdHandler()
const popupWindow = new PopupWindow()
export const CreatePopup = () => {
const { isLoggedIn } = useUser({ fetchUserOptions: { attemptSilent: false } })
const { isLoggedIn, user } = useUser({
fetchUserOptions: { attemptSilent: false },
})
const { mutateAsync: createRoom } = useCreateRoom()
const { t } = useTranslation('sdk', { keyPrefix: 'createPopup' })
const { client: vaultClient, hasKeys, isReady: vaultReady } = useVaultClient()
const { data: config } = useConfig()
const callbackId = useMemo(() => callbackIdHandler.getOrCreate(), [])
const [isCreating, setIsCreating] = useState(false)
const [showOnboarding, setShowOnboarding] = useState(false)
const onboardingContainerRef = useRef<HTMLDivElement>(null)
// Handle unauthenticated users by redirecting to login.
// Don't send callbackId to parent yet — we need the user to pick
// an encryption mode first. The callbackId is sent with createRoom.
useEffect(() => {
if (isLoggedIn === false) {
popupWindow.navigateToAuthentication()
}
}, [isLoggedIn])
const handleCreate = useCallback(async (mode: ApiEncryptionMode) => {
const handleCreate = async () => {
setIsCreating(true)
try {
const slug = generateRoomId()
const hash =
mode === ApiEncryptionMode.BASIC ? generatePassphrase() : undefined
// For advanced mode, generate the vault key at creation time
let encryptedSymmetricKey = ''
if (mode === ApiEncryptionMode.ADVANCED && vaultClient) {
// encryptWithoutKey requires data to encrypt, but we only care about
// the generated symmetric key (encryptedKeys), not the encrypted content.
// The same symmetric key will be used for all streams (video/audio/chat).
const dummyData = new Uint8Array(32).buffer
const { publicKey } = await vaultClient.getPublicKey()
const { encryptedKeys } = await vaultClient.encryptWithoutKey(
dummyData,
{ self: publicKey }
)
const keyBytes = new Uint8Array(encryptedKeys['self'])
encryptedSymmetricKey = btoa(String.fromCharCode(...keyBytes))
}
const isEncrypted =
!!config?.encryption?.enabled && !!user?.default_encryption
const hash = isEncrypted ? generatePassphrase() : undefined
const roomData = await createRoom({
slug,
encryptionMode: mode,
encryptedSymmetricKey,
isEncrypted,
})
popupWindow.sendRoomData({ slug: roomData.slug, hash }, () => {
@@ -78,54 +54,17 @@ export const CreatePopup = () => {
console.error('Failed to create meeting room:', error)
setIsCreating(false)
}
}, [createRoom, vaultClient])
// Handle vault onboarding completion
useEffect(() => {
if (!vaultClient || !showOnboarding) return
const handleOnboardingComplete = () => {
setShowOnboarding(false)
// After onboarding, create the advanced encrypted room
handleCreate(ApiEncryptionMode.ADVANCED)
}
const handleInterfaceClosed = () => {
setShowOnboarding(false)
}
vaultClient.on('onboarding:complete', handleOnboardingComplete)
vaultClient.on('interface:closed', handleInterfaceClosed)
return () => {
vaultClient.off('onboarding:complete', handleOnboardingComplete)
vaultClient.off('interface:closed', handleInterfaceClosed)
}
}, [vaultClient, showOnboarding, handleCreate])
// Open vault onboarding when container is ready
useEffect(() => {
if (showOnboarding && vaultClient && onboardingContainerRef.current) {
console.info('[CreatePopup] Opening vault onboarding in container', onboardingContainerRef.current)
vaultClient.openOnboarding(onboardingContainerRef.current)
} else if (showOnboarding) {
console.warn('[CreatePopup] Cannot open onboarding:', {
vaultClient: !!vaultClient,
container: !!onboardingContainerRef.current,
})
}
}, [showOnboarding, vaultClient])
const handleAdvancedClick = () => {
if (hasKeys) {
// Already onboarded, create directly
handleCreate(ApiEncryptionMode.ADVANCED)
} else if (vaultClient) {
// Need onboarding first
setShowOnboarding(true)
}
}
// Auto-create as soon as we know the user; the SDK popup is intentionally
// a single-action surface — no per-meeting picker.
useEffect(() => {
if (isLoggedIn && !isCreating && callbackId) {
void handleCreate()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isLoggedIn])
if (!isLoggedIn || isCreating) {
return (
<div
@@ -142,29 +81,6 @@ export const CreatePopup = () => {
)
}
if (showOnboarding) {
return (
<div className={css({ position: 'fixed', inset: 0, zIndex: 100, backgroundColor: 'white' })}>
<div
ref={onboardingContainerRef}
className={css({ position: 'absolute', inset: 0 })}
/>
<Button
variant="tertiaryText"
size="sm"
onPress={() => setShowOnboarding(false)}
style={{ position: 'absolute', top: '0.5rem', left: '0.5rem', zIndex: 101 }}
>
</Button>
</div>
)
}
// Vault is available if the client was loaded (script + init succeeded).
// Auth context (isReady) may not be set yet — the onboarding handles its own auth.
const vaultAvailable = !!vaultClient
return (
<div
className={css({
@@ -184,49 +100,10 @@ export const CreatePopup = () => {
>
{t('title')}
</Text>
<Button
variant="primary"
fullWidth
onPress={() => handleCreate(ApiEncryptionMode.NONE)}
>
<Button variant="primary" fullWidth onPress={handleCreate}>
<RiVideoOnLine size={18} />
{t('standard')}
{t('create')}
</Button>
<Button
variant="secondary"
fullWidth
onPress={() => handleCreate(ApiEncryptionMode.BASIC)}
>
<RiLockLine size={18} />
{t('encrypted')}
</Button>
<div
className={css({
borderTop: '1px solid',
borderColor: 'greyscale.100',
margin: '0.25rem 0',
})}
/>
<Button
variant="secondary"
fullWidth
isDisabled={!vaultAvailable}
onPress={handleAdvancedClick}
style={{ opacity: vaultAvailable ? 1 : 0.4, cursor: vaultAvailable ? 'pointer' : 'not-allowed' }}
>
<RiShieldCheckLine size={18} />
{t('advancedEncrypted')}
</Button>
<Text
variant="note"
className={css({ color: 'greyscale.500', fontSize: '0.75rem', lineHeight: 1.4 })}
>
{t('advancedDescription')}
</Text>
</VStack>
</div>
)
@@ -56,7 +56,7 @@ export class PopupManager {
case PopupMessageType.CALLBACK_ID:
onCallbackId(data.callbackId as string)
return
case PopupMessageType.ROOM_DATA:
case PopupMessageType.ROOM_DATA: {
if (!data?.room) return
onRoomData(data.room)
const baseUrl = getRouteUrl('room', data.room.slug)
@@ -68,6 +68,7 @@ export class PopupManager {
},
})
return
}
}
}
window.addEventListener('message', this.messageHandler)
@@ -0,0 +1,159 @@
/**
* Reusable "create encrypted meetings by default" toggle + confirmation
* dialog. Used both in the in-meeting Security tab and the home-page
* SettingsDialog so the option is reachable from outside a call.
*/
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useMutation } from '@tanstack/react-query'
import {
RiFileTextLine,
RiPhoneLine,
RiRecordCircleLine,
RiVideoOnLine,
} from '@remixicon/react'
import { Button, Dialog, Field, Text } from '@/primitives'
import { HStack, VStack } from '@/styled-system/jsx'
import { css } from '@/styled-system/css'
import { useUser } from '@/features/auth'
import { updateUserPreferences } from '@/features/auth/api/updateUserPreferences'
import { queryClient } from '@/api/queryClient'
import { keys } from '@/api/queryKeys'
import { useConfig } from '@/api/useConfig'
import { LoginButton } from '@/components/LoginButton'
export const EncryptionDefaultField = () => {
const { t } = useTranslation('settings', { keyPrefix: 'security' })
const { user, isLoggedIn } = useUser()
const { data: config } = useConfig()
const isFeatureEnabled = !!config?.encryption?.enabled
const [confirmTarget, setConfirmTarget] = useState<boolean | null>(null)
const { mutateAsync, isPending } = useMutation({
mutationFn: updateUserPreferences,
onSuccess: (updatedUser) => {
queryClient.setQueryData([keys.user], updatedUser)
},
})
const isOn = !!user?.default_encryption
const requestToggle = (next: boolean) => {
if (!user) return
if (next) {
setConfirmTarget(true)
return
}
void mutateAsync({ user: { id: user.id, default_encryption: false } })
}
const confirm = async () => {
if (!user || confirmTarget === null) return
await mutateAsync({
user: { id: user.id, default_encryption: confirmTarget },
})
setConfirmTarget(null)
}
if (!isLoggedIn) {
return (
<>
<Text variant="note" margin={false}>
{t('signInRequired')}
</Text>
<LoginButton />
</>
)
}
if (!isFeatureEnabled) {
return (
<Text variant="note" margin={false}>
{t('featureDisabled')}
</Text>
)
}
return (
<>
<Field
type="switch"
label={t('toggle.label')}
description={t('toggle.description')}
isSelected={isOn}
isDisabled={isPending}
onChange={requestToggle}
wrapperProps={{ noMargin: true, fullWidth: true }}
/>
<Dialog
isOpen={confirmTarget !== null}
onOpenChange={(open) => !open && setConfirmTarget(null)}
role="dialog"
type="flex"
title={t('confirmModal.title')}
>
<VStack
alignItems="start"
gap="0.75rem"
className={css({ maxWidth: '24rem' })}
>
<Text variant="sm">{t('confirmModal.description')}</Text>
<VStack gap="0.5rem" alignItems="start">
<ConfirmRow
icon={<RiPhoneLine size={16} />}
label={t('confirmModal.items.phone')}
/>
<ConfirmRow
icon={<RiVideoOnLine size={16} />}
label={t('confirmModal.items.devices')}
/>
<ConfirmRow
icon={<RiFileTextLine size={16} />}
label={t('confirmModal.items.transcription')}
/>
<ConfirmRow
icon={<RiRecordCircleLine size={16} />}
label={t('confirmModal.items.recording')}
/>
</VStack>
<Text
variant="note"
className={css({ fontSize: '0.8rem', color: 'greyscale.500' })}
>
{t('confirmModal.footnote')}
</Text>
<HStack gap="0.5rem" justify="end" className={css({ width: '100%' })}>
<Button
variant="secondary"
onPress={() => setConfirmTarget(null)}
>
{t('confirmModal.cancel')}
</Button>
<Button
variant="primary"
isDisabled={isPending}
onPress={confirm}
>
{t('confirmModal.confirm')}
</Button>
</HStack>
</VStack>
</Dialog>
</>
)
}
const ConfirmRow = ({
icon,
label,
}: {
icon: React.ReactNode
label: string
}) => (
<HStack gap="0.5rem" alignItems="center">
<span className={css({ color: 'greyscale.600' })}>{icon}</span>
<Text variant="sm" margin={false}>
{label}
</Text>
</HStack>
)
@@ -3,6 +3,7 @@ import { useLanguageLabels } from '@/i18n/useLanguageLabels'
import { A, Badge, Dialog, type DialogProps, Field, H, P } from '@/primitives'
import { useUser } from '@/features/auth'
import { LoginButton } from '@/components/LoginButton'
import { EncryptionDefaultField } from './EncryptionDefaultField'
export type SettingsDialogProps = Pick<DialogProps, 'isOpen' | 'onOpenChange'>
@@ -46,6 +47,8 @@ export const SettingsDialog = (props: SettingsDialogProps) => {
i18n.changeLanguage(lang as string)
}}
/>
<H lvl={2}>{t('security.heading')}</H>
<EncryptionDefaultField />
</Dialog>
)
}
@@ -9,6 +9,7 @@ import {
RiAccountCircleLine,
RiNotification3Line,
RiSettings3Line,
RiShieldKeyholeLine,
RiSpeakerLine,
RiVideoOnLine,
RiEyeLine,
@@ -19,6 +20,7 @@ import { NotificationsTab } from './tabs/NotificationsTab'
import { GeneralTab } from './tabs/GeneralTab'
import { AudioTab } from './tabs/AudioTab'
import { VideoTab } from './tabs/VideoTab'
import { SecurityTab } from './tabs/SecurityTab'
import { TranscriptionTab } from './tabs/TranscriptionTab'
import { ShortcutTab } from './tabs/ShortcutTab'
import { useRef } from 'react'
@@ -105,6 +107,10 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
<RiSettings3Line />
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.GENERAL}`)}
</Tab>
<Tab icon highlight id={SettingsDialogExtendedKey.SECURITY}>
<RiShieldKeyholeLine />
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.SECURITY}`)}
</Tab>
<Tab icon highlight id={SettingsDialogExtendedKey.NOTIFICATIONS}>
<RiNotification3Line />
{isWideScreen &&
@@ -136,6 +142,7 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
<AudioTab id={SettingsDialogExtendedKey.AUDIO} />
<VideoTab id={SettingsDialogExtendedKey.VIDEO} />
<GeneralTab id={SettingsDialogExtendedKey.GENERAL} />
<SecurityTab id={SettingsDialogExtendedKey.SECURITY} />
<NotificationsTab id={SettingsDialogExtendedKey.NOTIFICATIONS} />
<ShortcutTab id={SettingsDialogExtendedKey.SHORTCUTS} />
{/* Transcription tab won't be accessible if the tab is not active in the tab list */}
@@ -8,8 +8,6 @@ import { HStack } from '@/styled-system/jsx'
import { useState } from 'react'
import { LoginButton } from '@/components/LoginButton'
import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePersistentUserChoices'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { isEncryptedRoom as checkEncryptedRoom } from '@/features/rooms/api/ApiRoom'
export type AccountTabProps = Pick<DialogProps, 'onOpenChange'> &
Pick<TabPanelProps, 'id'>
@@ -18,10 +16,7 @@ export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
const { t } = useTranslation('settings')
const { saveUsername } = usePersistentUserChoices()
const room = useRoomContext()
const roomData = useRoomData()
const { user, isLoggedIn, logout } = useUser()
const isEncryptedRoom = checkEncryptedRoom(roomData)
const isNameLocked = isEncryptedRoom
const [name, setName] = useState(room?.localParticipant.name ?? '')
const userDisplay =
user?.full_name && user?.email
@@ -29,10 +24,8 @@ export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
: user?.email
const handleOnSubmit = () => {
if (!isNameLocked) {
if (room) room.localParticipant.setName(name)
saveUsername(name)
}
if (room) room.localParticipant.setName(name)
saveUsername(name)
if (onOpenChange) onOpenChange(false)
}
const handleOnCancel = () => {
@@ -42,20 +35,15 @@ export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
return (
<TabPanel padding={'md'} flex id={id}>
<H lvl={2}>{t('account.heading')}</H>
<div className={isNameLocked ? css({ opacity: 0.5, '& input': { cursor: 'not-allowed' } }) : undefined}>
<Field
type="text"
label={t('account.nameLabel')}
value={name}
onChange={setName}
isDisabled={isNameLocked}
isReadOnly={isNameLocked}
description={isNameLocked ? t('account.nameLockedEncryption') : undefined}
validate={(value) => {
return !value ? <p>{t('account.nameError')}</p> : null
}}
/>
</div>
<Field
type="text"
label={t('account.nameLabel')}
value={name}
onChange={setName}
validate={(value) => {
return !value ? <p>{t('account.nameError')}</p> : null
}}
/>
<H lvl={2}>{t('account.authentication')}</H>
{isLoggedIn ? (
<>
@@ -0,0 +1,21 @@
/**
* Security settings tab user-level encryption preferences only. The
* per-meeting pause control lives in the Admin panel (room moderation),
* since it's a moderation action, not a user preference.
*/
import { useTranslation } from 'react-i18next'
import { H } from '@/primitives'
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
import { EncryptionDefaultField } from '../EncryptionDefaultField'
export type SecurityTabProps = Pick<TabPanelProps, 'id'>
export const SecurityTab = ({ id }: SecurityTabProps) => {
const { t } = useTranslation('settings', { keyPrefix: 'security' })
return (
<TabPanel padding="md" flex id={id}>
<H lvl={2}>{t('heading')}</H>
<EncryptionDefaultField />
</TabPanel>
)
}
@@ -3,6 +3,7 @@ export enum SettingsDialogExtendedKey {
AUDIO = 'audio',
VIDEO = 'video',
GENERAL = 'general',
SECURITY = 'security',
NOTIFICATIONS = 'notifications',
TRANSCRIPTION = 'transcription',
SHORTCUTS = 'shortcuts',
+1 -122
View File
@@ -12,9 +12,6 @@ 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 { useCallback, useEffect, useRef, useState } from 'react'
const Logo = () => (
<img
@@ -93,50 +90,6 @@ 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 [showEncryptionModal, setShowEncryptionModal] = useState(false)
// Track whether the vault interface has been injected for the current modal session.
// This prevents re-injection when vault events (onboarding:complete, keys-destroyed)
// trigger re-renders via hasKeys state changes — which would destroy the iframe mid-flow.
const vaultInjectedRef = useRef(false)
const encryptionRefCallback = useCallback((el: HTMLDivElement | null) => {
encryptionContainerRef.current = el
}, [])
useEffect(() => {
const el = encryptionContainerRef.current
if (!showEncryptionModal || !el || !vaultClient || vaultInjectedRef.current) return
vaultInjectedRef.current = true
el.innerHTML = ''
if (hasKeys) {
vaultClient.openSettings(el)
} else {
vaultClient.openOnboarding(el)
}
const handleClosed = () => {
setShowEncryptionModal(false)
vaultClient.off('interface:closed', handleClosed)
}
vaultClient.on('interface:closed', handleClosed)
return () => {
vaultClient.off('interface:closed', handleClosed)
}
}, [showEncryptionModal, vaultClient])
// Reset injection flag when modal closes
useEffect(() => {
if (!showEncryptionModal) {
vaultInjectedRef.current = false
}
}, [showEncryptionModal])
const isEncryptionEnabled = !!config?.encryption?.enabled
const isEncryptionAvailable = isEncryptionEnabled && !!vaultClient
const userLabel = user?.full_name || user?.short_name || user?.email
const loggedInTooltip = t('loggedInUserTooltip')
const loggedInAriaLabel = userLabel
@@ -226,27 +179,11 @@ export const Header = () => {
</Button>
<MenuList
variant={'light'}
items={[
...(isEncryptionEnabled
? [
{
value: 'encryption',
label: isEncryptionAvailable
? (hasKeys ? t('encryptionSettings') : t('encryptionSetup'))
: t('encryptionUnavailable'),
isDisabled: !isEncryptionAvailable,
},
]
: []),
{ value: 'logout', label: t('logout') },
]}
items={[{ value: 'logout', label: t('logout') }]}
onAction={(value) => {
if (value === 'logout') {
logout()
}
if (value === 'encryption' && isEncryptionAvailable) {
setShowEncryptionModal(true)
}
}}
/>
</Menu>
@@ -256,64 +193,6 @@ export const Header = () => {
</nav>
</HStack>
</div>
{showEncryptionModal && vaultClient && (
<div
className={css({
position: 'fixed',
inset: 0,
zIndex: 9999,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
})}
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowEncryptionModal(false)
vaultClient.closeInterface()
}
}}
>
<div
className={css({
backgroundColor: 'white',
borderRadius: '0.75rem',
width: '90%',
maxWidth: '550px',
maxHeight: '85vh',
overflow: 'auto',
position: 'relative',
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)',
})}
>
<button
onClick={() => {
setShowEncryptionModal(false)
vaultClient.closeInterface()
}}
className={css({
position: 'absolute',
top: '0.75rem',
right: '0.75rem',
zIndex: 1,
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: '1.25rem',
color: 'greyscale.500',
_hover: { color: 'greyscale.900' },
})}
aria-label={t('close')}
>
</button>
<div
ref={encryptionRefCallback}
className={css({ minHeight: '300px' })}
/>
</div>
</div>
)}
</>
)
}
-3
View File
@@ -21,9 +21,6 @@
"proconnectLink": "What is ProConnect?"
},
"logout": "Logout",
"encryptionSetup": "Set up encryption",
"encryptionSettings": "Encryption settings",
"encryptionUnavailable": "Encryption (unavailable)",
"notFound": {
"heading": "Verify your meeting code",
"body": "Check that you have entered the correct meeting code in the URL. Example:"
+1 -17
View File
@@ -22,23 +22,7 @@
"moreAbout": "about {{appTitle}}",
"createMenu": {
"laterOption": "Create a meeting for a later date",
"instantOption": "Start an instant meeting",
"encryptedInstantOption": "Start an encrypted meeting",
"encryptedLaterOption": "Create an encrypted meeting for later"
},
"encryptionModeDialog": {
"title": "Choose encryption mode",
"description": "Select the level of encryption for your meeting.",
"basic": {
"title": "Basic encryption",
"description": "Protects your meeting with a shared passphrase. Accessible to everyone — no setup required."
},
"advanced": {
"title": "Advanced encryption",
"description": "Maximum security — the encryption key never leaves your browser. Requires encryption onboarding for all participants.",
"onboardingRequired": "You must complete the encryption setup in your account settings before using advanced encryption.",
"serviceUnavailable": "Advanced encryption is currently unavailable. Please try again later or contact your administrator."
}
"instantOption": "Start an instant meeting"
},
"laterMeetingDialog": {
"heading": "Your connection details",
+66 -91
View File
@@ -54,7 +54,7 @@
"toggleOn": "Click to turn on",
"usernameHint": "Shown to other participants",
"usernameLabel": "Your name",
"encryptedNameLocked": "Encrypted meeting — name from your account.",
"encryptedHint": "This meeting is end-to-end encrypted. Make sure you opened the same meeting link as the host.",
"errors": {
"usernameEmpty": "Your name cannot be empty"
},
@@ -85,15 +85,6 @@
"invalidKey": {
"title": "Invalid meeting link",
"body": "This encrypted meeting requires a valid encryption key in the URL. Please ask the meeting organizer for the correct link."
},
"advancedAuth": {
"title": "Authentication required",
"body": "This meeting uses advanced encryption. You must be logged in to join."
},
"advancedOnboarding": {
"title": "Encryption setup required",
"body": "This meeting uses advanced encryption. You must complete your encryption setup before you can join.",
"button": "Set up encryption"
}
},
"leaveRoomPrompt": "This will make you leave the meeting.",
@@ -363,7 +354,6 @@
},
"moreTools": {
"body": "Access more tools to enhance your meetings.",
"encryptedDisabled": "Transcription and recording are not available in encrypted meetings. The server cannot access the media content.",
"linkAriaLabel": "Open documentation about tools - opens in new window",
"moreLink": "Open documentation",
"tools": {
@@ -495,7 +485,6 @@
"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": {
@@ -527,6 +516,19 @@
"label": "Share their screen",
"description": "Disabling this option will prevent participants from sharing their screen, and any ongoing screen sharing will be stopped immediately."
}
},
"encryption": {
"title": "Encryption",
"description": "Temporarily pause end-to-end encryption so a phone or other external device can join. The meeting link still carries the encryption key, so you can resume at any time.",
"toggle": {
"label": "Pause encryption",
"descriptionLive": "Encryption is active for this meeting.",
"descriptionPaused": "Encryption is paused — external devices can join in plain audio and video."
},
"blocked": {
"recording": "Encryption can't resume while a recording is in progress. Stop the recording first.",
"transcript": "Encryption can't resume while transcription is running. Stop the transcript first."
}
}
},
"rating": {
@@ -587,12 +589,6 @@
"button": "Deny",
"label": "Deny {{name}} from the meeting",
"all": "Deny all"
},
"trust": {
"verified": "Identity verified — fingerprint is trusted.",
"refused": "WARNING — This fingerprint has been refused. This person may not be who they claim to be.",
"authenticated": "Authenticated identity (ProConnect). Click to check fingerprint.",
"anonymous": "Anonymous user — identity not verified. Verify their identity before accepting."
}
},
"moreOptions": "More options"
@@ -694,83 +690,62 @@
"participantTile": {
"screenShare": "{{name}}'s screen"
},
"identity": {
"proconnect": "Connected",
"anonymous": "Anonymous"
},
"roomStatus": {
"encrypted": "End-to-end encrypted",
"paused": "Encryption paused",
"recording": "Recording in progress",
"transcribing": "Transcription in progress"
},
"encryption": {
"banner": "Basic encryption",
"bannerStrong": "Advanced encryption",
"badge": {
"verified": "Verified — fingerprint trusted",
"unknown": "Not yet verified — click to check fingerprint",
"refused": "Refused — fingerprint previously rejected",
"authenticated": "Authenticated via ProConnect",
"anonymous": "Unverified identity",
"default": "Encrypted"
},
"bannerModal": {
"title": "End-to-end encrypted meeting",
"descriptionBasic": "This meeting uses basic end-to-end encryption. Audio and video are encrypted using a shared passphrase embedded in the meeting link. Anyone with the link can join and decrypt the content.",
"descriptionAdvanced": "This meeting uses advanced end-to-end encryption. The encryption key is managed by La Suite's encryption service and never leaves your device. All participants must complete encryption onboarding to join.",
"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)",
"limitation2Basic": "Security depends on keeping the meeting link private",
"limitation2Advanced": "All participants must complete encryption onboarding in their account settings before joining",
"note": "For the highest level of trust, use advanced encryption and ask participants to verify each other's fingerprints."
},
"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.",
"timeout": "Key exchange timed out",
"timeoutHint": "The encryption key could not be received from the host. They may not be in the meeting yet.",
"refresh": "Retry",
"vaultUnavailable": "This meeting requires advanced encryption, which is currently unavailable.",
"vaultUnavailableHint": "This may be a temporary issue. Try refreshing the page. If the problem persists, the encryption service may be undergoing maintenance — please try again later."
},
"fingerprint": {
"title": "Encryption identity",
"description": "This shows the encryption identity of this participant. It allows you to verify they are who they claim to be.",
"descriptionSelf": "This is your encryption identity as seen by other participants. They can use it to verify that you are who you claim to be.",
"descriptionSelfAnonymous": "You joined without signing in. Other participants see your name as self-declared and unverified. Sign in via ProConnect for a verified identity.",
"loading": "Checking encryption keys...",
"noKey": "This participant has not completed encryption onboarding. Their identity is verified by the authentication server (ProConnect) only — not by a cryptographic public key. They can set up encryption in their account settings.",
"noKeyBasicAuthenticated": "This participant is signed in via ProConnect. Their name and email are provided by the authentication server and cannot be falsified.",
"noKeyAnonymous": "This participant joined without signing in. Their identity is self-declared and not verified. Only accept anonymous participants if you can confirm their identity through another channel.",
"error": "Unable to check encryption keys. The encryption service may be unavailable.",
"fingerprintLabel": "Public key fingerprint",
"fingerprintHint": "Ask this participant to open their encryption settings and compare the fingerprint shown there with the one above.",
"trusted": "Fingerprint verified and trusted",
"trustedDescription": "You have previously verified this participant's encryption fingerprint. Their identity is cryptographically confirmed.",
"refused": "Fingerprint refused",
"refusedDescription": "You previously refused this fingerprint. This person's identity could not be verified.",
"unknownDescription": "This fingerprint has not been verified yet. Compare it with the participant (via phone, in person, or another secure channel) to confirm their identity.",
"accept": "Trust",
"refuse": "Refuse",
"changeDecision": "Change my decision",
"anonymous": "Unverified identity"
},
"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."
"mismatch": {
"missingPassphrase": {
"title": "This meeting needs an encryption passphrase",
"body": "The link you used does not include the encryption key. Ask the organizer to share the full link, or create a new encrypted meeting."
},
"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."
"unexpectedPassphrase": {
"title": "This meeting is not encrypted",
"body": "The link contains an encryption key but the meeting is not configured for end-to-end encryption. The link may have been altered. Create a fresh encrypted meeting to keep your conversation safe."
},
"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."
"createFresh": "Create a new encrypted meeting"
},
"pauseConfirm": {
"title": {
"recording": "Turn on recording?",
"transcript": "Turn on transcription?"
},
"description": "Encryption will pause while this feature is on. The server temporarily needs access to the media content to provide it.",
"learnMore": "Encryption resumes automatically once you stop the feature.",
"cancel": "Cancel",
"confirm": {
"recording": "Turn on",
"transcript": "Turn on"
}
},
"snackbar": {
"pausedTitle": "Encryption paused",
"pausedByMeTitle": "You paused encryption",
"reasonTranscript": "Encryption is paused while transcription is on. It will resume when transcription stops.",
"reasonRecording": "Encryption is paused while the meeting is being recorded. It will resume when recording stops.",
"reasonManual": "An admin turned off encryption for this meeting.",
"reasonSip": "Encryption was paused so a phone participant can join.",
"sipTitle": "A participant can't decrypt this meeting",
"sipBody": "{{name}} joined by phone or another device that cannot decrypt this meeting. Pause encryption from the Admin panel to let them in.",
"openAdmin": "Open admin",
"dismiss": "OK"
},
"sipBlocked": {
"title": "Can't decrypt this caller",
"bodyAdmin": "This participant joined by phone or another device that doesn't support end-to-end encryption. Pause encryption from the Admin panel to bridge their audio and video.",
"bodyParticipant": "This participant joined by phone or another device that doesn't support end-to-end encryption. Ask the host to pause encryption to let them in.",
"openAdmin": "Open admin"
},
"decryptionFailed": {
"title": "Decryption failed",
"body": "Check that you and this person are using the correct meeting link. If they are the only one you can't see, the issue is likely on their side."
}
}
}
+1 -5
View File
@@ -9,10 +9,6 @@
},
"createPopup": {
"title": "Create a meeting",
"standard": "Standard meeting",
"encrypted": "Encrypted meeting",
"advancedEncrypted": "Advanced encrypted meeting",
"advancedDescription": "Maximum security — encryption keys never leave your device. All participants must complete encryption setup before joining. Requires key backup.",
"advancedUnavailable": "Encryption service not available"
"create": "Create"
}
}
+33 -2
View File
@@ -5,8 +5,38 @@
"youAreNotLoggedIn": "You are not logged in.",
"nameLabel": "Your Name",
"authentication": "Authentication",
"nameError": "Your name cannot be empty",
"nameLockedEncryption": "In encrypted meetings, your name comes from your account and cannot be changed."
"nameError": "Your name cannot be empty"
},
"security": {
"heading": "Security",
"signInRequired": "Sign in to set encryption preferences.",
"featureDisabled": "End-to-end encryption is not available on this server.",
"defaultHeading": "New meetings",
"toggle": {
"label": "Encrypt new meetings by default",
"description": "When on, every new meeting you create starts end-to-end encrypted. You can still pause encryption per-meeting from this same Security panel."
},
"confirmModal": {
"title": "Turn on encryption?",
"description": "While encryption is on, these features are unavailable. Encryption can be turned off for individual meetings when needed.",
"items": {
"phone": "Phone dial-in",
"devices": "Meeting room devices",
"transcription": "Transcription",
"recording": "Recording"
},
"footnote": "You can change this preference at any time.",
"cancel": "Cancel",
"confirm": "Turn on"
},
"thisMeeting": {
"heading": "This meeting",
"toggle": {
"label": "Pause encryption",
"descriptionLive": "Encryption is active. Pause it to let a phone or other external device join — the link still carries the encryption key, so you can resume at any time.",
"descriptionPaused": "Encryption is paused. External devices can join in plain audio and video. Toggle off to resume protection."
}
}
},
"preferences": {
"title": "Preferences",
@@ -163,6 +193,7 @@
"audio": "Audio",
"video": "Video",
"general": "General",
"security": "Security",
"notifications": "Notifications",
"accessibility": "Accessibility",
"transcription": "Transcription",
-2
View File
@@ -21,8 +21,6 @@
"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 :"
+1 -17
View File
@@ -22,23 +22,7 @@
"moreAbout": "sur {{appTitle}}",
"createMenu": {
"laterOption": "Créer une réunion pour une date ultérieure",
"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"
},
"encryptionModeDialog": {
"title": "Choisir le mode de chiffrement",
"description": "Sélectionnez le niveau de chiffrement pour votre réunion.",
"basic": {
"title": "Chiffrement basique",
"description": "Protège votre réunion avec une phrase secrète partagée. Accessible à tous — aucune configuration requise."
},
"advanced": {
"title": "Chiffrement avancé",
"description": "Sécurité maximale — la clé de chiffrement ne quitte jamais votre navigateur. Nécessite la configuration du chiffrement pour tous les participants.",
"onboardingRequired": "Vous devez compléter la configuration du chiffrement dans les paramètres de votre compte avant d'utiliser le chiffrement avancé.",
"serviceUnavailable": "Le chiffrement avancé est actuellement indisponible. Veuillez réessayer plus tard ou contacter votre administrateur."
}
"instantOption": "Démarrer une réunion instantanée"
},
"laterMeetingDialog": {
"heading": "Vos informations de connexion",
+66 -91
View File
@@ -54,7 +54,7 @@
"toggleOn": "Cliquez pour activer",
"usernameHint": "Affiché aux autres participants",
"usernameLabel": "Votre nom",
"encryptedNameLocked": "Réunion chiffrée — nom issu de votre compte.",
"encryptedHint": "Cette réunion est chiffrée de bout en bout. Vérifiez que vous avez ouvert le même lien que l'organisateur.",
"errors": {
"usernameEmpty": "Votre nom ne peut pas être vide"
},
@@ -85,15 +85,6 @@
"invalidKey": {
"title": "Lien de réunion invalide",
"body": "Cette réunion chiffrée nécessite une clé de chiffrement valide dans l'URL. Veuillez demander le lien correct à l'organisateur de la réunion."
},
"advancedAuth": {
"title": "Authentification requise",
"body": "Cette réunion utilise le chiffrement avancé. Vous devez être connecté pour la rejoindre."
},
"advancedOnboarding": {
"title": "Configuration du chiffrement requise",
"body": "Cette réunion utilise le chiffrement avancé. Vous devez configurer votre chiffrement avant de pouvoir rejoindre.",
"button": "Configurer le chiffrement"
}
},
"leaveRoomPrompt": "Revenir à l'accueil vous fera quitter la réunion.",
@@ -363,7 +354,6 @@
},
"moreTools": {
"body": "Accéder à davantage d'outils pour améliorer vos réunions.",
"encryptedDisabled": "La transcription et l'enregistrement ne sont pas disponibles dans les réunions chiffrées. Le serveur ne peut pas accéder au contenu média.",
"linkAriaLabel": "Ouvrir la documentation sur les outils - ouvre dans une nouvelle fenêtre",
"moreLink": "Ouvrir la documentation",
"tools": {
@@ -495,7 +485,6 @@
"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": {
@@ -527,6 +516,19 @@
"label": "Partager leur écran",
"description": "En désactivant cette option, les participants ne pourront plus partager leur écran et tout partage en cours sera immédiatement interrompu."
}
},
"encryption": {
"title": "Chiffrement",
"description": "Mettre temporairement le chiffrement de bout en bout en pause pour qu'un téléphone ou un autre appareil externe puisse rejoindre. Le lien de la réunion porte toujours la clé, vous pouvez réactiver à tout moment.",
"toggle": {
"label": "Mettre le chiffrement en pause",
"descriptionLive": "Le chiffrement est actif pour cette réunion.",
"descriptionPaused": "Le chiffrement est en pause — les appareils externes peuvent rejoindre en audio et vidéo non chiffrés."
},
"blocked": {
"recording": "Le chiffrement ne peut pas être réactivé tant qu'un enregistrement est en cours. Arrêtez l'enregistrement d'abord.",
"transcript": "Le chiffrement ne peut pas être réactivé tant qu'une transcription est en cours. Arrêtez la transcription d'abord."
}
}
},
"rating": {
@@ -587,12 +589,6 @@
"button": "Refuser",
"label": "Refuser {{name}} dans la réunion",
"all": "Tout rejeter"
},
"trust": {
"verified": "Identité vérifiée — l'empreinte est de confiance.",
"refused": "ATTENTION — Cette empreinte a été refusée. Cette personne n'est peut-être pas celle qu'elle prétend être.",
"authenticated": "Identité authentifiée (ProConnect). Cliquez pour vérifier l'empreinte.",
"anonymous": "Utilisateur anonyme — identité non vérifiée. Vérifiez son identité avant d'accepter."
}
},
"moreOptions": "Plus d'options"
@@ -694,83 +690,62 @@
"participantTile": {
"screenShare": "Écran de {{name}}"
},
"identity": {
"proconnect": "Connecté",
"anonymous": "Anonyme"
},
"roomStatus": {
"encrypted": "Chiffré de bout en bout",
"paused": "Chiffrement en pause",
"recording": "Enregistrement en cours",
"transcribing": "Transcription en cours"
},
"encryption": {
"banner": "Chiffrement basique",
"bannerStrong": "Chiffrement avancé",
"badge": {
"verified": "Vérifié — empreinte approuvée",
"unknown": "Non vérifié — cliquez pour vérifier l'empreinte",
"refused": "Refusé — empreinte précédemment rejetée",
"authenticated": "Authentifié via ProConnect",
"anonymous": "Identité non vérifiée",
"default": "Chiffré"
},
"bannerModal": {
"title": "Réunion chiffrée de bout en bout",
"descriptionBasic": "Cette réunion utilise le chiffrement de bout en bout basique. L'audio et la vidéo sont chiffrés à l'aide d'une phrase secrète intégrée au lien de la réunion. Toute personne disposant du lien peut rejoindre et déchiffrer le contenu.",
"descriptionAdvanced": "Cette réunion utilise le chiffrement de bout en bout avancé. La clé de chiffrement est gérée par le service de chiffrement de La Suite et ne quitte jamais votre appareil. Tous les participants doivent effectuer l'onboarding chiffrement pour rejoindre.",
"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)",
"limitation2Basic": "La sécurité dépend de la confidentialité du lien de la réunion",
"limitation2Advanced": "Tous les participants doivent effectuer l'onboarding chiffrement dans les paramètres de leur compte avant de rejoindre",
"note": "Pour le plus haut niveau de confiance, utilisez le chiffrement avancé et demandez aux participants de vérifier mutuellement leurs 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.",
"timeout": "Échange de clés expiré",
"timeoutHint": "La clé de chiffrement n'a pas pu être reçue de l'hôte. Il n'est peut-être pas encore dans la réunion.",
"refresh": "Réessayer",
"vaultUnavailable": "Cette réunion nécessite le chiffrement avancé, qui est actuellement indisponible.",
"vaultUnavailableHint": "Il peut s'agir d'un problème temporaire. Essayez de rafraîchir la page. Si le problème persiste, le service de chiffrement est peut-être en maintenance — veuillez réessayer plus tard."
},
"fingerprint": {
"title": "Identité chiffrée",
"description": "Ceci montre l'identité de chiffrement de ce participant. Cela vous permet de vérifier qu'il est bien celui qu'il prétend être.",
"descriptionSelf": "Ceci est votre identité de chiffrement telle que vue par les autres participants. Ils peuvent l'utiliser pour vérifier que vous êtes bien qui vous prétendez être.",
"descriptionSelfAnonymous": "Vous avez rejoint sans vous connecter. Les autres participants voient votre nom comme auto-déclaré et non vérifié. Connectez-vous via ProConnect pour une identité vérifiée.",
"loading": "Vérification des clés de chiffrement...",
"noKey": "Ce participant n'a pas effectué l'onboarding chiffrement. Son identité est vérifiée par le serveur d'authentification (ProConnect) uniquement — pas par une clé publique cryptographique. Il peut configurer le chiffrement dans les paramètres de son compte.",
"noKeyBasicAuthenticated": "Ce participant est connecté via ProConnect. Son nom et son email proviennent du serveur d'authentification et ne peuvent pas être falsifiés.",
"noKeyAnonymous": "Ce participant a rejoint sans se connecter. Son identité est auto-déclarée et non vérifiée. N'acceptez les participants anonymes que si vous pouvez confirmer leur identité par un autre canal.",
"error": "Impossible de vérifier les clés de chiffrement. Le service de chiffrement est peut-être indisponible.",
"fingerprintLabel": "Empreinte de la clé publique",
"fingerprintHint": "Demandez à ce participant d'ouvrir ses paramètres de chiffrement et de comparer l'empreinte affichée avec celle ci-dessus.",
"trusted": "Empreinte vérifiée et approuvée",
"trustedDescription": "Vous avez précédemment vérifié l'empreinte de chiffrement de ce participant. Son identité est confirmée cryptographiquement.",
"refused": "Empreinte refusée",
"refusedDescription": "Vous avez précédemment refusé cette empreinte. L'identité de cette personne n'a pas pu être vérifiée.",
"unknownDescription": "Cette empreinte n'a pas encore été vérifiée. Comparez-la avec le participant (par téléphone, en personne ou par un autre canal sécurisé) pour confirmer son identité.",
"accept": "Approuver",
"refuse": "Refuser",
"changeDecision": "Modifier ma décision",
"anonymous": "Identité non vérifiée"
},
"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."
"mismatch": {
"missingPassphrase": {
"title": "Cette réunion nécessite une phrase secrète de chiffrement",
"body": "Le lien que vous avez utilisé ne contient pas la clé de chiffrement. Demandez à l'organisateur de partager le lien complet, ou créez une nouvelle réunion chiffré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."
"unexpectedPassphrase": {
"title": "Cette réunion n'est pas chiffrée",
"body": "Le lien contient une clé de chiffrement mais la réunion n'est pas configurée pour le chiffrement de bout en bout. Le lien a peut-être été altéré. Créez une nouvelle réunion chiffrée pour garder vos échanges sécurisés."
},
"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."
"createFresh": "Créer une nouvelle réunion chiffrée"
},
"pauseConfirm": {
"title": {
"recording": "Activer l'enregistrement ?",
"transcript": "Activer la transcription ?"
},
"description": "Le chiffrement sera mis en pause pendant cette opération. Le serveur a besoin d'un accès temporaire au contenu pour la fournir.",
"learnMore": "Le chiffrement reprendra automatiquement à l'arrêt de la fonctionnalité.",
"cancel": "Annuler",
"confirm": {
"recording": "Activer",
"transcript": "Activer"
}
},
"snackbar": {
"pausedTitle": "Chiffrement en pause",
"pausedByMeTitle": "Vous avez mis le chiffrement en pause",
"reasonTranscript": "Le chiffrement est en pause pendant la transcription. Il reprendra à l'arrêt.",
"reasonRecording": "Le chiffrement est en pause pendant l'enregistrement. Il reprendra à l'arrêt.",
"reasonManual": "Un administrateur a désactivé le chiffrement pour cette réunion.",
"reasonSip": "Le chiffrement a été mis en pause pour permettre à un participant téléphonique de rejoindre.",
"sipTitle": "Un participant ne peut pas déchiffrer cette réunion",
"sipBody": "{{name}} a rejoint par téléphone ou un appareil incompatible avec le chiffrement. Mettez le chiffrement en pause depuis le panneau Admin pour le laisser entrer.",
"openAdmin": "Ouvrir l'admin",
"dismiss": "OK"
},
"sipBlocked": {
"title": "Impossible de déchiffrer ce participant",
"bodyAdmin": "Ce participant a rejoint depuis un téléphone ou un appareil qui ne prend pas en charge le chiffrement de bout en bout. Mettez le chiffrement en pause depuis le panneau Admin pour relayer son audio et sa vidéo.",
"bodyParticipant": "Ce participant a rejoint depuis un téléphone ou un appareil qui ne prend pas en charge le chiffrement de bout en bout. Demandez à l'hôte de mettre le chiffrement en pause pour lui permettre d'entrer.",
"openAdmin": "Ouvrir l'admin"
},
"decryptionFailed": {
"title": "Échec du déchiffrement",
"body": "Vérifiez que vous et cette personne utilisez le même lien de réunion. Si vous êtes le seul à ne pas la voir, le problème vient probablement de son côté."
}
}
}
+1 -5
View File
@@ -9,10 +9,6 @@
},
"createPopup": {
"title": "Créer une réunion",
"standard": "Réunion standard",
"encrypted": "Réunion chiffrée",
"advancedEncrypted": "Réunion chiffrée avancée",
"advancedDescription": "Sécurité maximale — les clés de chiffrement ne quittent jamais votre appareil. Tous les participants doivent configurer le chiffrement avant de rejoindre. Nécessite une sauvegarde des clés.",
"advancedUnavailable": "Service de chiffrement non disponible"
"create": "Créer"
}
}
+33 -2
View File
@@ -5,8 +5,38 @@
"youAreNotLoggedIn": "Vous n'êtes pas connecté.",
"nameLabel": "Votre Nom",
"authentication": "Authentification",
"nameError": "Votre Nom ne peut pas être vide",
"nameLockedEncryption": "Dans les réunions chiffrées, votre nom provient de votre compte et ne peut pas être modifié."
"nameError": "Votre Nom ne peut pas être vide"
},
"security": {
"heading": "Sécurité",
"signInRequired": "Connectez-vous pour configurer vos préférences de chiffrement.",
"featureDisabled": "Le chiffrement de bout en bout n'est pas disponible sur ce serveur.",
"defaultHeading": "Nouvelles réunions",
"toggle": {
"label": "Chiffrer les nouvelles réunions par défaut",
"description": "Lorsque cette option est activée, chaque nouvelle réunion que vous créez démarre chiffrée de bout en bout. Vous pouvez toujours mettre le chiffrement en pause pour une réunion donnée depuis ce même panneau Sécurité."
},
"confirmModal": {
"title": "Activer le chiffrement ?",
"description": "Pendant que le chiffrement est actif, ces fonctionnalités sont indisponibles. Vous pourrez désactiver le chiffrement pour des réunions individuelles si besoin.",
"items": {
"phone": "Appel téléphonique entrant",
"devices": "Salles de réunion connectées",
"transcription": "Transcription",
"recording": "Enregistrement"
},
"footnote": "Vous pouvez modifier cette préférence à tout moment.",
"cancel": "Annuler",
"confirm": "Activer"
},
"thisMeeting": {
"heading": "Cette réunion",
"toggle": {
"label": "Mettre le chiffrement en pause",
"descriptionLive": "Le chiffrement est actif. Mettez-le en pause pour laisser un téléphone ou un autre appareil externe rejoindre — le lien porte toujours la clé de chiffrement, vous pourrez le réactiver à tout moment.",
"descriptionPaused": "Le chiffrement est en pause. Les appareils externes peuvent rejoindre en audio et vidéo non chiffrés. Désactivez pour réactiver la protection."
}
}
},
"preferences": {
"title": "Préférences",
@@ -163,6 +193,7 @@
"audio": "Audio",
"video": "Vidéo",
"general": "Général",
"security": "Sécurité",
"notifications": "Notifications",
"accessibility": "Accessibilité",
"transcription": "Transcription",