@agentdance/node-webrtc-dtls 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,692 @@
1
+ // DTLS 1.2 Transport (RFC 6347)
2
+ // Implements client and server handshake state machines.
3
+
4
+ import { EventEmitter } from 'node:events';
5
+ import * as crypto from 'node:crypto';
6
+
7
+ import {
8
+ ContentType,
9
+ type DtlsRecord,
10
+ DTLS_VERSION_1_2,
11
+ } from './types.js';
12
+ import { encodeRecord, decodeRecords, makeRecord } from './record.js';
13
+ import {
14
+ HandshakeType,
15
+ CipherSuites,
16
+ ExtensionType,
17
+ NamedCurve,
18
+ SrtpProtectionProfile,
19
+ type HandshakeMessage,
20
+ type ClientHello,
21
+ type ServerHello,
22
+ type ServerKeyExchange,
23
+ encodeHandshakeMessage,
24
+ decodeHandshakeMessage,
25
+ encodeClientHello,
26
+ decodeClientHello,
27
+ encodeServerHello,
28
+ decodeServerHello,
29
+ encodeHelloVerifyRequest,
30
+ decodeHelloVerifyRequest,
31
+ encodeCertificate,
32
+ decodeCertificate,
33
+ encodeServerKeyExchange,
34
+ decodeServerKeyExchange,
35
+ encodeClientKeyExchange,
36
+ decodeClientKeyExchange,
37
+ buildUseSrtpExtension,
38
+ buildSupportedGroupsExtension,
39
+ buildSignatureAlgorithmsExtension,
40
+ parseSrtpProfiles,
41
+ } from './handshake.js';
42
+ import {
43
+ prf,
44
+ computeMasterSecret,
45
+ expandKeyMaterial,
46
+ exportKeyingMaterial,
47
+ aesgcmEncrypt,
48
+ aesgcmDecrypt,
49
+ generateEcdhKeyPair,
50
+ computeEcdhPreMasterSecret,
51
+ encodeEcPublicKey,
52
+ ecdsaSign,
53
+ ecdsaVerify,
54
+ hmacSha256,
55
+ } from './crypto.js';
56
+ import {
57
+ type DtlsCertificate,
58
+ generateSelfSignedCertificate,
59
+ verifyFingerprint,
60
+ extractPublicKeyFromCert,
61
+ } from './certificate.js';
62
+ import { DtlsState, type HandshakeContext, type CipherState } from './state.js';
63
+
64
+ export { DtlsState };
65
+ export type { DtlsCertificate };
66
+
67
+ export interface SrtpKeyingMaterial {
68
+ clientKey: Buffer; // 16 bytes
69
+ clientSalt: Buffer; // 14 bytes
70
+ serverKey: Buffer; // 16 bytes
71
+ serverSalt: Buffer; // 14 bytes
72
+ profile: number; // SRTP_AES128_CM_SHA1_80 = 0x0001
73
+ }
74
+
75
+ export interface DtlsTransportOptions {
76
+ role: 'client' | 'server';
77
+ remoteFingerprint?: { algorithm: string; value: string };
78
+ certificate?: DtlsCertificate;
79
+ mtu?: number;
80
+ }
81
+
82
+ // Signature algorithm identifiers (RFC 5246 Section 7.4.1.4.1)
83
+ const SIG_HASH_SHA256 = 4;
84
+ const SIG_ALG_ECDSA = 3;
85
+ const NAMED_CURVE_P256 = 23;
86
+
87
+ export declare interface DtlsTransport {
88
+ on(event: 'connected', listener: (srtpKeys: SrtpKeyingMaterial) => void): this;
89
+ on(event: 'data', listener: (data: Buffer) => void): this;
90
+ on(event: 'error', listener: (err: Error) => void): this;
91
+ on(event: 'close', listener: () => void): this;
92
+ }
93
+
94
+ export class DtlsTransport extends EventEmitter {
95
+ readonly localCertificate: DtlsCertificate;
96
+
97
+ private _state: DtlsState = DtlsState.New;
98
+ private readonly role: 'client' | 'server';
99
+ private readonly remoteFingerprint: { algorithm: string; value: string } | undefined;
100
+ private readonly _mtu: number;
101
+
102
+ private sendCb: ((data: Buffer) => void) | undefined;
103
+ private _startResolve: ((keys: SrtpKeyingMaterial) => void) | undefined;
104
+ private _startReject: ((err: Error) => void) | undefined;
105
+
106
+ private ctx: HandshakeContext = {
107
+ messages: [],
108
+ sendMessageSeq: 0,
109
+ recvMessageSeq: 0,
110
+ };
111
+
112
+ private cipherState: CipherState | undefined;
113
+ private writeEpoch = 0;
114
+ private writeSeq = 0n;
115
+ private srtpKeys: SrtpKeyingMaterial | undefined;
116
+ private readonly _cookieSecret = crypto.randomBytes(32);
117
+
118
+ constructor(options: DtlsTransportOptions) {
119
+ super();
120
+ this.role = options.role;
121
+ this.remoteFingerprint = options.remoteFingerprint;
122
+ this._mtu = options.mtu ?? 1200;
123
+ this.localCertificate = options.certificate ?? generateSelfSignedCertificate();
124
+ }
125
+
126
+ getState(): DtlsState {
127
+ return this._state;
128
+ }
129
+
130
+ getLocalFingerprint(): { algorithm: 'sha-256'; value: string } {
131
+ return this.localCertificate.fingerprint;
132
+ }
133
+
134
+ setSendCallback(cb: (data: Buffer) => void): void {
135
+ this.sendCb = cb;
136
+ }
137
+
138
+ async start(): Promise<SrtpKeyingMaterial> {
139
+ if (this._state !== DtlsState.New) {
140
+ throw new Error('DtlsTransport already started');
141
+ }
142
+ this._state = DtlsState.Connecting;
143
+
144
+ return new Promise<SrtpKeyingMaterial>((resolve, reject) => {
145
+ this._startResolve = resolve;
146
+ this._startReject = reject;
147
+ if (this.role === 'client') {
148
+ this._sendClientHello(Buffer.alloc(0)).catch((e: unknown) =>
149
+ this._fail(e instanceof Error ? e : new Error(String(e))),
150
+ );
151
+ }
152
+ });
153
+ }
154
+
155
+ handleIncoming(data: Buffer): void {
156
+ if (this._state === DtlsState.Closed || this._state === DtlsState.Failed) return;
157
+ try {
158
+ const records = decodeRecords(data);
159
+ for (const record of records) {
160
+ this._processRecord(record);
161
+ }
162
+ } catch (e: unknown) {
163
+ this._fail(e instanceof Error ? e : new Error(String(e)));
164
+ }
165
+ }
166
+
167
+ // Maximum plaintext payload per DTLS record (RFC 6347 §4.1.1 limits to 2^14-1)
168
+ private static readonly MAX_RECORD_PLAINTEXT = 16383;
169
+
170
+ send(data: Buffer): void {
171
+ if (this._state !== DtlsState.Connected || !this.cipherState) {
172
+ throw new Error('DTLS not connected');
173
+ }
174
+ // Fragment large payloads into individual DTLS records
175
+ const maxLen = DtlsTransport.MAX_RECORD_PLAINTEXT;
176
+ if (data.length <= maxLen) {
177
+ this._transmit(this._encryptRecord(ContentType.ApplicationData, data));
178
+ } else {
179
+ let offset = 0;
180
+ while (offset < data.length) {
181
+ const end = Math.min(offset + maxLen, data.length);
182
+ this._transmit(this._encryptRecord(ContentType.ApplicationData, data.subarray(offset, end)));
183
+ offset = end;
184
+ }
185
+ }
186
+ }
187
+
188
+ close(): void {
189
+ if (this._state === DtlsState.Closed) return;
190
+ try {
191
+ const alert = Buffer.from([0x01, 0x00]);
192
+ this._transmit(encodeRecord(makeRecord(ContentType.Alert, this.writeEpoch, this.writeSeq++, alert)));
193
+ } catch { /* ignore */ }
194
+ this._state = DtlsState.Closed;
195
+ this.emit('close');
196
+ }
197
+
198
+ // ── Record dispatch ──────────────────────────────────────────────────────────
199
+
200
+ private _processRecord(record: DtlsRecord): void {
201
+ switch (record.contentType) {
202
+ case ContentType.Handshake: this._processHandshakeRecord(record); break;
203
+ case ContentType.ChangeCipherSpec: this._processChangeCipherSpec(); break;
204
+ case ContentType.ApplicationData: this._processApplicationData(record); break;
205
+ case ContentType.Alert: this._processAlert(record); break;
206
+ }
207
+ }
208
+
209
+ private _processHandshakeRecord(record: DtlsRecord): void {
210
+ let fragment: Buffer;
211
+ try {
212
+ fragment =
213
+ record.epoch > 0 && this.cipherState
214
+ ? this._decryptRecord(record)
215
+ : record.fragment;
216
+ } catch {
217
+ return; // ignore undecryptable records
218
+ }
219
+
220
+ let off = 0;
221
+ while (off < fragment.length) {
222
+ if (fragment.length - off < 12) break;
223
+ const preFragLen =
224
+ (fragment[off + 9]! << 16) |
225
+ (fragment[off + 10]! << 8) |
226
+ fragment[off + 11]!;
227
+ if (off + 12 + preFragLen > fragment.length) break;
228
+
229
+ const msg = decodeHandshakeMessage(fragment.subarray(off));
230
+ const msgSize = 12 + msg.fragmentLength;
231
+
232
+ // Add to transcript BEFORE processing (so peer's Finished is included)
233
+ this.ctx.messages.push(Buffer.from(fragment.subarray(off, off + msgSize)));
234
+ off += msgSize;
235
+
236
+ this._processHandshakeMessage(msg);
237
+ if (this._state === DtlsState.Failed) return;
238
+ }
239
+ }
240
+
241
+ private _processHandshakeMessage(msg: HandshakeMessage): void {
242
+ if (this.role === 'client') {
243
+ this._processAsClient(msg);
244
+ } else {
245
+ this._processAsServer(msg);
246
+ }
247
+ }
248
+
249
+ // ── Client state machine ─────────────────────────────────────────────────────
250
+
251
+ private _processAsClient(msg: HandshakeMessage): void {
252
+ switch (msg.msgType) {
253
+ case HandshakeType.HelloVerifyRequest: {
254
+ const hvr = decodeHelloVerifyRequest(msg.body);
255
+ // RFC 6347 §4.2.1: reset transcript/seqs before retrying
256
+ this.ctx.messages = [];
257
+ this.ctx.sendMessageSeq = 0;
258
+ this.ctx.recvMessageSeq = 0;
259
+ this._sendClientHello(hvr.cookie).catch((e: unknown) =>
260
+ this._fail(e instanceof Error ? e : new Error(String(e))),
261
+ );
262
+ break;
263
+ }
264
+ case HandshakeType.ServerHello: {
265
+ this.ctx.selectedCipherSuite = decodeServerHello(msg.body).cipherSuite;
266
+ this.ctx.serverRandom = Buffer.from(msg.body.subarray(2, 34));
267
+ break;
268
+ }
269
+ case HandshakeType.Certificate: {
270
+ const certs = decodeCertificate(msg.body);
271
+ const first = certs[0];
272
+ if (!first) { this._fail(new Error('No certificate')); return; }
273
+ this.ctx.peerCertDer = first;
274
+ if (this.remoteFingerprint && !verifyFingerprint(first, this.remoteFingerprint)) {
275
+ this._fail(new Error('Certificate fingerprint mismatch'));
276
+ }
277
+ break;
278
+ }
279
+ case HandshakeType.ServerKeyExchange: {
280
+ const ske = decodeServerKeyExchange(msg.body);
281
+ if (this.ctx.peerCertDer) {
282
+ const peerPk = extractPublicKeyFromCert(this.ctx.peerCertDer);
283
+ const toVerify = Buffer.concat([
284
+ this.ctx.clientRandom ?? Buffer.alloc(32),
285
+ this.ctx.serverRandom ?? Buffer.alloc(32),
286
+ Buffer.from([ske.curveType]),
287
+ Buffer.from([(ske.namedCurve >> 8) & 0xff, ske.namedCurve & 0xff]),
288
+ Buffer.from([ske.publicKey.length]),
289
+ ske.publicKey,
290
+ ]);
291
+ if (!ecdsaVerify(peerPk, toVerify, ske.signature)) {
292
+ this._fail(new Error('ServerKeyExchange signature verification failed'));
293
+ return;
294
+ }
295
+ }
296
+ this.ctx.peerEcPublicKeyBytes = ske.publicKey;
297
+ break;
298
+ }
299
+ case HandshakeType.ServerHelloDone:
300
+ this._sendClientKeyExchange().catch((e: unknown) =>
301
+ this._fail(e instanceof Error ? e : new Error(String(e))),
302
+ );
303
+ break;
304
+ case HandshakeType.Finished:
305
+ this._processServerFinished(msg.body);
306
+ break;
307
+ }
308
+ }
309
+
310
+ // ── Server state machine ─────────────────────────────────────────────────────
311
+
312
+ private _processAsServer(msg: HandshakeMessage): void {
313
+ console.log(`[DTLS server] received msgType=${msg.msgType} seq=${msg.messageSeq}`);
314
+ switch (msg.msgType) {
315
+ case HandshakeType.ClientHello: {
316
+ const ch = decodeClientHello(msg.body);
317
+ if (ch.cookie.length === 0) {
318
+ // First ClientHello: send HVR, reset transcript
319
+ const cookie = this._generateCookie(ch.random);
320
+ this.ctx.cookie = cookie;
321
+ this.ctx.messages = [];
322
+ this.ctx.sendMessageSeq = 0;
323
+ this.ctx.recvMessageSeq = 0;
324
+ this._transmitHelloVerifyRequest(cookie);
325
+ } else {
326
+ // Second ClientHello with cookie
327
+ if (this.ctx.cookie && !ch.cookie.equals(this.ctx.cookie)) {
328
+ this._fail(new Error('Invalid DTLS cookie'));
329
+ return;
330
+ }
331
+ this.ctx.clientRandom = Buffer.from(ch.random);
332
+ this._sendServerFlight(ch).catch((e: unknown) =>
333
+ this._fail(e instanceof Error ? e : new Error(String(e))),
334
+ );
335
+ }
336
+ break;
337
+ }
338
+ case HandshakeType.ClientKeyExchange: {
339
+ const cke = decodeClientKeyExchange(msg.body);
340
+ this.ctx.peerEcPublicKeyBytes = cke.publicKey;
341
+ if (this.ctx.ecdhPrivateKey && cke.publicKey) {
342
+ this.ctx.preMasterSecret = computeEcdhPreMasterSecret(
343
+ this.ctx.ecdhPrivateKey,
344
+ cke.publicKey,
345
+ );
346
+ }
347
+ break;
348
+ }
349
+ case HandshakeType.Finished:
350
+ this._processClientFinished(msg.body);
351
+ break;
352
+ }
353
+ }
354
+
355
+ // ── Client sends ─────────────────────────────────────────────────────────────
356
+
357
+ private async _sendClientHello(cookie: Buffer): Promise<void> {
358
+ const ecdh = generateEcdhKeyPair();
359
+ this.ctx.ecdhPrivateKey = ecdh.privateKey;
360
+ this.ctx.ecdhPublicKey = ecdh.publicKey;
361
+ const clientRandom = crypto.randomBytes(32);
362
+ this.ctx.clientRandom = clientRandom;
363
+
364
+ const hello: ClientHello = {
365
+ clientVersion: DTLS_VERSION_1_2,
366
+ random: clientRandom,
367
+ sessionId: Buffer.alloc(0),
368
+ cookie,
369
+ cipherSuites: [
370
+ CipherSuites.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
371
+ CipherSuites.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
372
+ ],
373
+ compressionMethods: [0],
374
+ extensions: [
375
+ { type: ExtensionType.UseSrtp, data: buildUseSrtpExtension([SrtpProtectionProfile.SRTP_AES128_CM_SHA1_80, SrtpProtectionProfile.SRTP_AES128_CM_SHA1_32]) },
376
+ { type: ExtensionType.SupportedGroups, data: buildSupportedGroupsExtension([NamedCurve.secp256r1]) },
377
+ { type: ExtensionType.SignatureAlgorithms, data: buildSignatureAlgorithmsExtension([{ hash: SIG_HASH_SHA256, sig: SIG_ALG_ECDSA }]) },
378
+ ],
379
+ };
380
+ const body = encodeClientHello(hello);
381
+ const seq = this.ctx.sendMessageSeq++;
382
+ this._sendHandshake({ msgType: HandshakeType.ClientHello, length: body.length, messageSeq: seq, fragmentOffset: 0, fragmentLength: body.length, body });
383
+ }
384
+
385
+ private async _sendClientKeyExchange(): Promise<void> {
386
+ if (!this.ctx.ecdhPrivateKey || !this.ctx.ecdhPublicKey || !this.ctx.peerEcPublicKeyBytes) {
387
+ this._fail(new Error('Missing ECDH keys for ClientKeyExchange'));
388
+ return;
389
+ }
390
+ this.ctx.preMasterSecret = computeEcdhPreMasterSecret(
391
+ this.ctx.ecdhPrivateKey,
392
+ this.ctx.peerEcPublicKeyBytes,
393
+ );
394
+ const myPkBytes = encodeEcPublicKey(this.ctx.ecdhPublicKey);
395
+ this._sendHandshakeBody(HandshakeType.ClientKeyExchange, encodeClientKeyExchange({ publicKey: myPkBytes }));
396
+ this._deriveKeys();
397
+ this._sendChangeCipherSpec();
398
+ this._sendFinished();
399
+ }
400
+
401
+ // ── Server sends ─────────────────────────────────────────────────────────────
402
+
403
+ /**
404
+ * Transmit HelloVerifyRequest WITHOUT adding it to the transcript.
405
+ * RFC 6347 §4.2.1: HVR is excluded from the handshake hash.
406
+ */
407
+ private _transmitHelloVerifyRequest(cookie: Buffer): void {
408
+ const hvr = encodeHelloVerifyRequest({ serverVersion: DTLS_VERSION_1_2, cookie });
409
+ const seq = this.ctx.sendMessageSeq++;
410
+ const hsBuf = encodeHandshakeMessage({
411
+ msgType: HandshakeType.HelloVerifyRequest,
412
+ length: hvr.length,
413
+ messageSeq: seq,
414
+ fragmentOffset: 0,
415
+ fragmentLength: hvr.length,
416
+ body: hvr,
417
+ });
418
+ this._transmit(encodeRecord(makeRecord(ContentType.Handshake, this.writeEpoch, this.writeSeq++, hsBuf)));
419
+ // NOT added to this.ctx.messages
420
+ }
421
+
422
+ private async _sendServerFlight(clientHello: ClientHello): Promise<void> {
423
+ const serverRandom = crypto.randomBytes(32);
424
+ this.ctx.serverRandom = serverRandom;
425
+
426
+ const supported: number[] = [
427
+ CipherSuites.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
428
+ CipherSuites.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
429
+ ];
430
+ const cipherSuite =
431
+ clientHello.cipherSuites.find((cs) => supported.includes(cs)) ??
432
+ CipherSuites.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256;
433
+ this.ctx.selectedCipherSuite = cipherSuite;
434
+
435
+ const serverHelloExts: Array<{ type: number; data: Buffer }> = [];
436
+ const srtpExt = clientHello.extensions.find((e) => e.type === ExtensionType.UseSrtp);
437
+ if (srtpExt) {
438
+ const profiles = parseSrtpProfiles(srtpExt.data);
439
+ const profile =
440
+ profiles.find((p) => p === SrtpProtectionProfile.SRTP_AES128_CM_SHA1_80) ??
441
+ profiles[0] ??
442
+ SrtpProtectionProfile.SRTP_AES128_CM_SHA1_80;
443
+ serverHelloExts.push({ type: ExtensionType.UseSrtp, data: buildUseSrtpExtension([profile]) });
444
+ }
445
+
446
+ const sh: ServerHello = {
447
+ serverVersion: DTLS_VERSION_1_2,
448
+ random: serverRandom,
449
+ sessionId: Buffer.alloc(0),
450
+ cipherSuite,
451
+ compressionMethod: 0,
452
+ extensions: serverHelloExts,
453
+ };
454
+ this._sendHandshakeBody(HandshakeType.ServerHello, encodeServerHello(sh));
455
+ this._sendHandshakeBody(HandshakeType.Certificate, encodeCertificate(this.localCertificate.cert));
456
+
457
+ const ecdhPair = generateEcdhKeyPair();
458
+ this.ctx.ecdhPrivateKey = ecdhPair.privateKey;
459
+ this.ctx.ecdhPublicKey = ecdhPair.publicKey;
460
+ const serverEcPk = encodeEcPublicKey(ecdhPair.publicKey);
461
+
462
+ const clientRandom = this.ctx.clientRandom ?? Buffer.alloc(32);
463
+ const toSign = Buffer.concat([
464
+ clientRandom, serverRandom,
465
+ Buffer.from([3]),
466
+ Buffer.from([(NAMED_CURVE_P256 >> 8) & 0xff, NAMED_CURVE_P256 & 0xff]),
467
+ Buffer.from([serverEcPk.length]),
468
+ serverEcPk,
469
+ ]);
470
+ const sig = ecdsaSign(this.localCertificate.privateKey, toSign);
471
+
472
+ const ske: ServerKeyExchange = {
473
+ curveType: 3,
474
+ namedCurve: NAMED_CURVE_P256,
475
+ publicKey: serverEcPk,
476
+ signatureAlgorithm: { hash: SIG_HASH_SHA256, signature: SIG_ALG_ECDSA },
477
+ signature: sig,
478
+ };
479
+ this._sendHandshakeBody(HandshakeType.ServerKeyExchange, encodeServerKeyExchange(ske));
480
+ this._sendHandshakeBody(HandshakeType.ServerHelloDone, Buffer.alloc(0));
481
+ }
482
+
483
+ // ── Key derivation ───────────────────────────────────────────────────────────
484
+
485
+ private _deriveKeys(): void {
486
+ const cr = this.ctx.clientRandom ?? Buffer.alloc(32);
487
+ const sr = this.ctx.serverRandom ?? Buffer.alloc(32);
488
+ if (!this.ctx.preMasterSecret) throw new Error('No pre-master secret');
489
+
490
+ const ms = computeMasterSecret(this.ctx.preMasterSecret, cr, sr);
491
+ const kb = expandKeyMaterial(ms, cr, sr, 16, 4);
492
+
493
+ this.cipherState = {
494
+ writeKey: this.role === 'client' ? kb.clientWriteKey : kb.serverWriteKey,
495
+ writeIv: this.role === 'client' ? kb.clientWriteIv : kb.serverWriteIv,
496
+ readKey: this.role === 'client' ? kb.serverWriteKey : kb.clientWriteKey,
497
+ readIv: this.role === 'client' ? kb.serverWriteIv : kb.clientWriteIv,
498
+ writeEpoch: 1, writeSeq: 0n,
499
+ readEpoch: 1, readSeq: 0n,
500
+ };
501
+
502
+ const srtpMat = exportKeyingMaterial(ms, cr, sr, 'EXTRACTOR-dtls_srtp', 60);
503
+ this.srtpKeys = {
504
+ clientKey: Buffer.from(srtpMat.subarray(0, 16)),
505
+ serverKey: Buffer.from(srtpMat.subarray(16, 32)),
506
+ clientSalt: Buffer.from(srtpMat.subarray(32, 46)),
507
+ serverSalt: Buffer.from(srtpMat.subarray(46, 60)),
508
+ profile: SrtpProtectionProfile.SRTP_AES128_CM_SHA1_80,
509
+ };
510
+ }
511
+
512
+ // ── ChangeCipherSpec / Finished ──────────────────────────────────────────────
513
+
514
+ private _sendChangeCipherSpec(): void {
515
+ this._transmit(encodeRecord(makeRecord(ContentType.ChangeCipherSpec, this.writeEpoch, this.writeSeq++, Buffer.from([1]))));
516
+ this.writeEpoch = 1;
517
+ this.writeSeq = 0n;
518
+ }
519
+
520
+ private _processChangeCipherSpec(): void {
521
+ // When server receives client's CCS, derive keys if not done yet
522
+ if (!this.cipherState && this.ctx.preMasterSecret) {
523
+ this._deriveKeys();
524
+ }
525
+ }
526
+
527
+ private _computeFinished(role: 'client' | 'server'): Buffer {
528
+ const cr = this.ctx.clientRandom ?? Buffer.alloc(32);
529
+ const sr = this.ctx.serverRandom ?? Buffer.alloc(32);
530
+ if (!this.ctx.preMasterSecret) throw new Error('No pre-master secret');
531
+ const ms = computeMasterSecret(this.ctx.preMasterSecret, cr, sr);
532
+ const hash = crypto.createHash('sha256').update(Buffer.concat(this.ctx.messages)).digest();
533
+ return prf(ms, role === 'client' ? 'client finished' : 'server finished', hash as Buffer, 12);
534
+ }
535
+
536
+ private _computePeerFinished(peerRole: 'client' | 'server'): Buffer {
537
+ const cr = this.ctx.clientRandom ?? Buffer.alloc(32);
538
+ const sr = this.ctx.serverRandom ?? Buffer.alloc(32);
539
+ if (!this.ctx.preMasterSecret) throw new Error('No pre-master secret');
540
+ const ms = computeMasterSecret(this.ctx.preMasterSecret, cr, sr);
541
+ // Exclude the peer's Finished message (the last one added) from the transcript
542
+ const msgs = this.ctx.messages.slice(0, -1);
543
+ const hash = crypto.createHash('sha256').update(Buffer.concat(msgs)).digest();
544
+ return prf(ms, peerRole === 'client' ? 'client finished' : 'server finished', hash as Buffer, 12);
545
+ }
546
+
547
+ private _sendFinished(): void {
548
+ const verifyData = this._computeFinished(this.role);
549
+ const hsBuf = this._buildHandshakeMessage(HandshakeType.Finished, verifyData);
550
+ this._transmit(this._encryptRecord(ContentType.Handshake, hsBuf));
551
+ // Add our own Finished to transcript AFTER computing verify_data
552
+ this.ctx.messages.push(hsBuf);
553
+ }
554
+
555
+ private _processServerFinished(body: Buffer): void {
556
+ const expected = this._computePeerFinished('server');
557
+ if (!body.equals(expected)) {
558
+ this._fail(new Error('Server Finished verification failed'));
559
+ return;
560
+ }
561
+ this._state = DtlsState.Connected;
562
+ if (this.srtpKeys) {
563
+ this._startResolve?.(this.srtpKeys);
564
+ this.emit('connected', this.srtpKeys);
565
+ }
566
+ }
567
+
568
+ private _processClientFinished(body: Buffer): void {
569
+ // Derive keys if CCS was not received first
570
+ if (!this.cipherState && this.ctx.preMasterSecret) {
571
+ this._deriveKeys();
572
+ }
573
+ const expected = this._computePeerFinished('client');
574
+ if (!body.equals(expected)) {
575
+ this._fail(new Error('Client Finished verification failed'));
576
+ return;
577
+ }
578
+ this._sendChangeCipherSpec();
579
+ this._sendFinished();
580
+ this._state = DtlsState.Connected;
581
+ if (this.srtpKeys) {
582
+ this._startResolve?.(this.srtpKeys);
583
+ this.emit('connected', this.srtpKeys);
584
+ }
585
+ }
586
+
587
+ // ── Application data / Alerts ────────────────────────────────────────────────
588
+
589
+ private _processApplicationData(record: DtlsRecord): void {
590
+ if (!this.cipherState) return;
591
+ try {
592
+ const decrypted = this._decryptRecord(record);
593
+ this.emit('data', decrypted);
594
+ } catch (e: unknown) {
595
+ this.emit('error', e instanceof Error ? e : new Error(String(e)));
596
+ }
597
+ }
598
+
599
+ private _processAlert(record: DtlsRecord): void {
600
+ if (record.fragment.length >= 2 && record.fragment[1] === 0) {
601
+ this._state = DtlsState.Closed;
602
+ this.emit('close');
603
+ }
604
+ }
605
+
606
+ // ── AES-128-GCM ──────────────────────────────────────────────────────────────
607
+
608
+ private _encryptRecord(contentType: ContentType, plaintext: Buffer): Buffer {
609
+ if (!this.cipherState) throw new Error('No cipher state');
610
+ const epoch = this.cipherState.writeEpoch;
611
+ const seq = this.cipherState.writeSeq++;
612
+ const explicit = seqBuf8(seq);
613
+ const nonce = Buffer.concat([this.cipherState.writeIv, explicit]);
614
+ const aad = buildAad(epoch, seq, contentType, plaintext.length);
615
+ const { ciphertext, tag } = aesgcmEncrypt(this.cipherState.writeKey, nonce, plaintext, aad);
616
+ return encodeRecord(makeRecord(contentType, epoch, seq, Buffer.concat([explicit, ciphertext, tag])));
617
+ }
618
+
619
+ private _decryptRecord(record: DtlsRecord): Buffer {
620
+ if (!this.cipherState) throw new Error('No cipher state');
621
+ const f = record.fragment;
622
+ if (f.length < 24) throw new Error('Encrypted record too short');
623
+ const explicit = f.subarray(0, 8);
624
+ const ciphertext = f.subarray(8, f.length - 16);
625
+ const tag = f.subarray(f.length - 16);
626
+ const nonce = Buffer.concat([this.cipherState.readIv, explicit]);
627
+ const aad = buildAad(record.epoch, record.sequenceNumber, record.contentType, ciphertext.length);
628
+ return aesgcmDecrypt(this.cipherState.readKey, nonce, ciphertext, tag, aad);
629
+ }
630
+
631
+ // ── Handshake helpers ────────────────────────────────────────────────────────
632
+
633
+ private _buildHandshakeMessage(msgType: HandshakeType, body: Buffer): Buffer {
634
+ return encodeHandshakeMessage({
635
+ msgType, length: body.length,
636
+ messageSeq: this.ctx.sendMessageSeq++,
637
+ fragmentOffset: 0, fragmentLength: body.length, body,
638
+ });
639
+ }
640
+
641
+ private _sendHandshakeBody(msgType: HandshakeType, body: Buffer): void {
642
+ const hsBuf = this._buildHandshakeMessage(msgType, body);
643
+ this.ctx.messages.push(hsBuf);
644
+ this._transmit(encodeRecord(makeRecord(ContentType.Handshake, this.writeEpoch, this.writeSeq++, hsBuf)));
645
+ }
646
+
647
+ private _sendHandshake(msg: HandshakeMessage): void {
648
+ const hsBuf = encodeHandshakeMessage(msg);
649
+ this.ctx.messages.push(hsBuf);
650
+ this._transmit(encodeRecord(makeRecord(ContentType.Handshake, this.writeEpoch, this.writeSeq++, hsBuf)));
651
+ }
652
+
653
+ // ── Cookie ───────────────────────────────────────────────────────────────────
654
+
655
+ private _generateCookie(clientRandom: Buffer): Buffer {
656
+ return hmacSha256(this._cookieSecret, clientRandom).subarray(0, 20);
657
+ }
658
+
659
+ // ── Transmit / Fail ──────────────────────────────────────────────────────────
660
+
661
+ private _transmit(data: Buffer): void {
662
+ this.sendCb?.(data);
663
+ }
664
+
665
+ private _fail(err: Error): void {
666
+ if (this._state === DtlsState.Failed || this._state === DtlsState.Closed) return;
667
+ this._state = DtlsState.Failed;
668
+ this._startReject?.(err);
669
+ this.emit('error', err);
670
+ }
671
+ }
672
+
673
+ // ── Utilities ────────────────────────────────────────────────────────────────
674
+
675
+ function seqBuf8(seq: bigint): Buffer {
676
+ const b = Buffer.allocUnsafe(8);
677
+ b.writeUInt32BE(Number((seq >> 32n) & 0xffffffffn), 0);
678
+ b.writeUInt32BE(Number(seq & 0xffffffffn), 4);
679
+ return b;
680
+ }
681
+
682
+ function buildAad(epoch: number, seq: bigint, ct: ContentType, len: number): Buffer {
683
+ const aad = Buffer.allocUnsafe(13);
684
+ aad.writeUInt16BE(epoch, 0);
685
+ aad.writeUInt16BE(Number((seq >> 32n) & 0xffffn), 2);
686
+ aad.writeUInt32BE(Number(seq & 0xffffffffn), 4);
687
+ aad.writeUInt8(ct, 8);
688
+ aad.writeUInt8(DTLS_VERSION_1_2.major, 9);
689
+ aad.writeUInt8(DTLS_VERSION_1_2.minor, 10);
690
+ aad.writeUInt16BE(len, 11);
691
+ return aad;
692
+ }