@gjsify/tls 0.1.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 ADDED
@@ -0,0 +1,510 @@
1
+ // Reference: Node.js lib/tls.js
2
+ // Reimplemented for GJS using Gio.TlsClientConnection / Gio.TlsServerConnection
3
+
4
+ import Gio from '@girs/gio-2.0';
5
+ import GLib from '@girs/glib-2.0';
6
+ import { Socket, Server } from 'node:net';
7
+ import { createNodeError, deferEmit } from '@gjsify/utils';
8
+
9
+ export const DEFAULT_MIN_VERSION = 'TLSv1.2';
10
+ export const DEFAULT_MAX_VERSION = 'TLSv1.3';
11
+ 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';
12
+
13
+ /** Returns a list of supported TLS cipher names (subset; implementation-defined). */
14
+ export function getCiphers(): string[] {
15
+ return [
16
+ 'aes-128-gcm', 'aes-256-gcm', 'chacha20-poly1305',
17
+ 'aes-128-cbc', 'aes-256-cbc',
18
+ ];
19
+ }
20
+
21
+ export interface PeerCertificate {
22
+ subject?: { CN?: string | string[]; [key: string]: unknown };
23
+ subjectaltname?: string;
24
+ [key: string]: unknown;
25
+ }
26
+
27
+ /** Removes a trailing dot from a fully-qualified domain name. */
28
+ function unfqdn(host: string): string {
29
+ return host.endsWith('.') ? host.slice(0, -1) : host;
30
+ }
31
+
32
+ /** Splits a hostname into parts, lower-cased, after removing trailing dots. */
33
+ function splitHost(host: string): string[] {
34
+ return unfqdn(host).toLowerCase().split('.');
35
+ }
36
+
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('.');
45
+ }
46
+ return patParts.every((p, i) => p === hostParts[i]);
47
+ }
48
+
49
+ /**
50
+ * Verifies that the certificate `cert` is valid for `hostname`.
51
+ * Returns an Error if the check fails, or undefined on success.
52
+ *
53
+ * Reference: Node.js lib/tls.js exports.checkServerIdentity
54
+ */
55
+ export function checkServerIdentity(hostname: string, cert: PeerCertificate): Error | undefined {
56
+ const subject = cert.subject;
57
+ const altNames = cert.subjectaltname as string | undefined;
58
+ const dnsNames: string[] = [];
59
+ const ips: string[] = [];
60
+
61
+ hostname = String(hostname);
62
+
63
+ if (altNames) {
64
+ const parts = altNames.split(', ');
65
+ 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
+ }
71
+ }
72
+ }
73
+
74
+ let valid = false;
75
+ let reason = 'Unknown reason';
76
+
77
+ hostname = unfqdn(hostname);
78
+
79
+ // Check numeric IP addresses
80
+ const isIPv4 = /^(\d{1,3}\.){3}\d{1,3}$/.test(hostname);
81
+ const isIPv6 = hostname.includes(':');
82
+ if (isIPv4 || isIPv6) {
83
+ valid = ips.some(ip => ip.toLowerCase() === hostname.toLowerCase());
84
+ if (!valid)
85
+ reason = `IP: ${hostname} is not in the cert's list: ${ips.join(', ')}`;
86
+ } else if (dnsNames.length > 0 || subject?.CN) {
87
+ const hostParts = splitHost(hostname);
88
+
89
+ if (dnsNames.length > 0) {
90
+ valid = dnsNames.some(pattern => checkWildcard(hostParts, pattern));
91
+ if (!valid)
92
+ reason = `Host: ${hostname}. is not in the cert's altnames: ${altNames}`;
93
+ } else {
94
+ const cn = subject!.CN as string | string[];
95
+ if (Array.isArray(cn)) {
96
+ valid = cn.some(c => checkWildcard(hostParts, c));
97
+ } else if (cn) {
98
+ valid = checkWildcard(hostParts, cn);
99
+ }
100
+ if (!valid)
101
+ reason = `Host: ${hostname}. is not cert's CN: ${cn}`;
102
+ }
103
+ } else {
104
+ reason = 'Cert does not contain a DNS name';
105
+ }
106
+
107
+ if (!valid) {
108
+ const err = new Error(reason) as NodeJS.ErrnoException & { reason: string; host: string; cert: PeerCertificate };
109
+ err.reason = reason;
110
+ err.host = hostname;
111
+ err.cert = cert;
112
+ return err;
113
+ }
114
+ return undefined;
115
+ }
116
+
117
+ export interface SecureContextOptions {
118
+ ca?: string | Buffer | Array<string | Buffer>;
119
+ cert?: string | Buffer | Array<string | Buffer>;
120
+ key?: string | Buffer | Array<string | Buffer>;
121
+ rejectUnauthorized?: boolean;
122
+ }
123
+
124
+ export interface TlsConnectOptions extends SecureContextOptions {
125
+ host?: string;
126
+ port?: number;
127
+ socket?: Socket;
128
+ servername?: string;
129
+ ALPNProtocols?: string[];
130
+ }
131
+
132
+ /**
133
+ * TLSSocket wraps a net.Socket with TLS via Gio.TlsConnection.
134
+ */
135
+ export class TLSSocket extends Socket {
136
+ encrypted = true;
137
+ authorized = false;
138
+ authorizationError?: string;
139
+ alpnProtocol: string | false = false;
140
+
141
+ /** @internal */
142
+ _tlsConnection: Gio.TlsConnection | null = null;
143
+
144
+ constructor(socket?: Socket, options?: SecureContextOptions) {
145
+ super();
146
+ }
147
+
148
+ /**
149
+ * @internal Wire the TLS connection's I/O streams into this socket
150
+ * so that read/write operations go through the encrypted channel.
151
+ */
152
+ _setupTlsStreams(tlsConn: Gio.TlsConnection): void {
153
+ 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;
159
+ }
160
+
161
+ /** Get the peer certificate info. */
162
+ getPeerCertificate(_detailed?: boolean): any {
163
+ if (!this._tlsConnection) return {};
164
+ try {
165
+ const cert = this._tlsConnection.get_peer_certificate();
166
+ if (!cert) return {};
167
+ return {
168
+ subject: {},
169
+ issuer: {},
170
+ valid_from: '',
171
+ valid_to: '',
172
+ };
173
+ } catch {
174
+ return {};
175
+ }
176
+ }
177
+
178
+ /** Get the negotiated TLS protocol version. */
179
+ getProtocol(): string | null {
180
+ if (!this._tlsConnection) return null;
181
+ try {
182
+ const proto = this._tlsConnection.get_protocol_version();
183
+ switch (proto) {
184
+ case Gio.TlsProtocolVersion.TLS_1_0: return 'TLSv1';
185
+ case Gio.TlsProtocolVersion.TLS_1_1: return 'TLSv1.1';
186
+ case Gio.TlsProtocolVersion.TLS_1_2: return 'TLSv1.2';
187
+ case Gio.TlsProtocolVersion.TLS_1_3: return 'TLSv1.3';
188
+ default: return null;
189
+ }
190
+ } catch {
191
+ return null;
192
+ }
193
+ }
194
+
195
+ /** Get the cipher info. */
196
+ getCipher(): { name: string; version: string } | null {
197
+ if (!this._tlsConnection) return null;
198
+ try {
199
+ const name = this._tlsConnection.get_ciphersuite_name();
200
+ return { name: name || 'unknown', version: this.getProtocol() || 'unknown' };
201
+ } catch {
202
+ return null;
203
+ }
204
+ }
205
+
206
+ /** Get the negotiated ALPN protocol. */
207
+ getAlpnProtocol(): string | false {
208
+ if (!this._tlsConnection) return false;
209
+ try {
210
+ const proto = this._tlsConnection.get_negotiated_protocol();
211
+ return proto || false;
212
+ } catch {
213
+ return false;
214
+ }
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Create a TLS client connection.
220
+ *
221
+ * Connects via TCP first (using net.Socket.connect), then upgrades
222
+ * the connection to TLS using Gio.TlsClientConnection.
223
+ */
224
+ export function connect(options: TlsConnectOptions, callback?: () => void): TLSSocket {
225
+ const socket = new TLSSocket(undefined, options);
226
+
227
+ if (callback) {
228
+ socket.once('secureConnect', callback);
229
+ }
230
+
231
+ const port = options.port || 443;
232
+ const host = options.host || 'localhost';
233
+ const servername = options.servername || host;
234
+ const rejectUnauthorized = options.rejectUnauthorized !== false;
235
+
236
+ // Listen for TCP connect, then upgrade to TLS
237
+ socket.once('connect', () => {
238
+ const rawConnection: Gio.SocketConnection | null = (socket as any)._connection;
239
+ if (!rawConnection) {
240
+ socket.destroy(new Error('No underlying connection for TLS upgrade'));
241
+ return;
242
+ }
243
+
244
+ try {
245
+ // Create TLS client connection wrapping the raw TCP connection
246
+ const connectable = Gio.NetworkAddress.new(servername, port);
247
+ const tlsConn = Gio.TlsClientConnection.new(
248
+ rawConnection as Gio.IOStream,
249
+ connectable,
250
+ ) as Gio.TlsClientConnection;
251
+
252
+ // Set server identity for certificate validation
253
+ tlsConn.set_server_identity(connectable);
254
+
255
+ // Set ALPN protocols if provided
256
+ if (options.ALPNProtocols && options.ALPNProtocols.length > 0) {
257
+ try {
258
+ (tlsConn as Gio.TlsClientConnection).set_advertised_protocols(options.ALPNProtocols);
259
+ } catch {
260
+ // ALPN may not be supported on all GnuTLS versions
261
+ }
262
+ }
263
+
264
+ // Handle certificate validation
265
+ if (!rejectUnauthorized) {
266
+ (tlsConn as Gio.TlsConnection).connect(
267
+ 'accept-certificate',
268
+ () => true,
269
+ );
270
+ }
271
+
272
+ // Perform TLS handshake asynchronously
273
+ const cancellable = new Gio.Cancellable();
274
+ (tlsConn as Gio.TlsConnection).handshake_async(
275
+ GLib.PRIORITY_DEFAULT,
276
+ cancellable,
277
+ (_source: Gio.TlsConnection | null, asyncResult: Gio.AsyncResult) => {
278
+ try {
279
+ (tlsConn as Gio.TlsConnection).handshake_finish(asyncResult);
280
+ socket.authorized = true;
281
+ socket._setupTlsStreams(tlsConn as Gio.TlsConnection);
282
+
283
+ // Get ALPN result
284
+ socket.alpnProtocol = socket.getAlpnProtocol();
285
+
286
+ // Restart reading with TLS streams
287
+ (socket as any)._reading = false;
288
+ (socket as any)._startReading();
289
+
290
+ socket.emit('secureConnect');
291
+ } catch (err: unknown) {
292
+ socket.authorized = false;
293
+ socket.authorizationError = err instanceof Error ? err.message : String(err);
294
+ if (rejectUnauthorized) {
295
+ socket.destroy(err instanceof Error ? err : new Error(String(err)));
296
+ } else {
297
+ // Still emit secureConnect but with authorized=false
298
+ socket._setupTlsStreams(tlsConn as Gio.TlsConnection);
299
+ socket.emit('secureConnect');
300
+ }
301
+ }
302
+ },
303
+ );
304
+ } catch (err: unknown) {
305
+ socket.destroy(err instanceof Error ? err : new Error(String(err)));
306
+ }
307
+ });
308
+
309
+ // Initiate TCP connection
310
+ socket.connect({ port, host });
311
+ return socket;
312
+ }
313
+
314
+ /**
315
+ * Create a TLS secure context.
316
+ */
317
+ export function createSecureContext(options?: SecureContextOptions): { context: any } {
318
+ return { context: options || {} };
319
+ }
320
+
321
+ export const rootCertificates: string[] = [];
322
+
323
+ export interface TlsServerOptions extends SecureContextOptions {
324
+ requestCert?: boolean;
325
+ rejectUnauthorized?: boolean;
326
+ ALPNProtocols?: string[];
327
+ }
328
+
329
+ /**
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.
349
+ */
350
+ export class TLSServer extends Server {
351
+ private _tlsCertificate: Gio.TlsCertificate | null = null;
352
+ private _tlsOptions: TlsServerOptions;
353
+ private _sniContexts = new Map<string, Gio.TlsCertificate>();
354
+
355
+ constructor(options?: TlsServerOptions, secureConnectionListener?: (socket: TLSSocket) => void) {
356
+ super();
357
+ this._tlsOptions = options || {};
358
+
359
+ if (secureConnectionListener) {
360
+ this.on('secureConnection', secureConnectionListener);
361
+ }
362
+
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
+ }
369
+ }
370
+ }
371
+
372
+ /**
373
+ * Add a context for SNI (Server Name Indication).
374
+ */
375
+ addContext(hostname: string, context: SecureContextOptions): void {
376
+ if (context.cert) {
377
+ 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', {}));
382
+ }
383
+ }
384
+ }
385
+
386
+ listen(...args: unknown[]): this {
387
+ this.on('connection', (socket: Socket) => {
388
+ this._upgradeTls(socket);
389
+ });
390
+ return super.listen(...(args as [any]));
391
+ }
392
+
393
+ /**
394
+ * Upgrade a raw TCP socket to TLS using Gio.TlsServerConnection.
395
+ */
396
+ private _upgradeTls(socket: Socket): void {
397
+ const rawConnection: Gio.SocketConnection | null = (socket as any)._connection;
398
+ if (!rawConnection) {
399
+ const err = new Error('Cannot upgrade socket: no underlying connection');
400
+ this.emit('tlsClientError', err, socket);
401
+ socket.destroy();
402
+ return;
403
+ }
404
+
405
+ if (!this._tlsCertificate) {
406
+ const err = new Error('TLS server has no certificate configured');
407
+ this.emit('tlsClientError', err, socket);
408
+ socket.destroy();
409
+ return;
410
+ }
411
+
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;
425
+ }
426
+
427
+ if (this._tlsOptions.rejectUnauthorized === false) {
428
+ (tlsConn as Gio.TlsConnection).connect(
429
+ 'accept-certificate',
430
+ () => true,
431
+ );
432
+ }
433
+
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
440
+ }
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
+
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();
463
+
464
+ this.emit('secureConnection', tlsSocket);
465
+ } catch (err: unknown) {
466
+ const nodeErr = createNodeError(err, 'handshake', {});
467
+ this.emit('tlsClientError', nodeErr, socket);
468
+ socket.destroy();
469
+ }
470
+ },
471
+ );
472
+ } catch (err: unknown) {
473
+ const nodeErr = createNodeError(err, 'tls_wrap', {});
474
+ this.emit('tlsClientError', nodeErr, socket);
475
+ socket.destroy();
476
+ }
477
+ }
478
+ }
479
+
480
+ /**
481
+ * Create a TLS server.
482
+ */
483
+ export function createServer(options?: TlsServerOptions, secureConnectionListener?: (socket: TLSSocket) => void): TLSServer;
484
+ export function createServer(secureConnectionListener?: (socket: TLSSocket) => void): TLSServer;
485
+ export function createServer(
486
+ optionsOrListener?: TlsServerOptions | ((socket: TLSSocket) => void),
487
+ secureConnectionListener?: (socket: TLSSocket) => void,
488
+ ): TLSServer {
489
+ if (typeof optionsOrListener === 'function') {
490
+ return new TLSServer(undefined, optionsOrListener);
491
+ }
492
+ return new TLSServer(optionsOrListener, secureConnectionListener);
493
+ }
494
+
495
+ export { TLSServer as Server };
496
+
497
+ export default {
498
+ TLSSocket,
499
+ TLSServer,
500
+ Server: TLSServer,
501
+ connect,
502
+ createServer,
503
+ createSecureContext,
504
+ checkServerIdentity,
505
+ getCiphers,
506
+ rootCertificates,
507
+ DEFAULT_MIN_VERSION,
508
+ DEFAULT_MAX_VERSION,
509
+ DEFAULT_CIPHERS,
510
+ };
package/src/test.mts ADDED
@@ -0,0 +1,6 @@
1
+
2
+ import { run } from '@gjsify/unit';
3
+
4
+ import testSuiteTls from './index.spec.js';
5
+
6
+ run({ testSuiteTls });
package/tsconfig.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "compilerOptions": {
3
+ "module": "ESNext",
4
+ "target": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "types": [
7
+ "node"
8
+ ],
9
+ "experimentalDecorators": true,
10
+ "emitDeclarationOnly": true,
11
+ "declaration": true,
12
+ "allowImportingTsExtensions": true,
13
+ "outDir": "lib",
14
+ "rootDir": "src",
15
+ "declarationDir": "lib/types",
16
+ "composite": true,
17
+ "skipLibCheck": true,
18
+ "allowJs": true,
19
+ "checkJs": false,
20
+ "strict": false
21
+ },
22
+ "include": [
23
+ "src/**/*.ts"
24
+ ],
25
+ "exclude": [
26
+ "src/test.ts",
27
+ "src/test.mts",
28
+ "src/**/*.spec.ts",
29
+ "src/**/*.spec.mts"
30
+ ]
31
+ }