Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions source/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export declare interface Options {
'--ssl-cert': Path;
'--ssl-key': Path;
'--ssl-pass': string;
'--https': boolean;
}

// The arguments passed to the CLI (the options + the positional arguments)
Expand Down
6 changes: 4 additions & 2 deletions source/utilities/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ const helpText = chalkTemplate`

--no-port-switching Do not open a port other than the one specified when it\'s taken.

--https Serve over HTTPS with an auto-generated self-signed certificate

{bold ENDPOINTS}

Listen endpoints (specified by the {bold --listen} or {bold -l} options above) instruct {cyan serve}
Expand Down Expand Up @@ -135,8 +137,7 @@ export const parseEndpoint = (uriOrPort: string): ParsedEndpoint => {
};
default:
throw new Error(
`Unknown --listen endpoint scheme (protocol): ${
url.protocol ?? 'undefined'
`Unknown --listen endpoint scheme (protocol): ${url.protocol ?? 'undefined'
}`,
);
}
Expand All @@ -159,6 +160,7 @@ const options = {
'--ssl-cert': String,
'--ssl-key': String,
'--ssl-pass': String,
'--https': Boolean,
'--no-request-logging': Boolean,
// A list of aliases for the above options.
'-h': '--help',
Expand Down
11 changes: 9 additions & 2 deletions source/utilities/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import compression from 'compression';
import isPortReachable from 'is-port-reachable';
import chalk from 'chalk';
import { getNetworkAddress, registerCloseListener } from './http.js';
import { generateCertificate } from './tls.js';
import { promisify } from './promise.js';
import { logger } from './logger.js';
import type { IncomingMessage, ServerResponse } from 'node:http';
Expand Down Expand Up @@ -114,9 +115,15 @@ export const startServer = async (
pfx: await readFile(sslCert),
passphrase: sslPass ? await readFile(sslPass, 'utf8') : '',
};
} else if (args['--https'] && !useSsl) {
// Auto-generate a self-signed certificate for local HTTPS.
const { key, cert } = generateCertificate();
serverConfig = { key, cert };
logger.info('Auto-generated self-signed certificate for HTTPS.');
}

const server = useSsl
const useHttps = useSsl || (args['--https'] && !useSsl);
const server = useHttps
? https.createServer(serverConfig, serverHandler)
: http.createServer(serverHandler);

Expand All @@ -143,7 +150,7 @@ export const startServer = async (
else address = details.address;
const ip = getNetworkAddress();

const protocol = useSsl ? 'https' : 'http';
const protocol = useHttps ? 'https' : 'http';
local = `${protocol}://${address}:${details.port}`;
network = ip ? `${protocol}://${ip}:${details.port}` : undefined;
}
Expand Down
194 changes: 194 additions & 0 deletions source/utilities/tls.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
// source/utilities/tls.ts
// Generate a self-signed certificate for local HTTPS.

import { generateKeyPairSync, createSign, randomBytes } from 'node:crypto';

/**
* Generates a self-signed TLS certificate for localhost.
*
* Uses Node's built-in crypto module — no external dependencies.
* The certificate is valid for localhost, 127.0.0.1, and ::1.
*
* @returns An object with PEM-encoded `key` and `cert` strings.
*/
export const generateCertificate = (): { key: string; cert: string } => {
const { privateKey, publicKey } = generateKeyPairSync('rsa', {
modulusLength: 2048,
});

// Export private key as PEM.
const keyPem = privateKey
.export({ type: 'pkcs8', format: 'pem' })
.toString();

// Build a self-signed X.509 v3 certificate using DER encoding.
const serialNumber = randomBytes(16);
// Ensure the serial number is positive (set high bit to 0).
serialNumber[0] &= 0x7f;

const now = new Date();
const notBefore = now;
const notAfter = new Date(now);
notAfter.setFullYear(notAfter.getFullYear() + 1);

// Encode the certificate TBS (To-Be-Signed) structure.
const issuerAndSubject = derSequence([
// CN=localhost
derSet([
derSequence([
derOid([2, 5, 4, 3]), // id-at-commonName
derUtf8String('localhost'),
]),
]),
]);

const publicKeyDer = publicKey.export({ type: 'spki', format: 'der' });

// Extensions: Subject Alternative Names (DNS:localhost, IP:127.0.0.1, IP:::1)
const sanExtension = derSequence([
derOid([2, 5, 29, 17]), // id-ce-subjectAltName
derOctetString(
derSequence([
// DNS:localhost
derContextTag(2, Buffer.from('localhost')),
// IP:127.0.0.1
derContextTag(7, Buffer.from([127, 0, 0, 1])),
// IP:::1
derContextTag(
7,
Buffer.from([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]),
),
]),
),
]);

const extensions = derContextConstructed(3, derSequence([sanExtension]));

const tbs = derSequence([
// Version: v3
derContextConstructed(0, derInteger(Buffer.from([2]))),
// Serial number
derInteger(serialNumber),
// Signature algorithm: SHA-256 with RSA
derSequence([derOid([1, 2, 840, 113549, 1, 1, 11]), derNull()]),
// Issuer
issuerAndSubject,
// Validity
derSequence([derUtcTime(notBefore), derUtcTime(notAfter)]),
// Subject
issuerAndSubject,
// Subject Public Key Info
Buffer.from(publicKeyDer),
// Extensions
extensions,
]);

// Sign the TBS.
const signer = createSign('SHA256');
signer.update(tbs);
const signature = signer.sign(privateKey);

// Assemble the full certificate.
const cert = derSequence([
tbs,
// Signature algorithm
derSequence([derOid([1, 2, 840, 113549, 1, 1, 11]), derNull()]),
// Signature value (bit string)
derBitString(signature),
]);

const certPem = `-----BEGIN CERTIFICATE-----\n${cert.toString('base64').match(/.{1,64}/g)!.join('\n')}\n-----END CERTIFICATE-----\n`;

return { key: keyPem, cert: certPem };
};

// --- DER encoding helpers ---

function derLength(length: number): Buffer {
if (length < 0x80) return Buffer.from([length]);
if (length < 0x100) return Buffer.from([0x81, length]);
return Buffer.from([0x82, (length >> 8) & 0xff, length & 0xff]);
}

function derWrap(tag: number, content: Buffer): Buffer {
return Buffer.concat([Buffer.from([tag]), derLength(content.length), content]);
}

function derSequence(items: Buffer[]): Buffer {
return derWrap(0x30, Buffer.concat(items));
}

function derSet(items: Buffer[]): Buffer {
return derWrap(0x31, Buffer.concat(items));
}

function derInteger(value: Buffer): Buffer {
// Ensure positive by prepending 0x00 if high bit is set.
const needsPad = value[0]! >= 0x80;
const content = needsPad
? Buffer.concat([Buffer.from([0x00]), value])
: value;
return derWrap(0x02, content);
}

function derBitString(content: Buffer): Buffer {
// Bit string: first byte is the number of unused bits (0).
return derWrap(0x03, Buffer.concat([Buffer.from([0x00]), content]));
}

function derOctetString(content: Buffer): Buffer {
return derWrap(0x04, content);
}

function derNull(): Buffer {
return Buffer.from([0x05, 0x00]);
}

function derOid(components: number[]): Buffer {
const bytes: number[] = [];
// First two components are encoded as 40*c0 + c1.
bytes.push(40 * components[0]! + components[1]!);

for (let i = 2; i < components.length; i++) {
let value = components[i]!;
if (value < 128) {
bytes.push(value);
} else {
const encoded: number[] = [];
encoded.push(value & 0x7f);
value >>= 7;
while (value > 0) {
encoded.push((value & 0x7f) | 0x80);
value >>= 7;
}
bytes.push(...encoded.reverse());
}
}

return derWrap(0x06, Buffer.from(bytes));
}

function derUtf8String(str: string): Buffer {
return derWrap(0x0c, Buffer.from(str, 'utf8'));
}

function derUtcTime(date: Date): Buffer {
const pad = (n: number) => n.toString().padStart(2, '0');
const str =
pad(date.getUTCFullYear() % 100) +
pad(date.getUTCMonth() + 1) +
pad(date.getUTCDate()) +
pad(date.getUTCHours()) +
pad(date.getUTCMinutes()) +
pad(date.getUTCSeconds()) +
'Z';
return derWrap(0x17, Buffer.from(str, 'ascii'));
}

function derContextTag(tag: number, content: Buffer): Buffer {
return derWrap(0x80 | tag, content);
}

function derContextConstructed(tag: number, content: Buffer): Buffer {
return derWrap(0xa0 | tag, content);
}