1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
|
// 1:1 voice and video calls (NIP-AC, the draft Amethyst, nospeak and Noscall
// speak): WebRTC between the two devices, its setup carried over nostr.
//
// Every setup message (offer, answer, ICE candidate, hang-up, reject,
// renegotiate) is an event signed by its sender and wrapped for its one
// recipient in an ephemeral gift wrap (kind 21059: NIP-44 to them, under a
// throwaway key, no seal). Relays see only who it's for. The sound and
// picture go device to device, encrypted by WebRTC itself (DTLS-SRTP); the
// fingerprints that pin that encryption ride in the signed offer and answer,
// so nobody between can stand in for either side. When the two can't reach
// each other directly, TURN relays the (still encrypted) packets.
//
// No DOM here: the app hands in how to sign, send, fetch ICE servers, open
// the camera, and who may ring; it gets back the call's state to draw.
import { finalizeEvent, generateSecretKey, verifyEvent } from 'nostr-tools/pure';
import * as nip44 from 'nostr-tools/nip44';
export const CALL_WRAP = 21059;
export const CALL = { OFFER: 25050, ANSWER: 25051, ICE: 25052, HANGUP: 25053, REJECT: 25054, RENEGOTIATE: 25055 };
const KINDS = new Set(Object.values(CALL));
const ALT = {
[CALL.OFFER]: 'WebRTC call offer', [CALL.ANSWER]: 'WebRTC call answer', [CALL.ICE]: 'WebRTC ICE candidate',
[CALL.HANGUP]: 'WebRTC call hangup', [CALL.REJECT]: 'WebRTC call rejection', [CALL.RENEGOTIATE]: 'WebRTC call renegotiation',
};
export const STALE_S = 20; // older setup messages are a relay replaying the past
const RING_MS = 60_000;
const ENDED_MS = 2500; // how long "Call ended" stays up
const now = () => Math.floor(Date.now() / 1000);
const tagOf = (ev, k) => (ev.tags.find((x) => x[0] === k) || [])[1];
export const callIdOf = (ev) => tagOf(ev, 'call-id');
export const uuid = () => (globalThis.crypto && crypto.randomUUID ? crypto.randomUUID()
: 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => ((Math.random() * 16) | (c === 'x' ? 0 : 8)).toString(16)));
// The unsigned setup message.
export function callEvent(kind, to, callId, content = '', extra = []) {
return { kind, created_at: now(), content, tags: [['p', to], ['call-id', callId], ['alt', ALT[kind]], ...extra] };
}
// A signed one, wrapped for one recipient.
export function wrapCall(signed, receiverPk) {
const eph = generateSecretKey();
return finalizeEvent({
kind: CALL_WRAP, created_at: now(), tags: [['p', receiverPk]],
content: nip44.encrypt(JSON.stringify(signed), nip44.getConversationKey(eph, receiverPk)),
}, eph);
}
// A wrap to us opened: the setup message inside, its signature good, or null
// (not a call — Concord's presence beats ride the same kind — or not ours).
// `decryptFrom(peerPk, ciphertext)`: our key's NIP-44 decryption.
export async function openCall(wrap, decryptFrom) {
if (!wrap || wrap.kind !== CALL_WRAP) return null;
let inner;
try { inner = JSON.parse(await decryptFrom(wrap.pubkey, wrap.content)); } catch { return null; }
if (!inner || !KINDS.has(inner.kind) || !callIdOf(inner) || !verifyEvent(inner)) return null;
return inner;
}
// ICE candidates as the spec writes them: JSON of the three fields.
export const candidateJson = (c) => JSON.stringify({ candidate: c.candidate, sdpMid: c.sdpMid, sdpMLineIndex: c.sdpMLineIndex });
// The calls of one identity. env:
// me() our pubkey
// sign(template) → signed event (our key or signer)
// send(peerPk, signed) wrap and publish to them (the app picks the relays)
// iceServers() → RTCIceServer[] (TURN from coinos, STUN at least)
// media(type, facing) → MediaStream: microphone, and camera for 'video'
// mayRing(pk) whether this person may ring us (followed, or a conversation)
// changed() the call's state moved: draw it
// trickle() false for a signer that's slow or asks each time (a
// bunker, Amber): the candidates go inside the offer or
// answer instead of one signed message each
// RTCPeerConnection (tests may pass their own)
export function createCalls(env) {
const PC = env.RTCPeerConnection || globalThis.RTCPeerConnection;
let call = null; // { id, peer, type, dir, state, reason, pc, local, remote, offer, remoteSet, ice: [], at, connectedAt, muted, camOff, facing }
const seen = new Set();
const early = new Map(); // peer -> candidates that came before their call did
let endTimer = null, ringTimer = null;
const changed = () => { try { env.changed(); } catch {} };
async function sendFor(c, kind, to, content = '', extra = []) {
const signed = await env.sign(callEvent(kind, to, c.id, content, extra));
await env.send(to, signed);
}
function stopMedia(c) {
for (const s of [c.local]) if (s) for (const t of s.getTracks()) { try { t.stop(); } catch {} }
try { c.pc && c.pc.close(); } catch {}
}
function end(reason, c = call) {
if (!c || c !== call || c.state === 'ended') return;
clearTimeout(ringTimer);
stopMedia(c);
c.state = 'ended'; c.reason = reason; c.endedAt = Date.now();
changed();
clearTimeout(endTimer);
endTimer = setTimeout(() => { if (call === c) { call = null; changed(); } }, ENDED_MS);
}
async function connection(c) {
const pc = new PC({ iceServers: await env.iceServers(), bundlePolicy: 'max-bundle' });
c.pc = pc;
c.remote = new MediaStream();
pc.ontrack = (e) => { for (const t of (e.streams[0] || { getTracks: () => [e.track] }).getTracks()) if (!c.remote.getTracks().includes(t)) c.remote.addTrack(t); changed(); };
c.trickle = env.trickle ? env.trickle() !== false : true;
pc.onicecandidate = (e) => { if (c.trickle && e.candidate && call === c && c.state !== 'ended') sendFor(c, CALL.ICE, c.peer, candidateJson(e.candidate)).catch(() => {}); };
pc.onconnectionstatechange = () => {
if (call !== c) return;
const s = pc.connectionState;
if (s === 'connected' && c.state !== 'connected') { c.state = 'connected'; c.connectedAt = Date.now(); clearTimeout(ringTimer); changed(); }
else if (s === 'failed') end('failed', c);
};
for (const t of c.local.getTracks()) pc.addTrack(t, c.local);
return pc;
}
// without trickle: the description once its candidates are gathered (a few seconds at most)
async function described(c) {
const pc = c.pc;
if (!c.trickle && pc.iceGatheringState !== 'complete') {
await new Promise((resolve) => {
const done = () => { if (pc.iceGatheringState === 'complete') resolve(); };
pc.addEventListener('icegatheringstatechange', done);
setTimeout(resolve, 4000);
});
}
return pc.localDescription.sdp;
}
async function addCandidates(c) {
const list = [...(early.get(c.peer) || []), ...c.ice];
early.delete(c.peer); c.ice = [];
for (const cand of list) { try { await c.pc.addIceCandidate(cand); } catch {} }
}
// ---- what the person does
async function start(peer, type = 'voice') {
if (call && call.state !== 'ended') throw new Error('busy');
clearTimeout(endTimer);
const c = call = { id: uuid(), peer, type, dir: 'out', state: 'starting', ice: [], at: Date.now(), muted: false, camOff: false, facing: 'user' };
changed();
try {
c.local = await env.media(type, c.facing);
if (call !== c) { stopMedia(c); return c; }
const pc = await connection(c);
await pc.setLocalDescription(await pc.createOffer());
c.state = 'offering';
changed();
await sendFor(c, CALL.OFFER, peer, await described(c), [['call-type', type]]);
ringTimer = setTimeout(() => { if (call === c && c.state === 'offering') { sendFor(c, CALL.HANGUP, peer, 'timeout').catch(() => {}); end('no-answer', c); } }, RING_MS);
} catch (e) {
end(e && e.name === 'NotAllowedError' ? 'no-permission' : 'error', c);
}
return c;
}
async function accept() {
const c = call;
if (!c || c.state !== 'incoming') return;
clearTimeout(ringTimer);
c.state = 'connecting';
changed();
try {
c.local = await env.media(c.type, c.facing);
if (call !== c) { stopMedia(c); return; }
const pc = await connection(c);
await pc.setRemoteDescription({ type: 'offer', sdp: c.offer });
c.remoteSet = true;
await addCandidates(c);
await pc.setLocalDescription(await pc.createAnswer());
const signed = await env.sign(callEvent(CALL.ANSWER, c.peer, c.id, await described(c)));
await env.send(c.peer, signed);
// our other devices stop ringing: answered here
env.send(env.me(), await env.sign(callEvent(CALL.ANSWER, env.me(), c.id, ''))).catch(() => {});
} catch (e) {
sendFor(c, CALL.HANGUP, c.peer, 'error').catch(() => {});
end(e && e.name === 'NotAllowedError' ? 'no-permission' : 'error', c);
}
}
async function reject() {
const c = call;
if (!c || c.state !== 'incoming') return;
sendFor(c, CALL.REJECT, c.peer).catch(() => {});
env.sign(callEvent(CALL.REJECT, env.me(), c.id)).then((s) => env.send(env.me(), s)).catch(() => {});
end('declined', c);
}
function hangup() {
const c = call;
if (!c || c.state === 'ended') return;
if (c.state === 'incoming') { reject(); return; }
sendFor(c, CALL.HANGUP, c.peer).catch(() => {});
end('hung-up', c);
}
function setMuted(on) {
if (!call || !call.local) return;
call.muted = on;
for (const t of call.local.getAudioTracks()) t.enabled = !on;
changed();
}
function setCamOff(on) {
if (!call || !call.local) return;
call.camOff = on;
for (const t of call.local.getVideoTracks()) t.enabled = !on;
changed();
}
// the other camera: the sender's track swapped, no new offer needed
async function flip() {
const c = call;
if (!c || !c.local || !c.local.getVideoTracks().length) return;
const facing = c.facing === 'user' ? 'environment' : 'user';
try {
const fresh = await env.media('video-only', facing);
const track = fresh.getVideoTracks()[0];
const sender = c.pc && c.pc.getSenders().find((s) => s.track && s.track.kind === 'video');
if (sender) await sender.replaceTrack(track);
for (const old of c.local.getVideoTracks()) { c.local.removeTrack(old); old.stop(); }
track.enabled = !c.camOff;
c.local.addTrack(track);
c.facing = facing;
changed();
} catch {}
}
// ---- what arrives
async function handle(ev) {
if (!ev || seen.has(ev.id)) return;
seen.add(ev.id);
if (seen.size > 5000) seen.clear();
if (now() - ev.created_at > STALE_S) return;
const me = env.me();
const id = callIdOf(ev), from = ev.pubkey, self = from === me;
const c = call;
switch (ev.kind) {
case CALL.OFFER: {
if (self) return;
if (c && c.state !== 'ended') {
if (c.id !== id) { const s = await env.sign(callEvent(CALL.REJECT, from, id, 'busy')); env.send(from, s).catch(() => {}); }
return;
}
if (!(await env.mayRing(from))) return;
clearTimeout(endTimer);
const type = tagOf(ev, 'call-type') === 'video' ? 'video' : 'voice';
const n = call = { id, peer: from, type, dir: 'in', state: 'incoming', offer: ev.content, ice: [], at: Date.now(), muted: false, camOff: false, facing: 'user' };
ringTimer = setTimeout(() => { if (call === n && n.state === 'incoming') end('missed', n); }, RING_MS);
changed();
return;
}
case CALL.ANSWER: {
if (!c || c.id !== id) return;
if (self) { if (c.state === 'incoming') end('answered-elsewhere', c); return; } // another of our devices took it
if (from !== c.peer) return;
if (c.state === 'offering') {
c.state = 'connecting'; changed();
try { await c.pc.setRemoteDescription({ type: 'answer', sdp: ev.content }); c.remoteSet = true; await addCandidates(c); }
catch { end('error', c); }
} else if (c.renegotiating) { // their answer to our renegotiation
c.renegotiating = false;
try { await c.pc.setRemoteDescription({ type: 'answer', sdp: ev.content }); } catch {}
}
return;
}
case CALL.ICE: {
if (self) return;
let cand; try { cand = JSON.parse(ev.content); } catch { return; }
if (c && c.id === id && from === c.peer) {
if (c.pc && c.remoteSet) { try { await c.pc.addIceCandidate(cand); } catch {} }
else c.ice.push(cand);
} else {
// before the offer that opens its call: kept for when it does
const list = early.get(from) || []; list.push(cand); early.set(from, list.slice(-50));
}
return;
}
case CALL.HANGUP: {
if (self || !c || c.id !== id || from !== c.peer) return;
end(c.state === 'incoming' ? 'missed' : 'peer-hung-up', c);
return;
}
case CALL.REJECT: {
if (!c || c.id !== id) return;
if (self) { if (c.state === 'incoming') end('declined-elsewhere', c); return; }
if (from !== c.peer) return;
end(ev.content === 'busy' ? 'busy' : 'peer-declined', c);
return;
}
case CALL.RENEGOTIATE: {
if (self || !c || c.id !== id || from !== c.peer || !c.pc) return;
try {
if (c.pc.signalingState === 'have-local-offer') {
if (me > from) return; // ours wins (the higher key), theirs is dropped
await c.pc.setLocalDescription({ type: 'rollback' });
}
await c.pc.setRemoteDescription({ type: 'offer', sdp: ev.content });
await c.pc.setLocalDescription(await c.pc.createAnswer());
await sendFor(c, CALL.ANSWER, c.peer, c.pc.localDescription.sdp);
} catch {}
return;
}
}
}
return {
get call() { return call; },
start, accept, reject, hangup, setMuted, setCamOff, flip, handle,
// a sign-out or account switch: whatever was going on ends here
stop() { if (call && call.state !== 'ended') hangup(); call = null; early.clear(); },
};
}
|