@gjsify/tls 0.3.20 → 0.4.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.
package/src/index.ts CHANGED
@@ -1,9 +1,51 @@
1
- // Reference: Node.js lib/tls.js
2
- // Reimplemented for GJS using Gio.TlsClientConnection / Gio.TlsServerConnection
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.
3
44
 
4
45
  import Gio from '@girs/gio-2.0';
5
46
  import GLib from '@girs/glib-2.0';
6
47
  import { Socket, Server } from 'node:net';
48
+ import type { Server as NetServer } from 'node:net';
7
49
  import { createNodeError, deferEmit } from '@gjsify/utils';
8
50
 
9
51
  export const DEFAULT_MIN_VERSION = 'TLSv1.2';
@@ -18,12 +60,201 @@ export function getCiphers(): string[] {
18
60
  ];
19
61
  }
20
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
+
21
132
  export interface PeerCertificate {
22
- subject?: { CN?: string | string[]; [key: string]: unknown };
133
+ subject?: CertSubject;
134
+ issuer?: CertSubject;
23
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;
24
143
  [key: string]: unknown;
25
144
  }
26
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
+
27
258
  /** Removes a trailing dot from a fully-qualified domain name. */
28
259
  function unfqdn(host: string): string {
29
260
  return host.endsWith('.') ? host.slice(0, -1) : host;
@@ -34,27 +265,67 @@ function splitHost(host: string): string[] {
34
265
  return unfqdn(host).toLowerCase().split('.');
35
266
  }
36
267
 
37
- /** Wildcard-match a hostname part list against a pattern (supports leading *). */
38
- function checkWildcard(hostParts: string[], pattern: string): boolean {
39
- const patParts = splitHost(pattern);
40
- if (patParts.length !== hostParts.length) return false;
41
- // Wildcard only valid in the leftmost label
42
- if (patParts[0] === '*') {
43
- // e.g. *.example.com — match any single label against *
44
- return patParts.slice(1).join('.') === hostParts.slice(1).join('.');
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;
45
300
  }
46
- return patParts.every((p, i) => p === hostParts[i]);
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';
47
317
  }
48
318
 
49
319
  /**
50
320
  * Verifies that the certificate `cert` is valid for `hostname`.
51
- * Returns an Error if the check fails, or undefined on success.
321
+ * Returns an Error (with code 'ERR_TLS_CERT_ALTNAME_INVALID') if the check
322
+ * fails, or `undefined` on success.
52
323
  *
53
- * Reference: Node.js lib/tls.js exports.checkServerIdentity
324
+ * Reference: Node.js lib/tls.js exports.checkServerIdentity (RFC 6125 §6.4.3).
54
325
  */
55
- export function checkServerIdentity(hostname: string, cert: PeerCertificate): Error | undefined {
326
+ export function checkServerIdentity(hostname: string, cert: PeerCertificate): CertAltNameError | undefined {
56
327
  const subject = cert.subject;
57
- const altNames = cert.subjectaltname as string | undefined;
328
+ const altNames = cert.subjectaltname;
58
329
  const dnsNames: string[] = [];
59
330
  const ips: string[] = [];
60
331
 
@@ -63,11 +334,8 @@ export function checkServerIdentity(hostname: string, cert: PeerCertificate): Er
63
334
  if (altNames) {
64
335
  const parts = altNames.split(', ');
65
336
  for (const name of parts) {
66
- if (name.startsWith('DNS:')) {
67
- dnsNames.push(name.slice(4));
68
- } else if (name.startsWith('IP Address:')) {
69
- ips.push(name.slice(11).trim());
70
- }
337
+ if (name.startsWith('DNS:')) dnsNames.push(name.slice(4));
338
+ else if (name.startsWith('IP Address:')) ips.push(name.slice(11).trim());
71
339
  }
72
340
  }
73
341
 
@@ -76,49 +344,87 @@ export function checkServerIdentity(hostname: string, cert: PeerCertificate): Er
76
344
 
77
345
  hostname = unfqdn(hostname);
78
346
 
79
- // Check numeric IP addresses
80
347
  const isIPv4 = /^(\d{1,3}\.){3}\d{1,3}$/.test(hostname);
81
348
  const isIPv6 = hostname.includes(':');
82
349
  if (isIPv4 || isIPv6) {
83
350
  valid = ips.some(ip => ip.toLowerCase() === hostname.toLowerCase());
84
- if (!valid)
351
+ if (!valid) {
85
352
  reason = `IP: ${hostname} is not in the cert's list: ${ips.join(', ')}`;
353
+ }
86
354
  } else if (dnsNames.length > 0 || subject?.CN) {
87
355
  const hostParts = splitHost(hostname);
88
356
 
89
357
  if (dnsNames.length > 0) {
90
- valid = dnsNames.some(pattern => checkWildcard(hostParts, pattern));
91
- if (!valid)
358
+ valid = dnsNames.some(pattern => checkHostMatch(hostParts, pattern.trim()));
359
+ if (!valid) {
92
360
  reason = `Host: ${hostname}. is not in the cert's altnames: ${altNames}`;
361
+ }
93
362
  } else {
94
- const cn = subject!.CN as string | string[];
363
+ const cn = subject?.CN;
95
364
  if (Array.isArray(cn)) {
96
- valid = cn.some(c => checkWildcard(hostParts, c));
365
+ valid = cn.some(c => checkHostMatch(hostParts, c));
97
366
  } else if (cn) {
98
- valid = checkWildcard(hostParts, cn);
367
+ valid = checkHostMatch(hostParts, cn);
99
368
  }
100
- if (!valid)
369
+ if (!valid) {
101
370
  reason = `Host: ${hostname}. is not cert's CN: ${cn}`;
371
+ }
102
372
  }
103
373
  } else {
104
374
  reason = 'Cert does not contain a DNS name';
105
375
  }
106
376
 
107
377
  if (!valid) {
108
- const err = new Error(reason) as NodeJS.ErrnoException & { reason: string; host: string; cert: PeerCertificate };
378
+ const err = new Error(reason) as CertAltNameError;
109
379
  err.reason = reason;
110
380
  err.host = hostname;
111
381
  err.cert = cert;
382
+ err.code = 'ERR_TLS_CERT_ALTNAME_INVALID';
112
383
  return err;
113
384
  }
114
385
  return undefined;
115
386
  }
116
387
 
388
+ // ============================================================================
389
+ // SecureContext
390
+ // ============================================================================
391
+
117
392
  export interface SecureContextOptions {
118
- ca?: string | Buffer | Array<string | Buffer>;
119
- cert?: string | Buffer | Array<string | Buffer>;
120
- key?: string | Buffer | Array<string | Buffer>;
393
+ ca?: PemInput;
394
+ cert?: PemInput;
395
+ key?: PemInput;
396
+ passphrase?: string;
121
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;
122
428
  }
123
429
 
124
430
  export interface TlsConnectOptions extends SecureContextOptions {
@@ -127,6 +433,20 @@ export interface TlsConnectOptions extends SecureContextOptions {
127
433
  socket?: Socket;
128
434
  servername?: string;
129
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;
130
450
  }
131
451
 
132
452
  /**
@@ -137,11 +457,14 @@ export class TLSSocket extends Socket {
137
457
  authorized = false;
138
458
  authorizationError?: string;
139
459
  alpnProtocol: string | false = false;
460
+ servername: string | undefined;
140
461
 
141
462
  /** @internal */
142
463
  _tlsConnection: Gio.TlsConnection | null = null;
464
+ /** @internal — preserved for diagnostics + future cert-chain verification. */
465
+ _secureContext: SecureContext | null = null;
143
466
 
144
- constructor(socket?: Socket, options?: SecureContextOptions) {
467
+ constructor(_socket?: Socket, _options?: SecureContextOptions) {
145
468
  super();
146
469
  }
147
470
 
@@ -151,25 +474,23 @@ export class TLSSocket extends Socket {
151
474
  */
152
475
  _setupTlsStreams(tlsConn: Gio.TlsConnection): void {
153
476
  this._tlsConnection = tlsConn;
154
- // Replace the underlying I/O streams with the TLS connection's streams
155
- (this as any)._inputStream = tlsConn.get_input_stream();
156
- (this as any)._outputStream = tlsConn.get_output_stream();
157
- // Store connection for teardown
158
- (this as any)._connection = tlsConn as unknown as Gio.SocketConnection;
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;
159
481
  }
160
482
 
161
- /** Get the peer certificate info. */
162
- getPeerCertificate(_detailed?: boolean): any {
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 {
163
489
  if (!this._tlsConnection) return {};
164
490
  try {
165
491
  const cert = this._tlsConnection.get_peer_certificate();
166
492
  if (!cert) return {};
167
- return {
168
- subject: {},
169
- issuer: {},
170
- valid_from: '',
171
- valid_to: '',
172
- };
493
+ return tlsCertToPeerCert(cert, detailed);
173
494
  } catch {
174
495
  return {};
175
496
  }
@@ -192,7 +513,7 @@ export class TLSSocket extends Socket {
192
513
  }
193
514
  }
194
515
 
195
- /** Get the cipher info. */
516
+ /** Get the negotiated cipher suite name + version. */
196
517
  getCipher(): { name: string; version: string } | null {
197
518
  if (!this._tlsConnection) return null;
198
519
  try {
@@ -203,7 +524,7 @@ export class TLSSocket extends Socket {
203
524
  }
204
525
  }
205
526
 
206
- /** Get the negotiated ALPN protocol. */
527
+ /** Get the negotiated ALPN protocol (or false if none). */
207
528
  getAlpnProtocol(): string | false {
208
529
  if (!this._tlsConnection) return false;
209
530
  try {
@@ -233,59 +554,94 @@ export function connect(options: TlsConnectOptions, callback?: () => void): TLSS
233
554
  const servername = options.servername || host;
234
555
  const rejectUnauthorized = options.rejectUnauthorized !== false;
235
556
 
236
- // Listen for TCP connect, then upgrade to TLS
557
+ const ctx = options.secureContext ?? createSecureContext(options);
558
+ socket._secureContext = ctx;
559
+ socket.servername = servername;
560
+ const customCheckServerIdentity = options.checkServerIdentity;
561
+
237
562
  socket.once('connect', () => {
238
- const rawConnection: Gio.SocketConnection | null = (socket as any)._connection;
563
+ const rawConnection = (socket as unknown as SocketInternals)._connection;
239
564
  if (!rawConnection) {
240
565
  socket.destroy(new Error('No underlying connection for TLS upgrade'));
241
566
  return;
242
567
  }
243
568
 
244
569
  try {
245
- // Create TLS client connection wrapping the raw TCP connection
246
570
  const connectable = Gio.NetworkAddress.new(servername, port);
247
571
  const tlsConn = Gio.TlsClientConnection.new(
248
- rawConnection as Gio.IOStream,
572
+ rawConnection as unknown as Gio.IOStream,
249
573
  connectable,
250
- ) as Gio.TlsClientConnection;
574
+ );
251
575
 
252
- // Set server identity for certificate validation
253
576
  tlsConn.set_server_identity(connectable);
254
577
 
255
- // Set ALPN protocols if provided
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
256
589
  if (options.ALPNProtocols && options.ALPNProtocols.length > 0) {
257
590
  try {
258
- (tlsConn as Gio.TlsClientConnection).set_advertised_protocols(options.ALPNProtocols);
591
+ tlsConn.set_advertised_protocols(options.ALPNProtocols);
259
592
  } catch {
260
- // ALPN may not be supported on all GnuTLS versions
593
+ // ALPN may not be supported
261
594
  }
262
595
  }
263
596
 
264
- // Handle certificate validation
265
- if (!rejectUnauthorized) {
266
- (tlsConn as Gio.TlsConnection).connect(
267
- 'accept-certificate',
268
- () => true,
269
- );
270
- }
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
+ });
271
616
 
272
- // Perform TLS handshake asynchronously
273
617
  const cancellable = new Gio.Cancellable();
274
- (tlsConn as Gio.TlsConnection).handshake_async(
618
+ tlsConn.handshake_async(
275
619
  GLib.PRIORITY_DEFAULT,
276
620
  cancellable,
277
621
  (_source: Gio.TlsConnection | null, asyncResult: Gio.AsyncResult) => {
278
622
  try {
279
- (tlsConn as Gio.TlsConnection).handshake_finish(asyncResult);
623
+ tlsConn.handshake_finish(asyncResult);
280
624
  socket.authorized = true;
281
- socket._setupTlsStreams(tlsConn as Gio.TlsConnection);
282
-
283
- // Get ALPN result
625
+ socket._setupTlsStreams(tlsConn);
284
626
  socket.alpnProtocol = socket.getAlpnProtocol();
285
627
 
286
- // Restart reading with TLS streams
287
- (socket as any)._reading = false;
288
- (socket as any)._startReading();
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();
289
645
 
290
646
  socket.emit('secureConnect');
291
647
  } catch (err: unknown) {
@@ -294,8 +650,7 @@ export function connect(options: TlsConnectOptions, callback?: () => void): TLSS
294
650
  if (rejectUnauthorized) {
295
651
  socket.destroy(err instanceof Error ? err : new Error(String(err)));
296
652
  } else {
297
- // Still emit secureConnect but with authorized=false
298
- socket._setupTlsStreams(tlsConn as Gio.TlsConnection);
653
+ socket._setupTlsStreams(tlsConn);
299
654
  socket.emit('secureConnect');
300
655
  }
301
656
  }
@@ -306,95 +661,114 @@ export function connect(options: TlsConnectOptions, callback?: () => void): TLSS
306
661
  }
307
662
  });
308
663
 
309
- // Initiate TCP connection
310
664
  socket.connect({ port, host });
311
665
  return socket;
312
666
  }
313
667
 
314
- /**
315
- * Create a TLS secure context.
316
- */
317
- export function createSecureContext(options?: SecureContextOptions): { context: any } {
318
- return { context: options || {} };
319
- }
320
-
321
668
  export const rootCertificates: string[] = [];
322
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
+
323
679
  export interface TlsServerOptions extends SecureContextOptions {
324
680
  requestCert?: boolean;
325
681
  rejectUnauthorized?: boolean;
326
682
  ALPNProtocols?: string[];
683
+ SNICallback?: SNICallback;
327
684
  }
328
685
 
329
686
  /**
330
- * Build a Gio.TlsCertificate from PEM cert+key strings.
331
- */
332
- function buildGioCertificate(cert: string | Buffer | Array<string | Buffer>, key?: string | Buffer | Array<string | Buffer>): Gio.TlsCertificate {
333
- const certStr = Array.isArray(cert)
334
- ? cert.map((c) => (typeof c === 'string' ? c : c.toString('utf-8'))).join('\n')
335
- : typeof cert === 'string' ? cert : cert.toString('utf-8');
336
-
337
- const keyStr = key
338
- ? Array.isArray(key)
339
- ? key.map((k) => (typeof k === 'string' ? k : k.toString('utf-8'))).join('\n')
340
- : typeof key === 'string' ? key : key.toString('utf-8')
341
- : '';
342
-
343
- const pem = keyStr ? `${certStr}\n${keyStr}` : certStr;
344
- return Gio.TlsCertificate.new_from_pem(pem, pem.length);
345
- }
346
-
347
- /**
348
- * TLSServer wraps a net.Server to accept TLS connections.
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.
349
690
  */
350
691
  export class TLSServer extends Server {
351
692
  private _tlsCertificate: Gio.TlsCertificate | null = null;
352
693
  private _tlsOptions: TlsServerOptions;
353
- private _sniContexts = new Map<string, Gio.TlsCertificate>();
694
+ private _sniContexts = new Map<string, SecureContext>();
695
+ /** @internal — exposed for tests. */
696
+ _secureContext: SecureContext;
354
697
 
355
698
  constructor(options?: TlsServerOptions, secureConnectionListener?: (socket: TLSSocket) => void) {
356
699
  super();
357
- this._tlsOptions = options || {};
700
+ this._tlsOptions = options ?? {};
701
+ this._secureContext = createSecureContext(this._tlsOptions);
702
+ this._tlsCertificate = this._secureContext.certificate;
358
703
 
359
704
  if (secureConnectionListener) {
360
705
  this.on('secureConnection', secureConnectionListener);
361
706
  }
362
707
 
363
- if (this._tlsOptions.cert) {
364
- try {
365
- this._tlsCertificate = buildGioCertificate(this._tlsOptions.cert, this._tlsOptions.key);
366
- } catch (err: unknown) {
367
- deferEmit(this, 'error', createNodeError(err, 'createServer', {}));
368
- }
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
+ ));
369
713
  }
370
714
  }
371
715
 
372
716
  /**
373
- * Add a context for SNI (Server Name Indication).
717
+ * Add an additional context for SNI (Server Name Indication). Uses RFC 6125
718
+ * matching against the requested server name.
374
719
  */
375
720
  addContext(hostname: string, context: SecureContextOptions): void {
376
- if (context.cert) {
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) {
377
747
  try {
378
- const cert = buildGioCertificate(context.cert, context.key);
379
- this._sniContexts.set(hostname, cert);
380
- } catch (err: unknown) {
381
- this.emit('error', createNodeError(err, 'addContext', {}));
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;
382
756
  }
383
757
  }
758
+ done(fallback);
384
759
  }
385
760
 
386
761
  listen(...args: unknown[]): this {
387
762
  this.on('connection', (socket: Socket) => {
388
763
  this._upgradeTls(socket);
389
764
  });
390
- return super.listen(...(args as [any]));
765
+ type ListenArgs = Parameters<NetServer['listen']>;
766
+ return (super.listen as (...a: ListenArgs) => this)(...(args as unknown as ListenArgs));
391
767
  }
392
768
 
393
- /**
394
- * Upgrade a raw TCP socket to TLS using Gio.TlsServerConnection.
395
- */
769
+ /** Upgrade a raw TCP socket to TLS using Gio.TlsServerConnection. */
396
770
  private _upgradeTls(socket: Socket): void {
397
- const rawConnection: Gio.SocketConnection | null = (socket as any)._connection;
771
+ const rawConnection = (socket as unknown as SocketInternals)._connection;
398
772
  if (!rawConnection) {
399
773
  const err = new Error('Cannot upgrade socket: no underlying connection');
400
774
  this.emit('tlsClientError', err, socket);
@@ -402,78 +776,101 @@ export class TLSServer extends Server {
402
776
  return;
403
777
  }
404
778
 
405
- if (!this._tlsCertificate) {
779
+ if (!this._tlsCertificate && this._sniContexts.size === 0 && !this._tlsOptions.SNICallback) {
406
780
  const err = new Error('TLS server has no certificate configured');
407
781
  this.emit('tlsClientError', err, socket);
408
782
  socket.destroy();
409
783
  return;
410
784
  }
411
785
 
412
- try {
413
- const tlsConn = Gio.TlsServerConnection.new(
414
- rawConnection as Gio.IOStream,
415
- this._tlsCertificate,
416
- );
417
-
418
- // Configure client authentication
419
- if (this._tlsOptions.requestCert) {
420
- tlsConn.authenticationMode = this._tlsOptions.rejectUnauthorized !== false
421
- ? Gio.TlsAuthenticationMode.REQUIRED
422
- : Gio.TlsAuthenticationMode.REQUESTED;
423
- } else {
424
- tlsConn.authenticationMode = Gio.TlsAuthenticationMode.NONE;
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;
425
796
  }
426
797
 
427
- if (this._tlsOptions.rejectUnauthorized === false) {
428
- (tlsConn as Gio.TlsConnection).connect(
429
- 'accept-certificate',
430
- () => true,
798
+ try {
799
+ const tlsConn = Gio.TlsServerConnection.new(
800
+ rawConnection as unknown as Gio.IOStream,
801
+ certificate,
431
802
  );
432
- }
433
803
 
434
- // Set ALPN protocols
435
- if (this._tlsOptions.ALPNProtocols && this._tlsOptions.ALPNProtocols.length > 0) {
436
- try {
437
- (tlsConn as any).set_advertised_protocols(this._tlsOptions.ALPNProtocols);
438
- } catch {
439
- // ALPN may not be supported
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;
440
811
  }
441
- }
442
-
443
- // Perform TLS handshake
444
- const cancellable = new Gio.Cancellable();
445
- (tlsConn as Gio.TlsConnection).handshake_async(
446
- GLib.PRIORITY_DEFAULT,
447
- cancellable,
448
- (_source: Gio.TlsConnection | null, asyncResult: Gio.AsyncResult) => {
449
- try {
450
- (tlsConn as Gio.TlsConnection).handshake_finish(asyncResult);
451
812
 
452
- // Create TLSSocket with TLS I/O streams wired up
453
- const tlsSocket = new TLSSocket();
454
- tlsSocket.encrypted = true;
455
- tlsSocket.authorized = true;
456
- tlsSocket._setupTlsStreams(tlsConn as Gio.TlsConnection);
457
-
458
- // Get ALPN result
459
- tlsSocket.alpnProtocol = tlsSocket.getAlpnProtocol();
460
-
461
- // Start reading on the TLS streams
462
- (tlsSocket as any)._startReading();
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
+ });
463
832
 
464
- this.emit('secureConnection', tlsSocket);
465
- } catch (err: unknown) {
466
- const nodeErr = createNodeError(err, 'handshake', {});
467
- this.emit('tlsClientError', nodeErr, socket);
468
- socket.destroy();
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
469
839
  }
470
- },
471
- );
472
- } catch (err: unknown) {
473
- const nodeErr = createNodeError(err, 'tls_wrap', {});
474
- this.emit('tlsClientError', nodeErr, socket);
475
- socket.destroy();
476
- }
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
+ });
477
874
  }
478
875
  }
479
876
 
@@ -494,7 +891,7 @@ export function createServer(
494
891
 
495
892
  export { TLSServer as Server };
496
893
 
497
- export default {
894
+ const tlsExports = {
498
895
  TLSSocket,
499
896
  TLSServer,
500
897
  Server: TLSServer,
@@ -508,3 +905,5 @@ export default {
508
905
  DEFAULT_MAX_VERSION,
509
906
  DEFAULT_CIPHERS,
510
907
  };
908
+
909
+ export default tlsExports;