js/net/tls

js/net/tls.ts

fino:tls — TLS socket layer.

TLS 1.3 specification: https://www.rfc-editor.org/rfc/rfc8446

TlsSocket extends Socket from fino:socket. TCP connection setup is shared via connectTcp() — no duplication. TlsSocket overrides split() to return TlsReader/TlsWriter (which handle SSL_read/SSL_write), and overrides close() to perform SSL teardown before the fd close.

TlsReader and TlsWriter extend the base Reader/Writer from fino:stream, implementing the template methods with SSL-specific I/O. All loop machinery (readability waiting, retry, backpressure, async iteration, pipe) is inherited — no duplication.

TLS options

{ hostname: 'example.com', // SNI + hostname verification ca: '/path/to/ca.pem', // custom CA file (optional) cert: '/path/client.pem', // client certificate (optional) key: '/path/client.key', // client private key (optional) rejectUnauthorized: true, // verify peer cert (default: true) alpn: ['h2', 'http/1.1', // client protocol preference (optional) }

Release policy

This module is release-supported in an OpenSSL-enabled build. Release CI must include a lane where tlsAvailable is true; builds without libssl may still run, but TLS tests and TLS-dependent HTTP features are expected to be skipped or gated explicitly.

Protocol selection is exposed through ALPN only. Callers may offer client protocol preferences with alpn, and servers that use this socket layer publish their supported protocols through their own listener configuration. Cipher-suite, minimum/maximum protocol version, and session-reuse controls intentionally use OpenSSL defaults in this API. If a deployment needs a stricter TLS policy, configure the OpenSSL installation or use a higher-level server API that exposes a narrower audited knob.

There is no public TLS session cache or session-ticket reuse API here. Mutual TLS uses handshake-time client certificates only; post-handshake client authentication is intentionally outside this module.

Close coordination

When split() is used, both TlsReader and TlsWriter share the SSL* pointer. When both halves close, SSL is shut down and freed, then the fd is closed via `super.close()`. When `close()` is called directly (without splitting), SSL teardown happens before calling `super.close()`.

import { TlsSocket } from 'fino:tls';

const socket = await TlsSocket.connect(
  { family: 'ipv4', ip: '93.184.216.34', port: 443 },
  { hostname: 'example.com', alpn: ['h2', 'http/1.1'] },
);
const [reader, writer] = socket.split();

Interfaces

interface TlsConnectOptions extends ConnectOptions {

Options for opening or upgrading a TLS socket.

rejectUnauthorized defaults to true; pass false only for local testing or explicitly trusted endpoints. hostname is used for SNI and certificate verification when provided.

const tls = await TlsSocket.connect(addr, {
  hostname: 'example.com',
  alpn: ['h2', 'http/1.1'],
});

Properties

hostname?: string

Hostname used for SNI and peer certificate checks.

await TlsSocket.connect(addr, { hostname: 'example.com' });
servername?: string

Alias for hostname, matching other TLS option surfaces.

If both names are provided they must be identical.

await TlsSocket.connect(addr, { servername: 'example.com' });
ca?: string

Path to a PEM CA bundle or file loaded with OpenSSL verify locations.

await TlsSocket.connect(addr, { hostname: 'internal.test', ca: '/etc/ssl/internal-ca.pem' });
cert?: string

Path to the PEM client certificate chain presented when requested.

Must be paired with key; partial certificate configuration throws before the TCP connection is opened.

await TlsSocket.connect(addr, { cert: './client.pem', key: './client.key' });
key?: string

Path to the PEM private key matching cert.

await TlsSocket.connect(addr, { cert: './client.pem', key: './client.key' });
rejectUnauthorized?: boolean

Whether to verify the peer certificate; defaults to true.

await TlsSocket.connect(addr, { rejectUnauthorized: false });
alpn?: string[]

ALPN protocol list offered by the client in preference order.

await TlsSocket.connect(addr, { hostname: 'example.com', alpn: ['h2', 'http/1.1'] });

interface TlsServerContextOptions {

Options for creating a server-side TLS context.

clientAuth: 'request' asks clients for a certificate but allows anonymous handshakes. clientAuth: 'require' fails the handshake when no acceptable client certificate is provided.

Properties

cert: string

PEM certificate chain presented by the server.

key: string

PEM private key matching cert.

ca?: string

PEM CA bundle used to verify client certificates.

clientAuth?: TlsClientAuth

Client certificate policy. Defaults to 'none'.

rejectUnauthorized?: boolean

Whether verification failures abort the handshake. Defaults to true.

alpn?: readonly string[]

ALPN protocols offered by the server.

interface TlsVerifyResult {

Verification status reported by OpenSSL after a TLS handshake.

Properties

code: number

OpenSSL verification code. 0 means success.

reason: string | null

Human-readable OpenSSL reason, or null when verification succeeded.

interface TlsPeerInfo {

Peer TLS identity metadata.

The certificate is the DER-encoded leaf certificate when one was presented. Treat it as authenticated identity only when authorized is true.

Properties

peerCertificate: Uint8Array | null

Leaf peer certificate in DER form, or null when none was presented.

verify: TlsVerifyResult

Verification status from OpenSSL.

authorized: boolean

True when OpenSSL verification succeeded.

Types

type TlsClientAuth = 'none' | 'request' | 'require'

Client-certificate policy for server-side TLS handshakes.

Classes

class TlsReader extends BufferedBytesReader {

Read half of a TLS connection. Extends BufferedBytesReader with SSL-specific I/O: uses SSL_read instead of read(2), checks SSL_pending before waiting for fd readability, and classifies SSL error codes correctly.

Buffering, structural reads (readExactly, readUntil, etc.), and the async iterator protocol are all inherited from BufferedBytesReader.

const [reader] = tls.split();
const bytes = await reader.read();

Constructors

constructor(ssl: object, fd: number, onClose: () => void | Promise<void>)

Wrap OpenSSL state and a non-blocking fd as a TLS reader.

The reader does not own the SSL pointer by itself; the close callback coordinates cleanup with the paired TlsWriter.

const reader = new TlsReader(ssl, fd, onClose);

Getters

get fd(): number

Underlying socket file descriptor.

console.log(reader.fd);

Methods

override async close(): Promise<void>

class TlsWriter extends BufferedBytesWriter {

Write half of a TLS connection. Extends BufferedBytesWriter with SSL-specific I/O: uses SSL_write instead of write(2) and handles SSL_ERROR_WANT_READ during TLS renegotiation.

Write coalescing, pipe(), and async close() are inherited from BufferedBytesWriter.

const [, writer] = tls.split();
await writer.write(new TextEncoder().encode('hello'));
await writer.flush();

Constructors

constructor(ssl: object, fd: number, onClose: () => void | Promise<void>)

Wrap OpenSSL state and a non-blocking fd as a TLS writer.

The writer shares SSL ownership with a TlsReader; cleanup runs through the supplied close callback.

const writer = new TlsWriter(ssl, fd, onClose);

Getters

get fd(): number

Underlying socket file descriptor.

console.log(writer.fd);

class TlsSocket extends Socket {

A connected TLS socket. Extends Socket with SSL state. TCP connection setup is shared with Socket via connectTcp(). split() and close() are overridden to handle SSL teardown.

tls instanceof Socket is true.

Use the static factories rather than the constructor directly:

const tls = await TlsSocket.connect({ family: 'ipv4', ip: '93.184.216.34', port: 443 }, { hostname: 'example.com' });
const [reader, writer] = tls.split();

Constructors

constructor(fd: number, remoteAddr: Address | null, ssl: object, sslCtx: object | null)

Wrap an established TLS session.

The constructor takes ownership of ssl and, when non-null, sslCtx. Prefer connect(), upgrade(), or accept() so handshakes and cleanup are coordinated.

const tls = new TlsSocket(fd, remoteAddr, ssl, sslCtx);

Getters

get negotiatedProtocol(): string | null

The ALPN protocol negotiated during the TLS handshake, or null if none.

if (tls.negotiatedProtocol === 'h2') console.log('HTTP/2');

Methods

getPeerCertificate(): Uint8Array | null

Return the peer leaf certificate as DER bytes, or null.

This is raw certificate material. Treat it as authenticated identity only when getPeerInfo().authorized is true.

const cert = tls.getPeerCertificate();
if (cert) console.log(cert.byteLength);
getVerifyResult(): TlsVerifyResult

Return OpenSSL's peer verification result for this session.

Code 0 means verification succeeded. When verification was disabled, OpenSSL may still report the peer certificate's validation state, but the result must not be treated as application authentication by itself.

const verify = tls.getVerifyResult();
console.log(verify.code, verify.reason);
getPeerInfo(): TlsPeerInfo

Return peer certificate and verification metadata together.

const info = tls.getPeerInfo();
if (info.authorized) console.log(info.peerCertificate?.byteLength);
split(): [TlsReader, TlsWriter]

Split into a [TlsReader, TlsWriter pair. SSL cleanup and fd close happen automatically when both halves have been closed.

Note: TLS does not support half-close (no SHUT_RD/SHUT_WR per half). Both sides must close before SSL_shutdown is issued.

const [reader, writer] = tls.split();
await writer.close();
await reader.close();
close()

Close both directions immediately. Performs SSL teardown, then delegates fd cleanup to Socket.close(). Idempotent.

tls.close();

Static Methods

static async connect(addr: Address, opts: TlsConnectOptions = {}): Promise<TlsSocket>

Connect to a remote address over TLS. Uses connectTcp() for the TCP layer (same helper as Socket.connect()), then performs the TLS handshake.

Throws when OpenSSL is unavailable, TCP connection fails, certificate verification fails, or the TLS handshake fails.

const tls = await TlsSocket.connect(addr, { hostname: 'example.com' });
static async upgrade(socket: Socket, opts: TlsConnectOptions = {}): Promise<TlsSocket>

Upgrade an existing connected Socket to TLS (client side). The original Socket should not be used after this call.

On failure, the original socket fd remains caller-owned. This allows the caller to decide whether to close or recover it.

const raw = await Socket.connect(addr);
const tls = await TlsSocket.upgrade(raw, { hostname: 'example.com' });
static async accept(fd: number, sslCtx: object): Promise<TlsSocket>

Accept a TLS connection (server side) on an already-accepted TCP fd.

sslCtx is borrowed from the server and is not freed by the returned socket. On handshake failure, the SSL object is freed and the fd remains caller-owned.

const tls = await TlsSocket.accept(fd, sslCtx);