Skip to content

End-to-end encrypted voice and video calls on PairUX (0.4.0, qc 0.4.0) - #281

Merged
ralyodio merged 1 commit into
masterfrom
feat/e2ee-calls
Oct 6, 2026
Merged

ralyodio merged 1 commit into
masterfrom
feat/e2ee-calls

Conversation

@ralyodio

@ralyodio ralyodio commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

Problem: the header's voice and video call buttons drove an MLKEMCallManager built with a null WebSocket. Its signalling lived on the WebSocket server the Next app no longer runs, so calls were dead.

Fix: calls now run on PairUX's SFU through @profullstack/pairux-embed (profullstack/pairux.com#133), with LiveKit end-to-end encryption.

How the key moves

  • Starting a call creates a fresh 32-byte media key.
  • The key goes to the conversation in a call message whose body is ML-KEM-1024 encrypted per participant, the same as any message.
  • Neither qrypt's server nor PairUX ever holds the key. Every audio and video frame is encrypted in the browser, so the SFU forwards ciphertext only.

Server: POST /api/calls/token checks the caller is a participant, then asks PairUX for a token with qrypt's partner key. The key (PAIRUX_PARTNER_KEY) lives only on the server, in app.env and the vault. The room is the conversation id, in PairUX's partner-qrypt-* namespace, which PairUX never records or analyses.

UI

  • CallPanel is an overlay hosting the PairUX widget: tiles, mic, camera, screen share and leave, with 🔒 shown per participant.
  • A browser that can't encrypt is told so. It never falls back to an unencrypted call.
  • Call invites render as "📞 Alice started a voice call · Join" cards, joinable for 6 hours.

qc: shows invites as text and never prints the key. A test covers this.

Migration: 20261006200000_message_type_call, already applied on dev2.

Verification

  • test:ci: 647/647.
  • next build passes and emits the E2EE worker chunk (checked).

Not covered yet

  • A real two-person call needs both deploys live. I'll test it in a browser after merge.
  • Mobile Safari support depends on the browser's insertable streams.

🤖 Generated with Claude Code

The header's call buttons drove an MLKEMCallManager built with a null
WebSocket (signalling on the server the app no longer runs), so calls
were dead. They now run on PairUX's SFU through @profullstack/pairux-embed
with LiveKit E2EE:

- Starting a call makes a fresh 32-byte media key and sends it to the
  conversation in a message of type 'call' whose body is ML-KEM-1024
  encrypted per participant, like any message. Neither qrypt's server nor
  PairUX ever holds the key; every frame is encrypted in the browser.
- POST /api/calls/token: checks you are in the conversation, then asks
  PairUX for a token with qrypt's partner key (PAIRUX_PARTNER_KEY, server
  only). Room = conversation id.
- CallPanel overlay mounts the PairUX widget (tiles, mic/camera/screen/
  leave); a browser that cannot encrypt is told so, never downgraded.
- Call invites render as "📞 Alice started a voice call · Join" cards
  (joinable for 6h); qc shows them as text and never prints the key.
- Migration 20261006200000: message_type 'call' (applied on dev2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 6, 2026

Copy link
Copy Markdown

ThreatCrush Security Scan

13 finding(s)

MEDIUM: 9 | LOW: 4

Severity Rule Location
MEDIUM redos-nested-quantifier src/app/api/profile/update/route.js:73
MEDIUM js-unescaped-html-sink src/app/blog/[slug]/page.jsx:38
MEDIUM js-unescaped-html-sink src/app/blog/[slug]/page.jsx:66
MEDIUM js-unescaped-html-sink src/app/faq/page.jsx:57
MEDIUM js-unescaped-html-sink src/app/layout.jsx:137
MEDIUM js-unescaped-html-sink src/app/layout.jsx:141
MEDIUM js-unescaped-html-sink src/app/page.jsx:47
MEDIUM redos-nested-quantifier src/lib/auth/dns-name.js:88
MEDIUM js-unescaped-html-sink src/lib/components/chat/MessageItem.jsx:121
LOW secret-generic-credential src/app/api/auth/register-anon/route.test.js:32
LOW secret-jwt tests/debug-sms.js:10
LOW secret-generic-credential tests/private-key-import-export.test.js:252
LOW secret-generic-credential tests/private-key-import-export.test.js:264

Snippets are redacted; ThreatCrush never prints matched credential material.

@socket-security

Copy link
Copy Markdown
Contributor

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Added@​profullstack/​pairux-embed@​0.1.0741008487100

View full report

@ralyodio
ralyodio merged commit ac28bf7 into master Oct 6, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant