Authorize direct file invitations within paired rooms and parties

This commit is contained in:
Jonathan Sykes
2026-10-03 20:26:07 +08:00
parent ef70da1806
commit 88e2e67eeb
6 changed files with 136 additions and 5 deletions

View File

@@ -0,0 +1,39 @@
/* Shared bounded control protocol: media bytes belong exclusively on DataChannel. */
(function (root) {
'use strict';
const CHUNK = 65536;
const text = (v, n) => typeof v === 'string' && v.length <= n && !/[\u0000-\u001f]/.test(v);
const id = (v) => text(v, 128) && /^[A-Za-z0-9_-]+$/.test(v);
function file(v) {
if (!v || !id(v.id) || !/^[a-f0-9]{64}$/.test(v.cid) || !Number.isSafeInteger(v.size) || v.size <= 0 || v.size > 4 * 1024 ** 3) return null;
if (!text(v.title, 300) || !text(v.channel || '', 200)) return null;
return { id: v.id, cid: v.cid, size: v.size, title: v.title, channel: v.channel || '', duration: Number.isFinite(v.duration) && v.duration >= 0 ? v.duration : 0 };
}
function parse(value) {
if (typeof value === 'string') { if (value.length > 65536) return null; try { value = JSON.parse(value); } catch { return null; } }
if (!value || typeof value !== 'object' || Array.isArray(value) || value.type !== 'direct') return null;
const m = { type: 'direct', action: value.action };
if (value.action === 'invite') {
const f = file(value.file); if (!id(value.to) || !f) return null;
return { ...m, to: value.to, file: f };
}
if (!['accept', 'decline', 'signal', 'complete'].includes(value.action) || !id(value.token)) return null;
m.token = value.token;
if (value.action === 'signal') {
const d = value.data;
if (!d || typeof d !== 'object') return null;
if (['offer', 'answer'].includes(d.kind) && typeof d.sdp === 'string' && d.sdp.length <= 60000) m.data = { kind: d.kind, sdp: d.sdp };
else if (d.kind === 'ice' && d.candidate && text(d.candidate.candidate, 2048)) {
const c = d.candidate;
if (!(c.sdpMid == null || text(c.sdpMid, 64)) || !(c.sdpMLineIndex == null || Number.isInteger(c.sdpMLineIndex) && c.sdpMLineIndex >= 0 && c.sdpMLineIndex < 100)) return null;
m.data = { kind: 'ice', candidate: { candidate: c.candidate, sdpMid: c.sdpMid ?? null, sdpMLineIndex: c.sdpMLineIndex ?? null } };
} else return null;
}
return m;
}
function offset(v, size) { if (!Number.isSafeInteger(v) || v < 0 || v > size) throw new Error('Invalid resume offset'); return v; }
function nextChunk(pos, size) { offset(pos, size); return { start: pos, end: Math.min(size, pos + CHUNK) }; }
const api = { CHUNK, file, parse, offset, nextChunk };
if (typeof module !== 'undefined') module.exports = api;
root.DirectProtocol = api;
}(typeof globalThis !== 'undefined' ? globalThis : this));

View File

@@ -0,0 +1,18 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const p = require('./direct-protocol.js');
test('direct protocol strips unexpected payload and validates file claims', () => {
const m = p.parse({ type: 'direct', action: 'invite', to: 'peer', file: { id: 'video', title: 'Song', cid: 'a'.repeat(64), size: 10, bytes: 'secret' }, bytes: 'not relayed' });
assert.equal(m.file.size, 10); assert.equal(m.bytes, undefined); assert.equal(m.file.bytes, undefined);
assert.equal(p.parse({ ...m, file: { ...m.file, size: Infinity } }), null);
assert.equal(p.parse(new Uint8Array(4)), null);
});
test('chunks retain exact resume offset and never exceed file end', () => {
assert.deepEqual(p.nextChunk(17, 100000), { start: 17, end: 65553 });
assert.deepEqual(p.nextChunk(99999, 100000), { start: 99999, end: 100000 });
for (const n of [-1, 100001, 0.5, NaN]) assert.throws(() => p.offset(n, 100000));
});
test('signalling only admits bounded SDP or ICE, never byte frames', () => {
assert.equal(p.parse({ type: 'direct', action: 'signal', token: 'abc', data: { kind: 'bytes', data: [1, 2] } }), null);
assert.equal(p.parse({ type: 'direct', action: 'signal', token: 'abc', data: { kind: 'offer', sdp: 'x'.repeat(60001) } }), null);
});

38
server/direct-relay.js Normal file
View File

@@ -0,0 +1,38 @@
import { randomBytes } from 'node:crypto';
import protocol from '../frontend/direct-protocol.js';
// One instance per authenticated room. It has no media endpoint or binary path.
export function createDirectRelay({ now = Date.now } = {}) {
const transfers = new Map();
const rates = new Map();
function handle(from, raw, peers, send) {
const m = protocol.parse(raw);
if (!m || !peers.has(from)) return false;
const t = now();
for (const [token, x] of transfers) if (x.expires < t || !peers.has(x.from) || !peers.has(x.to)) transfers.delete(token);
const rate = rates.get(from);
const r = rate && t - rate.start < 60000 ? rate : { start: t, count: 0 };
rates.set(from, r);
if (++r.count > 240) return false;
if (m.action === 'invite') {
if (m.to === from || !peers.has(m.to) || transfers.size >= 64) return false;
const token = randomBytes(24).toString('base64url');
transfers.set(token, { from, to: m.to, file: m.file, accepted: false, expires: t + 60000 });
send(peers.get(m.to), { type: 'direct', action: 'invite', from, token, file: m.file });
send(peers.get(from), { type: 'direct', action: 'invited', to: m.to, token, file: m.file });
return true;
}
const x = transfers.get(m.token);
if (!x || ![x.from, x.to].includes(from)) return false;
const target = from === x.from ? x.to : x.from;
if (m.action === 'accept') {
if (from !== x.to || x.accepted) return false;
x.accepted = true;
x.expires = t + 30 * 60000;
} else if (m.action === 'signal' && !x.accepted) return false;
else if (m.action === 'decline' || m.action === 'complete') transfers.delete(m.token);
send(peers.get(target), { ...m, from });
return true;
}
return { handle };
}

View File

@@ -0,0 +1,23 @@
import { test, expect } from 'bun:test';
import { createDirectRelay } from './direct-relay.js';
test('room membership, receiver acceptance and one-use invitation bind signalling', () => {
let clock = 0; const relay = createDirectRelay({ now: () => clock });
const peers = new Map([['host', 'hostSocket'], ['guest', 'guestSocket'], ['other', 'otherSocket']]);
const out = []; const send = (ws, m) => out.push({ ws, m });
const invite = { type: 'direct', action: 'invite', to: 'guest', file: { id: 'song', title: 'Song', cid: 'a'.repeat(64), size: 123 } };
expect(relay.handle('outsider', invite, peers, send)).toBe(false);
expect(relay.handle('host', { ...invite, to: 'outsider' }, peers, send)).toBe(false);
expect(relay.handle('host', invite, peers, send)).toBe(true);
const token = out[0].m.token;
const signal = { type: 'direct', action: 'signal', token, data: { kind: 'offer', sdp: 'valid' } };
expect(relay.handle('host', signal, peers, send)).toBe(false);
expect(relay.handle('other', { type: 'direct', action: 'accept', token }, peers, send)).toBe(false);
expect(relay.handle('guest', { type: 'direct', action: 'accept', token }, peers, send)).toBe(true);
expect(relay.handle('guest', { type: 'direct', action: 'accept', token }, peers, send)).toBe(false);
expect(relay.handle('other', signal, peers, send)).toBe(false);
expect(relay.handle('host', signal, peers, send)).toBe(true);
relay.handle('guest', { type: 'direct', action: 'complete', token }, peers, send);
expect(relay.handle('host', signal, peers, send)).toBe(false);
relay.handle('host', invite, peers, send); clock = 61000;
expect(relay.handle('guest', { type: 'direct', action: 'accept', token: out.at(-1).m.token }, peers, send)).toBe(false);
});

View File

@@ -18,6 +18,7 @@
* link); the host resumes it with the secret it was given.
* ========================================================================== */
import { createDirectRelay } from './direct-relay.js';
import { createDJQueue, changeDJQueue, queueSnapshot, takeDJVideo } from './party-dj.js';
import { randomBytes, randomInt, timingSafeEqual } from 'node:crypto';
@@ -89,7 +90,7 @@ export function createPartyHub({ now = () => Date.now() } = {}) {
if (p && !same(p.secret, d.secret)) { ws.close(4003, 'not the host of this party'); return; }
if (!p) {
const code = newCode();
p = { code, secret: randomBytes(18).toString('base64url'), hostPid: d.pid, members: new Map(), state: null, chat: [], dj: createDJQueue(), allowControl: false, hostOnline: true, hostLeftAt: 0 };
p = { direct: createDirectRelay(), code, secret: randomBytes(18).toString('base64url'), hostPid: d.pid, members: new Map(), state: null, chat: [], dj: createDJQueue(), allowControl: false, hostOnline: true, hostLeftAt: 0 };
parties.set(code, p);
}
p.hostPid = d.pid;
@@ -118,7 +119,9 @@ export function createPartyHub({ now = () => Date.now() } = {}) {
const me = p && p.members.get(d.pid);
if (!me || me.ws !== ws || !m || typeof m !== 'object') return;
const isHost = d.pid === p.hostPid;
if (m.type === 'dj') {
if (m.type === 'direct') {
p.direct.handle(me.pid, m, new Map([...p.members].map(([pid, member]) => [pid, member.ws])), send);
} else if (m.type === 'dj') {
if (m.action === 'take' && isHost) { const video = takeDJVideo(p.dj); send(ws, { type: 'dj-play', video }); if (video) broadcast(p, { type: 'dj', ...queueSnapshot(p.dj) }); }
else if (changeDJQueue(p.dj, m, me.pid, isHost)) broadcast(p, { type: 'dj', ...queueSnapshot(p.dj) });
} else if (m.type === 'state' && isHost) {

View File

@@ -28,6 +28,8 @@
* reach the server from the same public IP (i.e. the same home/church Wi-Fi).
* ========================================================================== */
import { createDirectRelay } from './direct-relay.js';
import { createHash, createHmac, randomBytes, randomInt, timingSafeEqual } from 'node:crypto';
export const REMOTE_COMMANDS = new Set([
@@ -145,7 +147,7 @@ export function createRemoteHub({ requireSameNetwork = false } = {}) {
const id = roomIdFor(d.secret);
let room = rooms.get(id);
if (!room) {
room = { id, secret: d.secret, host: null, hostName: d.name, hostIp: d.ip, remotes: new Map(), state: null, queue: null, code: null, codeExpires: 0, lastSeen: Date.now() };
room = { direct: createDirectRelay(), id, secret: d.secret, host: null, hostName: d.name, hostIp: d.ip, remotes: new Map(), state: null, queue: null, code: null, codeExpires: 0, lastSeen: Date.now() };
rooms.set(id, room);
}
if (room.host && room.host !== ws) {
@@ -158,7 +160,7 @@ export function createRemoteHub({ requireSameNetwork = false } = {}) {
d.roomId = id;
issueCode(room);
send(ws, { type: 'hello', roomId: id, code: room.code, codeExpires: room.codeExpires, remotes: remoteList(room) });
for (const r of room.remotes.values()) send(r.ws, { type: 'hello', hostName: room.hostName, state: room.state, queue: room.queue });
for (const r of room.remotes.values()) send(r.ws, { type: 'hello', you: r.ws.data.rid, hostName: room.hostName, state: room.state, queue: room.queue });
}
function openRemote(ws) {
@@ -172,7 +174,7 @@ export function createRemoteHub({ requireSameNetwork = false } = {}) {
if (prev && prev.ws !== ws) { try { prev.ws.close(4000, 'replaced'); } catch { /* gone */ } }
room.remotes.set(d.rid, { ws, name: d.name, since: Date.now() });
room.lastSeen = Date.now();
send(ws, { type: 'hello', hostName: room.hostName, state: room.state, queue: room.queue });
send(ws, { type: 'hello', you: d.rid, hostName: room.hostName, state: room.state, queue: room.queue });
send(room.host, { type: 'remotes', remotes: remoteList(room), joined: d.name });
}
@@ -185,6 +187,14 @@ export function createRemoteHub({ requireSameNetwork = false } = {}) {
const room = rooms.get(d.roomId);
if (!room) return;
room.lastSeen = Date.now();
if (m.type === 'direct') {
const me = d.role === 'host' ? (room.host === ws ? 'host' : null) : (room.remotes.get(d.rid)?.ws === ws ? d.rid : null);
if (!me) return;
const peers = new Map([...room.remotes].map(([rid, r]) => [rid, r.ws]));
if (room.host) peers.set('host', room.host);
room.direct.handle(me, m, peers, send);
return;
}
if (d.role === 'host') {
if (room.host !== ws) return;