From 9306943c8db10d4f2592cb4f9016ce8630572c25 Mon Sep 17 00:00:00 2001 From: Wenhao Ji Date: Fri, 22 May 2026 21:03:16 -0700 Subject: [PATCH 1/4] Add experimental verified-meeting host authentication Let a host prove their identity to guests with a passkey, defeating the peer-id squatting that's otherwise possible since the host's PeerJS id is derived from the meeting code. - Trust chain: invite URL pins SHA-256 of the host identity key; the passkey signs an ephemeral session key once per meeting (one biometric prompt); the session key signs each guest's fresh nonce. - Off by default behind a "Verified meeting" experimental toggle; the non-verified path is unchanged. - Guests get a waiting room + auto-join when the host isn't present yet. - Host and guests can open a Host identity dialog to compare the SSH-style fingerprint out-of-band (catches a tampered invite link). - One reusable passkey identity, synced across the host's devices. Limitation (documented in docs/verified-meetings.md): provides host authentication, not yet a fully app-layer-authenticated channel, so an active relay MITM is out of scope for this experimental cut. Co-Authored-By: Claude Opus 4.7 --- docs/verified-meetings.md | 112 ++++++++++++++++ src/components/MeetingRoom.tsx | 134 +++++++++++++++++++ src/components/ShareDialog.tsx | 91 ++++++++++++- src/crypto/encoding.test.ts | 59 +++++++++ src/crypto/encoding.ts | 73 ++++++++++ src/crypto/verify.ts | 211 +++++++++++++++++++++++++++++ src/i18n/locales/en.ts | 47 +++++++ src/i18n/locales/es.ts | 47 +++++++ src/i18n/locales/fr.ts | 47 +++++++ src/i18n/locales/ja.ts | 45 +++++++ src/i18n/locales/zh.ts | 40 ++++++ src/pages/HomePage.tsx | 117 +++++++++++++++- src/pages/MeetingPage.tsx | 204 +++++++++++++++++++++++++++- src/peer/MeetingClient.ts | 199 ++++++++++++++++++++++++--- src/peer/hostIdentity.ts | 119 +++++++++++++++++ src/peer/useMeeting.ts | 56 +++++++- src/peer/verification.test.ts | 216 ++++++++++++++++++++++++++++++ src/peer/verification.ts | 236 +++++++++++++++++++++++++++++++++ src/setupTests.ts | 20 ++- src/types.ts | 28 ++++ src/util/verifiedMeeting.ts | 46 +++++++ 21 files changed, 2106 insertions(+), 41 deletions(-) create mode 100644 docs/verified-meetings.md create mode 100644 src/crypto/encoding.test.ts create mode 100644 src/crypto/encoding.ts create mode 100644 src/crypto/verify.ts create mode 100644 src/peer/hostIdentity.ts create mode 100644 src/peer/verification.test.ts create mode 100644 src/peer/verification.ts create mode 100644 src/util/verifiedMeeting.ts diff --git a/docs/verified-meetings.md b/docs/verified-meetings.md new file mode 100644 index 0000000..4d6305f --- /dev/null +++ b/docs/verified-meetings.md @@ -0,0 +1,112 @@ +# Verified meetings (experimental) + +A meeting code alone proves nothing about *who* is hosting. The host's PeerJS +id is derived deterministically from the code (`rendezvous-`), so anyone +who knows the code can race to claim that id on the public broker and relay the +meeting as a fake "host". **Verified meetings** let a host prove their identity +to guests with a passkey, so guests can refuse to join an impostor. + +This is an **experimental, off-by-default** feature. With it disabled the app +behaves exactly as before — no passkeys, no extra handshake. + +## What the host shares + +When a host enables *Verified meeting* and starts one, the invite link carries +the host's public identity key: + +``` +https://www.predatorray.me/rendezvous/#/m/ABCDEF?vk=&va= +``` + +The Share dialog also shows a **fingerprint** (`SHA256:…`, like an SSH key +fingerprint). The host is encouraged to send the fingerprint over a *second* +channel (in person, a phone call, a signed email). + +In the meeting, both host and guests see a **"Verified" badge**; clicking it +opens a dialog showing the fingerprint (derived from the key pinned in the +invite URL). A guest compares that against the value the host gave them +out-of-band. This is the check that catches a **tampered link**: the +cryptographic handshake only proves the host holds whatever key is *in the +URL*, so if an attacker rewrote `vk` in the link the guest received, the +automatic check still passes — only the human fingerprint comparison detects it. + +## The handshake + +The host identity is a **passkey** (WebAuthn credential). The private key never +leaves the authenticator and syncs across the host's devices via iCloud +Keychain / Google Password Manager. The public key (and its fingerprint) is the +identity guests pin. + +To avoid a biometric prompt on every guest join, the host signs **once per +meeting**: + +1. **Session key.** On starting the meeting the host generates an ephemeral + ECDSA P-256 key pair (WebCrypto, in memory) and has the passkey sign the + session public key — one biometric prompt. This produces a *session cert*. +2. **Per guest.** When a guest connects it sends a fresh random `nonce`. The + host replies with the identity public key, the session public key, the + session cert, and a signature by the *session key* over the nonce. No + further biometric prompts. + +A guest verifies three links in the chain (`src/peer/verification.ts`): + +| # | Check | Defends against | +|---|-------|-----------------| +| 1 | `SHA-256(identity public key)` equals the fingerprint pinned in the URL | a swapped key | +| 2 | The session cert is a valid WebAuthn assertion by the identity key over `hash(code ‖ session public key)`, for this origin/RP, user-present | a key the passkey never vouched for | +| 3 | The session key signed *this guest's* fresh nonce | replay of a recorded cert | + +Only if all three hold does the guest send its name / state / media. Otherwise +it shows an error and refuses to join. + +## Waiting room + +If a guest opens a verified link before the host is present, it shows a +*waiting for host* screen and retries the connection until the host appears, +then verifies and joins automatically. + +## Cross-device + +- **Hosting** a created link from another of the host's devices works: the + synced passkey signs the session cert (chosen via a discoverable-credential + prompt), and it is checked against the key already pinned in the URL. +- **Creating a brand-new link** on a second device mints a *new* identity (a new + fingerprint), because the public key isn't recoverable from a synced passkey + without an assertion. Create your links from one device for a stable + fingerprint. +- Passkey sync is per-ecosystem (Apple **or** Google), as the user confirmed for + this build. + +## Threat model & limitations + +**Defended:** a peer-id squatter or anyone who knows only the code cannot +impersonate the host — they hold no passkey, and an impostor "host" that isn't +running verification answers `auth-unavailable`, which the guest treats as a +failure. The out-of-band fingerprint additionally defends against a tampered +invite link. + +**Not (yet) defended — documented honestly:** a fully active man-in-the-middle +that can both intercept the guest's connection *and* maintain a live connection +to the real host could relay challenges and responses. Closing this requires +binding the proof to the transport (e.g. signing the WebRTC DTLS fingerprint or +an app-layer ECDH exchange and encrypting all relayed traffic). That is a +deliberate follow-up; today's guarantee is host **authentication**, not a fully +authenticated/encrypted-at-the-app-layer channel on top of WebRTC's existing +DTLS. + +Because this is why it's labelled *experimental*, the failure copy tells guests +not to share anything sensitive when verification fails. + +## Code map + +- `src/crypto/encoding.ts` — base64url, SHA-256, byte helpers. +- `src/crypto/verify.ts` — ECDSA DER→raw, WebAuthn assertion & session-signature + verification, fingerprints. +- `src/peer/hostIdentity.ts` — passkey registration + local persistence of the + public parts. +- `src/peer/verification.ts` — challenge derivations, `HostSession` (the + once-per-meeting ceremony + per-guest signing), and `verifyAuthResponse` (the + guest's full check). +- `src/peer/MeetingClient.ts` — `auth-challenge` / `auth-response` wire messages, + guest waiting room + verify-before-join, host signing. +- `src/util/verifiedMeeting.ts` — the experimental flag and URL params. diff --git a/src/components/MeetingRoom.tsx b/src/components/MeetingRoom.tsx index 0bd458b..2228780 100644 --- a/src/components/MeetingRoom.tsx +++ b/src/components/MeetingRoom.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { + Alert, Box, Button, Chip, @@ -11,11 +12,16 @@ import { Drawer, IconButton, Stack, + TextField, + Tooltip, Typography, useMediaQuery, useTheme, } from '@mui/material'; import IosShareIcon from '@mui/icons-material/IosShare'; +import VerifiedUserIcon from '@mui/icons-material/VerifiedUser'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import CheckIcon from '@mui/icons-material/Check'; import VideoGrid from './VideoGrid'; import ChatPanel from './ChatPanel'; import Controls from './Controls'; @@ -25,6 +31,8 @@ import ThemeToggle from './ThemeToggle'; import LanguageMenu from '../i18n/LanguageMenu'; import { Member, TimelineItem } from '../types'; import { displayMeetingCode } from '../util/code'; +import { VerifiedKey } from '../util/verifiedMeeting'; +import { displayFingerprint, fingerprintOf } from '../crypto/verify'; import { useT } from '../i18n/useLangContext'; export interface MeetingRoomProps { @@ -49,6 +57,10 @@ export interface MeetingRoomProps { // When true, auto-open the share dialog once if the user is the host and // is currently alone in the room (used to prompt fresh hosts to invite). autoShareWhenAlone?: boolean; + // Verified meeting (experimental): the host identity key pinned in the URL, + // and (guest side) the confirmed host fingerprint once verification passes. + verifiedKey?: VerifiedKey; + verifiedFingerprint?: string | null; } // Presentational meeting room. Holds only UI state (open panels, splitter, @@ -72,6 +84,8 @@ export default function MeetingRoom({ onLeave, bannerText, autoShareWhenAlone, + verifiedKey, + verifiedFingerprint, }: MeetingRoomProps) { const t = useT(); const theme = useTheme(); @@ -82,6 +96,35 @@ export default function MeetingRoom({ const [participantsOpen, setParticipantsOpen] = useState(false); const [leaveOpen, setLeaveOpen] = useState(false); const [unread, setUnread] = useState(0); + const [identityOpen, setIdentityOpen] = useState(false); + const [identityCopied, setIdentityCopied] = useState(false); + const [hostFingerprint, setHostFingerprint] = useState(null); + + // The fingerprint a guest compares against what the host shared out-of-band. + // Derived from the key pinned in the invite URL (the same value verification + // confirms the host actually holds). + useEffect(() => { + if (!verifiedKey) { + setHostFingerprint(null); + return; + } + let active = true; + fingerprintOf(verifiedKey.publicKey).then((fp) => { + if (active) setHostFingerprint(displayFingerprint(fp)); + }); + return () => { + active = false; + }; + }, [verifiedKey]); + + const copyFingerprint = async () => { + if (!hostFingerprint) return; + try { + await navigator.clipboard.writeText(hostFingerprint); + } catch {} + setIdentityCopied(true); + window.setTimeout(() => setIdentityCopied(false), 1500); + }; // Rail split: ratio of vertical space given to participants when both // participants and chat panels are open. Adjusted by dragging the splitter. @@ -243,6 +286,29 @@ export default function MeetingRoom({ sx={{ height: 20, fontSize: 11, fontWeight: 600 }} /> )} + {verifiedKey && ( + + } + label={ + isHost + ? t.verify_badge_host + : verifiedFingerprint + ? t.verify_badge_verified + : t.verify_badge_pending + } + size="small" + color={ + !isHost && !verifiedFingerprint ? 'default' : 'success' + } + variant={ + !isHost && !verifiedFingerprint ? 'outlined' : 'filled' + } + onClick={() => setIdentityOpen(true)} + sx={{ height: 20, fontSize: 11, fontWeight: 600, cursor: 'pointer' }} + /> + + )} setShareOpen(false)} code={code} + verifiedKey={verifiedKey} /> + setIdentityOpen(false)} + maxWidth="xs" + fullWidth + > + + + + {t.verify_identity_title} + + + + + + {isHost + ? t.verify_identity_body_host + : verifiedFingerprint + ? t.verify_identity_status_verified + : t.verify_identity_status_pending} + + + e.target.select()} + /> + + + + {identityCopied ? : } + + + + + {!isHost && ( + + + {t.verify_identity_compare_hint} + + + )} + + + + + + + setLeaveOpen(false)}> {isHost ? t.meeting_end_for_everyone : t.meeting_leave_title} diff --git a/src/components/ShareDialog.tsx b/src/components/ShareDialog.tsx index 5d781af..e85e5fa 100644 --- a/src/components/ShareDialog.tsx +++ b/src/components/ShareDialog.tsx @@ -1,5 +1,6 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { + Alert, Box, Button, Dialog, @@ -15,6 +16,7 @@ import { import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import CheckIcon from '@mui/icons-material/Check'; import ShareIcon from '@mui/icons-material/Share'; +import VerifiedUserIcon from '@mui/icons-material/VerifiedUser'; import XIcon from '@mui/icons-material/X'; import FacebookIcon from '@mui/icons-material/Facebook'; import WhatsAppIcon from '@mui/icons-material/WhatsApp'; @@ -23,11 +25,16 @@ import RedditIcon from '@mui/icons-material/Reddit'; import EmailIcon from '@mui/icons-material/Email'; import { useT } from '../i18n/useLangContext'; import { displayMeetingCode } from '../util/code'; +import { VerifiedKey, verifiedKeyParams } from '../util/verifiedMeeting'; +import { displayFingerprint, fingerprintOf } from '../crypto/verify'; interface Props { open: boolean; onClose: () => void; code: string; + // When set, the meeting is verified: the link embeds the host key and the + // fingerprint is shown so it can be shared over a second channel. + verifiedKey?: VerifiedKey; } interface ShareTarget { @@ -82,18 +89,41 @@ const TARGETS: ShareTarget[] = [ }, ]; -export default function ShareDialog({ open, onClose, code }: Props) { +export default function ShareDialog({ open, onClose, code, verifiedKey }: Props) { const t = useT(); - const [copiedField, setCopiedField] = useState<'code' | 'link' | null>(null); + const [copiedField, setCopiedField] = useState< + 'code' | 'link' | 'fingerprint' | null + >(null); + const [fingerprint, setFingerprint] = useState(null); + + useEffect(() => { + if (!verifiedKey) { + setFingerprint(null); + return; + } + let active = true; + fingerprintOf(verifiedKey.publicKey).then((fp) => { + if (active) setFingerprint(displayFingerprint(fp)); + }); + return () => { + active = false; + }; + }, [verifiedKey]); const shownCode = displayMeetingCode(code); - const link = `${window.location.origin}${window.location.pathname}#/m/${shownCode}`; + const base = `${window.location.origin}${window.location.pathname}#/m/${shownCode}`; + const link = verifiedKey + ? `${base}?${new URLSearchParams(verifiedKeyParams(verifiedKey)).toString()}` + : base; const shareText = t.share_text(shownCode); const shareSubject = t.share_subject; const canNativeShare = typeof navigator !== 'undefined' && typeof navigator.share === 'function'; - const copy = async (value: string, which: 'code' | 'link') => { + const copy = async ( + value: string, + which: 'code' | 'link' | 'fingerprint' + ) => { try { await navigator.clipboard.writeText(value); } catch { @@ -169,6 +199,57 @@ export default function ShareDialog({ open, onClose, code }: Props) { + {verifiedKey && ( + + + + + {t.share_fingerprint_label} + + + + e.target.select()} + /> + + + fingerprint && copy(fingerprint, 'fingerprint')} + disabled={!fingerprint} + > + {copiedField === 'fingerprint' ? ( + + ) : ( + + )} + + + + + + + {t.share_fingerprint_hint} + + + + )} + { + it('round-trips arbitrary bytes', () => { + for (const len of [0, 1, 2, 3, 16, 31, 32, 91, 256]) { + const bytes = randomBytes(len); + const encoded = bytesToBase64url(bytes); + expect(encoded).not.toMatch(/[+/=]/); // url-safe, unpadded + expect(bytesEqual(base64urlToBytes(encoded), bytes)).toBe(true); + } + }); + + it('matches a known vector', () => { + // "hello" -> base64 "aGVsbG8=" -> base64url "aGVsbG8" + expect(bytesToBase64url(utf8('hello'))).toBe('aGVsbG8'); + expect(fromUtf8(base64urlToBytes('aGVsbG8'))).toBe('hello'); + }); +}); + +describe('sha256', () => { + it('hashes the empty string to the known digest', async () => { + const digest = await sha256(utf8('')); + expect(bytesToBase64url(digest)).toBe( + '47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU' + ); + }); +}); + +describe('concatBytes / bytesEqual', () => { + it('concatenates in order', () => { + const out = concatBytes( + Uint8Array.from([1, 2]), + Uint8Array.from([3]), + Uint8Array.from([4, 5]) + ); + expect(Array.from(out)).toEqual([1, 2, 3, 4, 5]); + }); + + it('compares equal and unequal arrays', () => { + expect(bytesEqual(Uint8Array.from([1, 2]), Uint8Array.from([1, 2]))).toBe( + true + ); + expect(bytesEqual(Uint8Array.from([1, 2]), Uint8Array.from([1, 3]))).toBe( + false + ); + expect(bytesEqual(Uint8Array.from([1]), Uint8Array.from([1, 2]))).toBe( + false + ); + }); +}); diff --git a/src/crypto/encoding.ts b/src/crypto/encoding.ts new file mode 100644 index 0000000..c5d2f67 --- /dev/null +++ b/src/crypto/encoding.ts @@ -0,0 +1,73 @@ +// Low-level encoding helpers shared by the verified-meeting crypto. +// +// Everything that travels over the wire or sits in a URL is base64url +// (RFC 4648 §5, no padding) so it survives query strings and JSON without +// escaping. + +export function bytesToBase64url(input: ArrayBuffer | Uint8Array): string { + const bytes = input instanceof Uint8Array ? input : new Uint8Array(input); + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} + +export function base64urlToBytes(input: string): Uint8Array { + const normalized = input.replace(/-/g, '+').replace(/_/g, '/'); + const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4); + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +export function utf8(input: string): Uint8Array { + return textEncoder.encode(input); +} + +export function fromUtf8(input: ArrayBuffer | Uint8Array): string { + return textDecoder.decode(input); +} + +export function concatBytes(...parts: Uint8Array[]): Uint8Array { + const total = parts.reduce((n, p) => n + p.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const p of parts) { + out.set(p, offset); + offset += p.length; + } + return out; +} + +export async function sha256(data: Uint8Array): Promise { + // Copy into a fresh ArrayBuffer so we never hand digest() a view backed by + // a SharedArrayBuffer (which subtle.digest rejects in some browsers). + const buf = data.slice().buffer; + const digest = await crypto.subtle.digest('SHA-256', buf); + return new Uint8Array(digest); +} + +export function randomBytes(length: number): Uint8Array { + const out = new Uint8Array(length); + crypto.getRandomValues(out); + return out; +} + +// Constant-time-ish comparison for two equal-purpose byte strings. Not a +// hard guarantee in JS, but avoids the most obvious early-exit timing leak. +export function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i]; + return diff === 0; +} diff --git a/src/crypto/verify.ts b/src/crypto/verify.ts new file mode 100644 index 0000000..f0fb9b1 --- /dev/null +++ b/src/crypto/verify.ts @@ -0,0 +1,211 @@ +// Verification primitives for the "verified meeting" feature. +// +// A guest never touches a passkey. It only *verifies*: it checks that the +// public key the host presents matches the fingerprint pinned in the invite +// URL, that a WebAuthn assertion over the host's ephemeral session key is +// valid, and that the session key signed a fresh per-guest nonce. All of that +// is plain WebCrypto, which is why this module has no WebAuthn ceremony code. + +import { + base64urlToBytes, + bytesEqual, + bytesToBase64url, + concatBytes, + fromUtf8, + sha256, + utf8, +} from './encoding'; + +// COSE algorithm identifiers we accept for the host identity passkey. +// Platform passkeys (iCloud Keychain, Google Password Manager) issue ES256; +// RS256 is supported as a fallback for older Windows Hello credentials. +export const COSE_ES256 = -7; +export const COSE_RS256 = -257; + +export function isSupportedAlg(alg: number): boolean { + return alg === COSE_ES256 || alg === COSE_RS256; +} + +/** + * Converts an ASN.1 DER ECDSA signature (`SEQUENCE { r INTEGER, s INTEGER }`) + * into the fixed-width `r || s` form WebCrypto expects. WebAuthn authenticators + * emit DER; WebCrypto's `verify` wants raw, so every ES256 assertion has to be + * translated here. + */ +export function derEcdsaToRaw(der: Uint8Array, coordSize = 32): Uint8Array { + let offset = 0; + if (der[offset++] !== 0x30) throw new Error('Invalid DER: expected sequence'); + // Sequence length (assume short form — ECDSA sigs are well under 128 bytes). + let seqLen = der[offset++]; + if (seqLen & 0x80) { + const n = seqLen & 0x7f; + seqLen = 0; + for (let i = 0; i < n; i++) seqLen = (seqLen << 8) | der[offset++]; + } + const readInt = (): Uint8Array => { + if (der[offset++] !== 0x02) throw new Error('Invalid DER: expected integer'); + const len = der[offset++]; + let bytes = der.slice(offset, offset + len); + offset += len; + // Strip leading sign byte, then left-pad to the coordinate width. + while (bytes.length > coordSize && bytes[0] === 0x00) bytes = bytes.slice(1); + if (bytes.length < coordSize) { + const padded = new Uint8Array(coordSize); + padded.set(bytes, coordSize - bytes.length); + bytes = padded; + } + return bytes; + }; + const r = readInt(); + const s = readInt(); + return concatBytes(r, s); +} + +async function importVerifyKey( + spki: Uint8Array, + alg: number +): Promise { + const buf = spki.slice().buffer; + if (alg === COSE_ES256) { + return crypto.subtle.importKey( + 'spki', + buf, + { name: 'ECDSA', namedCurve: 'P-256' }, + false, + ['verify'] + ); + } + if (alg === COSE_RS256) { + return crypto.subtle.importKey( + 'spki', + buf, + { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, + false, + ['verify'] + ); + } + throw new Error(`Unsupported key algorithm ${alg}`); +} + +async function verifySignature( + spki: Uint8Array, + alg: number, + rawOrDerSignature: Uint8Array, + data: Uint8Array, + signatureIsDer: boolean +): Promise { + const key = await importVerifyKey(spki, alg); + const dataBuf = data.slice().buffer; + if (alg === COSE_ES256) { + const raw = signatureIsDer + ? derEcdsaToRaw(rawOrDerSignature) + : rawOrDerSignature; + return crypto.subtle.verify( + { name: 'ECDSA', hash: 'SHA-256' }, + key, + raw.slice().buffer, + dataBuf + ); + } + // RS256 signatures are raw in both WebAuthn and WebCrypto. + return crypto.subtle.verify( + { name: 'RSASSA-PKCS1-v1_5' }, + key, + rawOrDerSignature.slice().buffer, + dataBuf + ); +} + +/** A fingerprint is the SHA-256 of the SPKI public key, shown SSH-style. */ +export async function fingerprintOf(publicKeySpkiB64url: string): Promise { + const digest = await sha256(base64urlToBytes(publicKeySpkiB64url)); + return bytesToBase64url(digest); +} + +/** + * Renders a fingerprint the way OpenSSH does (`SHA256:`), so the value + * the host reads aloud or pastes into a calendar invite is unambiguous and + * recognizable. + */ +export function displayFingerprint(fingerprintB64url: string): string { + return `SHA256:${fingerprintB64url}`; +} + +interface ClientData { + type: string; + challenge: string; + origin: string; +} + +function parseClientData(clientDataJSON: Uint8Array): ClientData { + return JSON.parse(fromUtf8(clientDataJSON)); +} + +export interface AssertionVerifyInput { + // The trusted identity key, taken from the invite URL fingerprint chain. + identityPublicKeySpki: Uint8Array; + identityAlg: number; + // Raw WebAuthn assertion fields. + authenticatorData: Uint8Array; + clientDataJSON: Uint8Array; + signature: Uint8Array; + // What the guest expects to have been signed / where. + expectedChallenge: Uint8Array; + expectedOrigin: string; + expectedRpId: string; +} + +/** + * Verifies a WebAuthn assertion the host produced over its session key. + * Returns true only if the assertion is structurally valid, was generated for + * this origin/RP, signed exactly the challenge we expected, and the signature + * checks out against the pinned identity key. + */ +export async function verifyWebAuthnAssertion( + input: AssertionVerifyInput +): Promise { + const client = parseClientData(input.clientDataJSON); + if (client.type !== 'webauthn.get') return false; + if (client.origin !== input.expectedOrigin) return false; + if (client.challenge !== bytesToBase64url(input.expectedChallenge)) { + return false; + } + + // authenticatorData = rpIdHash(32) || flags(1) || signCount(4) || ... + if (input.authenticatorData.length < 37) return false; + const rpIdHash = input.authenticatorData.slice(0, 32); + const expectedRpIdHash = await sha256(utf8(input.expectedRpId)); + if (!bytesEqual(rpIdHash, expectedRpIdHash)) return false; + const flags = input.authenticatorData[32]; + const userPresent = (flags & 0x01) === 0x01; + if (!userPresent) return false; + + const clientDataHash = await sha256(input.clientDataJSON); + const signedData = concatBytes(input.authenticatorData, clientDataHash); + return verifySignature( + input.identityPublicKeySpki, + input.identityAlg, + input.signature, + signedData, + /* signatureIsDer */ true + ); +} + +/** + * Verifies a raw ECDSA-P256 signature made by the host's ephemeral session + * key (not a passkey — a WebCrypto key), used for the fresh per-guest liveness + * proof. + */ +export async function verifySessionSignature( + sessionPublicKeySpki: Uint8Array, + signature: Uint8Array, + data: Uint8Array +): Promise { + return verifySignature( + sessionPublicKeySpki, + COSE_ES256, + signature, + data, + /* signatureIsDer */ false + ); +} diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index a74011e..7d0fcda 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -108,6 +108,53 @@ const en = { theme_light: 'light', language_change: 'Change language', + + // Verified meeting (experimental) + verify_toggle_label: 'Verified meeting', + verify_experimental_tag: 'Experimental', + verify_toggle_hint: + 'Guests cryptographically verify it is really you hosting, using a passkey.', + verify_unsupported: 'Passkeys are not supported in this browser.', + verify_host_button: 'Host verified meeting', + verify_create_failed: 'Could not create your passkey identity.', + + verify_host_unlock_title: 'Unlock to host', + verify_host_unlock_body: + 'Confirm with your passkey so guests can verify this meeting is really hosted by you.', + verify_host_unlock_cta: 'Verify with passkey', + verify_host_unlocking: 'Waiting for passkey…', + verify_host_unlock_failed: 'Passkey verification failed.', + + verify_waiting_title: 'Waiting for the host', + verify_waiting_body: + 'This meeting hasn’t started yet. You’ll join automatically once the host arrives.', + verify_checking: 'Verifying host identity…', + + verify_badge_host: 'Verified host', + verify_badge_verified: 'Verified', + verify_badge_pending: 'Verifying…', + + verify_error_timeout: 'The host did not respond to the identity check.', + verify_error_unavailable: + 'This meeting could not be verified. The host isn’t running verification, or someone may be impersonating them.', + verify_error_failed: + 'Host identity could not be verified. Do not share anything sensitive — you may not be talking to the real host.', + + share_fingerprint_label: 'Host fingerprint', + share_copy_fingerprint: 'Copy fingerprint', + share_fingerprint_hint: + 'Share this fingerprint over a separate channel (in person, a call). Guests can compare it to confirm there’s no impostor.', + + verify_identity_view: 'View host identity', + verify_identity_title: 'Host identity', + verify_identity_body_host: + 'This is your meeting’s fingerprint. Guests who were given it can confirm they reached you.', + verify_identity_status_verified: + 'This host’s identity has been cryptographically verified.', + verify_identity_status_pending: + 'Verifying the host’s identity…', + verify_identity_compare_hint: + 'Compare this with the fingerprint the host gave you separately (in person, a call, a signed message). If they don’t match, the link may have been tampered with — don’t trust the meeting.', } as const; export default en; diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 967dc4c..1f018ff 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -108,6 +108,53 @@ const es = { theme_light: 'claro', language_change: 'Cambiar idioma', + + // Reunión verificada (experimental) + verify_toggle_label: 'Reunión verificada', + verify_experimental_tag: 'Experimental', + verify_toggle_hint: + 'Los invitados verifican criptográficamente que de verdad eres tú quien organiza, mediante una llave de acceso.', + verify_unsupported: 'Este navegador no admite llaves de acceso.', + verify_host_button: 'Organizar reunión verificada', + verify_create_failed: 'No se pudo crear tu identidad con llave de acceso.', + + verify_host_unlock_title: 'Desbloquear para organizar', + verify_host_unlock_body: + 'Confirma con tu llave de acceso para que los invitados puedan verificar que esta reunión la organizas tú.', + verify_host_unlock_cta: 'Verificar con llave de acceso', + verify_host_unlocking: 'Esperando la llave de acceso…', + verify_host_unlock_failed: 'Falló la verificación con llave de acceso.', + + verify_waiting_title: 'Esperando al anfitrión', + verify_waiting_body: + 'Esta reunión aún no ha empezado. Te unirás automáticamente cuando llegue el anfitrión.', + verify_checking: 'Verificando la identidad del anfitrión…', + + verify_badge_host: 'Anfitrión verificado', + verify_badge_verified: 'Verificado', + verify_badge_pending: 'Verificando…', + + verify_error_timeout: + 'El anfitrión no respondió a la verificación de identidad.', + verify_error_unavailable: + 'No se pudo verificar esta reunión. El anfitrión no usa verificación, o alguien podría estar suplantándolo.', + verify_error_failed: + 'No se pudo verificar la identidad del anfitrión. No compartas nada sensible: puede que no estés hablando con el anfitrión real.', + + share_fingerprint_label: 'Huella del anfitrión', + share_copy_fingerprint: 'Copiar huella', + share_fingerprint_hint: + 'Comparte esta huella por otro canal (en persona, por llamada). Los invitados pueden compararla para descartar a un impostor.', + + verify_identity_view: 'Ver identidad del anfitrión', + verify_identity_title: 'Identidad del anfitrión', + verify_identity_body_host: + 'Esta es la huella de tu reunión. Los invitados a quienes se la diste pueden confirmar que te localizaron a ti.', + verify_identity_status_verified: + 'La identidad de este anfitrión se ha verificado criptográficamente.', + verify_identity_status_pending: 'Verificando la identidad del anfitrión…', + verify_identity_compare_hint: + 'Compárala con la huella que el anfitrión te dio por separado (en persona, por llamada, en un mensaje firmado). Si no coinciden, el enlace pudo haber sido manipulado: no confíes en la reunión.', } as const; export default es; diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 2e9191c..230b2d1 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -108,6 +108,53 @@ const fr = { theme_light: 'clair', language_change: 'Changer de langue', + + // Réunion vérifiée (expérimental) + verify_toggle_label: 'Réunion vérifiée', + verify_experimental_tag: 'Expérimental', + verify_toggle_hint: + 'Les invités vérifient de façon cryptographique que c’est bien vous qui animez, via une clé d’accès.', + verify_unsupported: + 'Les clés d’accès ne sont pas prises en charge par ce navigateur.', + verify_host_button: 'Animer une réunion vérifiée', + verify_create_failed: 'Impossible de créer votre identité par clé d’accès.', + + verify_host_unlock_title: 'Déverrouiller pour animer', + verify_host_unlock_body: + 'Confirmez avec votre clé d’accès afin que les invités puissent vérifier que cette réunion est bien animée par vous.', + verify_host_unlock_cta: 'Vérifier avec la clé d’accès', + verify_host_unlocking: 'En attente de la clé d’accès…', + verify_host_unlock_failed: 'Échec de la vérification par clé d’accès.', + + verify_waiting_title: 'En attente de l’hôte', + verify_waiting_body: + 'Cette réunion n’a pas encore commencé. Vous rejoindrez automatiquement dès que l’hôte arrivera.', + verify_checking: 'Vérification de l’identité de l’hôte…', + + verify_badge_host: 'Hôte vérifié', + verify_badge_verified: 'Vérifié', + verify_badge_pending: 'Vérification…', + + verify_error_timeout: 'L’hôte n’a pas répondu à la vérification d’identité.', + verify_error_unavailable: + 'Cette réunion n’a pas pu être vérifiée. L’hôte n’utilise pas la vérification, ou quelqu’un l’usurpe peut-être.', + verify_error_failed: + 'L’identité de l’hôte n’a pas pu être vérifiée. Ne partagez rien de sensible : vous ne parlez peut-être pas au véritable hôte.', + + share_fingerprint_label: 'Empreinte de l’hôte', + share_copy_fingerprint: 'Copier l’empreinte', + share_fingerprint_hint: + 'Partagez cette empreinte par un autre canal (en personne, par appel). Les invités peuvent la comparer pour écarter tout imposteur.', + + verify_identity_view: 'Voir l’identité de l’hôte', + verify_identity_title: 'Identité de l’hôte', + verify_identity_body_host: + 'Voici l’empreinte de votre réunion. Les invités à qui vous l’avez communiquée peuvent confirmer qu’ils vous ont bien joint.', + verify_identity_status_verified: + 'L’identité de cet hôte a été vérifiée de façon cryptographique.', + verify_identity_status_pending: 'Vérification de l’identité de l’hôte…', + verify_identity_compare_hint: + 'Comparez ceci avec l’empreinte que l’hôte vous a communiquée séparément (en personne, par appel, par message signé). Si elles ne correspondent pas, le lien a peut-être été falsifié — ne faites pas confiance à la réunion.', } as const; export default fr; diff --git a/src/i18n/locales/ja.ts b/src/i18n/locales/ja.ts index 0785180..61a6039 100644 --- a/src/i18n/locales/ja.ts +++ b/src/i18n/locales/ja.ts @@ -108,6 +108,51 @@ const ja = { theme_light: 'ライト', language_change: '言語を変更', + + // 検証済みミーティング(実験的) + verify_toggle_label: '検証済みミーティング', + verify_experimental_tag: '実験的', + verify_toggle_hint: + 'ゲストはパスキーを使って、本当にあなたが主催していることを暗号的に検証します。', + verify_unsupported: 'このブラウザはパスキーに対応していません。', + verify_host_button: '検証済みミーティングを主催', + verify_create_failed: 'パスキーIDを作成できませんでした。', + + verify_host_unlock_title: '主催するにはロック解除', + verify_host_unlock_body: + 'パスキーで確認すると、ゲストはこのミーティングを本当にあなたが主催していることを検証できます。', + verify_host_unlock_cta: 'パスキーで確認', + verify_host_unlocking: 'パスキーを待っています…', + verify_host_unlock_failed: 'パスキーの検証に失敗しました。', + + verify_waiting_title: 'ホストを待っています', + verify_waiting_body: + 'このミーティングはまだ開始されていません。ホストが参加すると自動的に参加します。', + verify_checking: 'ホストの本人確認中…', + + verify_badge_host: '検証済みホスト', + verify_badge_verified: '検証済み', + verify_badge_pending: '検証中…', + + verify_error_timeout: 'ホストが本人確認に応答しませんでした。', + verify_error_unavailable: + 'このミーティングを検証できませんでした。ホストが検証を有効にしていないか、誰かがなりすましている可能性があります。', + verify_error_failed: + 'ホストの本人確認ができませんでした。機密情報は共有しないでください。本物のホストと話していない可能性があります。', + + share_fingerprint_label: 'ホストのフィンガープリント', + share_copy_fingerprint: 'フィンガープリントをコピー', + share_fingerprint_hint: + 'このフィンガープリントを別の手段(対面・通話)で共有してください。ゲストは照合してなりすましがないことを確認できます。', + + verify_identity_view: 'ホストの身元を表示', + verify_identity_title: 'ホストの身元', + verify_identity_body_host: + 'これはこのミーティングのフィンガープリントです。これを伝えたゲストは、本当にあなたに接続できたことを確認できます。', + verify_identity_status_verified: 'このホストの身元は暗号的に検証されました。', + verify_identity_status_pending: 'ホストの身元を検証中…', + verify_identity_compare_hint: + 'ホストから別の手段(対面・通話・署名付きメッセージ)で伝えられたフィンガープリントと照合してください。一致しない場合、リンクが改ざんされている可能性があります。そのミーティングを信用しないでください。', } as const; export default ja; diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index dabe33f..a586246 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -105,6 +105,46 @@ const zh = { theme_light: '浅色', language_change: '切换语言', + + // 已验证会议(实验性) + verify_toggle_label: '已验证会议', + verify_experimental_tag: '实验性', + verify_toggle_hint: '访客通过通行密钥以加密方式验证确实是你在主持。', + verify_unsupported: '此浏览器不支持通行密钥。', + verify_host_button: '主持已验证会议', + verify_create_failed: '无法创建你的通行密钥身份。', + + verify_host_unlock_title: '解锁以主持', + verify_host_unlock_body: '用你的通行密钥确认,以便访客能验证这场会议确实由你主持。', + verify_host_unlock_cta: '用通行密钥验证', + verify_host_unlocking: '正在等待通行密钥…', + verify_host_unlock_failed: '通行密钥验证失败。', + + verify_waiting_title: '正在等待主持人', + verify_waiting_body: '会议尚未开始。主持人到场后你将自动加入。', + verify_checking: '正在验证主持人身份…', + + verify_badge_host: '已验证主持人', + verify_badge_verified: '已验证', + verify_badge_pending: '验证中…', + + verify_error_timeout: '主持人未响应身份验证。', + verify_error_unavailable: '无法验证此会议。主持人未启用验证,或有人可能在冒充主持人。', + verify_error_failed: + '无法验证主持人身份。请勿分享任何敏感信息——你可能并非在与真正的主持人通话。', + + share_fingerprint_label: '主持人指纹', + share_copy_fingerprint: '复制指纹', + share_fingerprint_hint: + '请通过另一渠道(当面、电话)分享此指纹。访客可对照确认没有冒充者。', + + verify_identity_view: '查看主持人身份', + verify_identity_title: '主持人身份', + verify_identity_body_host: '这是本次会议的指纹。收到它的访客可据此确认确实联系到了你。', + verify_identity_status_verified: '该主持人的身份已通过加密验证。', + verify_identity_status_pending: '正在验证主持人身份…', + verify_identity_compare_hint: + '请将此指纹与主持人通过其他方式(当面、电话、已签名的消息)告知你的指纹进行对照。若不一致,链接可能已被篡改——请勿信任该会议。', } as const; export default zh; diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 4ad5d30..362c412 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -5,14 +5,19 @@ import { Container, Divider, Stack, + Switch, TextField, + Tooltip, Typography, Paper, Alert, + CircularProgress, + FormControlLabel, Link, } from '@mui/material'; import VideocamIcon from '@mui/icons-material/Videocam'; import LoginIcon from '@mui/icons-material/Login'; +import VerifiedUserIcon from '@mui/icons-material/VerifiedUser'; import { useNavigate } from 'react-router-dom'; import ThemeToggle from '../components/ThemeToggle'; import { @@ -21,6 +26,15 @@ import { normalizeMeetingCode, } from '../util/code'; import { getStoredName, setStoredName } from '../util/storage'; +import { + getOrCreateHostIdentity, + webAuthnAvailable, +} from '../peer/hostIdentity'; +import { + isVerifiedFeatureEnabled, + setVerifiedFeatureEnabled, + verifiedKeyParams, +} from '../util/verifiedMeeting'; import { useT } from '../i18n/useLangContext'; import LanguageMenu from '../i18n/LanguageMenu'; @@ -33,10 +47,14 @@ export default function HomePage() { const [code, setCode] = useState(''); const [error, setError] = useState(null); const [nameTouched, setNameTouched] = useState(false); + const [verifiedEnabled, setVerifiedEnabled] = useState(false); + const [busy, setBusy] = useState(false); const nameInputRef = useRef(null); + const passkeysAvailable = webAuthnAvailable(); useEffect(() => { setName(getStoredName()); + setVerifiedEnabled(isVerifiedFeatureEnabled()); }, []); const trimmedName = name.trim(); @@ -51,22 +69,52 @@ export default function HomePage() { nameInputRef.current?.focus(); }; - const proceed = (mode: Mode, meetingCode: string) => { + const proceed = ( + mode: Mode, + meetingCode: string, + extra?: Record + ) => { setStoredName(trimmedName); const search = new URLSearchParams(); search.set('name', trimmedName); if (mode === 'host') search.set('host', '1'); + if (extra) { + for (const [k, v] of Object.entries(extra)) search.set(k, v); + } navigate(`/m/${meetingCode}?${search.toString()}`); }; - const handleHost = () => { + const toggleVerified = (enabled: boolean) => { + setVerifiedEnabled(enabled); + setVerifiedFeatureEnabled(enabled); + }; + + const handleHost = async () => { setError(null); if (!hasName) { setError(t.home_error_name); focusName(); return; } - proceed('host', generateMeetingCode()); + if (!verifiedEnabled) { + proceed('host', generateMeetingCode()); + return; + } + // Verified meeting: mint (or reuse) the host's passkey identity, then carry + // its public key in the invite URL so guests can verify the host. + setBusy(true); + try { + const identity = await getOrCreateHostIdentity(); + proceed( + 'host', + generateMeetingCode(), + verifiedKeyParams({ publicKey: identity.publicKey, alg: identity.alg }) + ); + } catch (e: any) { + setError(e?.message ?? t.verify_create_failed); + } finally { + setBusy(false); + } }; const handleJoin = () => { @@ -186,13 +234,72 @@ export default function HomePage() { + + + toggleVerified(e.target.checked)} + inputProps={{ 'aria-label': t.verify_toggle_label }} + /> + } + label={ + + + + {t.verify_toggle_label} + + + {t.verify_experimental_tag} + + + + {t.verify_toggle_hint} + + + } + /> + + + {t.home_or_join} diff --git a/src/pages/MeetingPage.tsx b/src/pages/MeetingPage.tsx index acc44ca..6a82b89 100644 --- a/src/pages/MeetingPage.tsx +++ b/src/pages/MeetingPage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Box, Button, @@ -6,11 +6,17 @@ import { Stack, Typography, } from '@mui/material'; +import VerifiedUserIcon from '@mui/icons-material/VerifiedUser'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import MeetingRoom from '../components/MeetingRoom'; import { useMeeting } from '../peer/useMeeting'; import { useIsSpeaking } from '../peer/useIsSpeaking'; +import { VerificationConfig } from '../peer/MeetingClient'; +import { HostSession } from '../peer/verification'; +import { loadHostIdentity } from '../peer/hostIdentity'; +import { fingerprintOf, displayFingerprint } from '../crypto/verify'; import { displayMeetingCode, isValidMeetingCode } from '../util/code'; +import { parseVerifiedKey, VerifiedKey } from '../util/verifiedMeeting'; import { getStoredName, setStoredName } from '../util/storage'; import { useT } from '../i18n/useLangContext'; @@ -23,6 +29,9 @@ export default function MeetingPage() { // Lock host flag at mount: subsequent URL changes shouldn't flip you // from host to guest mid-meeting. const [isHost] = useState(() => searchParams.get('host') === '1'); + const [verifiedKey] = useState(() => + parseVerifiedKey(searchParams) + ); const [initialName] = useState(() => ((searchParams.get('name') || '').trim() || getStoredName().trim()) ); @@ -92,32 +101,213 @@ export default function MeetingPage() { ); } + // A verified host must unlock with their passkey before the meeting starts. + if (isHost && verifiedKey) { + return ( + + ); + } + + // A guest joining a verified link verifies the host's identity automatically. + if (!isHost && verifiedKey) { + return ( + + ); + } + return ( ); } +/** + * Pre-meeting screen for a verified host. Performs the one-time passkey + * ceremony (within a user gesture) that vouches for this session's key, then + * mounts the live meeting with verification enabled. + */ +function VerifiedHostGate({ + code, + name, + verifiedKey, +}: { + code: string; + name: string; + verifiedKey: VerifiedKey; +}) { + const navigate = useNavigate(); + const t = useT(); + const [session, setSession] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const unlock = async () => { + setBusy(true); + setError(null); + try { + const created = await HostSession.create({ + code, + identityCredentialId: loadHostIdentity()?.credentialId ?? null, + identityPublicKey: verifiedKey.publicKey, + identityAlg: verifiedKey.alg, + }); + setSession(created); + } catch (e: any) { + setError(e?.message ?? t.verify_host_unlock_failed); + } finally { + setBusy(false); + } + }; + + if (session) { + return ( + + ); + } + + return ( + + + + {t.verify_host_unlock_title} + + {t.verify_host_unlock_body} + + {error && ( + + {error} + + )} + + + + + ); +} + +/** + * Wrapper that derives the expected host fingerprint from the invite URL key, + * then runs the live meeting as a verifying guest. + */ +function VerifiedGuest({ + code, + name, + verifiedKey, +}: { + code: string; + name: string; + verifiedKey: VerifiedKey; +}) { + const t = useT(); + const [config, setConfig] = useState(null); + + useEffect(() => { + let active = true; + fingerprintOf(verifiedKey.publicKey).then((expectedFingerprint) => { + if (active) setConfig({ role: 'guest', expectedFingerprint }); + }); + return () => { + active = false; + }; + }, [verifiedKey.publicKey]); + + if (!config) { + return ( + + + + {t.meeting_preparing} + + + ); + } + + return ( + + ); +} + function LiveMeeting({ code, name, isHost, + verification, + verifiedKey, }: { code: string; name: string; isHost: boolean; + verification?: VerificationConfig; + verifiedKey?: VerifiedKey; }) { const navigate = useNavigate(); const t = useT(); - const meeting = useMeeting({ code, name, isHost }); + const meeting = useMeeting({ code, name, isHost, verification }); const isSpeaking = useIsSpeaking(meeting.localStream, meeting.audioEnabled); - if (meeting.phase === 'preparing' || meeting.phase === 'joining') { + if (meeting.phase === 'waiting') { + return ( + + + + {t.verify_waiting_title} + + {t.verify_waiting_body} + + + + + ); + } + + if ( + meeting.phase === 'preparing' || + meeting.phase === 'joining' || + meeting.phase === 'verifying' + ) { return ( - {meeting.phase === 'preparing' + {meeting.phase === 'verifying' + ? t.verify_checking + : meeting.phase === 'preparing' ? t.meeting_preparing : isHost ? t.meeting_starting @@ -174,6 +364,12 @@ function LiveMeeting({ videoEnabled={meeting.videoEnabled} isSpeaking={isSpeaking} autoShareWhenAlone + verifiedKey={verifiedKey} + verifiedFingerprint={ + meeting.verifiedFingerprint + ? displayFingerprint(meeting.verifiedFingerprint) + : null + } onToggleAudio={meeting.toggleAudio} onToggleVideo={meeting.toggleVideo} onSendChat={meeting.sendChat} diff --git a/src/peer/MeetingClient.ts b/src/peer/MeetingClient.ts index a67579b..207dc78 100644 --- a/src/peer/MeetingClient.ts +++ b/src/peer/MeetingClient.ts @@ -1,5 +1,6 @@ import Peer, { DataConnection, MediaConnection } from 'peerjs'; import { + AuthResponsePayload, ChatMessage, Member, SystemMessage, @@ -11,6 +12,15 @@ import { peerIdForMeeting, randomClientPeerId, } from '../util/code'; +import { HostSession, freshNonce, verifyAuthResponse } from './verification'; + +/** + * Verified-meeting configuration (experimental). Absent for ordinary meetings, + * in which case the client behaves exactly as it always has. + */ +export type VerificationConfig = + | { role: 'host'; session: HostSession } + | { role: 'guest'; expectedFingerprint: string }; /** * Event payloads for subscribers. @@ -27,6 +37,10 @@ export interface MeetingEvents { detail?: string ) => void; ready: (selfId: string) => void; + // Verified meeting (experimental) lifecycle, guest side only. + waiting: () => void; // host not present yet; waiting room + verifying: () => void; // connected, checking host identity + verified: (fingerprint: string) => void; // host identity confirmed } type EventName = keyof MeetingEvents; @@ -69,6 +83,7 @@ interface ConstructorArgs { code: string; name: string; isHost: boolean; + verification?: VerificationConfig; } interface MetadataPayload { @@ -99,15 +114,24 @@ export class MeetingClient { // Client-only state. private hostDataConn: DataConnection | null = null; + // Verified meeting (experimental) state. + private verification?: VerificationConfig; + private clientConnected = false; // data conn to host is open + private waitingForHost = false; + private authNonce: string | null = null; + private authTimer: ReturnType | null = null; + private retryTimer: ReturnType | null = null; + // Tracks remote streams by logical peer id for both host and client. private remoteStreams = new Map(); private listeners: { [K in EventName]?: Set> } = {}; - constructor({ code, name, isHost }: ConstructorArgs) { + constructor({ code, name, isHost, verification }: ConstructorArgs) { this.code = code; this.name = name; this.isHost = isHost; + this.verification = verification; this.hostId = peerIdForMeeting(code); } @@ -280,6 +304,10 @@ export class MeetingClient { private handleMessageFromClient(conn: DataConnection, msg: WireMessage): void { switch (msg.type) { + case 'auth-challenge': { + this.respondToAuthChallenge(conn, msg.nonce); + break; + } case 'hello': { const member: Member = { id: conn.peer, @@ -340,6 +368,24 @@ export class MeetingClient { } } + private respondToAuthChallenge(conn: DataConnection, nonce: string): void { + const v = this.verification; + if (!v || v.role !== 'host') { + // We are not a verified host — tell the guest so it can bail fast. + this.safeSend(conn, { type: 'auth-unavailable' }); + return; + } + v.session + .signFor(nonce) + .then((payload) => + this.safeSend(conn, { type: 'auth-response', payload }) + ) + .catch((e) => { + console.error('failed to sign auth challenge', e); + this.safeSend(conn, { type: 'auth-unavailable' }); + }); + } + private handleClientGone(peerId: string): void { const member = this.members.get(peerId); if (!member) return; @@ -463,39 +509,57 @@ export class MeetingClient { // ---- Client ---- + private isVerifiedGuest(): boolean { + return this.verification?.role === 'guest'; + } + private startAsClient(): void { const peer = this.peer!; // Accept incoming calls from host (they carry other peers' streams). peer.on('call', (call) => this.handleIncomingCallAsClient(call)); peer.on('error', (err) => { - console.error('Client peer error', err); - // PeerJS surfaces peer-unavailable when host id doesn't exist. + // PeerJS surfaces peer-unavailable when the host id isn't registered. if ((err as any).type === 'peer-unavailable') { + // Verified meetings show a waiting room and keep retrying until the + // host appears; ordinary meetings keep the original behavior. + if (this.isVerifiedGuest() && !this.clientConnected) { + this.enterWaiting(); + return; + } this.emit('ended', 'error', 'Meeting not found.'); this.shutdown(); + return; } + console.error('Client peer error', err); }); + this.connectToHost(); + } + + private connectToHost(): void { + const peer = this.peer; + if (!peer) return; + // Drop any dead connection from a previous (failed) waiting-room attempt. + if (this.hostDataConn) { + try { + this.hostDataConn.close(); + } catch {} + } const dc = peer.connect(this.hostId, { reliable: true }); this.hostDataConn = dc; dc.on('open', () => { - this.safeSend(dc, { type: 'hello', name: this.name }); - this.publishOwnState(); - // Place a call to host with our stream so host can relay it. - if (this.localStream && this.peer) { - const meta: MetadataPayload = { peerId: this.selfId }; - const outgoing = this.peer.call(this.hostId, this.localStream, { - metadata: meta, - }); - outgoing?.on('stream', (incoming) => { - // This is host's own stream. - this.remoteStreams.set(this.hostId, incoming); - this.emit('remoteStream', this.hostId, incoming); - }); + this.clientConnected = true; + this.waitingForHost = false; + if (this.retryTimer) { + clearTimeout(this.retryTimer); + this.retryTimer = null; } + this.onHostConnectionOpen(dc); }); dc.on('data', (raw) => this.handleMessageFromHost(raw as WireMessage)); dc.on('close', () => { + // Ignore stale connections abandoned while retrying in the waiting room. + if (!this.clientConnected) return; this.emit('ended', 'host-left'); this.shutdown(); }); @@ -504,8 +568,103 @@ export class MeetingClient { }); } + private enterWaiting(): void { + if (!this.waitingForHost) { + this.waitingForHost = true; + this.emit('waiting'); + } + if (this.retryTimer) clearTimeout(this.retryTimer); + this.retryTimer = setTimeout(() => { + if (this.peer) this.connectToHost(); + }, 2500); + } + + private onHostConnectionOpen(dc: DataConnection): void { + if (this.isVerifiedGuest()) { + // Verify the host's identity before revealing our name, state or media. + this.authNonce = freshNonce(); + this.emit('verifying'); + this.safeSend(dc, { type: 'auth-challenge', nonce: this.authNonce }); + if (this.authTimer) clearTimeout(this.authTimer); + this.authTimer = setTimeout( + () => this.failVerification('verify-timeout'), + 15000 + ); + return; + } + this.joinHost(dc); + } + + // Sends hello, publishes state and offers our stream. Runs only once the + // host is trusted: immediately for ordinary meetings, after a successful + // identity check for verified ones. + private joinHost(dc: DataConnection): void { + this.safeSend(dc, { type: 'hello', name: this.name }); + this.publishOwnState(); + // Place a call to host with our stream so host can relay it. + if (this.localStream && this.peer) { + const meta: MetadataPayload = { peerId: this.selfId }; + const outgoing = this.peer.call(this.hostId, this.localStream, { + metadata: meta, + }); + outgoing?.on('stream', (incoming) => { + // This is host's own stream. + this.remoteStreams.set(this.hostId, incoming); + this.emit('remoteStream', this.hostId, incoming); + }); + } + } + + private failVerification(reasonCode: string): void { + if (this.authTimer) { + clearTimeout(this.authTimer); + this.authTimer = null; + } + this.emit('ended', 'error', reasonCode); + this.shutdown(); + } + + private handleAuthResponse(payload: AuthResponsePayload): void { + const v = this.verification; + if (!v || v.role !== 'guest' || !this.authNonce) return; + const nonce = this.authNonce; + verifyAuthResponse({ + payload, + expectedFingerprint: v.expectedFingerprint, + code: this.code, + nonce, + origin: window.location.origin, + rpId: window.location.hostname, + }) + .then((result) => { + if (this.authTimer) { + clearTimeout(this.authTimer); + this.authTimer = null; + } + if (!result.ok) { + console.warn('host verification failed:', result.reason); + this.failVerification('verify-failed'); + return; + } + this.emit('verified', result.fingerprint); + const dc = this.hostDataConn; + if (dc && dc.open) this.joinHost(dc); + }) + .catch(() => this.failVerification('verify-failed')); + } + private handleMessageFromHost(msg: WireMessage): void { switch (msg.type) { + case 'auth-response': { + this.handleAuthResponse(msg.payload); + break; + } + case 'auth-unavailable': { + // The peer answering for this meeting is not running verification — + // either a misconfigured host or an impostor squatting the id. + this.failVerification('verify-unavailable'); + break; + } case 'welcome': { // Track host id (it might differ from the static derived one in edge // cases; trust what host says). @@ -572,6 +731,14 @@ export class MeetingClient { // ---- Teardown ---- private shutdown(): void { + if (this.retryTimer) { + clearTimeout(this.retryTimer); + this.retryTimer = null; + } + if (this.authTimer) { + clearTimeout(this.authTimer); + this.authTimer = null; + } try { this.peer?.destroy(); } catch {} diff --git a/src/peer/hostIdentity.ts b/src/peer/hostIdentity.ts new file mode 100644 index 0000000..677cb49 --- /dev/null +++ b/src/peer/hostIdentity.ts @@ -0,0 +1,119 @@ +// The host's long-term cryptographic identity for verified meetings. +// +// The identity *is* a passkey (WebAuthn credential). The private key never +// leaves the authenticator and syncs across the host's devices via iCloud +// Keychain / Google Password Manager. We only persist the *public* parts +// (credential id + public key) locally so links can be minted without a +// ceremony and so we can target the right credential when re-authenticating. + +import { + base64urlToBytes, + bytesToBase64url, + randomBytes, +} from '../crypto/encoding'; +import { COSE_ES256, COSE_RS256, isSupportedAlg } from '../crypto/verify'; + +const STORAGE_KEY = 'rendezvous.hostIdentity.v1'; + +export interface HostIdentity { + credentialId: string; // base64url of the WebAuthn credential raw id + publicKey: string; // base64url SPKI + alg: number; // COSE algorithm id + createdAt: number; +} + +export function webAuthnAvailable(): boolean { + return ( + typeof window !== 'undefined' && + typeof window.PublicKeyCredential === 'function' && + typeof navigator !== 'undefined' && + !!navigator.credentials + ); +} + +export function loadHostIdentity(): HostIdentity | null { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as HostIdentity; + if (!parsed.credentialId || !parsed.publicKey || !isSupportedAlg(parsed.alg)) { + return null; + } + return parsed; + } catch { + return null; + } +} + +function storeHostIdentity(identity: HostIdentity): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(identity)); + } catch { + // Non-fatal: the identity still works for this session, links just won't + // be instant next time. + } +} + +/** + * Returns the existing host identity, or creates one by registering a new + * passkey. Registration shows the platform's biometric / passkey UI, so this + * must be called from within a user gesture. + */ +export async function getOrCreateHostIdentity(): Promise { + const existing = loadHostIdentity(); + if (existing) return existing; + + if (!webAuthnAvailable()) { + throw new Error('Passkeys are not supported in this browser.'); + } + + const credential = (await navigator.credentials.create({ + publicKey: { + // rp.id is intentionally omitted so it defaults to the current domain + // (works on localhost in dev and www.predatorray.me in prod). + rp: { name: 'Rendezvous' }, + user: { + id: randomBytes(16), + name: 'rendezvous-host', + displayName: 'Rendezvous Host', + }, + challenge: randomBytes(32), + pubKeyCredParams: [ + { type: 'public-key', alg: COSE_ES256 }, + { type: 'public-key', alg: COSE_RS256 }, + ], + authenticatorSelection: { + residentKey: 'required', + requireResidentKey: true, + userVerification: 'preferred', + }, + attestation: 'none', + }, + })) as PublicKeyCredential | null; + + if (!credential) throw new Error('Passkey creation was cancelled.'); + + const response = credential.response as AuthenticatorAttestationResponse; + if (typeof response.getPublicKey !== 'function') { + throw new Error('This browser cannot expose the passkey public key.'); + } + const spki = response.getPublicKey(); + if (!spki) throw new Error('Passkey did not return a public key.'); + const alg = response.getPublicKeyAlgorithm(); + if (!isSupportedAlg(alg)) { + throw new Error(`Passkey uses an unsupported algorithm (${alg}).`); + } + + const identity: HostIdentity = { + credentialId: bytesToBase64url(new Uint8Array(credential.rawId)), + publicKey: bytesToBase64url(new Uint8Array(spki)), + alg, + createdAt: Date.now(), + }; + storeHostIdentity(identity); + return identity; +} + +export function credentialIdToBytes(id: string): Uint8Array { + return base64urlToBytes(id); +} diff --git a/src/peer/useMeeting.ts b/src/peer/useMeeting.ts index 683123a..e4b4803 100644 --- a/src/peer/useMeeting.ts +++ b/src/peer/useMeeting.ts @@ -1,21 +1,40 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { MeetingClient } from './MeetingClient'; +import { MeetingClient, VerificationConfig } from './MeetingClient'; import { Member, TimelineItem } from '../types'; import { useT } from '../i18n/useLangContext'; +import { Translations } from '../i18n/translations.type'; interface UseMeetingArgs { code: string; name: string; isHost: boolean; + verification?: VerificationConfig; } export type MeetingPhase = | 'preparing' | 'joining' + | 'waiting' + | 'verifying' | 'live' | 'ended' | 'error'; +// Verification failure codes emitted by MeetingClient, mapped to copy here so +// the client layer stays i18n-free. +function mapErrorDetail(detail: string | undefined, t: Translations): string { + switch (detail) { + case 'verify-timeout': + return t.verify_error_timeout; + case 'verify-unavailable': + return t.verify_error_unavailable; + case 'verify-failed': + return t.verify_error_failed; + default: + return detail ?? t.meeting_error_default; + } +} + export interface UseMeetingState { phase: MeetingPhase; errorMessage: string | null; @@ -27,6 +46,8 @@ export interface UseMeetingState { remoteStreams: Map; audioEnabled: boolean; videoEnabled: boolean; + // Verified meeting: the confirmed host fingerprint once verification passes. + verifiedFingerprint: string | null; sendChat: (text: string) => void; toggleAudio: () => void; toggleVideo: () => void; @@ -57,6 +78,7 @@ export function useMeeting({ code, name, isHost, + verification, }: UseMeetingArgs): UseMeetingState { const t = useT(); const [phase, setPhase] = useState('preparing'); @@ -73,21 +95,44 @@ export function useMeeting({ ); const [audioEnabled, setAudioEnabled] = useState(true); const [videoEnabled, setVideoEnabled] = useState(true); + const [verifiedFingerprint, setVerifiedFingerprint] = useState( + null + ); const clientRef = useRef(null); const startedRef = useRef(false); + // Verified guests don't go "live" until the host's identity checks out, so + // their phase is driven by events rather than start() resolving. + const verifiedGuest = verification?.role === 'guest'; useEffect(() => { if (startedRef.current) return; startedRef.current = true; let cancelled = false; - const client = new MeetingClient({ code, name, isHost }); + const client = new MeetingClient({ code, name, isHost, verification }); clientRef.current = client; const unsubs: Array<() => void> = []; unsubs.push(client.on('ready', (id) => setSelfId(id))); + unsubs.push( + client.on('waiting', () => { + if (!cancelled) setPhase('waiting'); + }) + ); + unsubs.push( + client.on('verifying', () => { + if (!cancelled) setPhase('verifying'); + }) + ); + unsubs.push( + client.on('verified', (fingerprint) => { + if (cancelled) return; + setVerifiedFingerprint(fingerprint); + setPhase('live'); + }) + ); unsubs.push( client.on('members', (m) => { setMembers(m); @@ -132,7 +177,7 @@ export function useMeeting({ unsubs.push( client.on('ended', (reason, detail) => { if (reason === 'error') { - setErrorMessage(detail ?? t.meeting_error_default); + setErrorMessage(mapErrorDetail(detail, t)); setPhase('error'); } else { setEndedReason(reason); @@ -155,7 +200,9 @@ export function useMeeting({ setPhase('joining'); try { await client.start(stream); - if (!cancelled) setPhase('live'); + // Verified guests stay in 'joining' until a waiting/verifying/verified + // event moves them; everyone else goes live as soon as start resolves. + if (!cancelled && !verifiedGuest) setPhase('live'); } catch (e: any) { if (cancelled) return; console.error('Meeting start failed', e); @@ -220,6 +267,7 @@ export function useMeeting({ remoteStreams, audioEnabled, videoEnabled, + verifiedFingerprint, sendChat, toggleAudio, toggleVideo, diff --git a/src/peer/verification.test.ts b/src/peer/verification.test.ts new file mode 100644 index 0000000..1f4f343 --- /dev/null +++ b/src/peer/verification.test.ts @@ -0,0 +1,216 @@ +import { + livenessData, + sessionCertChallenge, + verifyAuthResponse, +} from './verification'; +import { AuthResponsePayload } from '../types'; +import { fingerprintOf } from '../crypto/verify'; +import { + bytesToBase64url, + concatBytes, + sha256, + utf8, +} from '../crypto/encoding'; + +const ORIGIN = 'https://www.predatorray.me'; +const RP_ID = 'www.predatorray.me'; +const CODE = 'abcdef'; + +// --- helpers (the test plays the role of an honest host) --- + +async function genEcdsa(): Promise { + return crypto.subtle.generateKey( + { name: 'ECDSA', namedCurve: 'P-256' }, + true, + ['sign', 'verify'] + ); +} + +async function exportSpki(key: CryptoKey): Promise { + return new Uint8Array(await crypto.subtle.exportKey('spki', key)); +} + +async function signRaw(key: CryptoKey, data: Uint8Array): Promise { + return new Uint8Array( + await crypto.subtle.sign( + { name: 'ECDSA', hash: 'SHA-256' }, + key, + data.slice().buffer + ) + ); +} + +// WebAuthn assertions carry DER-encoded ECDSA signatures; WebCrypto emits raw +// r||s, so the test re-encodes to DER the way an authenticator would. +function rawToDer(raw: Uint8Array): Uint8Array { + const encodeInt = (bytes: Uint8Array): number[] => { + let i = 0; + while (i < bytes.length - 1 && bytes[i] === 0) i++; + let v = Array.from(bytes.slice(i)); + if (v[0] & 0x80) v = [0x00, ...v]; + return [0x02, v.length, ...v]; + }; + const r = encodeInt(raw.slice(0, 32)); + const s = encodeInt(raw.slice(32, 64)); + const body = [...r, ...s]; + return Uint8Array.from([0x30, body.length, ...body]); +} + +interface HostKit { + payload: AuthResponsePayload; + expectedFingerprint: string; + nonce: string; +} + +interface Overrides { + origin?: string; + rpId?: string; + userPresent?: boolean; + certChallengeCode?: string; // bind the cert to a different code + livenessNonce?: string; // sign a different nonce than the guest sent +} + +async function makeHostResponse( + nonce: string, + o: Overrides = {} +): Promise { + const identity = await genEcdsa(); + const session = await genEcdsa(); + const identitySpki = await exportSpki(identity.publicKey); + const sessionSpki = await exportSpki(session.publicKey); + const identityPublicKey = bytesToBase64url(identitySpki); + const sessionPublicKey = bytesToBase64url(sessionSpki); + + // Session cert: identity "passkey" signs an authenticatorData||hash(clientData). + const challenge = await sessionCertChallenge( + o.certChallengeCode ?? CODE, + sessionSpki + ); + const clientData = utf8( + JSON.stringify({ + type: 'webauthn.get', + challenge: bytesToBase64url(challenge), + origin: o.origin ?? ORIGIN, + crossOrigin: false, + }) + ); + const rpIdHash = await sha256(utf8(o.rpId ?? RP_ID)); + const flags = o.userPresent === false ? 0x00 : 0x01; + const authData = concatBytes( + rpIdHash, + Uint8Array.from([flags]), + Uint8Array.from([0, 0, 0, 0]) // signCount + ); + const signedData = concatBytes(authData, await sha256(clientData)); + const certSigDer = rawToDer(await signRaw(identity.privateKey, signedData)); + + // Liveness: session key signs the guest's nonce. + const liveness = await signRaw( + session.privateKey, + livenessData(CODE, o.livenessNonce ?? nonce) + ); + + const payload: AuthResponsePayload = { + identityPublicKey, + identityAlg: -7, + sessionPublicKey, + sessionCert: { + authenticatorData: bytesToBase64url(authData), + clientDataJSON: bytesToBase64url(clientData), + signature: bytesToBase64url(certSigDer), + }, + liveness: bytesToBase64url(liveness), + }; + return { + payload, + expectedFingerprint: await fingerprintOf(identityPublicKey), + nonce, + }; +} + +describe('verifyAuthResponse', () => { + it('accepts an honest host response', async () => { + const kit = await makeHostResponse('nonce-1'); + const result = await verifyAuthResponse({ + payload: kit.payload, + expectedFingerprint: kit.expectedFingerprint, + code: CODE, + nonce: kit.nonce, + origin: ORIGIN, + rpId: RP_ID, + }); + expect(result.ok).toBe(true); + if (result.ok) expect(result.fingerprint).toBe(kit.expectedFingerprint); + }); + + it('rejects when the fingerprint does not match the pinned URL value', async () => { + const kit = await makeHostResponse('nonce-2'); + const result = await verifyAuthResponse({ + payload: kit.payload, + expectedFingerprint: 'totally-different-fingerprint', + code: CODE, + nonce: kit.nonce, + origin: ORIGIN, + rpId: RP_ID, + }); + expect(result).toEqual({ ok: false, reason: 'fingerprint-mismatch' }); + }); + + it('rejects a replayed certificate that signed a stale nonce', async () => { + const kit = await makeHostResponse('fresh-nonce', { + livenessNonce: 'old-nonce', + }); + const result = await verifyAuthResponse({ + payload: kit.payload, + expectedFingerprint: kit.expectedFingerprint, + code: CODE, + nonce: 'fresh-nonce', + origin: ORIGIN, + rpId: RP_ID, + }); + expect(result).toEqual({ ok: false, reason: 'liveness-invalid' }); + }); + + it('rejects an assertion produced for a different origin', async () => { + const kit = await makeHostResponse('nonce-3', { + origin: 'https://evil.example.com', + }); + const result = await verifyAuthResponse({ + payload: kit.payload, + expectedFingerprint: kit.expectedFingerprint, + code: CODE, + nonce: kit.nonce, + origin: ORIGIN, + rpId: RP_ID, + }); + expect(result).toEqual({ ok: false, reason: 'cert-invalid' }); + }); + + it('rejects when user-presence flag is not set', async () => { + const kit = await makeHostResponse('nonce-4', { userPresent: false }); + const result = await verifyAuthResponse({ + payload: kit.payload, + expectedFingerprint: kit.expectedFingerprint, + code: CODE, + nonce: kit.nonce, + origin: ORIGIN, + rpId: RP_ID, + }); + expect(result).toEqual({ ok: false, reason: 'cert-invalid' }); + }); + + it('rejects a cert bound to a different meeting code', async () => { + const kit = await makeHostResponse('nonce-5', { + certChallengeCode: 'zzzzzz', + }); + const result = await verifyAuthResponse({ + payload: kit.payload, + expectedFingerprint: kit.expectedFingerprint, + code: CODE, + nonce: kit.nonce, + origin: ORIGIN, + rpId: RP_ID, + }); + expect(result).toEqual({ ok: false, reason: 'cert-invalid' }); + }); +}); diff --git a/src/peer/verification.ts b/src/peer/verification.ts new file mode 100644 index 0000000..20356f8 --- /dev/null +++ b/src/peer/verification.ts @@ -0,0 +1,236 @@ +// The verified-meeting handshake, tying together the encoding/verify +// primitives and the WebAuthn ceremony. +// +// Trust chain a guest checks: +// 1. identity public key --fingerprint--> the value pinned in the URL +// 2. identity passkey --WebAuthn cert--> ephemeral session public key +// 3. session key --raw signature--> this guest's fresh nonce +// (1) pins who the host is, (2) lets the host sign for guests without a +// biometric prompt per guest, (3) proves the host is live right now. + +import { AuthResponsePayload } from '../types'; +import { + base64urlToBytes, + bytesToBase64url, + concatBytes, + randomBytes, + sha256, + utf8, +} from '../crypto/encoding'; +import { + fingerprintOf, + verifySessionSignature, + verifyWebAuthnAssertion, +} from '../crypto/verify'; +import { credentialIdToBytes } from './hostIdentity'; + +const CERT_CONTEXT = 'rendezvous-session-cert-v1'; +const LIVENESS_CONTEXT = 'rendezvous-liveness-v1'; + +/** The bytes the host's passkey signs to vouch for a session key. Exported for + * tests that synthesize a host response. */ +export async function sessionCertChallenge( + code: string, + sessionPublicKeySpki: Uint8Array +): Promise { + return sha256( + concatBytes(utf8(`${CERT_CONTEXT}:${code}:`), sessionPublicKeySpki) + ); +} + +/** The bytes the session key signs for a specific guest nonce. */ +export function livenessData(code: string, nonce: string): Uint8Array { + return utf8(`${LIVENESS_CONTEXT}:${code}:${nonce}`); +} + +export function freshNonce(): string { + return bytesToBase64url(randomBytes(32)); +} + +// ---- Host side ---- + +export interface HostSessionInput { + code: string; + identityCredentialId: string | null; // null when relying on a synced passkey + identityPublicKey: string; // SPKI base64url (from storage or the invite URL) + identityAlg: number; +} + +/** + * Runs the host's once-per-meeting passkey ceremony: it mints an ephemeral + * session key and has the passkey sign it. Must be called from a user gesture + * (it triggers the biometric prompt). The returned object can then sign any + * number of guest challenges with no further prompts. + */ +export class HostSession { + private constructor( + private readonly code: string, + private readonly sessionPrivateKey: CryptoKey, + private readonly sessionPublicKeySpki: Uint8Array, + private readonly identityPublicKey: string, + private readonly identityAlg: number, + private readonly cert: { + authenticatorData: string; + clientDataJSON: string; + signature: string; + } + ) {} + + static async create(input: HostSessionInput): Promise { + const keyPair = await crypto.subtle.generateKey( + { name: 'ECDSA', namedCurve: 'P-256' }, + true, + ['sign', 'verify'] + ); + const sessionPublicKeySpki = new Uint8Array( + await crypto.subtle.exportKey('spki', keyPair.publicKey) + ); + + const challenge = await sessionCertChallenge( + input.code, + sessionPublicKeySpki + ); + const allowCredentials: PublicKeyCredentialDescriptor[] = + input.identityCredentialId + ? [ + { + type: 'public-key', + id: credentialIdToBytes(input.identityCredentialId), + }, + ] + : []; + + const assertion = (await navigator.credentials.get({ + publicKey: { + challenge: challenge.slice().buffer, + allowCredentials, + userVerification: 'preferred', + }, + })) as PublicKeyCredential | null; + if (!assertion) throw new Error('Passkey verification was cancelled.'); + + const response = assertion.response as AuthenticatorAssertionResponse; + const cert = { + authenticatorData: bytesToBase64url( + new Uint8Array(response.authenticatorData) + ), + clientDataJSON: bytesToBase64url(new Uint8Array(response.clientDataJSON)), + signature: bytesToBase64url(new Uint8Array(response.signature)), + }; + + const session = new HostSession( + input.code, + keyPair.privateKey, + sessionPublicKeySpki, + input.identityPublicKey, + input.identityAlg, + cert + ); + + // Self-check: confirm the passkey we just used actually matches the + // identity pinned in the invite URL. Catches "wrong passkey picked" on a + // second device before any guest fails verification. + const probe = await session.signFor(freshNonce()); + const ok = await verifyAuthResponse({ + payload: probe, + expectedFingerprint: await fingerprintOf(input.identityPublicKey), + code: input.code, + nonce: '', // ignored: selfCheck skips the liveness step + origin: window.location.origin, + rpId: window.location.hostname, + selfCheck: true, + }); + if (!ok.ok) { + throw new Error( + 'The selected passkey does not match this meeting. Pick the passkey you used to create the link.' + ); + } + return session; + } + + async signFor(nonce: string): Promise { + const signature = new Uint8Array( + await crypto.subtle.sign( + { name: 'ECDSA', hash: 'SHA-256' }, + this.sessionPrivateKey, + livenessData(this.code, nonce).slice().buffer + ) + ); + return { + identityPublicKey: this.identityPublicKey, + identityAlg: this.identityAlg, + sessionPublicKey: bytesToBase64url(this.sessionPublicKeySpki), + sessionCert: this.cert, + liveness: bytesToBase64url(signature), + }; + } +} + +// ---- Guest side ---- + +export interface VerifyInput { + payload: AuthResponsePayload; + expectedFingerprint: string; // base64url SHA-256 of the pinned identity key + code: string; + nonce: string; + origin: string; + rpId: string; + // When true, skip the liveness/nonce check (used by the host self-test where + // the nonce was generated internally and we only care about the cert chain). + selfCheck?: boolean; +} + +export type VerifyResult = + | { ok: true; fingerprint: string } + | { ok: false; reason: string }; + +/** + * The guest's full check of an `auth-response`. Returns ok only when every + * link in the trust chain holds. + */ +export async function verifyAuthResponse( + input: VerifyInput +): Promise { + const { payload } = input; + try { + const identitySpki = base64urlToBytes(payload.identityPublicKey); + + // (1) Pin: the presented identity key must hash to the URL fingerprint. + const presentedFingerprint = await fingerprintOf(payload.identityPublicKey); + if (presentedFingerprint !== input.expectedFingerprint) { + return { + ok: false, + reason: 'fingerprint-mismatch', + }; + } + + // (2) Cert: the passkey vouches for the session key. + const sessionSpki = base64urlToBytes(payload.sessionPublicKey); + const expectedChallenge = await sessionCertChallenge(input.code, sessionSpki); + const certOk = await verifyWebAuthnAssertion({ + identityPublicKeySpki: identitySpki, + identityAlg: payload.identityAlg, + authenticatorData: base64urlToBytes(payload.sessionCert.authenticatorData), + clientDataJSON: base64urlToBytes(payload.sessionCert.clientDataJSON), + signature: base64urlToBytes(payload.sessionCert.signature), + expectedChallenge, + expectedOrigin: input.origin, + expectedRpId: input.rpId, + }); + if (!certOk) return { ok: false, reason: 'cert-invalid' }; + + // (3) Liveness: the session key signed this guest's fresh nonce. + if (!input.selfCheck) { + const livenessOk = await verifySessionSignature( + sessionSpki, + base64urlToBytes(payload.liveness), + livenessData(input.code, input.nonce) + ); + if (!livenessOk) return { ok: false, reason: 'liveness-invalid' }; + } + + return { ok: true, fingerprint: presentedFingerprint }; + } catch (e) { + return { ok: false, reason: 'malformed' }; + } +} diff --git a/src/setupTests.ts b/src/setupTests.ts index b079662..80da676 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -16,11 +16,17 @@ if (!('ResizeObserver' in window)) { }; } -if (!(window as any).crypto || !(window as any).crypto.getRandomValues) { - (window as any).crypto = { - getRandomValues: (buf: Uint32Array) => { - for (let i = 0; i < buf.length; i++) buf[i] = Math.floor(Math.random() * 0xffffffff); - return buf; - }, - }; +// jsdom doesn't ship WebCrypto's SubtleCrypto, which the verified-meeting +// code relies on. Borrow Node's implementation so those modules (and their +// tests) have real getRandomValues + subtle. +{ + // eslint-disable-next-line @typescript-eslint/no-var-requires + const nodeCrypto = require('crypto').webcrypto; + const existing = (window as any).crypto; + if (!existing || !existing.subtle) { + (window as any).crypto = nodeCrypto; + } + if (typeof (global as any).crypto === 'undefined') { + (global as any).crypto = (window as any).crypto; + } } diff --git a/src/types.ts b/src/types.ts index b61ee5d..874d6c6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -35,10 +35,38 @@ export function isSystem(item: TimelineItem): item is SystemMessage { return (item as SystemMessage).system === true; } +// ----- Verified meeting (experimental) handshake ----- + +// A passkey assertion the host produces once per hosting session over its +// ephemeral session key. base64url-encoded raw WebAuthn fields. +export interface SessionCert { + authenticatorData: string; + clientDataJSON: string; + signature: string; +} + +// Sent by a verified host in response to a guest's `auth-challenge`. Lets the +// guest confirm it is talking to the real host before exchanging any data. +export interface AuthResponsePayload { + // The host identity (passkey) public key, SPKI base64url. The guest cross + // checks its fingerprint against the one pinned in the invite URL. + identityPublicKey: string; + identityAlg: number; // COSE algorithm id (e.g. -7 for ES256) + // Ephemeral per-session key the passkey vouches for via `sessionCert`. + sessionPublicKey: string; // SPKI base64url + sessionCert: SessionCert; + // Raw ECDSA signature by the session key over the guest's fresh nonce, + // proving the host is live (not a replayed certificate). base64url. + liveness: string; +} + // ----- Wire protocol (carried over PeerJS DataConnections) ----- export type WireMessage = | { type: 'hello'; name: string } + | { type: 'auth-challenge'; nonce: string } // client -> host + | { type: 'auth-response'; payload: AuthResponsePayload } // host -> client + | { type: 'auth-unavailable' } // host -> client: not running verification | { type: 'welcome'; selfId: string; diff --git a/src/util/verifiedMeeting.ts b/src/util/verifiedMeeting.ts new file mode 100644 index 0000000..27ca2c0 --- /dev/null +++ b/src/util/verifiedMeeting.ts @@ -0,0 +1,46 @@ +// Glue between the verified-meeting crypto and the rest of the app: the +// experimental on/off flag and the invite-URL query parameters that carry the +// host's identity key. + +const EXPERIMENTAL_KEY = 'rendezvous.experimental.verified'; + +// Query params on a verified invite link: +// vk = host identity public key, SPKI base64url +// va = COSE algorithm id of that key (e.g. -7) +export const VK_PARAM = 'vk'; +export const VA_PARAM = 'va'; + +export interface VerifiedKey { + publicKey: string; // SPKI base64url + alg: number; +} + +export function isVerifiedFeatureEnabled(): boolean { + try { + return localStorage.getItem(EXPERIMENTAL_KEY) === '1'; + } catch { + return false; + } +} + +export function setVerifiedFeatureEnabled(enabled: boolean): void { + try { + if (enabled) localStorage.setItem(EXPERIMENTAL_KEY, '1'); + else localStorage.removeItem(EXPERIMENTAL_KEY); + } catch { + // ignore storage failures + } +} + +export function parseVerifiedKey(params: URLSearchParams): VerifiedKey | null { + const publicKey = params.get(VK_PARAM); + const algRaw = params.get(VA_PARAM); + if (!publicKey || !algRaw) return null; + const alg = Number(algRaw); + if (!Number.isFinite(alg)) return null; + return { publicKey, alg }; +} + +export function verifiedKeyParams(key: VerifiedKey): Record { + return { [VK_PARAM]: key.publicKey, [VA_PARAM]: String(key.alg) }; +} From 507ea050d40c190decbceabc9f9c39d3683a871b Mon Sep 17 00:00:00 2001 From: Wenhao Ji Date: Fri, 22 May 2026 22:00:45 -0700 Subject: [PATCH 2/4] Let the passkey holder re-host an unhosted verified meeting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared invite link is the guest link (no host=1), so a verified host who created a link ahead of time — or left and came back — was stranded in the waiting room as a guest with no way to host. - Waiting room now offers "Host this meeting"; claiming runs the passkey ceremony against the identity pinned in the URL and switches into hosting. - Verified guests fall back to the waiting room (and retry) on a connect timeout, instead of hanging on "Joining…" when the broker holds a stale registration for a host that just left. - Re-hosting retries the peer-id claim for a short grace window while the broker releases the previous registration. - e2e: drive a real passkey via the CDP virtual authenticator and cover the create-link-ahead / host-it-later flow. - README + docs updated. Co-Authored-By: Claude Opus 4.7 --- README.md | 63 ++++++++++++++++++ docs/verified-meetings.md | 15 ++++- e2e/verified-rehost.spec.ts | 96 ++++++++++++++++++++++++++++ src/i18n/locales/en.ts | 2 + src/i18n/locales/es.ts | 2 + src/i18n/locales/fr.ts | 2 + src/i18n/locales/ja.ts | 2 + src/i18n/locales/zh.ts | 2 + src/pages/MeetingPage.tsx | 123 ++++++++++++++++++++++++++++++++---- src/peer/MeetingClient.ts | 74 +++++++++++++++++++--- 10 files changed, 359 insertions(+), 22 deletions(-) create mode 100644 e2e/verified-rehost.spec.ts diff --git a/README.md b/README.md index 91c73a1..950a43c 100644 --- a/README.md +++ b/README.md @@ -44,8 +44,52 @@ PeerJS public broker is used only for the initial WebRTC signaling. - Chat history is preserved by the host so late joiners see prior messages - Sharable invite link and copy-able meeting code - Host leaving ends the meeting for everyone +- **Verified meetings (experimental)** — host proves their identity with a + passkey so guests can’t be fooled by an impostor ([details](#verified-meetings-experimental)) - No accounts, no passcodes, fully static-site deployable +## Verified meetings (experimental) + +A meeting code proves nothing about *who* is hosting: the host’s PeerJS id is +derived from the code (`rendezvous-`), so anyone who knows the code can +race to claim that id on the public broker and relay the meeting as a fake +"host". Verified meetings let a host prove their identity to guests with a +**passkey** (WebAuthn), so guests can refuse to join an impostor. + +It’s **off by default**. Flip the *Verified meeting* switch on the home page +(host side only); guests need nothing — verification kicks in automatically +when they open a verified link. + +How it works, briefly: + +- The host identity is a passkey; its public key (and a `SHA256:…` fingerprint) + is carried in the invite URL. The private key never leaves the authenticator + and syncs across the host’s devices via iCloud Keychain / Google Password + Manager. +- The passkey signs an ephemeral session key **once per meeting** (a single + biometric prompt); that session key then signs each guest’s fresh nonce, so + there’s no prompt per guest. +- A guest verifies three things before sending any data: the identity key + matches the URL fingerprint, the passkey vouches for the session key, and the + session key signed *this guest’s* nonce. If any check fails it refuses to + join. +- Both sides can open a **Host identity** dialog to compare the fingerprint + out-of-band (SSH-style) — the only thing that catches a *tampered* invite + link, since the crypto otherwise trusts whatever key is in the URL. +- Guests opening a verified link before the host is present see a **waiting + room** and join automatically once the host appears. +- The invite link a host shares is the *guest* link (no `host=1`). When the + real host opens it for an unhosted meeting, the waiting room offers **“Host + this meeting”** — claiming it with the passkey starts hosting. This is what + lets a host create a link ahead of time (or leave and come back) and still + host it, rather than being stuck as a guest. + +This delivers host **authentication** (it stops peer-id squatters and +impostors), not yet a fully app-layer-authenticated channel — an active relay +MITM is a deliberate follow-up. See +[`docs/verified-meetings.md`](docs/verified-meetings.md) for the full protocol, +threat model, and limitations. + ## Tech stack - React 19 + TypeScript (Create React App) @@ -103,6 +147,11 @@ client-side SPA rewrites (e.g. GitHub Pages). - `src/pages/` — Home and Meeting pages. - `src/components/` — `VideoGrid`, `VideoTile`, `ChatDrawer`, `Controls`, `ShareDialog`. +- `src/crypto/` — verified-meeting primitives (base64url/SHA-256, WebAuthn + assertion & signature verification, fingerprints). +- `src/peer/hostIdentity.ts` + `src/peer/verification.ts` — the passkey + identity and the verified-meeting handshake (see + [`docs/verified-meetings.md`](docs/verified-meetings.md)). ### Wire protocol @@ -118,6 +167,9 @@ host: | `timeline` | host → all | Authoritative chat or system event | | `state` | client → host | Participant changed audio/video | | `end` | host → all | Host is leaving — meeting is over | +| `auth-challenge` | client → host | Verified meetings: guest’s fresh nonce | +| `auth-response` | host → client | Verified meetings: identity key + session cert + nonce signature | +| `auth-unavailable` | host → client | Verified meetings: responder isn’t running verification | ### Media topology @@ -159,6 +211,17 @@ flowchart LR path, and media/data are relayed through a TURN server — meaning that traffic is proxied by a third-party server rather than flowing directly between peers. +- Verified meetings provide host **authentication**, not a fully + app-layer-authenticated channel: an active relay MITM that intercepts the + guest *and* connects to the real host is out of scope for this experimental + cut. Passkeys are also pinned to the origin domain (the WebAuthn RP id), so a + verified identity created on `www.example.com` won’t validate on a bare + `example.com` — keep the canonical host consistent. See + [`docs/verified-meetings.md`](docs/verified-meetings.md). +- The public PeerJS broker doesn’t release a departed host’s peer id + immediately, so re-hosting the *same* code seconds after leaving can briefly + fail (re-hosting retries for a short grace window). Coming back later, or + running your own PeerServer, avoids this. [1]: https://github.com/predatorray/rendezvous/blob/main/LICENSE [2]: https://github.com/predatorray/rendezvous/actions/workflows/ci.yml diff --git a/docs/verified-meetings.md b/docs/verified-meetings.md index 4d6305f..8a6ecca 100644 --- a/docs/verified-meetings.md +++ b/docs/verified-meetings.md @@ -59,11 +59,22 @@ A guest verifies three links in the chain (`src/peer/verification.ts`): Only if all three hold does the guest send its name / state / media. Otherwise it shows an error and refuses to join. -## Waiting room +## Waiting room & re-hosting If a guest opens a verified link before the host is present, it shows a *waiting for host* screen and retries the connection until the host appears, -then verifies and joins automatically. +then verifies and joins automatically. If the connection neither opens nor +reports `peer-unavailable` (a stale broker registration for a host that just +left), a connect timeout falls back to the same waiting room and keeps retrying. + +The link a host shares is the *guest* link (no `host=1`), so the waiting room +also offers **“Host this meeting”**: a passkey holder can claim the host role +for an unhosted meeting. This is what lets a host create a link ahead of time — +or leave and come back — and still host it instead of being stranded as a +guest. Claiming runs the same passkey ceremony (`HostSession.create`) against +the identity pinned in the URL; the self-check rejects a passkey that doesn’t +match. Re-hosting retries the peer-id claim for a short grace window because the +public broker doesn’t release a departed host’s id immediately. ## Cross-device diff --git a/e2e/verified-rehost.spec.ts b/e2e/verified-rehost.spec.ts new file mode 100644 index 0000000..4bb75b0 --- /dev/null +++ b/e2e/verified-rehost.spec.ts @@ -0,0 +1,96 @@ +import { BrowserContext, Page } from '@playwright/test'; +import { expect, freshMeetingCode, test } from './fixtures'; + +/** + * Verified meetings need a passkey, so we attach a CDP virtual authenticator + * to the context. It persists credentials across navigations on the page, so + * the identity created on the home page is still usable when claiming a host + * role later. + */ +async function addVirtualAuthenticator(context: BrowserContext, page: Page) { + const client = await context.newCDPSession(page); + await client.send('WebAuthn.enable', { enableUI: false }); + await client.send('WebAuthn.addVirtualAuthenticator', { + options: { + protocol: 'ctap2', + transport: 'internal', + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + automaticPresenceSimulation: true, + }, + }); +} + +async function dismissShareDialog(page: Page) { + const done = page.getByRole('button', { name: 'Done' }); + if (await done.isVisible().catch(() => false)) { + await done.click(); + await expect(done).toBeHidden(); + } +} + +test.describe('Verified meeting re-hosting', () => { + // Regression test for: a verified host who shares an invite link (the guest + // link, with no `host=1`) and then opens it themselves was stranded in the + // waiting room, treated as a guest, with no way to host. The fix lets the + // passkey holder claim the host role from the waiting room — which is also + // the "create the link ahead of time, host it later" use case. + test('the passkey holder can host an unhosted verified meeting from its invite link', async ({ + browser, + }) => { + test.setTimeout(120_000); + const context = await browser.newContext({ + permissions: ['camera', 'microphone'], + }); + const page = await context.newPage(); + await addVirtualAuthenticator(context, page); + + // 1. Create the verified identity from the home page (mints a passkey). + await page.goto('/'); + await page.getByLabel('Your name').fill('Alice'); + await page.getByRole('switch', { name: /Verified meeting/ }).click(); + await page.getByRole('button', { name: 'Host verified meeting' }).click(); + + // We land on the unlock gate; the URL now carries the identity key (vk/va). + await expect( + page.getByRole('button', { name: 'Verify with passkey' }) + ).toBeVisible({ timeout: 30_000 }); + const created = new URL(page.url()); + const hostParams = new URLSearchParams( + created.hash.slice(created.hash.indexOf('?') + 1) + ); + const vk = hostParams.get('vk')!; + const va = hostParams.get('va')!; + expect(vk).toBeTruthy(); + expect(va).toBeTruthy(); + + // 2. Build an invite link for a *fresh* code nobody is hosting yet — the + // "link created ahead of the meeting" the host would share. + const code = freshMeetingCode(); + const inviteParams = new URLSearchParams({ name: 'Alice', vk, va }); + const inviteLink = `${created.origin}${created.pathname}#/m/${code}?${inviteParams.toString()}`; + + // 3. Opening it with no host present shows the waiting room — and, for the + // passkey holder, the option to start hosting. reload() forces a fresh + // document load (a real recipient opens the link cold), so the page + // reads the guest URL rather than reusing the host route's mount state. + await page.goto(inviteLink); + await page.reload(); + await expect(page.getByText('Waiting for the host')).toBeVisible({ + timeout: 30_000, + }); + const claim = page.getByRole('button', { name: 'Host this meeting' }); + await expect(claim).toBeVisible(); + + // 4. Claiming with the passkey makes this client the verified host. + await claim.click(); + await expect(page.locator('button[aria-label="Leave"]')).toBeVisible({ + timeout: 30_000, + }); + await dismissShareDialog(page); + await expect(page.getByText('Verified host')).toBeVisible(); + + await context.close(); + }); +}); diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 7d0fcda..41e082f 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -128,6 +128,8 @@ const en = { verify_waiting_title: 'Waiting for the host', verify_waiting_body: 'This meeting hasn’t started yet. You’ll join automatically once the host arrives.', + verify_waiting_host_question: 'Are you the host?', + verify_waiting_host_cta: 'Host this meeting', verify_checking: 'Verifying host identity…', verify_badge_host: 'Verified host', diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 1f018ff..8db7c89 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -128,6 +128,8 @@ const es = { verify_waiting_title: 'Esperando al anfitrión', verify_waiting_body: 'Esta reunión aún no ha empezado. Te unirás automáticamente cuando llegue el anfitrión.', + verify_waiting_host_question: '¿Eres el anfitrión?', + verify_waiting_host_cta: 'Organizar esta reunión', verify_checking: 'Verificando la identidad del anfitrión…', verify_badge_host: 'Anfitrión verificado', diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 230b2d1..2f8fcb1 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -129,6 +129,8 @@ const fr = { verify_waiting_title: 'En attente de l’hôte', verify_waiting_body: 'Cette réunion n’a pas encore commencé. Vous rejoindrez automatiquement dès que l’hôte arrivera.', + verify_waiting_host_question: 'Êtes-vous l’hôte ?', + verify_waiting_host_cta: 'Animer cette réunion', verify_checking: 'Vérification de l’identité de l’hôte…', verify_badge_host: 'Hôte vérifié', diff --git a/src/i18n/locales/ja.ts b/src/i18n/locales/ja.ts index 61a6039..0e0d1d6 100644 --- a/src/i18n/locales/ja.ts +++ b/src/i18n/locales/ja.ts @@ -128,6 +128,8 @@ const ja = { verify_waiting_title: 'ホストを待っています', verify_waiting_body: 'このミーティングはまだ開始されていません。ホストが参加すると自動的に参加します。', + verify_waiting_host_question: 'あなたがホストですか?', + verify_waiting_host_cta: 'このミーティングを主催', verify_checking: 'ホストの本人確認中…', verify_badge_host: '検証済みホスト', diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index a586246..92c2897 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -122,6 +122,8 @@ const zh = { verify_waiting_title: '正在等待主持人', verify_waiting_body: '会议尚未开始。主持人到场后你将自动加入。', + verify_waiting_host_question: '你是主持人吗?', + verify_waiting_host_cta: '主持本次会议', verify_checking: '正在验证主持人身份…', verify_badge_host: '已验证主持人', diff --git a/src/pages/MeetingPage.tsx b/src/pages/MeetingPage.tsx index 6a82b89..c833d13 100644 --- a/src/pages/MeetingPage.tsx +++ b/src/pages/MeetingPage.tsx @@ -3,6 +3,7 @@ import { Box, Button, CircularProgress, + Divider, Stack, Typography, } from '@mui/material'; @@ -38,6 +39,11 @@ export default function MeetingPage() { const [confirmedName, setConfirmedName] = useState(initialName); const [nameDraft, setNameDraft] = useState(initialName); + // Set when a verified guest who holds the host passkey takes over hosting an + // unhosted meeting (re-hosting a link they created earlier). + const [takeoverSession, setTakeoverSession] = useState( + null + ); const normalizedCode = code.trim().toLowerCase(); const validCode = isValidMeetingCode(normalizedCode); @@ -114,11 +120,25 @@ export default function MeetingPage() { // A guest joining a verified link verifies the host's identity automatically. if (!isHost && verifiedKey) { + // If this guest reclaimed an unhosted meeting with their passkey, run as + // the host instead. + if (takeoverSession) { + return ( + + ); + } return ( ); } @@ -220,10 +240,12 @@ function VerifiedGuest({ code, name, verifiedKey, + onBecameHost, }: { code: string; name: string; verifiedKey: VerifiedKey; + onBecameHost: (session: HostSession) => void; }) { const t = useT(); const [config, setConfig] = useState(null); @@ -256,6 +278,7 @@ function VerifiedGuest({ isHost={false} verification={config} verifiedKey={verifiedKey} + onBecameHost={onBecameHost} /> ); } @@ -266,12 +289,15 @@ function LiveMeeting({ isHost, verification, verifiedKey, + onBecameHost, }: { code: string; name: string; isHost: boolean; verification?: VerificationConfig; verifiedKey?: VerifiedKey; + // Present for verified guests: lets the real host reclaim an unhosted meeting. + onBecameHost?: (session: HostSession) => void; }) { const navigate = useNavigate(); const t = useT(); @@ -280,18 +306,12 @@ function LiveMeeting({ if (meeting.phase === 'waiting') { return ( - - - - {t.verify_waiting_title} - - {t.verify_waiting_body} - - - - + navigate('/')} + /> ); } @@ -382,6 +402,85 @@ function LiveMeeting({ ); } +/** + * Shown to a verified guest while the host is absent. A guest who actually + * holds the host passkey can reclaim hosting from here — this is what lets a + * host re-host a meeting they created earlier (the shared link is the guest + * link, with no `host=1`). + */ +function WaitingRoom({ + code, + verifiedKey, + onBecameHost, + onLeave, +}: { + code: string; + verifiedKey?: VerifiedKey; + onBecameHost?: (session: HostSession) => void; + onLeave: () => void; +}) { + const t = useT(); + const [claiming, setClaiming] = useState(false); + const [error, setError] = useState(null); + const canClaim = !!verifiedKey && !!onBecameHost; + + const claimHost = async () => { + if (!verifiedKey || !onBecameHost) return; + setClaiming(true); + setError(null); + try { + const session = await HostSession.create({ + code, + identityCredentialId: loadHostIdentity()?.credentialId ?? null, + identityPublicKey: verifiedKey.publicKey, + identityAlg: verifiedKey.alg, + }); + onBecameHost(session); // unmounts this screen and starts hosting + } catch (e: any) { + setError(e?.message ?? t.verify_host_unlock_failed); + setClaiming(false); + } + }; + + return ( + + + + {t.verify_waiting_title} + + {t.verify_waiting_body} + + {canClaim && ( + <> + + {t.verify_waiting_host_question} + + {error && ( + + {error} + + )} + + + )} + + + + ); +} + function CenteredCard({ children }: { children: React.ReactNode }) { return ( | null = null; private retryTimer: ReturnType | null = null; + private connectTimer: ReturnType | null = null; // Tracks remote streams by logical peer id for both host and client. private remoteStreams = new Map(); @@ -169,12 +170,26 @@ export class MeetingClient { const peerId = this.isHost ? this.hostId : randomClientPeerId(this.code); const iceServers = await fetchIceServers(); + await this.openPeerWithRetry(peerId, iceServers); + + this.emit('ready', this.selfId); + + if (this.isHost) { + this.startAsHost(); + } else { + this.startAsClient(); + } + } + + private openPeer( + peerId: string, + iceServers: RTCIceServer[] | null + ): Promise { this.peer = new Peer(peerId, { debug: 1, ...(iceServers ? { config: { iceServers } } : {}), }); - - await new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const peer = this.peer!; const onOpen = (id: string) => { this.selfId = id; @@ -188,13 +203,34 @@ export class MeetingClient { peer.once('open', onOpen); peer.once('error', onErr); }); + } - this.emit('ready', this.selfId); - - if (this.isHost) { - this.startAsHost(); - } else { - this.startAsClient(); + /** + * Opens the PeerJS peer. A verified host re-hosting a meeting they just left + * can transiently get `unavailable-id` while the broker releases the previous + * registration, so for that case we retry for a short window instead of + * failing outright. All other peers open once (unchanged behavior). + */ + private async openPeerWithRetry( + peerId: string, + iceServers: RTCIceServer[] | null + ): Promise { + const verifiedHost = this.isHost && this.verification?.role === 'host'; + const maxAttempts = verifiedHost ? 6 : 1; + for (let attempt = 1; ; attempt++) { + try { + await this.openPeer(peerId, iceServers); + return; + } catch (err) { + try { + this.peer?.destroy(); + } catch {} + this.peer = null; + const retriable = + verifiedHost && (err as any)?.type === 'unavailable-id'; + if (!retriable || attempt >= maxAttempts) throw err; + await new Promise((r) => setTimeout(r, 2500)); + } } } @@ -547,6 +583,16 @@ export class MeetingClient { } const dc = peer.connect(this.hostId, { reliable: true }); this.hostDataConn = dc; + // A verified guest can't rely solely on `peer-unavailable`: if the broker + // still holds a stale registration for a host that just left, the + // connection neither opens nor errors. Time out and fall back to the + // waiting room (which retries) so we never hang on "joining". + if (this.isVerifiedGuest()) { + if (this.connectTimer) clearTimeout(this.connectTimer); + this.connectTimer = setTimeout(() => { + if (!this.clientConnected) this.enterWaiting(); + }, 10000); + } dc.on('open', () => { this.clientConnected = true; this.waitingForHost = false; @@ -554,6 +600,10 @@ export class MeetingClient { clearTimeout(this.retryTimer); this.retryTimer = null; } + if (this.connectTimer) { + clearTimeout(this.connectTimer); + this.connectTimer = null; + } this.onHostConnectionOpen(dc); }); dc.on('data', (raw) => this.handleMessageFromHost(raw as WireMessage)); @@ -569,6 +619,10 @@ export class MeetingClient { } private enterWaiting(): void { + if (this.connectTimer) { + clearTimeout(this.connectTimer); + this.connectTimer = null; + } if (!this.waitingForHost) { this.waitingForHost = true; this.emit('waiting'); @@ -739,6 +793,10 @@ export class MeetingClient { clearTimeout(this.authTimer); this.authTimer = null; } + if (this.connectTimer) { + clearTimeout(this.connectTimer); + this.connectTimer = null; + } try { this.peer?.destroy(); } catch {} From 2b48d1fc64e3614c18a50f815e6531b9a2d3a7d8 Mon Sep 17 00:00:00 2001 From: Wenhao Ji Date: Fri, 22 May 2026 22:11:50 -0700 Subject: [PATCH 3/4] Let an ordinary meeting be re-hosted from its invite link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ordinary (non-verified) meetings dead-ended at "Meeting not found" when the host had left, so a host couldn't re-host using the invite link — unlike verified meetings. The unhosted error screen now offers a "Host this meeting" button that remounts as host and claims the code (no passkey needed). - e2e: a guest can host an ordinary meeting that has no host. - README: note re-hosting from the invite link. Co-Authored-By: Claude Opus 4.7 --- README.md | 2 ++ e2e/nonverified-rehost.spec.ts | 41 ++++++++++++++++++++++++++++++++++ src/i18n/locales/en.ts | 1 + src/i18n/locales/es.ts | 1 + src/i18n/locales/fr.ts | 1 + src/i18n/locales/ja.ts | 1 + src/i18n/locales/zh.ts | 1 + src/pages/MeetingPage.tsx | 40 +++++++++++++++++++++++++++++---- 8 files changed, 84 insertions(+), 4 deletions(-) create mode 100644 e2e/nonverified-rehost.spec.ts diff --git a/README.md b/README.md index 950a43c..458410a 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,8 @@ PeerJS public broker is used only for the initial WebRTC signaling. - Chat history is preserved by the host so late joiners see prior messages - Sharable invite link and copy-able meeting code - Host leaving ends the meeting for everyone +- Opening an invite link to a meeting with no active host offers to host it, + so a host can leave and re-host from the same link - **Verified meetings (experimental)** — host proves their identity with a passkey so guests can’t be fooled by an impostor ([details](#verified-meetings-experimental)) - No accounts, no passcodes, fully static-site deployable diff --git a/e2e/nonverified-rehost.spec.ts b/e2e/nonverified-rehost.spec.ts new file mode 100644 index 0000000..65a33a6 --- /dev/null +++ b/e2e/nonverified-rehost.spec.ts @@ -0,0 +1,41 @@ +import { Page } from '@playwright/test'; +import { expect, freshMeetingCode, test } from './fixtures'; + +async function dismissShareDialog(page: Page) { + const done = page.getByRole('button', { name: 'Done' }); + if (await done.isVisible().catch(() => false)) { + await done.click(); + await expect(done).toBeHidden(); + } +} + +test.describe('Ordinary meeting re-hosting', () => { + // A host can leave and re-host an ordinary (non-verified) meeting from the + // invite link — the unhosted meeting offers a "Host this meeting" button + // instead of dead-ending at the error screen. + test('a guest can host an ordinary meeting that has no host', async ({ + browser, + }) => { + const context = await browser.newContext({ + permissions: ['camera', 'microphone'], + }); + const page = await context.newPage(); + + // Open an ordinary invite link for a code nobody is hosting. + const code = freshMeetingCode(); + await page.goto(`/#/m/${code}?name=Alice`); + + // No host present → the error screen now offers re-hosting. + const host = page.getByRole('button', { name: 'Host this meeting' }); + await expect(host).toBeVisible({ timeout: 30_000 }); + + // Claiming hosts the meeting. + await host.click(); + await expect(page.locator('button[aria-label="Leave"]')).toBeVisible({ + timeout: 30_000, + }); + await dismissShareDialog(page); + + await context.close(); + }); +}); diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 41e082f..3f8059e 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -21,6 +21,7 @@ const en = { meeting_invalid_code: 'Invalid meeting code', meeting_back_home: 'Back to home', + meeting_host_this: 'Host this meeting', meeting_join_title: 'Join meeting', meeting_enter_name: 'Please enter your name to continue.', meeting_your_name: 'Your name', diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 8db7c89..d6b056b 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -21,6 +21,7 @@ const es = { meeting_invalid_code: 'Código de reunión no válido', meeting_back_home: 'Volver al inicio', + meeting_host_this: 'Organizar esta reunión', meeting_join_title: 'Unirse a la reunión', meeting_enter_name: 'Por favor, introduce tu nombre para continuar.', meeting_your_name: 'Tu nombre', diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 2f8fcb1..8480d1c 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -21,6 +21,7 @@ const fr = { meeting_invalid_code: 'Code de réunion invalide', meeting_back_home: 'Retour à l’accueil', + meeting_host_this: 'Animer cette réunion', meeting_join_title: 'Rejoindre la réunion', meeting_enter_name: 'Veuillez entrer votre nom pour continuer.', meeting_your_name: 'Votre nom', diff --git a/src/i18n/locales/ja.ts b/src/i18n/locales/ja.ts index 0e0d1d6..2ec5015 100644 --- a/src/i18n/locales/ja.ts +++ b/src/i18n/locales/ja.ts @@ -21,6 +21,7 @@ const ja = { meeting_invalid_code: '無効なミーティングコード', meeting_back_home: 'ホームに戻る', + meeting_host_this: 'このミーティングを主催', meeting_join_title: 'ミーティングに参加', meeting_enter_name: '続行するにはお名前を入力してください。', meeting_your_name: 'お名前', diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index 92c2897..7a25b52 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -21,6 +21,7 @@ const zh = { meeting_invalid_code: '无效的会议代码', meeting_back_home: '返回首页', + meeting_host_this: '主持本次会议', meeting_join_title: '加入会议', meeting_enter_name: '请输入你的名字以继续。', meeting_your_name: '你的名字', diff --git a/src/pages/MeetingPage.tsx b/src/pages/MeetingPage.tsx index c833d13..8f98af6 100644 --- a/src/pages/MeetingPage.tsx +++ b/src/pages/MeetingPage.tsx @@ -8,6 +8,7 @@ import { Typography, } from '@mui/material'; import VerifiedUserIcon from '@mui/icons-material/VerifiedUser'; +import VideocamIcon from '@mui/icons-material/Videocam'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import MeetingRoom from '../components/MeetingRoom'; import { useMeeting } from '../peer/useMeeting'; @@ -44,6 +45,9 @@ export default function MeetingPage() { const [takeoverSession, setTakeoverSession] = useState( null ); + // Same idea for ordinary (non-verified) meetings: the host opens their own + // invite link, finds no one hosting, and re-hosts it. + const [plainTakeover, setPlainTakeover] = useState(false); const normalizedCode = code.trim().toLowerCase(); const validCode = isValidMeetingCode(normalizedCode); @@ -143,8 +147,18 @@ export default function MeetingPage() { ); } + // Ordinary meeting. A guest who can't reach a host may re-host the same code + // (remounting as host — the `key` change tears down the guest client first). return ( - + setPlainTakeover(true) : undefined + } + /> ); } @@ -290,6 +304,7 @@ function LiveMeeting({ verification, verifiedKey, onBecameHost, + onClaimHostPlain, }: { code: string; name: string; @@ -298,6 +313,8 @@ function LiveMeeting({ verifiedKey?: VerifiedKey; // Present for verified guests: lets the real host reclaim an unhosted meeting. onBecameHost?: (session: HostSession) => void; + // Present for ordinary-meeting guests: re-host the same code (no passkey). + onClaimHostPlain?: () => void; }) { const navigate = useNavigate(); const t = useT(); @@ -347,9 +364,24 @@ function LiveMeeting({ {meeting.errorMessage} - + + {onClaimHostPlain && ( + + )} + + ); } From 38766ff6b1539052887ff99807bd97cee46d44e4 Mon Sep 17 00:00:00 2001 From: Wenhao Ji Date: Fri, 22 May 2026 22:27:50 -0700 Subject: [PATCH 4/4] Show a waiting room (not an error) for unhosted ordinary meetings Ordinary (non-verified) meetings now match verified ones: a guest who opens a meeting before the host is present sees "Waiting for the host" and auto-joins once a host goes live, instead of dead-ending on "Couldn't join the meeting". - MeetingClient: all guests (not just verified) route to the waiting room + retry on peer-unavailable / connect timeout; emit a `joined` event on connect. - useMeeting: guests go live on joined/verified rather than when the broker connects, avoiding a flash of empty room before the waiting room. - MeetingPage: the waiting room's "Host this meeting" now works for ordinary meetings too; dropped the dead error-screen button and unused i18n key. - Tests: peer-unavailable now asserts waiting+retry, added an auto-join test, switched MeetingClient tests to fake timers; non-verified e2e expects the waiting room. README updated. Co-Authored-By: Claude Opus 4.7 --- README.md | 5 ++- e2e/nonverified-rehost.spec.ts | 7 +++- src/i18n/locales/en.ts | 1 - src/i18n/locales/es.ts | 1 - src/i18n/locales/fr.ts | 1 - src/i18n/locales/ja.ts | 1 - src/i18n/locales/zh.ts | 1 - src/pages/MeetingPage.tsx | 68 +++++++++++++++++----------------- src/peer/MeetingClient.test.ts | 37 ++++++++++++++++-- src/peer/MeetingClient.ts | 36 +++++++++--------- src/peer/useMeeting.ts | 17 ++++++--- 11 files changed, 104 insertions(+), 71 deletions(-) diff --git a/README.md b/README.md index 458410a..af73d62 100644 --- a/README.md +++ b/README.md @@ -44,8 +44,9 @@ PeerJS public broker is used only for the initial WebRTC signaling. - Chat history is preserved by the host so late joiners see prior messages - Sharable invite link and copy-able meeting code - Host leaving ends the meeting for everyone -- Opening an invite link to a meeting with no active host offers to host it, - so a host can leave and re-host from the same link +- Opening a meeting before its host arrives shows a **waiting room** and joins + automatically once a host is live; if nobody is hosting, you can host it + yourself — so a host can leave and re-host from the same invite link - **Verified meetings (experimental)** — host proves their identity with a passkey so guests can’t be fooled by an impostor ([details](#verified-meetings-experimental)) - No accounts, no passcodes, fully static-site deployable diff --git a/e2e/nonverified-rehost.spec.ts b/e2e/nonverified-rehost.spec.ts index 65a33a6..a89b0fb 100644 --- a/e2e/nonverified-rehost.spec.ts +++ b/e2e/nonverified-rehost.spec.ts @@ -25,9 +25,12 @@ test.describe('Ordinary meeting re-hosting', () => { const code = freshMeetingCode(); await page.goto(`/#/m/${code}?name=Alice`); - // No host present → the error screen now offers re-hosting. + // No host present → the waiting room (not an error), with a re-host option. + await expect(page.getByText('Waiting for the host')).toBeVisible({ + timeout: 30_000, + }); const host = page.getByRole('button', { name: 'Host this meeting' }); - await expect(host).toBeVisible({ timeout: 30_000 }); + await expect(host).toBeVisible(); // Claiming hosts the meeting. await host.click(); diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 3f8059e..41e082f 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -21,7 +21,6 @@ const en = { meeting_invalid_code: 'Invalid meeting code', meeting_back_home: 'Back to home', - meeting_host_this: 'Host this meeting', meeting_join_title: 'Join meeting', meeting_enter_name: 'Please enter your name to continue.', meeting_your_name: 'Your name', diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index d6b056b..8db7c89 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -21,7 +21,6 @@ const es = { meeting_invalid_code: 'Código de reunión no válido', meeting_back_home: 'Volver al inicio', - meeting_host_this: 'Organizar esta reunión', meeting_join_title: 'Unirse a la reunión', meeting_enter_name: 'Por favor, introduce tu nombre para continuar.', meeting_your_name: 'Tu nombre', diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 8480d1c..2f8fcb1 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -21,7 +21,6 @@ const fr = { meeting_invalid_code: 'Code de réunion invalide', meeting_back_home: 'Retour à l’accueil', - meeting_host_this: 'Animer cette réunion', meeting_join_title: 'Rejoindre la réunion', meeting_enter_name: 'Veuillez entrer votre nom pour continuer.', meeting_your_name: 'Votre nom', diff --git a/src/i18n/locales/ja.ts b/src/i18n/locales/ja.ts index 2ec5015..0e0d1d6 100644 --- a/src/i18n/locales/ja.ts +++ b/src/i18n/locales/ja.ts @@ -21,7 +21,6 @@ const ja = { meeting_invalid_code: '無効なミーティングコード', meeting_back_home: 'ホームに戻る', - meeting_host_this: 'このミーティングを主催', meeting_join_title: 'ミーティングに参加', meeting_enter_name: '続行するにはお名前を入力してください。', meeting_your_name: 'お名前', diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index 7a25b52..92c2897 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -21,7 +21,6 @@ const zh = { meeting_invalid_code: '无效的会议代码', meeting_back_home: '返回首页', - meeting_host_this: '主持本次会议', meeting_join_title: '加入会议', meeting_enter_name: '请输入你的名字以继续。', meeting_your_name: '你的名字', diff --git a/src/pages/MeetingPage.tsx b/src/pages/MeetingPage.tsx index 8f98af6..8f81c88 100644 --- a/src/pages/MeetingPage.tsx +++ b/src/pages/MeetingPage.tsx @@ -327,6 +327,7 @@ function LiveMeeting({ code={code} verifiedKey={verifiedKey} onBecameHost={onBecameHost} + onClaimHostPlain={onClaimHostPlain} onLeave={() => navigate('/')} /> ); @@ -364,24 +365,9 @@ function LiveMeeting({ {meeting.errorMessage} - - {onClaimHostPlain && ( - - )} - - + ); } @@ -444,33 +430,41 @@ function WaitingRoom({ code, verifiedKey, onBecameHost, + onClaimHostPlain, onLeave, }: { code: string; verifiedKey?: VerifiedKey; onBecameHost?: (session: HostSession) => void; + onClaimHostPlain?: () => void; onLeave: () => void; }) { const t = useT(); const [claiming, setClaiming] = useState(false); const [error, setError] = useState(null); - const canClaim = !!verifiedKey && !!onBecameHost; + // Verified meetings reclaim via a passkey ceremony; ordinary meetings just + // remount as host. + const verifiedClaim = !!verifiedKey && !!onBecameHost; + const canClaim = verifiedClaim || !!onClaimHostPlain; const claimHost = async () => { - if (!verifiedKey || !onBecameHost) return; - setClaiming(true); - setError(null); - try { - const session = await HostSession.create({ - code, - identityCredentialId: loadHostIdentity()?.credentialId ?? null, - identityPublicKey: verifiedKey.publicKey, - identityAlg: verifiedKey.alg, - }); - onBecameHost(session); // unmounts this screen and starts hosting - } catch (e: any) { - setError(e?.message ?? t.verify_host_unlock_failed); - setClaiming(false); + if (verifiedClaim) { + setClaiming(true); + setError(null); + try { + const session = await HostSession.create({ + code, + identityCredentialId: loadHostIdentity()?.credentialId ?? null, + identityPublicKey: verifiedKey!.publicKey, + identityAlg: verifiedKey!.alg, + }); + onBecameHost!(session); // unmounts this screen and starts hosting + } catch (e: any) { + setError(e?.message ?? t.verify_host_unlock_failed); + setClaiming(false); + } + } else if (onClaimHostPlain) { + onClaimHostPlain(); } }; @@ -497,7 +491,13 @@ function WaitingRoom({ onClick={claimHost} disabled={claiming} startIcon={ - claiming ? : + claiming ? ( + + ) : verifiedClaim ? ( + + ) : ( + + ) } sx={{ textTransform: 'none' }} > diff --git a/src/peer/MeetingClient.test.ts b/src/peer/MeetingClient.test.ts index 27db091..97cb1f2 100644 --- a/src/peer/MeetingClient.test.ts +++ b/src/peer/MeetingClient.test.ts @@ -54,7 +54,16 @@ async function startClient() { } describe('MeetingClient', () => { - beforeEach(() => resetPeerJsMock()); + beforeEach(() => { + resetPeerJsMock(); + // The guest waiting-room uses setTimeout for retries / connect timeouts. + // Fake timers keep those deterministic and prevent leaks across tests. + jest.useFakeTimers(); + }); + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); it('host: resolves start() on peer open and emits ready + members for self', async () => { const ready = jest.fn(); @@ -285,12 +294,34 @@ describe('MeetingClient', () => { expect(ended).toHaveBeenCalledWith('host-left'); }); - it('client: peer-unavailable error triggers ended error', async () => { + it('client: peer-unavailable shows the waiting room and retries (no error)', async () => { + const waiting = jest.fn(); const ended = jest.fn(); const { client, peer } = await startClient(); + client.on('waiting', waiting); client.on('ended', ended); + const before = peer.outgoingConnects.length; + peer.fakeError({ type: 'peer-unavailable' }); - expect(ended).toHaveBeenCalledWith('error', 'Meeting not found.'); + expect(waiting).toHaveBeenCalledTimes(1); + expect(ended).not.toHaveBeenCalled(); + + // Rather than giving up, it schedules another connection attempt. + jest.advanceTimersByTime(2500); + expect(peer.outgoingConnects.length).toBe(before + 1); + }); + + it('client: auto-joins when a host appears after waiting', async () => { + const joined = jest.fn(); + const { client, peer } = await startClient(); + client.on('joined', joined); + + peer.fakeError({ type: 'peer-unavailable' }); // host not here yet + jest.advanceTimersByTime(2500); // retry connects again + const dc = peer.outgoingConnects[peer.outgoingConnects.length - 1]; + dc.fakeOpen(); // host has arrived + + expect(joined).toHaveBeenCalled(); }); it('client: sendChat sends chat-send to the host', async () => { diff --git a/src/peer/MeetingClient.ts b/src/peer/MeetingClient.ts index d0def1a..3a34faf 100644 --- a/src/peer/MeetingClient.ts +++ b/src/peer/MeetingClient.ts @@ -37,8 +37,10 @@ export interface MeetingEvents { detail?: string ) => void; ready: (selfId: string) => void; - // Verified meeting (experimental) lifecycle, guest side only. + // Guest lifecycle. waiting: () => void; // host not present yet; waiting room + joined: () => void; // connected to host (ordinary meeting goes live) + // Verified meeting (experimental) only. verifying: () => void; // connected, checking host identity verified: (fingerprint: string) => void; // host identity confirmed } @@ -556,14 +558,11 @@ export class MeetingClient { peer.on('error', (err) => { // PeerJS surfaces peer-unavailable when the host id isn't registered. if ((err as any).type === 'peer-unavailable') { - // Verified meetings show a waiting room and keep retrying until the - // host appears; ordinary meetings keep the original behavior. - if (this.isVerifiedGuest() && !this.clientConnected) { - this.enterWaiting(); - return; - } - this.emit('ended', 'error', 'Meeting not found.'); - this.shutdown(); + // The host isn't here yet. Show the waiting room and keep retrying + // until a host appears (then auto-join), rather than dead-ending on an + // error — this is the same experience for ordinary and verified + // meetings. + if (!this.clientConnected) this.enterWaiting(); return; } console.error('Client peer error', err); @@ -583,16 +582,14 @@ export class MeetingClient { } const dc = peer.connect(this.hostId, { reliable: true }); this.hostDataConn = dc; - // A verified guest can't rely solely on `peer-unavailable`: if the broker - // still holds a stale registration for a host that just left, the - // connection neither opens nor errors. Time out and fall back to the - // waiting room (which retries) so we never hang on "joining". - if (this.isVerifiedGuest()) { - if (this.connectTimer) clearTimeout(this.connectTimer); - this.connectTimer = setTimeout(() => { - if (!this.clientConnected) this.enterWaiting(); - }, 10000); - } + // A guest can't rely solely on `peer-unavailable`: if the broker still + // holds a stale registration for a host that just left, the connection + // neither opens nor errors. Time out and fall back to the waiting room + // (which retries) so we never hang on "joining". + if (this.connectTimer) clearTimeout(this.connectTimer); + this.connectTimer = setTimeout(() => { + if (!this.clientConnected) this.enterWaiting(); + }, 10000); dc.on('open', () => { this.clientConnected = true; this.waitingForHost = false; @@ -654,6 +651,7 @@ export class MeetingClient { // identity check for verified ones. private joinHost(dc: DataConnection): void { this.safeSend(dc, { type: 'hello', name: this.name }); + this.emit('joined'); this.publishOwnState(); // Place a call to host with our stream so host can relay it. if (this.localStream && this.peer) { diff --git a/src/peer/useMeeting.ts b/src/peer/useMeeting.ts index e4b4803..0a97d61 100644 --- a/src/peer/useMeeting.ts +++ b/src/peer/useMeeting.ts @@ -101,9 +101,9 @@ export function useMeeting({ const clientRef = useRef(null); const startedRef = useRef(false); - // Verified guests don't go "live" until the host's identity checks out, so - // their phase is driven by events rather than start() resolving. - const verifiedGuest = verification?.role === 'guest'; + // Guests don't go "live" the moment the broker connects — they wait until + // they've actually joined a host (or, for verified meetings, verified it). + // Their phase is driven by events; only the host goes live on start(). useEffect(() => { if (startedRef.current) return; @@ -121,6 +121,11 @@ export function useMeeting({ if (!cancelled) setPhase('waiting'); }) ); + unsubs.push( + client.on('joined', () => { + if (!cancelled) setPhase('live'); + }) + ); unsubs.push( client.on('verifying', () => { if (!cancelled) setPhase('verifying'); @@ -200,9 +205,9 @@ export function useMeeting({ setPhase('joining'); try { await client.start(stream); - // Verified guests stay in 'joining' until a waiting/verifying/verified - // event moves them; everyone else goes live as soon as start resolves. - if (!cancelled && !verifiedGuest) setPhase('live'); + // Only the host goes live on start(). Guests stay in 'joining' until a + // waiting / joined / verified event moves them. + if (!cancelled && isHost) setPhase('live'); } catch (e: any) { if (cancelled) return; console.error('Meeting start failed', e);