@gjsify/tls 0.4.0 → 0.4.3

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/src/index.ts DELETED
@@ -1,909 +0,0 @@
1
- // Reference: Node.js lib/tls.js, lib/_tls_common.js, lib/_tls_wrap.js
2
- // Reimplemented for GJS using Gio.TlsClientConnection / Gio.TlsServerConnection /
3
- // Gio.TlsCertificate.
4
- //
5
- // Node-TLS option / API → Gio.TLS property/method mapping (authoritative for this file):
6
- //
7
- // Node option / API → Gio binding
8
- // ─────────────────────────────────────────────────────────────────────────
9
- // tls.createSecureContext({cert}) → Gio.TlsCertificate.new_from_pem
10
- // {key} (separate PEM) → PEM concatenated then new_from_pem
11
- // {ca} (PEM string or array) → per-block Gio.TlsCertificate.new_from_pem
12
- // used as trust anchors via cert.verify()
13
- // {rejectUnauthorized: false} → TlsConnection 'accept-certificate'
14
- // signal returns true
15
- // {minVersion}/{maxVersion}/ → Not exposed by Gio (handled by GnuTLS
16
- // {ciphers} backend); stored for diagnostics only
17
- // {ALPNProtocols} → TlsConnection.set_advertised_protocols
18
- // tlsSocket.alpnProtocol → TlsConnection.get_negotiated_protocol
19
- // {servername} (SNI) → TlsClientConnection.set_server_identity
20
- // (Gio.NetworkAddress with hostname)
21
- // tls.connect({cert,key}) (mTLS) → TlsConnection.set_certificate(client_cert)
22
- // tls.createServer({SNICallback}) → Best-effort: see "Open TODOs" — Gio
23
- // does not surface the ClientHello
24
- // server_name to JS before handshake.
25
- // tlsSocket.getPeerCertificate() → TlsConnection.get_peer_certificate +
26
- // TlsCertificate.get_subject_name /
27
- // get_issuer_name / get_dns_names /
28
- // get_ip_addresses / get_not_valid_* /
29
- // certificate_pem
30
- // detailed=true issuer chain → TlsCertificate.get_issuer (walked)
31
- // tlsSocket.getProtocol() → TlsConnection.get_protocol_version
32
- // tlsSocket.getCipher() → TlsConnection.get_ciphersuite_name
33
- // server: {requestCert, → TlsServerConnection.authentication_mode
34
- // rejectUnauthorized} REQUESTED / REQUIRED / NONE
35
- //
36
- // Documented gaps (see STATUS.md "Open TODOs"):
37
- // - SNI server-side selection from ClientHello: Gio does not expose
38
- // server_name extension before handshake; SNICallback is consulted but
39
- // selection is approximate.
40
- // - OCSP stapling: not exposed by Gio.
41
- // - TLS session resumption ('session' event, {session} option): GnuTLS
42
- // resumption API is not surfaced via GI.
43
- // - Custom DH/ECDH params, ticket keys: not exposed.
44
-
45
- import Gio from '@girs/gio-2.0';
46
- import GLib from '@girs/glib-2.0';
47
- import { Socket, Server } from 'node:net';
48
- import type { Server as NetServer } from 'node:net';
49
- import { createNodeError, deferEmit } from '@gjsify/utils';
50
-
51
- export const DEFAULT_MIN_VERSION = 'TLSv1.2';
52
- export const DEFAULT_MAX_VERSION = 'TLSv1.3';
53
- export const DEFAULT_CIPHERS = 'TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384';
54
-
55
- /** Returns a list of supported TLS cipher names (subset; implementation-defined). */
56
- export function getCiphers(): string[] {
57
- return [
58
- 'aes-128-gcm', 'aes-256-gcm', 'chacha20-poly1305',
59
- 'aes-128-cbc', 'aes-256-cbc',
60
- ];
61
- }
62
-
63
- // ============================================================================
64
- // PEM helpers
65
- // ============================================================================
66
-
67
- type PemInput = string | Buffer | Uint8Array | Array<string | Buffer | Uint8Array>;
68
-
69
- /** Coerce a PEM input (string, Buffer/Uint8Array, or array) to a single PEM string. */
70
- function pemToString(value: PemInput): string {
71
- if (Array.isArray(value)) {
72
- return value.map(pemToString).join('\n');
73
- }
74
- if (typeof value === 'string') return value;
75
- if (value && typeof (value as Buffer).toString === 'function') {
76
- try {
77
- return (value as Buffer).toString('utf-8');
78
- } catch {
79
- return new TextDecoder('utf-8').decode(value as Uint8Array);
80
- }
81
- }
82
- return String(value);
83
- }
84
-
85
- /** Split a concatenated PEM blob into individual `-----BEGIN ...-----...-----END ...-----` blocks. */
86
- function splitPemBlocks(pem: string): string[] {
87
- const out: string[] = [];
88
- const re = /-----BEGIN [^-]+-----[\s\S]*?-----END [^-]+-----/g;
89
- let m: RegExpExecArray | null;
90
- while ((m = re.exec(pem)) !== null) {
91
- out.push(m[0]);
92
- }
93
- return out;
94
- }
95
-
96
- /** Build a TlsCertificate (and chain) from PEM strings. The first cert and key are the leaf. */
97
- function buildGioCertificate(cert: PemInput, key?: PemInput): Gio.TlsCertificate {
98
- const certPem = pemToString(cert);
99
- const keyPem = key ? pemToString(key) : '';
100
- const pem = keyPem ? `${certPem}\n${keyPem}` : certPem;
101
- return Gio.TlsCertificate.new_from_pem(pem, pem.length);
102
- }
103
-
104
- /** Parse a CA bundle (PEM string or array) into a list of TlsCertificate trust anchors. */
105
- function buildCaCertificates(ca: PemInput): Gio.TlsCertificate[] {
106
- const blocks: string[] = [];
107
- if (Array.isArray(ca)) {
108
- for (const item of ca) blocks.push(...splitPemBlocks(pemToString(item)));
109
- } else {
110
- blocks.push(...splitPemBlocks(pemToString(ca)));
111
- }
112
- const out: Gio.TlsCertificate[] = [];
113
- for (const block of blocks) {
114
- try {
115
- out.push(Gio.TlsCertificate.new_from_pem(block, block.length));
116
- } catch {
117
- // Skip blocks that aren't certificates (DH params, comments, etc).
118
- }
119
- }
120
- return out;
121
- }
122
-
123
- // ============================================================================
124
- // Peer-certificate extraction
125
- // ============================================================================
126
-
127
- export interface CertSubject {
128
- CN?: string | string[];
129
- [key: string]: unknown;
130
- }
131
-
132
- export interface PeerCertificate {
133
- subject?: CertSubject;
134
- issuer?: CertSubject;
135
- subjectaltname?: string;
136
- valid_from?: string;
137
- valid_to?: string;
138
- fingerprint?: string;
139
- fingerprint256?: string;
140
- serialNumber?: string;
141
- raw?: Uint8Array;
142
- issuerCertificate?: PeerCertificate;
143
- [key: string]: unknown;
144
- }
145
-
146
- /** Parse a distinguished name string (e.g. "CN=example.com,O=Foo") into a key→value object. */
147
- function parseDistinguishedName(dn: string | null): CertSubject {
148
- if (!dn) return {};
149
- const out: CertSubject = {};
150
- for (const part of dn.split(/,(?![^=]*=)/)) {
151
- const eq = part.indexOf('=');
152
- if (eq < 0) continue;
153
- const key = part.slice(0, eq).trim();
154
- const value = part.slice(eq + 1).trim();
155
- const existing = out[key];
156
- if (existing === undefined) out[key] = value;
157
- else if (Array.isArray(existing)) existing.push(value);
158
- else out[key] = [existing as string, value];
159
- }
160
- return out;
161
- }
162
-
163
- /** Format a GLib.DateTime as an OpenSSL-style validity string. */
164
- function formatCertDate(dt: GLib.DateTime | null): string {
165
- if (!dt) return '';
166
- try { return dt.format('%b %d %H:%M:%S %Y GMT') ?? ''; } catch { return ''; }
167
- }
168
-
169
- /** Build the "subjectaltname" string from DNS names + IP addresses (Node format). */
170
- function formatAltNames(cert: Gio.TlsCertificate): string {
171
- const parts: string[] = [];
172
- try {
173
- const dns = cert.get_dns_names();
174
- if (dns) {
175
- for (const b of dns) {
176
- const data = b.get_data();
177
- if (!data) continue;
178
- parts.push(`DNS:${new TextDecoder('utf-8').decode(data)}`);
179
- }
180
- }
181
- } catch { /* not all backends support this */ }
182
- try {
183
- const ips = cert.get_ip_addresses();
184
- if (ips) for (const ip of ips) parts.push(`IP Address:${ip.to_string()}`);
185
- } catch { /* same */ }
186
- return parts.join(', ');
187
- }
188
-
189
- /** Compute SHA-1 / SHA-256 fingerprint strings from raw DER bytes (`AA:BB:CC:…`). */
190
- function fingerprintFromBytes(bytes: Uint8Array, algo: GLib.ChecksumType): string {
191
- try {
192
- const cs = new GLib.Checksum(algo);
193
- cs.update(bytes);
194
- const hex = cs.get_string();
195
- if (!hex) return '';
196
- const out: string[] = [];
197
- for (let i = 0; i < hex.length; i += 2) out.push(hex.slice(i, i + 2).toUpperCase());
198
- return out.join(':');
199
- } catch {
200
- return '';
201
- }
202
- }
203
-
204
- /** Decode a single PEM cert block into raw DER bytes. */
205
- function pemToDer(pem: string): Uint8Array {
206
- const m = /-----BEGIN CERTIFICATE-----([\s\S]*?)-----END CERTIFICATE-----/.exec(pem);
207
- if (!m) return new Uint8Array(0);
208
- const b64 = m[1].replace(/[\s\r\n]+/g, '');
209
- try {
210
- const atob = (globalThis as { atob?: (s: string) => string }).atob;
211
- if (!atob) return new Uint8Array(0);
212
- const bin = atob(b64);
213
- const out = new Uint8Array(bin.length);
214
- for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
215
- return out;
216
- } catch {
217
- return new Uint8Array(0);
218
- }
219
- }
220
-
221
- /** Convert a single TlsCertificate to the Node `getPeerCertificate()` shape. */
222
- function tlsCertToPeerCert(cert: Gio.TlsCertificate, detailed: boolean): PeerCertificate {
223
- const out: PeerCertificate = {};
224
- try { out.subject = parseDistinguishedName(cert.get_subject_name()); } catch { /* */ }
225
- try { out.issuer = parseDistinguishedName(cert.get_issuer_name()); } catch { /* */ }
226
- out.subjectaltname = formatAltNames(cert);
227
- try {
228
- out.valid_from = formatCertDate(cert.get_not_valid_before());
229
- out.valid_to = formatCertDate(cert.get_not_valid_after());
230
- } catch { /* */ }
231
- try {
232
- const c = cert as unknown as { certificate_pem?: string; certificatePem?: string };
233
- const pemProp = c.certificate_pem ?? c.certificatePem;
234
- if (pemProp) {
235
- const der = pemToDer(pemProp);
236
- out.raw = der;
237
- out.fingerprint = fingerprintFromBytes(der, GLib.ChecksumType.SHA1);
238
- out.fingerprint256 = fingerprintFromBytes(der, GLib.ChecksumType.SHA256);
239
- }
240
- } catch { /* */ }
241
- if (detailed) {
242
- try {
243
- const issuerCert = cert.get_issuer();
244
- if (issuerCert && !issuerCert.is_same(cert)) {
245
- out.issuerCertificate = tlsCertToPeerCert(issuerCert, true);
246
- } else if (issuerCert) {
247
- out.issuerCertificate = out; // self-signed: Node returns self-ref
248
- }
249
- } catch { /* */ }
250
- }
251
- return out;
252
- }
253
-
254
- // ============================================================================
255
- // RFC 6125 hostname matching
256
- // ============================================================================
257
-
258
- /** Removes a trailing dot from a fully-qualified domain name. */
259
- function unfqdn(host: string): string {
260
- return host.endsWith('.') ? host.slice(0, -1) : host;
261
- }
262
-
263
- /** Splits a hostname into parts, lower-cased, after removing trailing dots. */
264
- function splitHost(host: string): string[] {
265
- return unfqdn(host).toLowerCase().split('.');
266
- }
267
-
268
- /** Reject control / non-ASCII bytes in pattern labels (RFC 6125 sanity). */
269
- function isPrintableAscii(s: string): boolean {
270
- // U+0021 ('!') through U+007E ('~')
271
- for (let i = 0; i < s.length; i++) {
272
- const c = s.charCodeAt(i);
273
- if (c < 0x21 || c > 0x7E) return false;
274
- }
275
- return true;
276
- }
277
-
278
- /**
279
- * Match a hostname (already split into labels) against a single pattern from
280
- * a SAN DNS entry or CN. Implements RFC 6125 §6.4.3:
281
- * - wildcard valid only in the leftmost label
282
- * - wildcard label may not contain Punycode A-labels (`xn--`)
283
- * - `*.tld` (two-label patterns) are rejected
284
- * - exactly one wildcard per label
285
- */
286
- function checkHostMatch(hostParts: string[], pattern: string): boolean {
287
- if (!pattern) return false;
288
- const patternParts = splitHost(pattern);
289
- if (hostParts.length !== patternParts.length) return false;
290
- if (patternParts.includes('')) return false;
291
- if (!patternParts.every(isPrintableAscii)) return false;
292
- for (let i = hostParts.length - 1; i > 0; i--) {
293
- if (hostParts[i] !== patternParts[i]) return false;
294
- }
295
- const hostSub = hostParts[0];
296
- const patSub = patternParts[0];
297
- const wildSplit = patSub.split('*', 3);
298
- if (wildSplit.length === 1 || patSub.includes('xn--')) {
299
- return hostSub === patSub;
300
- }
301
- if (wildSplit.length > 2) return false;
302
- if (patternParts.length <= 2) return false;
303
- const prefix = wildSplit[0];
304
- const suffix = wildSplit[1];
305
- if (prefix.length + suffix.length > hostSub.length) return false;
306
- if (!hostSub.startsWith(prefix)) return false;
307
- if (!hostSub.endsWith(suffix)) return false;
308
- return true;
309
- }
310
-
311
- /** Error returned by checkServerIdentity, with Node-compatible shape. */
312
- export interface CertAltNameError extends Error {
313
- reason: string;
314
- host: string;
315
- cert: PeerCertificate;
316
- code: 'ERR_TLS_CERT_ALTNAME_INVALID';
317
- }
318
-
319
- /**
320
- * Verifies that the certificate `cert` is valid for `hostname`.
321
- * Returns an Error (with code 'ERR_TLS_CERT_ALTNAME_INVALID') if the check
322
- * fails, or `undefined` on success.
323
- *
324
- * Reference: Node.js lib/tls.js exports.checkServerIdentity (RFC 6125 §6.4.3).
325
- */
326
- export function checkServerIdentity(hostname: string, cert: PeerCertificate): CertAltNameError | undefined {
327
- const subject = cert.subject;
328
- const altNames = cert.subjectaltname;
329
- const dnsNames: string[] = [];
330
- const ips: string[] = [];
331
-
332
- hostname = String(hostname);
333
-
334
- if (altNames) {
335
- const parts = altNames.split(', ');
336
- for (const name of parts) {
337
- if (name.startsWith('DNS:')) dnsNames.push(name.slice(4));
338
- else if (name.startsWith('IP Address:')) ips.push(name.slice(11).trim());
339
- }
340
- }
341
-
342
- let valid = false;
343
- let reason = 'Unknown reason';
344
-
345
- hostname = unfqdn(hostname);
346
-
347
- const isIPv4 = /^(\d{1,3}\.){3}\d{1,3}$/.test(hostname);
348
- const isIPv6 = hostname.includes(':');
349
- if (isIPv4 || isIPv6) {
350
- valid = ips.some(ip => ip.toLowerCase() === hostname.toLowerCase());
351
- if (!valid) {
352
- reason = `IP: ${hostname} is not in the cert's list: ${ips.join(', ')}`;
353
- }
354
- } else if (dnsNames.length > 0 || subject?.CN) {
355
- const hostParts = splitHost(hostname);
356
-
357
- if (dnsNames.length > 0) {
358
- valid = dnsNames.some(pattern => checkHostMatch(hostParts, pattern.trim()));
359
- if (!valid) {
360
- reason = `Host: ${hostname}. is not in the cert's altnames: ${altNames}`;
361
- }
362
- } else {
363
- const cn = subject?.CN;
364
- if (Array.isArray(cn)) {
365
- valid = cn.some(c => checkHostMatch(hostParts, c));
366
- } else if (cn) {
367
- valid = checkHostMatch(hostParts, cn);
368
- }
369
- if (!valid) {
370
- reason = `Host: ${hostname}. is not cert's CN: ${cn}`;
371
- }
372
- }
373
- } else {
374
- reason = 'Cert does not contain a DNS name';
375
- }
376
-
377
- if (!valid) {
378
- const err = new Error(reason) as CertAltNameError;
379
- err.reason = reason;
380
- err.host = hostname;
381
- err.cert = cert;
382
- err.code = 'ERR_TLS_CERT_ALTNAME_INVALID';
383
- return err;
384
- }
385
- return undefined;
386
- }
387
-
388
- // ============================================================================
389
- // SecureContext
390
- // ============================================================================
391
-
392
- export interface SecureContextOptions {
393
- ca?: PemInput;
394
- cert?: PemInput;
395
- key?: PemInput;
396
- passphrase?: string;
397
- rejectUnauthorized?: boolean;
398
- ciphers?: string;
399
- minVersion?: string;
400
- maxVersion?: string;
401
- ALPNProtocols?: string[];
402
- }
403
-
404
- /** Internal "secure context" — parsed TLS material shared by tls.connect/createServer. */
405
- export interface SecureContext {
406
- certificate: Gio.TlsCertificate | null;
407
- caCertificates: Gio.TlsCertificate[];
408
- options: SecureContextOptions;
409
- /**
410
- * Node-compat handle (Node returns a `SecureContext` with an internal native
411
- * `context` field). We have no native handle, so this points back at the
412
- * SecureContext object itself — `ctx.context !== undefined` matches Node.
413
- */
414
- context: SecureContext;
415
- }
416
-
417
- /** Build a SecureContext from PEM material. Buffer/Uint8Array/string all accepted. */
418
- export function createSecureContext(options?: SecureContextOptions): SecureContext {
419
- const opts = options ?? {};
420
- let certificate: Gio.TlsCertificate | null = null;
421
- if (opts.cert) {
422
- try { certificate = buildGioCertificate(opts.cert, opts.key); } catch { certificate = null; }
423
- }
424
- const caCertificates = opts.ca ? buildCaCertificates(opts.ca) : [];
425
- const ctx = { certificate, caCertificates, options: opts } as SecureContext;
426
- ctx.context = ctx; // Node-compat self-reference
427
- return ctx;
428
- }
429
-
430
- export interface TlsConnectOptions extends SecureContextOptions {
431
- host?: string;
432
- port?: number;
433
- socket?: Socket;
434
- servername?: string;
435
- ALPNProtocols?: string[];
436
- /** Pre-built secure context from createSecureContext(). */
437
- secureContext?: SecureContext;
438
- /** Custom server-identity check (runs after the GnuTLS-level check). */
439
- checkServerIdentity?: (host: string, cert: PeerCertificate) => Error | undefined;
440
- }
441
-
442
- /** Internal helper: cast a Socket to its private-field shape (we own the impl). */
443
- interface SocketInternals {
444
- _connection: Gio.SocketConnection | null;
445
- _ioStream: Gio.IOStream | null;
446
- _inputStream: Gio.InputStream | null;
447
- _outputStream: Gio.OutputStream | null;
448
- _reading: boolean;
449
- _startReading(): void;
450
- }
451
-
452
- /**
453
- * TLSSocket wraps a net.Socket with TLS via Gio.TlsConnection.
454
- */
455
- export class TLSSocket extends Socket {
456
- encrypted = true;
457
- authorized = false;
458
- authorizationError?: string;
459
- alpnProtocol: string | false = false;
460
- servername: string | undefined;
461
-
462
- /** @internal */
463
- _tlsConnection: Gio.TlsConnection | null = null;
464
- /** @internal — preserved for diagnostics + future cert-chain verification. */
465
- _secureContext: SecureContext | null = null;
466
-
467
- constructor(_socket?: Socket, _options?: SecureContextOptions) {
468
- super();
469
- }
470
-
471
- /**
472
- * @internal Wire the TLS connection's I/O streams into this socket
473
- * so that read/write operations go through the encrypted channel.
474
- */
475
- _setupTlsStreams(tlsConn: Gio.TlsConnection): void {
476
- this._tlsConnection = tlsConn;
477
- const internals = this as unknown as SocketInternals;
478
- internals._inputStream = tlsConn.get_input_stream();
479
- internals._outputStream = tlsConn.get_output_stream();
480
- internals._connection = tlsConn as unknown as Gio.SocketConnection;
481
- }
482
-
483
- /**
484
- * Get the peer certificate. When `detailed` is true, walks the issuer chain
485
- * via `Gio.TlsCertificate.get_issuer()` and populates `issuerCertificate`
486
- * recursively (with a self-reference on the root for compatibility).
487
- */
488
- getPeerCertificate(detailed = false): PeerCertificate {
489
- if (!this._tlsConnection) return {};
490
- try {
491
- const cert = this._tlsConnection.get_peer_certificate();
492
- if (!cert) return {};
493
- return tlsCertToPeerCert(cert, detailed);
494
- } catch {
495
- return {};
496
- }
497
- }
498
-
499
- /** Get the negotiated TLS protocol version. */
500
- getProtocol(): string | null {
501
- if (!this._tlsConnection) return null;
502
- try {
503
- const proto = this._tlsConnection.get_protocol_version();
504
- switch (proto) {
505
- case Gio.TlsProtocolVersion.TLS_1_0: return 'TLSv1';
506
- case Gio.TlsProtocolVersion.TLS_1_1: return 'TLSv1.1';
507
- case Gio.TlsProtocolVersion.TLS_1_2: return 'TLSv1.2';
508
- case Gio.TlsProtocolVersion.TLS_1_3: return 'TLSv1.3';
509
- default: return null;
510
- }
511
- } catch {
512
- return null;
513
- }
514
- }
515
-
516
- /** Get the negotiated cipher suite name + version. */
517
- getCipher(): { name: string; version: string } | null {
518
- if (!this._tlsConnection) return null;
519
- try {
520
- const name = this._tlsConnection.get_ciphersuite_name();
521
- return { name: name || 'unknown', version: this.getProtocol() || 'unknown' };
522
- } catch {
523
- return null;
524
- }
525
- }
526
-
527
- /** Get the negotiated ALPN protocol (or false if none). */
528
- getAlpnProtocol(): string | false {
529
- if (!this._tlsConnection) return false;
530
- try {
531
- const proto = this._tlsConnection.get_negotiated_protocol();
532
- return proto || false;
533
- } catch {
534
- return false;
535
- }
536
- }
537
- }
538
-
539
- /**
540
- * Create a TLS client connection.
541
- *
542
- * Connects via TCP first (using net.Socket.connect), then upgrades
543
- * the connection to TLS using Gio.TlsClientConnection.
544
- */
545
- export function connect(options: TlsConnectOptions, callback?: () => void): TLSSocket {
546
- const socket = new TLSSocket(undefined, options);
547
-
548
- if (callback) {
549
- socket.once('secureConnect', callback);
550
- }
551
-
552
- const port = options.port || 443;
553
- const host = options.host || 'localhost';
554
- const servername = options.servername || host;
555
- const rejectUnauthorized = options.rejectUnauthorized !== false;
556
-
557
- const ctx = options.secureContext ?? createSecureContext(options);
558
- socket._secureContext = ctx;
559
- socket.servername = servername;
560
- const customCheckServerIdentity = options.checkServerIdentity;
561
-
562
- socket.once('connect', () => {
563
- const rawConnection = (socket as unknown as SocketInternals)._connection;
564
- if (!rawConnection) {
565
- socket.destroy(new Error('No underlying connection for TLS upgrade'));
566
- return;
567
- }
568
-
569
- try {
570
- const connectable = Gio.NetworkAddress.new(servername, port);
571
- const tlsConn = Gio.TlsClientConnection.new(
572
- rawConnection as unknown as Gio.IOStream,
573
- connectable,
574
- );
575
-
576
- tlsConn.set_server_identity(connectable);
577
-
578
- // Client certificate (mTLS)
579
- if (ctx.certificate) {
580
- try {
581
- tlsConn.set_certificate(ctx.certificate);
582
- } catch (err: unknown) {
583
- // eslint-disable-next-line no-console
584
- console.warn('[tls] failed to set client certificate:', err);
585
- }
586
- }
587
-
588
- // ALPN
589
- if (options.ALPNProtocols && options.ALPNProtocols.length > 0) {
590
- try {
591
- tlsConn.set_advertised_protocols(options.ALPNProtocols);
592
- } catch {
593
- // ALPN may not be supported
594
- }
595
- }
596
-
597
- // Certificate validation: by default rely on system trust store +
598
- // 'accept-certificate' returning false. With a custom CA we accept
599
- // peer certs that validate against `ctx.caCertificates`. With
600
- // `rejectUnauthorized: false`, accept everything.
601
- tlsConn.connect('accept-certificate', (
602
- _conn: Gio.TlsConnection,
603
- peerCert: Gio.TlsCertificate,
604
- _errors: Gio.TlsCertificateFlags,
605
- ): boolean => {
606
- if (!rejectUnauthorized) return true;
607
- if (ctx.caCertificates.length === 0) return false;
608
- for (const ca of ctx.caCertificates) {
609
- try {
610
- const flags = peerCert.verify(connectable, ca);
611
- if (flags === Gio.TlsCertificateFlags.NO_FLAGS) return true;
612
- } catch { /* try next */ }
613
- }
614
- return false;
615
- });
616
-
617
- const cancellable = new Gio.Cancellable();
618
- tlsConn.handshake_async(
619
- GLib.PRIORITY_DEFAULT,
620
- cancellable,
621
- (_source: Gio.TlsConnection | null, asyncResult: Gio.AsyncResult) => {
622
- try {
623
- tlsConn.handshake_finish(asyncResult);
624
- socket.authorized = true;
625
- socket._setupTlsStreams(tlsConn);
626
- socket.alpnProtocol = socket.getAlpnProtocol();
627
-
628
- // Custom server-identity check (post-handshake, mirrors Node).
629
- if (customCheckServerIdentity) {
630
- const peer = socket.getPeerCertificate();
631
- const idErr = customCheckServerIdentity(servername, peer);
632
- if (idErr) {
633
- socket.authorized = false;
634
- socket.authorizationError = idErr.message;
635
- if (rejectUnauthorized) {
636
- socket.destroy(idErr);
637
- return;
638
- }
639
- }
640
- }
641
-
642
- const internals = socket as unknown as SocketInternals;
643
- internals._reading = false;
644
- internals._startReading();
645
-
646
- socket.emit('secureConnect');
647
- } catch (err: unknown) {
648
- socket.authorized = false;
649
- socket.authorizationError = err instanceof Error ? err.message : String(err);
650
- if (rejectUnauthorized) {
651
- socket.destroy(err instanceof Error ? err : new Error(String(err)));
652
- } else {
653
- socket._setupTlsStreams(tlsConn);
654
- socket.emit('secureConnect');
655
- }
656
- }
657
- },
658
- );
659
- } catch (err: unknown) {
660
- socket.destroy(err instanceof Error ? err : new Error(String(err)));
661
- }
662
- });
663
-
664
- socket.connect({ port, host });
665
- return socket;
666
- }
667
-
668
- export const rootCertificates: string[] = [];
669
-
670
- // ============================================================================
671
- // TLSServer / createServer
672
- // ============================================================================
673
-
674
- export type SNICallback = (
675
- servername: string,
676
- cb: (err: Error | null, ctx?: SecureContext) => void,
677
- ) => void;
678
-
679
- export interface TlsServerOptions extends SecureContextOptions {
680
- requestCert?: boolean;
681
- rejectUnauthorized?: boolean;
682
- ALPNProtocols?: string[];
683
- SNICallback?: SNICallback;
684
- }
685
-
686
- /**
687
- * TLSServer accepts incoming TCP connections and upgrades each to TLS via
688
- * `Gio.TlsServerConnection`. Supports mTLS via `requestCert`+`rejectUnauthorized`,
689
- * SNI selection via `addContext`/`SNICallback`, and ALPN negotiation.
690
- */
691
- export class TLSServer extends Server {
692
- private _tlsCertificate: Gio.TlsCertificate | null = null;
693
- private _tlsOptions: TlsServerOptions;
694
- private _sniContexts = new Map<string, SecureContext>();
695
- /** @internal — exposed for tests. */
696
- _secureContext: SecureContext;
697
-
698
- constructor(options?: TlsServerOptions, secureConnectionListener?: (socket: TLSSocket) => void) {
699
- super();
700
- this._tlsOptions = options ?? {};
701
- this._secureContext = createSecureContext(this._tlsOptions);
702
- this._tlsCertificate = this._secureContext.certificate;
703
-
704
- if (secureConnectionListener) {
705
- this.on('secureConnection', secureConnectionListener);
706
- }
707
-
708
- if (this._tlsOptions.cert && !this._tlsCertificate) {
709
- // PEM provided but failed to parse — emit error asynchronously.
710
- deferEmit(this as unknown as NetServer, 'error', createNodeError(
711
- new Error('Failed to parse TLS certificate'), 'createServer', {},
712
- ));
713
- }
714
- }
715
-
716
- /**
717
- * Add an additional context for SNI (Server Name Indication). Uses RFC 6125
718
- * matching against the requested server name.
719
- */
720
- addContext(hostname: string, context: SecureContextOptions): void {
721
- try {
722
- const ctx = createSecureContext(context);
723
- this._sniContexts.set(hostname.toLowerCase(), ctx);
724
- } catch (err: unknown) {
725
- this.emit('error', createNodeError(err, 'addContext', {}));
726
- }
727
- }
728
-
729
- /**
730
- * Resolve a SecureContext for the given server name. Order:
731
- * 1. exact match in `_sniContexts`
732
- * 2. RFC 6125 wildcard match in `_sniContexts`
733
- * 3. SNICallback (if provided)
734
- * 4. fall through to the server's default context
735
- */
736
- private _resolveSniContext(servername: string | null, done: (ctx: SecureContext) => void): void {
737
- const fallback = this._secureContext;
738
- if (!servername) { done(fallback); return; }
739
- const lower = servername.toLowerCase();
740
- const exact = this._sniContexts.get(lower);
741
- if (exact) { done(exact); return; }
742
- const hostParts = splitHost(lower);
743
- for (const [pattern, ctx] of this._sniContexts) {
744
- if (checkHostMatch(hostParts, pattern)) { done(ctx); return; }
745
- }
746
- if (this._tlsOptions.SNICallback) {
747
- try {
748
- this._tlsOptions.SNICallback(servername, (err: Error | null, ctx?: SecureContext) => {
749
- if (err || !ctx) { done(fallback); return; }
750
- done(ctx);
751
- });
752
- return;
753
- } catch {
754
- done(fallback);
755
- return;
756
- }
757
- }
758
- done(fallback);
759
- }
760
-
761
- listen(...args: unknown[]): this {
762
- this.on('connection', (socket: Socket) => {
763
- this._upgradeTls(socket);
764
- });
765
- type ListenArgs = Parameters<NetServer['listen']>;
766
- return (super.listen as (...a: ListenArgs) => this)(...(args as unknown as ListenArgs));
767
- }
768
-
769
- /** Upgrade a raw TCP socket to TLS using Gio.TlsServerConnection. */
770
- private _upgradeTls(socket: Socket): void {
771
- const rawConnection = (socket as unknown as SocketInternals)._connection;
772
- if (!rawConnection) {
773
- const err = new Error('Cannot upgrade socket: no underlying connection');
774
- this.emit('tlsClientError', err, socket);
775
- socket.destroy();
776
- return;
777
- }
778
-
779
- if (!this._tlsCertificate && this._sniContexts.size === 0 && !this._tlsOptions.SNICallback) {
780
- const err = new Error('TLS server has no certificate configured');
781
- this.emit('tlsClientError', err, socket);
782
- socket.destroy();
783
- return;
784
- }
785
-
786
- // SNI: Gio does not surface ClientHello server_name to JS pre-handshake;
787
- // we use the server's default certificate. Real-world SNI multiplexing
788
- // is documented in STATUS.md "Open TODOs".
789
- this._resolveSniContext(null, (ctx) => {
790
- const certificate = ctx.certificate ?? this._tlsCertificate;
791
- if (!certificate) {
792
- const err = new Error('SNI resolution returned no certificate');
793
- this.emit('tlsClientError', err, socket);
794
- socket.destroy();
795
- return;
796
- }
797
-
798
- try {
799
- const tlsConn = Gio.TlsServerConnection.new(
800
- rawConnection as unknown as Gio.IOStream,
801
- certificate,
802
- );
803
-
804
- // Client-cert / mTLS configuration
805
- if (this._tlsOptions.requestCert) {
806
- tlsConn.authenticationMode = this._tlsOptions.rejectUnauthorized !== false
807
- ? Gio.TlsAuthenticationMode.REQUIRED
808
- : Gio.TlsAuthenticationMode.REQUESTED;
809
- } else {
810
- tlsConn.authenticationMode = Gio.TlsAuthenticationMode.NONE;
811
- }
812
-
813
- const requireClientCert = !!this._tlsOptions.requestCert
814
- && this._tlsOptions.rejectUnauthorized !== false;
815
- const clientCAs = this._secureContext.caCertificates;
816
-
817
- tlsConn.connect('accept-certificate', (
818
- _conn: Gio.TlsConnection,
819
- peerCert: Gio.TlsCertificate,
820
- _errors: Gio.TlsCertificateFlags,
821
- ): boolean => {
822
- if (!requireClientCert) return true;
823
- if (clientCAs.length === 0) return false;
824
- for (const ca of clientCAs) {
825
- try {
826
- const flags = peerCert.verify(null, ca);
827
- if (flags === Gio.TlsCertificateFlags.NO_FLAGS) return true;
828
- } catch { /* try next */ }
829
- }
830
- return false;
831
- });
832
-
833
- // ALPN
834
- if (this._tlsOptions.ALPNProtocols && this._tlsOptions.ALPNProtocols.length > 0) {
835
- try {
836
- tlsConn.set_advertised_protocols(this._tlsOptions.ALPNProtocols);
837
- } catch {
838
- // ALPN may not be supported
839
- }
840
- }
841
-
842
- const cancellable = new Gio.Cancellable();
843
- tlsConn.handshake_async(
844
- GLib.PRIORITY_DEFAULT,
845
- cancellable,
846
- (_source: Gio.TlsConnection | null, asyncResult: Gio.AsyncResult) => {
847
- try {
848
- tlsConn.handshake_finish(asyncResult);
849
-
850
- const tlsSocket = new TLSSocket();
851
- tlsSocket.encrypted = true;
852
- tlsSocket.authorized = true;
853
- tlsSocket._secureContext = ctx;
854
- tlsSocket._setupTlsStreams(tlsConn);
855
- tlsSocket.alpnProtocol = tlsSocket.getAlpnProtocol();
856
-
857
- const internals = tlsSocket as unknown as SocketInternals;
858
- internals._startReading();
859
-
860
- this.emit('secureConnection', tlsSocket);
861
- } catch (err: unknown) {
862
- const nodeErr = createNodeError(err, 'handshake', {});
863
- this.emit('tlsClientError', nodeErr, socket);
864
- socket.destroy();
865
- }
866
- },
867
- );
868
- } catch (err: unknown) {
869
- const nodeErr = createNodeError(err, 'tls_wrap', {});
870
- this.emit('tlsClientError', nodeErr, socket);
871
- socket.destroy();
872
- }
873
- });
874
- }
875
- }
876
-
877
- /**
878
- * Create a TLS server.
879
- */
880
- export function createServer(options?: TlsServerOptions, secureConnectionListener?: (socket: TLSSocket) => void): TLSServer;
881
- export function createServer(secureConnectionListener?: (socket: TLSSocket) => void): TLSServer;
882
- export function createServer(
883
- optionsOrListener?: TlsServerOptions | ((socket: TLSSocket) => void),
884
- secureConnectionListener?: (socket: TLSSocket) => void,
885
- ): TLSServer {
886
- if (typeof optionsOrListener === 'function') {
887
- return new TLSServer(undefined, optionsOrListener);
888
- }
889
- return new TLSServer(optionsOrListener, secureConnectionListener);
890
- }
891
-
892
- export { TLSServer as Server };
893
-
894
- const tlsExports = {
895
- TLSSocket,
896
- TLSServer,
897
- Server: TLSServer,
898
- connect,
899
- createServer,
900
- createSecureContext,
901
- checkServerIdentity,
902
- getCiphers,
903
- rootCertificates,
904
- DEFAULT_MIN_VERSION,
905
- DEFAULT_MAX_VERSION,
906
- DEFAULT_CIPHERS,
907
- };
908
-
909
- export default tlsExports;