Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ TURNS_SERVER_URL=turns:turn.pairux.com:5349
# ===========================================
LIVEKIT_API_KEY=devkey
LIVEKIT_API_SECRET=secret

# Partner apps (e.g. qrypt.chat) that run end-to-end encrypted calls through
# @profullstack/pairux-embed and POST /api/v1/partner/token. Only the SHA-256
# of each partner key (pux_pk_...) is stored here; see apps/web/src/lib/partners.ts.
# PAIRUX_PARTNERS=[{"id":"qrypt","name":"qrypt.chat","keySha256":"<sha256 hex>","maxParticipants":16}]
# Local dev: ws://localhost:7880
# Production: wss://sfu.pairux.com
NEXT_PUBLIC_LIVEKIT_URL=ws://localhost:7880
Expand Down
4 changes: 4 additions & 0 deletions .gitleaksignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,7 @@
# gitignored so local settings are never committed again.
a301ecc80cdc48ccb7a9e3050235089db787701b:.claude/settings.local.json:generic-api-key:91
f70a3ef1f2770499c3aea1b3e1cf98641eb44e87:.claude/settings.local.json:generic-api-key:91

# A fake partner key in a test (pux_pk_qrypt_test_key_…), never a real one; the
# test now builds its keys at runtime. Commit 3cdbdbe, PR #133.
3cdbdbefeb57ea51f068a9552fb5369a5c2dcac9:apps/web/src/app/api/v1/partner/token/route.test.ts:generic-api-key:16
98 changes: 98 additions & 0 deletions apps/web/src/app/api/v1/partner/token/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { hashPartnerKey, partnersFromEnv, authenticatePartner } from '@/lib/partners';

const mockAddGrant = vi.fn();
const mockCtor = vi.fn();
vi.mock('livekit-server-sdk', () => ({
AccessToken: vi.fn().mockImplementation((...args: unknown[]) => {
mockCtor(...args);
return { addGrant: mockAddGrant, toJwt: () => Promise.resolve('jwt') };
}),
}));
vi.mock('@/lib/ice-servers', () => ({
getIceServers: () => Promise.resolve([{ urls: 'turn:turn.pairux.com:3478' }]),
}));

// Built at runtime: a key-shaped literal trips secret scanners, and this one is fake.
const KEY = ['pux', 'pk', 'q'.repeat(32)].join('_');
const WRONG_KEY = ['pux', 'pk', 'w'.repeat(32)].join('_');
const req = (body: unknown, key: string | null = KEY) =>
new Request('https://pairux.com/api/v1/partner/token', {
method: 'POST',
headers: {
'content-type': 'application/json',
...(key ? { authorization: `Bearer ${key}` } : {}),
},
body: JSON.stringify(body),
});

beforeEach(() => {
vi.stubEnv(
'PAIRUX_PARTNERS',
JSON.stringify([
{ id: 'qrypt', name: 'qrypt.chat', keySha256: hashPartnerKey(KEY), maxParticipants: 8 },
])
);
vi.stubEnv('LIVEKIT_API_KEY', 'k');
vi.stubEnv('LIVEKIT_API_SECRET', 's');
vi.stubEnv('NEXT_PUBLIC_LIVEKIT_URL', 'wss://sfu.pairux.com');
mockAddGrant.mockClear();
mockCtor.mockClear();
});

describe('partner keys', () => {
it('store only a hash and match in constant time', () => {
const partners = partnersFromEnv();
expect(partners).toEqual([
{ id: 'qrypt', name: 'qrypt.chat', keySha256: hashPartnerKey(KEY), maxParticipants: 8 },
]);
expect(JSON.stringify(partners)).not.toContain(KEY);
expect(authenticatePartner(req({}), partners)?.id).toBe('qrypt');
expect(authenticatePartner(req({}, WRONG_KEY), partners)).toBeNull();
expect(authenticatePartner(req({}, null), partners)).toBeNull();
});

it('ignore malformed config', () => {
expect(partnersFromEnv('not json')).toEqual([]);
expect(partnersFromEnv(JSON.stringify([{ id: 'Bad Id', keySha256: 'x' }]))).toEqual([]);
});
});

describe('POST /api/v1/partner/token', () => {
it('mints a token for a room in the partner namespace', async () => {
const { POST } = await import('./route');
const res = await POST(req({ room: 'conv-123', identity: 'user-9', name: 'Alice' }));
expect(res.status).toBe(200);
const json = (await res.json()) as { data?: Record<string, unknown> } & Record<string, unknown>;
const data = json.data ?? json;
expect(data).toMatchObject({
token: 'jwt',
url: 'wss://sfu.pairux.com',
roomName: 'partner-qrypt-conv-123',
e2ee: true,
maxParticipants: 8,
});
expect(mockCtor).toHaveBeenCalledWith(
'k',
's',
expect.objectContaining({ identity: 'qrypt:user-9', name: 'Alice', ttl: '6h' })
);
expect(mockAddGrant).toHaveBeenCalledWith(
expect.objectContaining({ room: 'partner-qrypt-conv-123', roomJoin: true, canPublish: true })
);
});

it('refuses a missing or wrong key', async () => {
const { POST } = await import('./route');
expect((await POST(req({ room: 'r', identity: 'u', name: 'A' }, null))).status).toBe(401);
expect((await POST(req({ room: 'r', identity: 'u', name: 'A' }, WRONG_KEY))).status).toBe(401);
expect(mockCtor).not.toHaveBeenCalled();
});

it('refuses a room id that could escape the namespace', async () => {
const { POST } = await import('./route');
const res = await POST(req({ room: '../session-abc', identity: 'u', name: 'A' }));
expect(res.status).toBe(400);
expect(mockCtor).not.toHaveBeenCalled();
});
});
87 changes: 87 additions & 0 deletions apps/web/src/app/api/v1/partner/token/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { z } from 'zod';
import { AccessToken } from 'livekit-server-sdk';
import type { VideoGrant } from 'livekit-server-sdk';
import { successResponse, errorResponse, handleApiError } from '@/lib/api';
import { getIceServers } from '@/lib/ice-servers';
import { FixedWindowRateLimiter, getClientIp } from '@/lib/rate-limit';
import { authenticatePartner, partnerRoomName } from '@/lib/partners';

/**
* POST /api/v1/partner/token: a partner app's SERVER mints a LiveKit token for
* one of its users, for an end-to-end encrypted call run with
* @profullstack/pairux-embed. See src/lib/partners.ts.
*
* Authorization: Bearer pux_pk_…
* { "room": "<partner's room id>", "identity": "<partner's user id>", "name": "Alice" }
* -> { token, url, roomName, iceServers, e2ee: true }
*
* The media key never comes here: partners share it between participants
* themselves, so PairUX forwards only ciphertext.
*/
const requestsByIp = new FixedWindowRateLimiter(60, 60_000);
const requestsByPartner = new FixedWindowRateLimiter(600, 60_000);

const bodySchema = z.object({
room: z.string().regex(/^[A-Za-z0-9_-]{1,64}$/, 'room: 1-64 letters, digits, _ or -'),
identity: z.string().regex(/^[A-Za-z0-9_.:@-]{1,64}$/, 'identity: 1-64 safe characters'),
name: z.string().trim().min(1).max(50),
});

export async function POST(request: Request) {
try {
const ipLimit = requestsByIp.check(getClientIp(request));
if (!ipLimit.success) {
return errorResponse(
`Too many requests. Try again in ${String(ipLimit.retryAfterSeconds)} seconds.`,
429
);
}

const partner = authenticatePartner(request);
if (!partner) return errorResponse('Unknown or missing partner key', 401);

const partnerLimit = requestsByPartner.check(partner.id);
if (!partnerLimit.success) {
return errorResponse(
`Too many requests. Try again in ${String(partnerLimit.retryAfterSeconds)} seconds.`,
429
);
}

const apiKey = process.env.LIVEKIT_API_KEY;
const apiSecret = process.env.LIVEKIT_API_SECRET;
if (!apiKey || !apiSecret) return errorResponse('LiveKit not configured', 503);

const body: unknown = await request.json().catch(() => ({}));
const { room, identity, name } = bodySchema.parse(body);
const roomName = partnerRoomName(partner, room);

const token = new AccessToken(apiKey, apiSecret, {
// Namespaced so two partners' user ids can never collide in a room.
identity: `${partner.id}:${identity}`,
name,
ttl: '6h',
metadata: JSON.stringify({ partner: partner.id, e2ee: true }),
});
const grant: VideoGrant = {
room: roomName,
roomJoin: true,
canPublish: true,
canSubscribe: true,
canPublishData: true,
canUpdateOwnMetadata: false,
};
token.addGrant(grant);

return successResponse({
token: await token.toJwt(),
url: process.env.NEXT_PUBLIC_LIVEKIT_URL,
roomName,
maxParticipants: partner.maxParticipants,
iceServers: await getIceServers(),
e2ee: true,
});
} catch (error) {
return handleApiError(error);
}
}
73 changes: 73 additions & 0 deletions apps/web/src/lib/partners.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* Partner apps (e.g. qrypt.chat) that run end-to-end encrypted calls on
* PairUX's SFU through @profullstack/pairux-embed.
*
* A partner's SERVER holds a key `pux_pk_…` and calls POST /api/v1/partner/token
* to mint a LiveKit token for one of its users. PairUX never sees partner user
* accounts, and never sees the media: the partner shares the media key between
* its participants over its own encrypted channel. Rooms live in the partner's
* own namespace (`partner-<id>-<room>`), which recording, restreaming and call
* analysis never touch (they only act on `session-*` rooms).
*
* Configuration, no database: PAIRUX_PARTNERS is JSON
* [{ "id": "qrypt", "name": "qrypt.chat", "keySha256": "<hex>", "maxParticipants": 16 }]
* Only the SHA-256 of a key is stored, so the env never holds a usable key.
*/
import { createHash, timingSafeEqual } from 'node:crypto';

export interface Partner {
id: string;
name: string;
keySha256: string;
maxParticipants: number;
}

const ID_RE = /^[a-z0-9][a-z0-9-]{1,30}$/;

export function partnersFromEnv(raw = process.env.PAIRUX_PARTNERS): Partner[] {
if (!raw) return [];
let list: unknown;
try {
list = JSON.parse(raw);
} catch {
return [];
}
if (!Array.isArray(list)) return [];
return list.flatMap((p: Partial<Partner>) =>
typeof p.id === 'string' &&
ID_RE.test(p.id) &&
typeof p.keySha256 === 'string' &&
/^[0-9a-f]{64}$/.test(p.keySha256)
? [
{
id: p.id,
name: typeof p.name === 'string' ? p.name : p.id,
keySha256: p.keySha256,
maxParticipants: Number(p.maxParticipants) > 0 ? Number(p.maxParticipants) : 16,
},
]
: []
);
}

export const hashPartnerKey = (key: string) => createHash('sha256').update(key).digest('hex');

/** The partner whose key is presented as `Authorization: Bearer pux_pk_…`, or null. */
export function authenticatePartner(
request: Request,
partners = partnersFromEnv()
): Partner | null {
const match = /^Bearer\s+(pux_pk_[A-Za-z0-9_-]{20,})$/.exec(
request.headers.get('authorization') ?? ''
);
if (!match?.[1]) return null;
const presented = Buffer.from(hashPartnerKey(match[1]), 'hex');
for (const p of partners) {
const expected = Buffer.from(p.keySha256, 'hex');
if (expected.length === presented.length && timingSafeEqual(expected, presented)) return p;
}
return null;
}

/** The LiveKit room for a partner's room id: always inside its own namespace. */
export const partnerRoomName = (partner: Partner, room: string) => `partner-${partner.id}-${room}`;
49 changes: 49 additions & 0 deletions packages/embed/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# @profullstack/pairux-embed

End-to-end encrypted voice, video and screen sharing for any web app, on [PairUX](https://pairux.com)'s SFU.

Your app supplies the **media key**: 32 random bytes that you share with the other participants over your own encrypted channel. Every audio and video frame is encrypted in the browser before it leaves, so PairUX's servers only ever forward ciphertext and can neither hear nor see the call. If a browser can't encrypt, the library refuses to join rather than falling back to a plaintext call.

```sh
npm install @profullstack/pairux-embed
```

## 1. Your server gets a token

Partner apps get a key (`pux_pk_…`) from PairUX. Keep it on your server and mint a token per user:

```js
const res = await fetch('https://pairux.com/api/v1/partner/token', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PAIRUX_PARTNER_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ room: conversationId, identity: userId, name: displayName }),
});
const { data } = await res.json(); // { token, url, roomName, iceServers, e2ee: true }
```

Rooms live in your own namespace. PairUX never records, restreams or analyses partner rooms.

## 2. Your client joins, encrypted

```js
import { PairuxCall, mountCall, newMediaKey } from '@profullstack/pairux-embed';

// Whoever starts the call makes the key and sends it to the others over
// YOUR end-to-end encrypted channel (qrypt.chat sends it ML-KEM-1024 encrypted).
const key = newMediaKey();

const call = new PairuxCall({ url: data.url, token: data.token, iceServers: data.iceServers, key });
const ui = mountCall(document.getElementById('call'), call, { onLeave: () => ui.destroy() });
await call.join({ audio: true, video: false });
```

`mountCall` draws participant tiles and the mic, camera, screen and leave controls in plain DOM, with no framework. To theme it, set `--pxe-bg`, `--pxe-tile`, `--pxe-fg`, `--pxe-muted`, `--pxe-accent` and `--pxe-danger` on the container. To build your own UI instead, use `PairuxCall` directly. Its events are `participants`, `track`, `state` and `error`, and its methods are `setMic`, `setCamera`, `setScreen`, `rotateKey` and `leave`.

**The E2EE worker:** by default the library loads `livekit-client/e2ee-worker` with `new Worker(new URL(...), import.meta.url)`, which Vite, webpack 5 and Next.js resolve. If your bundler doesn't, pass `worker`.

## Licence

MIT.
55 changes: 55 additions & 0 deletions packages/embed/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
{
"name": "@profullstack/pairux-embed",
"version": "0.1.0",
"description": "End-to-end encrypted voice, video and screen sharing for any web app, on PairUX's SFU. The host app supplies the media key; the server only forwards ciphertext.",
"license": "MIT",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./package.json": "./package.json"
},
"files": [
"dist",
"README.md"
],
"keywords": [
"webrtc",
"e2ee",
"end-to-end-encryption",
"video-call",
"voice-chat",
"screen-sharing",
"livekit",
"embed",
"pairux"
],
"repository": {
"type": "git",
"url": "git+https://github.com/profullstack/pairux.com.git",
"directory": "packages/embed"
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"typecheck": "tsc --noEmit",
"lint": "eslint src/",
"test": "vitest run",
"clean": "rm -rf dist"
},
"dependencies": {
"livekit-client": "^2.17.0"
},
"devDependencies": {
"typescript": "^5.7.0",
"vitest": "^3.2.0",
"jsdom": "^25.0.0"
},
"engines": {
"node": ">=18"
}
}
Loading
Loading