Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,55 @@ 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 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

## Verified meetings (experimental)

A meeting code proves nothing about *who* is hosting: the host’s PeerJS id is
derived from the code (`rendezvous-<code>`), 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)
Expand Down Expand Up @@ -103,6 +150,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

Expand All @@ -118,6 +170,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

Expand Down Expand Up @@ -159,6 +214,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
123 changes: 123 additions & 0 deletions docs/verified-meetings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# 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-<code>`), 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=<public-key>&va=<alg>
```

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 & 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. 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

- **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.
44 changes: 44 additions & 0 deletions e2e/nonverified-rehost.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
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 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();

// 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();
});
});
96 changes: 96 additions & 0 deletions e2e/verified-rehost.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading