diff --git a/frontend/direct-protocol.js b/frontend/direct-protocol.js new file mode 100644 index 0000000..a60c3b0 --- /dev/null +++ b/frontend/direct-protocol.js @@ -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)); diff --git a/frontend/direct-protocol.test.js b/frontend/direct-protocol.test.js new file mode 100644 index 0000000..fc4819d --- /dev/null +++ b/frontend/direct-protocol.test.js @@ -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); +}); diff --git a/server/direct-relay.js b/server/direct-relay.js new file mode 100644 index 0000000..2fd8f85 --- /dev/null +++ b/server/direct-relay.js @@ -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 }; +} diff --git a/server/direct-relay.test.js b/server/direct-relay.test.js new file mode 100644 index 0000000..0c71576 --- /dev/null +++ b/server/direct-relay.test.js @@ -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); +}); diff --git a/server/party.js b/server/party.js index 7108edd..85de44b 100644 --- a/server/party.js +++ b/server/party.js @@ -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) { diff --git a/server/remote.js b/server/remote.js index c2666f7..040b730 100644 --- a/server/remote.js +++ b/server/remote.js @@ -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;