node-rtc-connection 1.0.19 → 2.0.4
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.
- package/README.md +94 -85
- package/dist/index.cjs +20 -5606
- package/dist/index.mjs +25 -5598
- package/dist/types/crypto/der.d.ts +107 -0
- package/dist/types/crypto/x509.d.ts +56 -0
- package/dist/types/datachannel/RTCDataChannel.d.ts +179 -0
- package/dist/types/dtls/RTCCertificate.d.ts +163 -0
- package/dist/types/dtls/cipher.d.ts +81 -0
- package/dist/types/dtls/connection.d.ts +81 -0
- package/dist/types/dtls/prf.d.ts +29 -0
- package/dist/types/dtls/protocol.d.ts +127 -0
- package/dist/types/foundation/ByteBufferQueue.d.ts +71 -0
- package/dist/types/foundation/RTCError.d.ts +152 -0
- package/dist/types/ice/RTCIceCandidate.d.ts +161 -0
- package/dist/types/ice/ice-agent.d.ts +154 -0
- package/dist/types/ice/stun-message.d.ts +92 -0
- package/dist/types/index.d.ts +29 -0
- package/dist/types/peerconnection/RTCPeerConnection.d.ts +74 -0
- package/dist/types/sctp/association.d.ts +77 -0
- package/dist/types/sctp/chunks.d.ts +200 -0
- package/dist/types/sctp/crc32c.d.ts +24 -0
- package/dist/types/sctp/datachannel-manager.d.ts +51 -0
- package/dist/types/sctp/dcep.d.ts +56 -0
- package/dist/types/sdp/RTCSessionDescription.d.ts +73 -0
- package/dist/types/sdp/sdp-utils.d.ts +103 -0
- package/dist/types/stun/stun-client.d.ts +119 -0
- package/dist/types/transport-stack.d.ts +68 -0
- package/package.json +26 -21
- package/src/crypto/der.ts +205 -0
- package/src/crypto/x509.ts +146 -0
- package/src/datachannel/RTCDataChannel.ts +388 -0
- package/src/dtls/RTCCertificate.ts +396 -0
- package/src/dtls/cipher.ts +198 -0
- package/src/dtls/connection.ts +974 -0
- package/src/dtls/prf.ts +62 -0
- package/src/dtls/protocol.ts +204 -0
- package/src/foundation/{ByteBufferQueue.js → ByteBufferQueue.ts} +74 -72
- package/src/foundation/{RTCError.js → RTCError.ts} +110 -60
- package/src/ice/{RTCIceCandidate.js → RTCIceCandidate.ts} +140 -92
- package/src/ice/ice-agent.ts +609 -0
- package/src/ice/stun-message.ts +260 -0
- package/src/index.ts +72 -0
- package/src/peerconnection/RTCPeerConnection.ts +430 -0
- package/src/sctp/association.ts +523 -0
- package/src/sctp/chunks.ts +350 -0
- package/src/sctp/crc32c.ts +57 -0
- package/src/sctp/datachannel-manager.ts +187 -0
- package/src/sctp/dcep.ts +94 -0
- package/src/sdp/{RTCSessionDescription.js → RTCSessionDescription.ts} +42 -29
- package/src/sdp/sdp-utils.ts +229 -0
- package/src/stun/{stun-client.js → stun-client.ts} +346 -187
- package/src/transport-stack.ts +165 -0
- package/dist/index.cjs.map +0 -1
- package/dist/index.mjs.map +0 -1
- package/src/datachannel/RTCDataChannel.js +0 -354
- package/src/dtls/RTCCertificate.js +0 -310
- package/src/dtls/RTCDtlsTransport.js +0 -247
- package/src/ice/RTCIceTransport.js +0 -1018
- package/src/index.d.ts +0 -400
- package/src/index.js +0 -92
- package/src/network/network-transport.js +0 -478
- package/src/peerconnection/RTCPeerConnection.js +0 -875
- package/src/sctp/RTCSctpTransport.js +0 -253
- package/src/sdp/sdp-utils.js +0 -224
|
@@ -0,0 +1,974 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file connection.ts
|
|
3
|
+
* @description DTLS 1.2 connection state machine (client and server roles).
|
|
4
|
+
* @module dtls/connection
|
|
5
|
+
*
|
|
6
|
+
* Implements the handshake for TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 over an
|
|
7
|
+
* abstract datagram channel. The owner supplies an `output` callback to send
|
|
8
|
+
* datagrams and feeds inbound datagrams to `handlePacket`. On success the
|
|
9
|
+
* connection emits 'connect'; application records arrive via 'data' and are
|
|
10
|
+
* sent via `send`.
|
|
11
|
+
*
|
|
12
|
+
* Scope: one cipher suite, secp256r1, ECDSA P-256 certificates, extended
|
|
13
|
+
* master secret. This is the subset Chromium/Firefox negotiate for data
|
|
14
|
+
* channels, so it interoperates with browsers while staying pure-Node.
|
|
15
|
+
*
|
|
16
|
+
* References: RFC 6347 (DTLS 1.2), RFC 5246 (TLS 1.2), RFC 7627 (EMS),
|
|
17
|
+
* RFC 8422 (ECC cipher suites), RFC 5288 (AES-GCM).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
'use strict';
|
|
21
|
+
|
|
22
|
+
import * as crypto from 'crypto';
|
|
23
|
+
import { EventEmitter } from 'events';
|
|
24
|
+
import * as P from './protocol';
|
|
25
|
+
import { prf } from './prf';
|
|
26
|
+
import * as cipher from './cipher';
|
|
27
|
+
import * as x509 from '../crypto/x509';
|
|
28
|
+
|
|
29
|
+
const HANDSHAKE_TIMEOUT_MS = 1000; // initial retransmit timer (doubles per RFC 6347)
|
|
30
|
+
const MAX_RETRANSMITS = 10;
|
|
31
|
+
const MAX_FRAGMENT = 1200; // keep handshake fragments inside a typical MTU
|
|
32
|
+
|
|
33
|
+
const ROLE = Object.freeze({ CLIENT: 'client', SERVER: 'server' });
|
|
34
|
+
|
|
35
|
+
const STATE = Object.freeze({
|
|
36
|
+
NEW: 'new',
|
|
37
|
+
HANDSHAKING: 'handshaking',
|
|
38
|
+
CONNECTED: 'connected',
|
|
39
|
+
CLOSED: 'closed',
|
|
40
|
+
FAILED: 'failed',
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/** A certificate fingerprint as advertised in SDP a=fingerprint. */
|
|
44
|
+
interface Fingerprint {
|
|
45
|
+
algorithm: string;
|
|
46
|
+
value: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Callback used to verify the peer's certificate fingerprint. */
|
|
50
|
+
type VerifyFingerprint = (
|
|
51
|
+
fp: Fingerprint,
|
|
52
|
+
remoteCertDer: Buffer
|
|
53
|
+
) => boolean;
|
|
54
|
+
|
|
55
|
+
/** Constructor options for {@link DtlsConnection}. */
|
|
56
|
+
interface DtlsConnectionOptions {
|
|
57
|
+
/** 'client' | 'server' */
|
|
58
|
+
role: string;
|
|
59
|
+
/** local DER certificate */
|
|
60
|
+
certDer: Buffer;
|
|
61
|
+
/** local EC private key */
|
|
62
|
+
privateKey: crypto.KeyObject;
|
|
63
|
+
/** called with the peer cert fingerprint; return false to reject. */
|
|
64
|
+
verifyFingerprint?: VerifyFingerprint;
|
|
65
|
+
/** send a datagram to the peer */
|
|
66
|
+
output: (datagram: Buffer) => void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A handshake message queued for sending: a type plus its body. */
|
|
70
|
+
interface HandshakeMessage {
|
|
71
|
+
type: number;
|
|
72
|
+
body: Buffer;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A record-layer datagram queued in the current flight. */
|
|
76
|
+
interface FlightDatagram {
|
|
77
|
+
type: number;
|
|
78
|
+
payload: Buffer;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** State for reassembling a fragmented inbound handshake message. */
|
|
82
|
+
interface ReassemblyEntry {
|
|
83
|
+
type: number;
|
|
84
|
+
length: number;
|
|
85
|
+
data: Buffer;
|
|
86
|
+
received: number;
|
|
87
|
+
ranges: Array<[number, number]>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @class DtlsConnection
|
|
92
|
+
* @extends EventEmitter
|
|
93
|
+
*/
|
|
94
|
+
class DtlsConnection extends EventEmitter {
|
|
95
|
+
role: string;
|
|
96
|
+
state: string;
|
|
97
|
+
|
|
98
|
+
#certDer: Buffer;
|
|
99
|
+
#privateKey: crypto.KeyObject;
|
|
100
|
+
#verifyFingerprint: VerifyFingerprint | null;
|
|
101
|
+
#output: (datagram: Buffer) => void;
|
|
102
|
+
|
|
103
|
+
// Record layer state.
|
|
104
|
+
#sendEpoch: number;
|
|
105
|
+
#sendSeq: number;
|
|
106
|
+
#handshakeMessageSeq: number;
|
|
107
|
+
|
|
108
|
+
// Cipher state (set after key derivation).
|
|
109
|
+
#writeCipher: cipher.GcmCipher | null;
|
|
110
|
+
#readCipher: cipher.GcmCipher | null;
|
|
111
|
+
#sendEncrypted: boolean;
|
|
112
|
+
|
|
113
|
+
// Handshake crypto material.
|
|
114
|
+
#clientRandom: Buffer | null;
|
|
115
|
+
#serverRandom: Buffer | null;
|
|
116
|
+
#cookie: Buffer;
|
|
117
|
+
#ecdh: crypto.ECDH | null;
|
|
118
|
+
#masterSecret: Buffer | null;
|
|
119
|
+
#remoteCertDer: Buffer | null;
|
|
120
|
+
#remoteEcdhePub: Buffer | null;
|
|
121
|
+
#useExtendedMasterSecret: boolean;
|
|
122
|
+
|
|
123
|
+
#transcript: Buffer[];
|
|
124
|
+
|
|
125
|
+
#reassembly: Map<number, ReassemblyEntry>;
|
|
126
|
+
#nextExpectedHsSeq: number;
|
|
127
|
+
|
|
128
|
+
#lastFlight: FlightDatagram[];
|
|
129
|
+
#retransmitTimer: NodeJS.Timeout | null;
|
|
130
|
+
#retransmitCount: number;
|
|
131
|
+
|
|
132
|
+
#handshakeDone: boolean;
|
|
133
|
+
|
|
134
|
+
#cookieSecret?: Buffer;
|
|
135
|
+
#renegRequested?: boolean;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* @param opts connection options
|
|
139
|
+
*/
|
|
140
|
+
constructor(opts: DtlsConnectionOptions) {
|
|
141
|
+
super();
|
|
142
|
+
this.role = opts.role;
|
|
143
|
+
this.#certDer = opts.certDer;
|
|
144
|
+
this.#privateKey = opts.privateKey;
|
|
145
|
+
this.#verifyFingerprint = opts.verifyFingerprint || null;
|
|
146
|
+
this.#output = opts.output;
|
|
147
|
+
|
|
148
|
+
this.state = STATE.NEW;
|
|
149
|
+
|
|
150
|
+
// Record layer state.
|
|
151
|
+
this.#sendEpoch = 0;
|
|
152
|
+
this.#sendSeq = 0; // 48-bit record seq within current epoch
|
|
153
|
+
this.#handshakeMessageSeq = 0;
|
|
154
|
+
|
|
155
|
+
// Cipher state (set after key derivation).
|
|
156
|
+
this.#writeCipher = null; // GcmCipher for our outbound epoch-1 records
|
|
157
|
+
this.#readCipher = null; // GcmCipher for inbound epoch-1 records
|
|
158
|
+
this.#sendEncrypted = false; // becomes true after we send ChangeCipherSpec
|
|
159
|
+
|
|
160
|
+
// Handshake crypto material.
|
|
161
|
+
this.#clientRandom = null;
|
|
162
|
+
this.#serverRandom = null;
|
|
163
|
+
this.#cookie = Buffer.alloc(0);
|
|
164
|
+
this.#ecdh = null; // local ECDH keypair
|
|
165
|
+
this.#masterSecret = null;
|
|
166
|
+
this.#remoteCertDer = null;
|
|
167
|
+
this.#remoteEcdhePub = null; // peer ECDHE public point (server role)
|
|
168
|
+
this.#useExtendedMasterSecret = false;
|
|
169
|
+
|
|
170
|
+
// Transcript of handshake messages (DTLS 12-byte header + body), used for
|
|
171
|
+
// Finished / CertificateVerify / EMS. Excludes HelloVerifyRequest and the
|
|
172
|
+
// first (cookieless) ClientHello, per RFC 6347.
|
|
173
|
+
this.#transcript = [];
|
|
174
|
+
|
|
175
|
+
// Reassembly of inbound fragmented handshake messages, keyed by msg seq.
|
|
176
|
+
this.#reassembly = new Map();
|
|
177
|
+
this.#nextExpectedHsSeq = 0;
|
|
178
|
+
|
|
179
|
+
// Last flight we sent, for retransmission.
|
|
180
|
+
this.#lastFlight = [];
|
|
181
|
+
this.#retransmitTimer = null;
|
|
182
|
+
this.#retransmitCount = 0;
|
|
183
|
+
|
|
184
|
+
this.#handshakeDone = false;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Begin the handshake (client sends the first flight). */
|
|
188
|
+
start(): void {
|
|
189
|
+
if (this.state !== STATE.NEW) return;
|
|
190
|
+
this.state = STATE.HANDSHAKING;
|
|
191
|
+
if (this.role === ROLE.CLIENT) {
|
|
192
|
+
this.#clientRandom = this.#makeRandom();
|
|
193
|
+
this.#sendClientHello();
|
|
194
|
+
}
|
|
195
|
+
// Server waits for ClientHello.
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** 32-byte Random (4-byte gmt_unix_time || 28 random). */
|
|
199
|
+
#makeRandom(): Buffer {
|
|
200
|
+
const r = crypto.randomBytes(32);
|
|
201
|
+
r.writeUInt32BE(Math.floor(Date.now() / 1000), 0);
|
|
202
|
+
return r;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ---- Outbound record/handshake plumbing ---------------------------------
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Emit a set of handshake messages as one flight: fragment, frame as records,
|
|
209
|
+
* append to transcript, and arm retransmission.
|
|
210
|
+
* @param messages
|
|
211
|
+
*/
|
|
212
|
+
#sendFlight(messages: HandshakeMessage[]): void {
|
|
213
|
+
const datagrams: FlightDatagram[] = [];
|
|
214
|
+
for (const msg of messages) {
|
|
215
|
+
const seq = this.#handshakeMessageSeq++;
|
|
216
|
+
// Full (unfragmented) message goes into the transcript hash.
|
|
217
|
+
const full = P.encodeHandshake(msg.type, seq, msg.body);
|
|
218
|
+
this.#transcript.push(full);
|
|
219
|
+
|
|
220
|
+
// Fragment the handshake body across records if large.
|
|
221
|
+
const total = msg.body.length;
|
|
222
|
+
let offset = 0;
|
|
223
|
+
do {
|
|
224
|
+
const chunk = msg.body.slice(offset, offset + MAX_FRAGMENT);
|
|
225
|
+
const hdr = Buffer.alloc(12);
|
|
226
|
+
hdr.writeUInt8(msg.type, 0);
|
|
227
|
+
P.uint24(total).copy(hdr, 1);
|
|
228
|
+
hdr.writeUInt16BE(seq, 4);
|
|
229
|
+
P.uint24(offset).copy(hdr, 6);
|
|
230
|
+
P.uint24(chunk.length).copy(hdr, 9);
|
|
231
|
+
const fragment = Buffer.concat([hdr, chunk]);
|
|
232
|
+
datagrams.push({ type: P.CONTENT_TYPE.HANDSHAKE, payload: fragment });
|
|
233
|
+
offset += chunk.length;
|
|
234
|
+
} while (offset < total);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
this.#lastFlight = datagrams;
|
|
238
|
+
this.#retransmitCount = 0;
|
|
239
|
+
this.#flushFlight();
|
|
240
|
+
this.#armRetransmit();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Encode each queued message as a record and send. */
|
|
244
|
+
#flushFlight(): void {
|
|
245
|
+
for (const d of this.#lastFlight) {
|
|
246
|
+
this.#sendRecord(d.type, d.payload);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Send a ChangeCipherSpec record (epoch boundary on our side). */
|
|
251
|
+
#sendChangeCipherSpec(): void {
|
|
252
|
+
this.#sendRecord(P.CONTENT_TYPE.CHANGE_CIPHER_SPEC, Buffer.from([1]));
|
|
253
|
+
// After CCS, subsequent records use the new epoch and are encrypted.
|
|
254
|
+
this.#sendEpoch = 1;
|
|
255
|
+
this.#sendSeq = 0;
|
|
256
|
+
this.#sendEncrypted = true;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Frame a payload as a DTLS record, encrypting if we're past CCS.
|
|
261
|
+
* @param type
|
|
262
|
+
* @param payload
|
|
263
|
+
*/
|
|
264
|
+
#sendRecord(type: number, payload: Buffer): void {
|
|
265
|
+
let fragment = payload;
|
|
266
|
+
const seq = this.#sendSeq++;
|
|
267
|
+
if (this.#sendEncrypted && this.#writeCipher) {
|
|
268
|
+
fragment = this.#writeCipher.encrypt(this.#sendEpoch, seq, type, P.DTLS_1_2, payload);
|
|
269
|
+
}
|
|
270
|
+
const record = P.encodeRecord(type, this.#sendEpoch, seq, fragment, P.DTLS_1_2);
|
|
271
|
+
this.#output(record);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
#armRetransmit(): void {
|
|
275
|
+
this.#clearRetransmit();
|
|
276
|
+
this.#retransmitTimer = setTimeout(() => {
|
|
277
|
+
if (this.#handshakeDone || this.state !== STATE.HANDSHAKING) return;
|
|
278
|
+
if (this.#retransmitCount >= MAX_RETRANSMITS) {
|
|
279
|
+
this.#fail(new Error('DTLS handshake timed out'));
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
this.#retransmitCount++;
|
|
283
|
+
this.#flushFlight();
|
|
284
|
+
// Exponential backoff.
|
|
285
|
+
this.#armRetransmit();
|
|
286
|
+
}, HANDSHAKE_TIMEOUT_MS * Math.pow(2, this.#retransmitCount));
|
|
287
|
+
// A pending retransmit must not, by itself, keep the process alive.
|
|
288
|
+
if (this.#retransmitTimer.unref) this.#retransmitTimer.unref();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
#clearRetransmit(): void {
|
|
292
|
+
if (this.#retransmitTimer) {
|
|
293
|
+
clearTimeout(this.#retransmitTimer);
|
|
294
|
+
this.#retransmitTimer = null;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ---- Inbound ------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Feed an inbound datagram (one UDP packet, possibly several records).
|
|
302
|
+
* @param packet
|
|
303
|
+
*/
|
|
304
|
+
handlePacket(packet: Buffer): void {
|
|
305
|
+
if (this.state === STATE.CLOSED || this.state === STATE.FAILED) return;
|
|
306
|
+
let records: P.Record[];
|
|
307
|
+
try {
|
|
308
|
+
records = P.parseRecords(packet);
|
|
309
|
+
} catch (err) {
|
|
310
|
+
return; // ignore malformed datagrams
|
|
311
|
+
}
|
|
312
|
+
for (const rec of records) {
|
|
313
|
+
try {
|
|
314
|
+
this.#handleRecord(rec);
|
|
315
|
+
} catch (err) {
|
|
316
|
+
this.#fail(err instanceof Error ? err : new Error(String(err)));
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
#handleRecord(rec: P.Record): void {
|
|
323
|
+
let fragment = rec.fragment;
|
|
324
|
+
|
|
325
|
+
// Decrypt records from the peer's encrypted epoch.
|
|
326
|
+
if (rec.epoch >= 1) {
|
|
327
|
+
if (!this.#readCipher) {
|
|
328
|
+
// Can't decrypt yet (keys not derived) — drop.
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
fragment = this.#readCipher.decrypt(rec.epoch, rec.seq, rec.type, rec.version, rec.fragment);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
switch (rec.type) {
|
|
335
|
+
case P.CONTENT_TYPE.HANDSHAKE:
|
|
336
|
+
this.#handleHandshakeFragment(fragment);
|
|
337
|
+
break;
|
|
338
|
+
case P.CONTENT_TYPE.CHANGE_CIPHER_SPEC:
|
|
339
|
+
// Peer switched to its encrypted epoch; records now carry epoch 1,
|
|
340
|
+
// which _handleRecord already routes through the read cipher.
|
|
341
|
+
break;
|
|
342
|
+
case P.CONTENT_TYPE.APPLICATION_DATA:
|
|
343
|
+
if (this.state === STATE.CONNECTED) this.emit('data', fragment);
|
|
344
|
+
break;
|
|
345
|
+
case P.CONTENT_TYPE.ALERT:
|
|
346
|
+
this.#handleAlert(fragment);
|
|
347
|
+
break;
|
|
348
|
+
default:
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
#handleAlert(fragment: Buffer): void {
|
|
354
|
+
if (fragment.length < 2) return;
|
|
355
|
+
const level = fragment[0]!;
|
|
356
|
+
const desc = fragment[1]!;
|
|
357
|
+
if (desc === P.ALERT_DESC.CLOSE_NOTIFY) {
|
|
358
|
+
this.close();
|
|
359
|
+
} else if (level === P.ALERT_LEVEL.FATAL) {
|
|
360
|
+
this.#fail(new Error(`DTLS fatal alert: ${desc}`));
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Reassemble a (possibly fragmented) handshake message, then dispatch
|
|
366
|
+
* complete messages in order.
|
|
367
|
+
* @param buf - one handshake fragment (12-byte header + chunk)
|
|
368
|
+
*/
|
|
369
|
+
#handleHandshakeFragment(buf: Buffer): void {
|
|
370
|
+
const h = P.parseHandshake(buf);
|
|
371
|
+
|
|
372
|
+
// Initialize / fetch reassembly buffer for this message_seq.
|
|
373
|
+
let entry = this.#reassembly.get(h.messageSeq);
|
|
374
|
+
if (!entry) {
|
|
375
|
+
entry = { type: h.msgType, length: h.length, data: Buffer.alloc(h.length), received: 0, ranges: [] };
|
|
376
|
+
this.#reassembly.set(h.messageSeq, entry);
|
|
377
|
+
}
|
|
378
|
+
// Copy this fragment into place (ignore duplicates/overlap simply).
|
|
379
|
+
h.body.copy(entry.data, h.fragmentOffset);
|
|
380
|
+
entry.received = Math.max(entry.received, h.fragmentOffset + h.fragmentLength);
|
|
381
|
+
|
|
382
|
+
// Dispatch any in-order, fully-received messages.
|
|
383
|
+
while (true) {
|
|
384
|
+
const next = this.#reassembly.get(this.#nextExpectedHsSeq);
|
|
385
|
+
if (!next || next.received < next.length) break;
|
|
386
|
+
this.#reassembly.delete(this.#nextExpectedHsSeq);
|
|
387
|
+
this.#nextExpectedHsSeq++;
|
|
388
|
+
this.#dispatchHandshake(next.type, next.data);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Add a received handshake message to the transcript (reconstructed as a
|
|
394
|
+
* single unfragmented message, per RFC 6347 §4.2.6).
|
|
395
|
+
*/
|
|
396
|
+
#appendInboundTranscript(type: number, body: Buffer): void {
|
|
397
|
+
const seq = this.#nextExpectedHsSeq - 1; // message_seq just consumed
|
|
398
|
+
this.#transcript.push(P.encodeHandshake(type, seq, body));
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
#dispatchHandshake(type: number, body: Buffer): void {
|
|
402
|
+
if (this.role === ROLE.CLIENT) {
|
|
403
|
+
this.#clientHandle(type, body);
|
|
404
|
+
} else {
|
|
405
|
+
this.#serverHandle(type, body);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
#transcriptHash(): Buffer {
|
|
410
|
+
const h = crypto.createHash('sha256');
|
|
411
|
+
for (const m of this.#transcript) h.update(m);
|
|
412
|
+
return h.digest();
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Raw concatenation of all transcript handshake messages (for signing). */
|
|
416
|
+
#transcriptBytes(): Buffer {
|
|
417
|
+
return Buffer.concat(this.#transcript);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ---- CLIENT role --------------------------------------------------------
|
|
421
|
+
|
|
422
|
+
#sendClientHello(): void {
|
|
423
|
+
const body = this.#buildClientHello();
|
|
424
|
+
if (this.#cookie.length === 0) {
|
|
425
|
+
// First ClientHello is excluded from the transcript: send as a raw
|
|
426
|
+
// handshake record without recording it, and without retransmit arming
|
|
427
|
+
// beyond the cookie exchange.
|
|
428
|
+
const seq = this.#handshakeMessageSeq++; // message_seq 0
|
|
429
|
+
const fragment = P.encodeHandshake(P.HANDSHAKE_TYPE.CLIENT_HELLO, seq, body);
|
|
430
|
+
this.#lastFlight = [{ type: P.CONTENT_TYPE.HANDSHAKE, payload: fragment }];
|
|
431
|
+
this.#flushFlight();
|
|
432
|
+
this.#armRetransmit();
|
|
433
|
+
} else {
|
|
434
|
+
// Second ClientHello (with cookie) starts the real transcript.
|
|
435
|
+
// It reuses message_seq = 1.
|
|
436
|
+
const seq = this.#handshakeMessageSeq++; // message_seq 1
|
|
437
|
+
const full = P.encodeHandshake(P.HANDSHAKE_TYPE.CLIENT_HELLO, seq, body);
|
|
438
|
+
this.#transcript.push(full);
|
|
439
|
+
this.#lastFlight = [{ type: P.CONTENT_TYPE.HANDSHAKE, payload: full }];
|
|
440
|
+
this.#retransmitCount = 0;
|
|
441
|
+
this.#flushFlight();
|
|
442
|
+
this.#armRetransmit();
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
#buildClientHello(): Buffer {
|
|
447
|
+
const parts: Buffer[] = [];
|
|
448
|
+
parts.push(Buffer.from([0xfe, 0xfd])); // client_version DTLS 1.2
|
|
449
|
+
parts.push(this.#clientRandom!);
|
|
450
|
+
parts.push(P.vec8(Buffer.alloc(0))); // session_id (empty)
|
|
451
|
+
parts.push(P.vec8(this.#cookie)); // cookie
|
|
452
|
+
// cipher_suites
|
|
453
|
+
const cs = Buffer.alloc(2);
|
|
454
|
+
cs.writeUInt16BE(P.CIPHER_SUITE, 0);
|
|
455
|
+
parts.push(P.vec16(cs));
|
|
456
|
+
// compression_methods: null only
|
|
457
|
+
parts.push(P.vec8(Buffer.from([0x00])));
|
|
458
|
+
// extensions
|
|
459
|
+
parts.push(P.vec16(this.#buildClientExtensions()));
|
|
460
|
+
return Buffer.concat(parts);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
#buildClientExtensions(): Buffer {
|
|
464
|
+
const exts: Buffer[] = [];
|
|
465
|
+
|
|
466
|
+
// supported_groups: secp256r1
|
|
467
|
+
const groups = Buffer.alloc(2);
|
|
468
|
+
groups.writeUInt16BE(P.NAMED_GROUP.secp256r1, 0);
|
|
469
|
+
exts.push(this.#ext(P.EXTENSION.SUPPORTED_GROUPS, P.vec16(groups)));
|
|
470
|
+
|
|
471
|
+
// ec_point_formats: uncompressed
|
|
472
|
+
exts.push(this.#ext(P.EXTENSION.EC_POINT_FORMATS, P.vec8(Buffer.from([P.EC_POINT_FORMAT.uncompressed]))));
|
|
473
|
+
|
|
474
|
+
// signature_algorithms: ecdsa_secp256r1_sha256
|
|
475
|
+
const sigalgs = Buffer.from([P.HASH_ALG.sha256, P.SIG_ALG.ecdsa]);
|
|
476
|
+
exts.push(this.#ext(P.EXTENSION.SIGNATURE_ALGORITHMS, P.vec16(sigalgs)));
|
|
477
|
+
|
|
478
|
+
// extended_master_secret (empty)
|
|
479
|
+
exts.push(this.#ext(P.EXTENSION.EXTENDED_MASTER_SECRET, Buffer.alloc(0)));
|
|
480
|
+
|
|
481
|
+
return Buffer.concat(exts);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
#ext(type: number, body: Buffer): Buffer {
|
|
485
|
+
const head = Buffer.alloc(4);
|
|
486
|
+
head.writeUInt16BE(type, 0);
|
|
487
|
+
head.writeUInt16BE(body.length, 2);
|
|
488
|
+
return Buffer.concat([head, body]);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
#clientHandle(type: number, body: Buffer): void {
|
|
492
|
+
switch (type) {
|
|
493
|
+
case P.HANDSHAKE_TYPE.HELLO_VERIFY_REQUEST: {
|
|
494
|
+
// Extract cookie and resend ClientHello. Not added to transcript.
|
|
495
|
+
// body: server_version(2) || cookie<0..255>
|
|
496
|
+
const cookieLen = body.readUInt8(2);
|
|
497
|
+
this.#cookie = body.slice(3, 3 + cookieLen);
|
|
498
|
+
this.#clearRetransmit();
|
|
499
|
+
// Reset message seq: RFC 6347 — second ClientHello has message_seq 1.
|
|
500
|
+
this.#sendClientHello();
|
|
501
|
+
break;
|
|
502
|
+
}
|
|
503
|
+
case P.HANDSHAKE_TYPE.SERVER_HELLO:
|
|
504
|
+
this.#appendInboundTranscript(type, body);
|
|
505
|
+
this.#parseServerHello(body);
|
|
506
|
+
break;
|
|
507
|
+
case P.HANDSHAKE_TYPE.CERTIFICATE:
|
|
508
|
+
this.#appendInboundTranscript(type, body);
|
|
509
|
+
this.#remoteCertDer = this.#parseCertificate(body);
|
|
510
|
+
break;
|
|
511
|
+
case P.HANDSHAKE_TYPE.SERVER_KEY_EXCHANGE:
|
|
512
|
+
this.#appendInboundTranscript(type, body);
|
|
513
|
+
this.#parseServerKeyExchange(body);
|
|
514
|
+
break;
|
|
515
|
+
case P.HANDSHAKE_TYPE.CERTIFICATE_REQUEST:
|
|
516
|
+
// We always send our certificate (WebRTC is mutual-auth), so the
|
|
517
|
+
// request only needs to be folded into the transcript.
|
|
518
|
+
this.#appendInboundTranscript(type, body);
|
|
519
|
+
break;
|
|
520
|
+
case P.HANDSHAKE_TYPE.SERVER_HELLO_DONE:
|
|
521
|
+
this.#appendInboundTranscript(type, body);
|
|
522
|
+
this.#clearRetransmit();
|
|
523
|
+
this.#sendClientSecondFlight();
|
|
524
|
+
break;
|
|
525
|
+
case P.HANDSHAKE_TYPE.FINISHED:
|
|
526
|
+
this.#verifyPeerFinished(body, P.FINISHED_LABEL.SERVER);
|
|
527
|
+
this.#appendInboundTranscript(type, body);
|
|
528
|
+
this.#onHandshakeComplete();
|
|
529
|
+
break;
|
|
530
|
+
default:
|
|
531
|
+
break;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
#parseServerHello(body: Buffer): void {
|
|
536
|
+
// server_version(2) || random(32) || session_id<vec8> || cipher_suite(2)
|
|
537
|
+
// || compression(1) || extensions<vec16>
|
|
538
|
+
let o = 2;
|
|
539
|
+
this.#serverRandom = body.slice(o, o + 32);
|
|
540
|
+
o += 32;
|
|
541
|
+
const sidLen = body.readUInt8(o);
|
|
542
|
+
o += 1 + sidLen;
|
|
543
|
+
const suite = body.readUInt16BE(o);
|
|
544
|
+
o += 2;
|
|
545
|
+
if (suite !== P.CIPHER_SUITE) {
|
|
546
|
+
throw new Error(`Server chose unsupported cipher suite 0x${suite.toString(16)}`);
|
|
547
|
+
}
|
|
548
|
+
o += 1; // compression
|
|
549
|
+
// Parse extensions for extended_master_secret.
|
|
550
|
+
if (o + 2 <= body.length) {
|
|
551
|
+
const extLen = body.readUInt16BE(o);
|
|
552
|
+
o += 2;
|
|
553
|
+
const end = o + extLen;
|
|
554
|
+
while (o + 4 <= end) {
|
|
555
|
+
const etype = body.readUInt16BE(o);
|
|
556
|
+
const elen = body.readUInt16BE(o + 2);
|
|
557
|
+
o += 4;
|
|
558
|
+
if (etype === P.EXTENSION.EXTENDED_MASTER_SECRET) {
|
|
559
|
+
this.#useExtendedMasterSecret = true;
|
|
560
|
+
}
|
|
561
|
+
o += elen;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
#sendClientSecondFlight(): void {
|
|
567
|
+
// Generate our ECDHE key.
|
|
568
|
+
this.#ecdh = crypto.createECDH('prime256v1');
|
|
569
|
+
this.#ecdh.generateKeys();
|
|
570
|
+
const clientPub = this.#ecdh.getPublicKey(); // uncompressed point (65 bytes)
|
|
571
|
+
|
|
572
|
+
// Compute pre-master secret = ECDH(serverPub).
|
|
573
|
+
const pms = this.#ecdh.computeSecret(this.#remoteEcdhePub!);
|
|
574
|
+
|
|
575
|
+
// client Certificate
|
|
576
|
+
const certMsg = this.#buildCertificateMessage();
|
|
577
|
+
// ClientKeyExchange: ECPoint as vec8
|
|
578
|
+
const cke = P.vec8(clientPub);
|
|
579
|
+
|
|
580
|
+
// Build the messages we're about to send so the transcript is correct for
|
|
581
|
+
// CertificateVerify (which signs everything through ClientKeyExchange) and
|
|
582
|
+
// for the master secret (EMS hashes through ClientKeyExchange).
|
|
583
|
+
const certSeq = this.#handshakeMessageSeq;
|
|
584
|
+
const ckeSeq = certSeq + 1;
|
|
585
|
+
const certFull = P.encodeHandshake(P.HANDSHAKE_TYPE.CERTIFICATE, certSeq, certMsg);
|
|
586
|
+
const ckeFull = P.encodeHandshake(P.HANDSHAKE_TYPE.CLIENT_KEY_EXCHANGE, ckeSeq, cke);
|
|
587
|
+
|
|
588
|
+
// Master secret derivation.
|
|
589
|
+
if (this.#useExtendedMasterSecret) {
|
|
590
|
+
const h = crypto.createHash('sha256');
|
|
591
|
+
for (const m of this.#transcript) h.update(m);
|
|
592
|
+
h.update(certFull);
|
|
593
|
+
h.update(ckeFull);
|
|
594
|
+
const sessionHash = h.digest();
|
|
595
|
+
this.#masterSecret = cipher.deriveExtendedMasterSecret(pms, sessionHash);
|
|
596
|
+
} else {
|
|
597
|
+
this.#masterSecret = cipher.deriveMasterSecret(pms, this.#clientRandom!, this.#serverRandom!);
|
|
598
|
+
}
|
|
599
|
+
this.#deriveCipherKeys();
|
|
600
|
+
|
|
601
|
+
// CertificateVerify: sign the raw handshake transcript through
|
|
602
|
+
// ClientKeyExchange. crypto.sign applies SHA-256 itself, so we feed it the
|
|
603
|
+
// concatenated messages, not a pre-computed digest.
|
|
604
|
+
const cvData = Buffer.concat([...this.#transcript, certFull, ckeFull]);
|
|
605
|
+
const cvSig = crypto.sign('sha256', cvData, { key: this.#privateKey, dsaEncoding: 'der' });
|
|
606
|
+
const cvBody = Buffer.concat([
|
|
607
|
+
Buffer.from([P.HASH_ALG.sha256, P.SIG_ALG.ecdsa]),
|
|
608
|
+
P.vec16(cvSig),
|
|
609
|
+
]);
|
|
610
|
+
|
|
611
|
+
// Now actually send: Certificate, ClientKeyExchange, CertificateVerify
|
|
612
|
+
// as a flight (these get recorded in transcript by _sendFlight), then CCS,
|
|
613
|
+
// then Finished.
|
|
614
|
+
this.#sendFlight([
|
|
615
|
+
{ type: P.HANDSHAKE_TYPE.CERTIFICATE, body: certMsg },
|
|
616
|
+
{ type: P.HANDSHAKE_TYPE.CLIENT_KEY_EXCHANGE, body: cke },
|
|
617
|
+
{ type: P.HANDSHAKE_TYPE.CERTIFICATE_VERIFY, body: cvBody },
|
|
618
|
+
]);
|
|
619
|
+
|
|
620
|
+
this.#sendChangeCipherSpec();
|
|
621
|
+
this.#sendFinished(P.FINISHED_LABEL.CLIENT);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// ---- SERVER role --------------------------------------------------------
|
|
625
|
+
|
|
626
|
+
#serverHandle(type: number, body: Buffer): void {
|
|
627
|
+
switch (type) {
|
|
628
|
+
case P.HANDSHAKE_TYPE.CLIENT_HELLO:
|
|
629
|
+
this.#handleClientHello(body);
|
|
630
|
+
break;
|
|
631
|
+
case P.HANDSHAKE_TYPE.CERTIFICATE:
|
|
632
|
+
this.#appendInboundTranscript(type, body);
|
|
633
|
+
this.#remoteCertDer = this.#parseCertificate(body);
|
|
634
|
+
break;
|
|
635
|
+
case P.HANDSHAKE_TYPE.CLIENT_KEY_EXCHANGE: {
|
|
636
|
+
this.#appendInboundTranscript(type, body);
|
|
637
|
+
const pubLen = body.readUInt8(0);
|
|
638
|
+
this.#remoteEcdhePub = body.slice(1, 1 + pubLen);
|
|
639
|
+
const pms = this.#ecdh!.computeSecret(this.#remoteEcdhePub);
|
|
640
|
+
if (this.#useExtendedMasterSecret) {
|
|
641
|
+
this.#masterSecret = cipher.deriveExtendedMasterSecret(pms, this.#transcriptHash());
|
|
642
|
+
} else {
|
|
643
|
+
this.#masterSecret = cipher.deriveMasterSecret(pms, this.#clientRandom!, this.#serverRandom!);
|
|
644
|
+
}
|
|
645
|
+
this.#deriveCipherKeys();
|
|
646
|
+
break;
|
|
647
|
+
}
|
|
648
|
+
case P.HANDSHAKE_TYPE.CERTIFICATE_VERIFY:
|
|
649
|
+
this.#verifyClientCertificateVerify(body);
|
|
650
|
+
this.#appendInboundTranscript(type, body);
|
|
651
|
+
break;
|
|
652
|
+
case P.HANDSHAKE_TYPE.FINISHED:
|
|
653
|
+
this.#verifyPeerFinished(body, P.FINISHED_LABEL.CLIENT);
|
|
654
|
+
this.#appendInboundTranscript(type, body);
|
|
655
|
+
// Server responds with its own CCS + Finished.
|
|
656
|
+
this.#sendChangeCipherSpec();
|
|
657
|
+
this.#sendFinished(P.FINISHED_LABEL.SERVER);
|
|
658
|
+
this.#onHandshakeComplete();
|
|
659
|
+
break;
|
|
660
|
+
default:
|
|
661
|
+
break;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
#handleClientHello(body: Buffer): void {
|
|
666
|
+
// Parse enough to extract random, cookie, and extensions.
|
|
667
|
+
let o = 2; // skip client_version
|
|
668
|
+
const random = body.slice(o, o + 32);
|
|
669
|
+
o += 32;
|
|
670
|
+
const sidLen = body.readUInt8(o);
|
|
671
|
+
o += 1 + sidLen;
|
|
672
|
+
const cookieLen = body.readUInt8(o);
|
|
673
|
+
const cookie = body.slice(o + 1, o + 1 + cookieLen);
|
|
674
|
+
o += 1 + cookieLen;
|
|
675
|
+
const csLen = body.readUInt16BE(o);
|
|
676
|
+
const cipherSuites = body.slice(o + 2, o + 2 + csLen);
|
|
677
|
+
o += 2 + csLen;
|
|
678
|
+
const compLen = body.readUInt8(o);
|
|
679
|
+
o += 1 + compLen;
|
|
680
|
+
// Extensions
|
|
681
|
+
let emsRequested = false;
|
|
682
|
+
// Secure renegotiation (RFC 5746): the client signals support via the
|
|
683
|
+
// renegotiation_info extension or the SCSV cipher (0x00FF). OpenSSL 3.x
|
|
684
|
+
// requires the server to acknowledge it, or it aborts with
|
|
685
|
+
// handshake_failure; older OpenSSL and browsers tolerate its absence.
|
|
686
|
+
let renegRequested = false;
|
|
687
|
+
for (let i = 0; i + 1 < cipherSuites.length; i += 2) {
|
|
688
|
+
if (cipherSuites.readUInt16BE(i) === 0x00ff) renegRequested = true;
|
|
689
|
+
}
|
|
690
|
+
if (o + 2 <= body.length) {
|
|
691
|
+
const extLen = body.readUInt16BE(o);
|
|
692
|
+
o += 2;
|
|
693
|
+
const end = o + extLen;
|
|
694
|
+
while (o + 4 <= end) {
|
|
695
|
+
const etype = body.readUInt16BE(o);
|
|
696
|
+
const elen = body.readUInt16BE(o + 2);
|
|
697
|
+
o += 4;
|
|
698
|
+
if (etype === P.EXTENSION.EXTENDED_MASTER_SECRET) emsRequested = true;
|
|
699
|
+
if (etype === P.EXTENSION.RENEGOTIATION_INFO) renegRequested = true;
|
|
700
|
+
o += elen;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
this.#renegRequested = renegRequested;
|
|
704
|
+
|
|
705
|
+
if (cookie.length === 0) {
|
|
706
|
+
// Stateless cookie exchange: reply with HelloVerifyRequest. Not part of
|
|
707
|
+
// the transcript, and we do not yet commit any state.
|
|
708
|
+
this.#clientRandom = random;
|
|
709
|
+
this.#sendHelloVerifyRequest(this.#makeCookie(random));
|
|
710
|
+
// The client resends ClientHello as message_seq 1; expect that next.
|
|
711
|
+
this.#reassembly.clear();
|
|
712
|
+
this.#nextExpectedHsSeq = 1;
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// Validate cookie.
|
|
717
|
+
const expected = this.#makeCookie(random);
|
|
718
|
+
if (!cookie.equals(expected)) {
|
|
719
|
+
// Tolerate by just re-issuing HVR.
|
|
720
|
+
this.#sendHelloVerifyRequest(expected);
|
|
721
|
+
this.#reassembly.clear();
|
|
722
|
+
this.#nextExpectedHsSeq = 1;
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// Cookie OK — this ClientHello starts the transcript.
|
|
727
|
+
this.#clientRandom = random;
|
|
728
|
+
this.#useExtendedMasterSecret = emsRequested;
|
|
729
|
+
this.#appendInboundTranscript(P.HANDSHAKE_TYPE.CLIENT_HELLO, body);
|
|
730
|
+
this.#sendServerFlight();
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
#makeCookie(clientRandom: Buffer): Buffer {
|
|
734
|
+
if (!this.#cookieSecret) this.#cookieSecret = crypto.randomBytes(32);
|
|
735
|
+
return crypto.createHmac('sha256', this.#cookieSecret).update(clientRandom).digest().slice(0, 20);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
#sendHelloVerifyRequest(cookie: Buffer): void {
|
|
739
|
+
// body: server_version(2) || cookie<vec8>
|
|
740
|
+
const body = Buffer.concat([Buffer.from([0xfe, 0xff]), P.vec8(cookie)]);
|
|
741
|
+
// HVR uses message_seq 0 and is not retransmitted via flight machinery.
|
|
742
|
+
const fragment = P.encodeHandshake(P.HANDSHAKE_TYPE.HELLO_VERIFY_REQUEST, 0, body);
|
|
743
|
+
this.#sendRecord(P.CONTENT_TYPE.HANDSHAKE, fragment);
|
|
744
|
+
// Server-side handshake message seq for the real flight starts at 1.
|
|
745
|
+
this.#handshakeMessageSeq = 1;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
#sendServerFlight(): void {
|
|
749
|
+
this.#serverRandom = this.#makeRandom();
|
|
750
|
+
this.#ecdh = crypto.createECDH('prime256v1');
|
|
751
|
+
this.#ecdh.generateKeys();
|
|
752
|
+
const serverPub = this.#ecdh.getPublicKey();
|
|
753
|
+
|
|
754
|
+
// ServerHello
|
|
755
|
+
const shBody = this.#buildServerHello();
|
|
756
|
+
|
|
757
|
+
// Certificate
|
|
758
|
+
const certMsg = this.#buildCertificateMessage();
|
|
759
|
+
|
|
760
|
+
// ServerKeyExchange: ServerECDHParams + signature
|
|
761
|
+
const ecdhParams = Buffer.concat([
|
|
762
|
+
Buffer.from([0x03]), // curve_type = named_curve
|
|
763
|
+
(() => { const b = Buffer.alloc(2); b.writeUInt16BE(P.NAMED_GROUP.secp256r1, 0); return b; })(),
|
|
764
|
+
P.vec8(serverPub),
|
|
765
|
+
]);
|
|
766
|
+
const signed = Buffer.concat([this.#clientRandom!, this.#serverRandom, ecdhParams]);
|
|
767
|
+
const sig = crypto.sign('sha256', signed, { key: this.#privateKey, dsaEncoding: 'der' });
|
|
768
|
+
const skeBody = Buffer.concat([
|
|
769
|
+
ecdhParams,
|
|
770
|
+
Buffer.from([P.HASH_ALG.sha256, P.SIG_ALG.ecdsa]),
|
|
771
|
+
P.vec16(sig),
|
|
772
|
+
]);
|
|
773
|
+
|
|
774
|
+
// CertificateRequest: ask client for an ECDSA cert (WebRTC is mutual-auth).
|
|
775
|
+
const certTypes = P.vec8(Buffer.from([P.CERT_TYPE.ecdsa_sign, P.CERT_TYPE.rsa_sign]));
|
|
776
|
+
const sigAlgs = P.vec16(Buffer.from([P.HASH_ALG.sha256, P.SIG_ALG.ecdsa]));
|
|
777
|
+
const cas = P.vec16(Buffer.alloc(0));
|
|
778
|
+
const crBody = Buffer.concat([certTypes, sigAlgs, cas]);
|
|
779
|
+
|
|
780
|
+
// ServerHelloDone
|
|
781
|
+
const shdBody = Buffer.alloc(0);
|
|
782
|
+
|
|
783
|
+
this.#sendFlight([
|
|
784
|
+
{ type: P.HANDSHAKE_TYPE.SERVER_HELLO, body: shBody },
|
|
785
|
+
{ type: P.HANDSHAKE_TYPE.CERTIFICATE, body: certMsg },
|
|
786
|
+
{ type: P.HANDSHAKE_TYPE.SERVER_KEY_EXCHANGE, body: skeBody },
|
|
787
|
+
{ type: P.HANDSHAKE_TYPE.CERTIFICATE_REQUEST, body: crBody },
|
|
788
|
+
{ type: P.HANDSHAKE_TYPE.SERVER_HELLO_DONE, body: shdBody },
|
|
789
|
+
]);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
#buildServerHello(): Buffer {
|
|
793
|
+
const parts: Buffer[] = [];
|
|
794
|
+
parts.push(Buffer.from([0xfe, 0xfd])); // server_version DTLS 1.2
|
|
795
|
+
parts.push(this.#serverRandom!);
|
|
796
|
+
parts.push(P.vec8(Buffer.alloc(0))); // session_id empty
|
|
797
|
+
const cs = Buffer.alloc(2);
|
|
798
|
+
cs.writeUInt16BE(P.CIPHER_SUITE, 0);
|
|
799
|
+
parts.push(cs); // cipher_suite (2 bytes, not a vector)
|
|
800
|
+
parts.push(Buffer.from([0x00])); // compression null
|
|
801
|
+
// extensions
|
|
802
|
+
const exts: Buffer[] = [];
|
|
803
|
+
if (this.#useExtendedMasterSecret) {
|
|
804
|
+
exts.push(this.#ext(P.EXTENSION.EXTENDED_MASTER_SECRET, Buffer.alloc(0)));
|
|
805
|
+
}
|
|
806
|
+
exts.push(this.#ext(P.EXTENSION.EC_POINT_FORMATS, P.vec8(Buffer.from([P.EC_POINT_FORMAT.uncompressed]))));
|
|
807
|
+
// Acknowledge secure renegotiation with an empty renegotiated_connection
|
|
808
|
+
// (a 1-byte zero-length vector). Required by OpenSSL 3.x clients.
|
|
809
|
+
if (this.#renegRequested) {
|
|
810
|
+
exts.push(this.#ext(P.EXTENSION.RENEGOTIATION_INFO, P.vec8(Buffer.alloc(0))));
|
|
811
|
+
}
|
|
812
|
+
parts.push(P.vec16(Buffer.concat(exts)));
|
|
813
|
+
return Buffer.concat(parts);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
#verifyClientCertificateVerify(body: Buffer): void {
|
|
817
|
+
// body: SignatureAndHashAlgorithm(2) || signature<vec16>
|
|
818
|
+
const sigLen = body.readUInt16BE(2);
|
|
819
|
+
const sig = body.slice(4, 4 + sigLen);
|
|
820
|
+
// Verify over the raw transcript through ClientKeyExchange (crypto.verify
|
|
821
|
+
// hashes internally, mirroring the signer).
|
|
822
|
+
const data = this.#transcriptBytes();
|
|
823
|
+
const pub = this.#publicKeyFromCert(this.#remoteCertDer!);
|
|
824
|
+
const ok = crypto.verify('sha256', data, { key: pub, dsaEncoding: 'der' }, sig);
|
|
825
|
+
if (!ok) throw new Error('Client CertificateVerify signature invalid');
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// ---- Shared handshake helpers ------------------------------------------
|
|
829
|
+
|
|
830
|
+
/** Build a Certificate message carrying our single DER cert. */
|
|
831
|
+
#buildCertificateMessage(): Buffer {
|
|
832
|
+
// certificate_list: each entry is cert<vec24>; whole list is vec24.
|
|
833
|
+
const entry = P.vec24(this.#certDer);
|
|
834
|
+
return P.vec24(entry);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/** Parse a Certificate message and return the first cert's DER. */
|
|
838
|
+
#parseCertificate(body: Buffer): Buffer | null {
|
|
839
|
+
// body: certificate_list<vec24> of cert<vec24>
|
|
840
|
+
const listLen = P.readUint24(body, 0);
|
|
841
|
+
let o = 3;
|
|
842
|
+
const end = 3 + listLen;
|
|
843
|
+
if (o + 3 > end) {
|
|
844
|
+
// Empty certificate list.
|
|
845
|
+
return null;
|
|
846
|
+
}
|
|
847
|
+
const certLen = P.readUint24(body, o);
|
|
848
|
+
o += 3;
|
|
849
|
+
const certDer = body.slice(o, o + certLen);
|
|
850
|
+
|
|
851
|
+
// Fingerprint verification against the SDP-advertised value.
|
|
852
|
+
if (this.#verifyFingerprint) {
|
|
853
|
+
const fp = { algorithm: 'sha-256', value: x509.fingerprint(certDer, 'sha-256') };
|
|
854
|
+
if (!this.#verifyFingerprint(fp, certDer)) {
|
|
855
|
+
throw new Error('Remote certificate fingerprint mismatch');
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
return certDer;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
#parseServerKeyExchange(body: Buffer): void {
|
|
862
|
+
// ServerECDHParams: curve_type(1) || named_curve(2) || public<vec8>
|
|
863
|
+
// then SignatureAndHashAlgorithm(2) || signature<vec16>
|
|
864
|
+
let o = 0;
|
|
865
|
+
const curveType = body.readUInt8(o); o += 1;
|
|
866
|
+
const namedCurve = body.readUInt16BE(o); o += 2;
|
|
867
|
+
if (curveType !== 3 || namedCurve !== P.NAMED_GROUP.secp256r1) {
|
|
868
|
+
throw new Error('Unsupported ECDHE curve from server');
|
|
869
|
+
}
|
|
870
|
+
const pubLen = body.readUInt8(o); o += 1;
|
|
871
|
+
const serverPub = body.slice(o, o + pubLen); o += pubLen;
|
|
872
|
+
this.#remoteEcdhePub = serverPub;
|
|
873
|
+
|
|
874
|
+
// Verify the signature over client_random || server_random || ECDHParams.
|
|
875
|
+
const ecdhParams = body.slice(0, o);
|
|
876
|
+
// skip SignatureAndHashAlgorithm(2)
|
|
877
|
+
o += 2;
|
|
878
|
+
const sigLen = body.readUInt16BE(o); o += 2;
|
|
879
|
+
const sig = body.slice(o, o + sigLen);
|
|
880
|
+
const signed = Buffer.concat([this.#clientRandom!, this.#serverRandom!, ecdhParams]);
|
|
881
|
+
const pub = this.#publicKeyFromCert(this.#remoteCertDer!);
|
|
882
|
+
const ok = crypto.verify('sha256', signed, { key: pub, dsaEncoding: 'der' }, sig);
|
|
883
|
+
if (!ok) throw new Error('ServerKeyExchange signature invalid');
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
#publicKeyFromCert(certDer: Buffer): crypto.KeyObject {
|
|
887
|
+
const cert = new crypto.X509Certificate(certDer);
|
|
888
|
+
return cert.publicKey;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
#deriveCipherKeys(): void {
|
|
892
|
+
const { clientKey, serverKey, clientIV, serverIV } = cipher.deriveKeys(
|
|
893
|
+
this.#masterSecret!,
|
|
894
|
+
this.#clientRandom!,
|
|
895
|
+
this.#serverRandom!
|
|
896
|
+
);
|
|
897
|
+
if (this.role === ROLE.CLIENT) {
|
|
898
|
+
this.#writeCipher = new cipher.GcmCipher(clientKey, clientIV);
|
|
899
|
+
this.#readCipher = new cipher.GcmCipher(serverKey, serverIV);
|
|
900
|
+
} else {
|
|
901
|
+
this.#writeCipher = new cipher.GcmCipher(serverKey, serverIV);
|
|
902
|
+
this.#readCipher = new cipher.GcmCipher(clientKey, clientIV);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
#sendFinished(label: string): void {
|
|
907
|
+
const verifyData = prf(this.#masterSecret!, label, this.#transcriptHash(), 12);
|
|
908
|
+
// Finished is itself a handshake message and goes into the transcript.
|
|
909
|
+
const seq = this.#handshakeMessageSeq++;
|
|
910
|
+
const full = P.encodeHandshake(P.HANDSHAKE_TYPE.FINISHED, seq, verifyData);
|
|
911
|
+
this.#transcript.push(full);
|
|
912
|
+
this.#sendRecord(P.CONTENT_TYPE.HANDSHAKE, full);
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
#verifyPeerFinished(body: Buffer, label: string): void {
|
|
916
|
+
const expected = prf(this.#masterSecret!, label, this.#transcriptHash(), 12);
|
|
917
|
+
if (!crypto.timingSafeEqual(body, expected)) {
|
|
918
|
+
throw new Error('Peer Finished verify_data mismatch');
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
#onHandshakeComplete(): void {
|
|
923
|
+
if (this.#handshakeDone) return;
|
|
924
|
+
this.#handshakeDone = true;
|
|
925
|
+
this.#clearRetransmit();
|
|
926
|
+
this.state = STATE.CONNECTED;
|
|
927
|
+
this.emit('connect');
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// ---- Application data ----------------------------------------------------
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* Send application data over the established connection.
|
|
934
|
+
* @param data
|
|
935
|
+
*/
|
|
936
|
+
send(data: Buffer): void {
|
|
937
|
+
if (this.state !== STATE.CONNECTED) {
|
|
938
|
+
throw new Error('DTLS connection not established');
|
|
939
|
+
}
|
|
940
|
+
this.#sendRecord(P.CONTENT_TYPE.APPLICATION_DATA, data);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/** Send a close_notify and tear down. */
|
|
944
|
+
close(): void {
|
|
945
|
+
if (this.state === STATE.CLOSED || this.state === STATE.FAILED) return;
|
|
946
|
+
try {
|
|
947
|
+
if (this.#sendEncrypted) {
|
|
948
|
+
this.#sendRecord(
|
|
949
|
+
P.CONTENT_TYPE.ALERT,
|
|
950
|
+
Buffer.from([P.ALERT_LEVEL.WARNING, P.ALERT_DESC.CLOSE_NOTIFY])
|
|
951
|
+
);
|
|
952
|
+
}
|
|
953
|
+
} catch (_) {
|
|
954
|
+
// best-effort
|
|
955
|
+
}
|
|
956
|
+
this.#clearRetransmit();
|
|
957
|
+
this.state = STATE.CLOSED;
|
|
958
|
+
this.emit('close');
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
#fail(err: Error): void {
|
|
962
|
+
if (this.state === STATE.FAILED || this.state === STATE.CLOSED) return;
|
|
963
|
+
this.#clearRetransmit();
|
|
964
|
+
this.state = STATE.FAILED;
|
|
965
|
+
this.emit('error', err);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/** The peer's certificate DER, available after the handshake. */
|
|
969
|
+
getRemoteCertificate(): Buffer | null {
|
|
970
|
+
return this.#remoteCertDer;
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
export { DtlsConnection, ROLE, STATE };
|