diff --git a/README.md b/README.md index e3e616e..c4bd37c 100644 --- a/README.md +++ b/README.md @@ -96,17 +96,25 @@ Default hostname to listen. ### `https` +- Type: Boolean | Object - Default: `false` -Listen with `https` protocol. By default uses a self-signed certificated. +Listen on https with SSL enabled. -### `certificate` +#### Self Signed Certificate -Path to https certificate files `{ key, cert }` +By setting `https: true`, listhen will use an auto generated self-signed certificate. -### `selfsigned` +You can set https to an object for custom options. Possible options: -Options for self-signed certificate (see [selfsigned](https://github.com/jfromaniello/selfsigned)). +- `domains`: (Array) Default is `['localhost', '127.0.0.1', '::1']`. +- `validityDays`: (Number) Default is `1`. + +#### User Provided Certificate + +Set `https: { cert, key }` where cert and key are path to the ssl certificates. + +You can also provide inline cert and key instead of reading from filesystem. In this case, they should start with `--`. ### `showURL` diff --git a/package.json b/package.json index 4ce8e14..8ef7607 100644 --- a/package.json +++ b/package.json @@ -30,12 +30,14 @@ "defu": "^6.1.0", "get-port-please": "^2.6.1", "http-shutdown": "^1.2.2", - "selfsigned": "^2.1.1", + "ip-regex": "^5.0.0", + "node-forge": "^1.3.1", "ufo": "^0.8.5" }, "devDependencies": { "@nuxtjs/eslint-config-typescript": "^11.0.0", "@types/node": "^18.7.18", + "@types/node-forge": "^1.0.4", "@vitest/coverage-c8": "^0.23.2", "eslint": "^8.23.1", "jiti": "^1.15.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2bb1371..bd0e4a7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3,6 +3,7 @@ lockfileVersion: 5.4 specifiers: '@nuxtjs/eslint-config-typescript': ^11.0.0 '@types/node': ^18.7.18 + '@types/node-forge': ^1.0.4 '@vitest/coverage-c8': ^0.23.2 clipboardy: ^3.0.0 colorette: ^2.0.19 @@ -10,8 +11,9 @@ specifiers: eslint: ^8.23.1 get-port-please: ^2.6.1 http-shutdown: ^1.2.2 + ip-regex: ^5.0.0 jiti: ^1.15.0 - selfsigned: ^2.1.1 + node-forge: ^1.3.1 standard-version: ^9.5.0 typescript: ^4.8.3 ufo: ^0.8.5 @@ -24,12 +26,14 @@ dependencies: defu: 6.1.0 get-port-please: 2.6.1 http-shutdown: 1.2.2 - selfsigned: 2.1.1 + ip-regex: 5.0.0 + node-forge: 1.3.1 ufo: 0.8.5 devDependencies: '@nuxtjs/eslint-config-typescript': 11.0.0_irgkl5vooow2ydyo6aokmferha '@types/node': 18.7.18 + '@types/node-forge': 1.0.4 '@vitest/coverage-c8': 0.23.2 eslint: 8.23.1 jiti: 1.15.0 @@ -523,6 +527,12 @@ packages: resolution: {integrity: sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==} dev: true + /@types/node-forge/1.0.4: + resolution: {integrity: sha512-UpX8LTRrarEZPQvQqF5/6KQAqZolOVckH7txWdlsWIJrhBFFtwEUTcqeDouhrJl6t0F7Wg5cyUOAqqF8a6hheg==} + dependencies: + '@types/node': 18.7.18 + dev: true + /@types/node/18.7.18: resolution: {integrity: sha512-m+6nTEOadJZuTPkKR/SYK3A2d7FZrgElol9UP1Kae90VVU4a6mxnPuLiIW1m4Cq4gZ/nWb9GrdVXJCoCazDAbg==} dev: true @@ -2604,6 +2614,11 @@ packages: side-channel: 1.0.4 dev: true + /ip-regex/5.0.0: + resolution: {integrity: sha512-fOCG6lhoKKakwv+C6KdsOnGvgXnmgfmp0myi3bcNwj3qfwPAxRKWEuFhvEFF7ceYIz6+1jRZ+yguLFAmUNPEfw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dev: false + /is-arrayish/0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} dev: true @@ -3613,13 +3628,6 @@ packages: resolution: {integrity: sha512-zIvPdjOH8fv8CgrPT5eqtxHQXmPNnV/vHJYffZhE43KZkvULvpCTvOt1HPlFaCZx287INL9qaqrZg34e8NgI4g==} dev: true - /selfsigned/2.1.1: - resolution: {integrity: sha512-GSL3aowiF7wa/WtSFwnUrludWFoNhftq8bUkH9pkzjpN2XSPOAYEgg6e0sS9s0rZwgJzJiQRPU18A6clnoW5wQ==} - engines: {node: '>=10'} - dependencies: - node-forge: 1.3.1 - dev: false - /semver/5.7.1: resolution: {integrity: sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==} hasBin: true diff --git a/src/cert.ts b/src/cert.ts new file mode 100644 index 0000000..f0208eb --- /dev/null +++ b/src/cert.ts @@ -0,0 +1,126 @@ +// Rewrite from https://github.com/Subash/mkcert 1.5.1 (MIT) + +import { promisify } from 'node:util' +import forge from 'node-forge' +import ipRegex from 'ip-regex' + +export interface Certificate { + key: string + cert: string +} + +// SSL Cert + +export interface SSLCertOptions { + commonName?: string + domains: string[] + validityDays: number + caKey: string + caCert: string +} + +export async function generateSSLCert (opts: SSLCertOptions): Promise { + // Certificate Attributes (https://git.io/fptna) + const attributes = [ + // Use the first address as common name if no common name is provided + { name: 'commonName', value: opts.commonName || opts.domains[0] } + ] + + // Required certificate extensions for a tls certificate + const extensions = [ + { name: 'basicConstraints', cA: false, critical: true }, + { name: 'keyUsage', digitalSignature: true, keyEncipherment: true, critical: true }, + { name: 'extKeyUsage', serverAuth: true, clientAuth: true }, + { + name: 'subjectAltName', + altNames: opts.domains.map((domain) => { + // Available Types: https://git.io/fptng + const types = { domain: 2, ip: 7 } + const isIp = ipRegex({ exact: true }).test(domain) + if (isIp) { return { type: types.ip, ip: domain } } + return { type: types.domain, value: domain } + }) + } + ] + + const ca = forge.pki.certificateFromPem(opts.caCert) + + return await generateCert({ + subject: attributes, + issuer: ca.subject.attributes, + extensions, + validityDays: opts.validityDays, + signWith: opts.caKey + }) +} + +// CA + +export interface CAOptions { + commonName?: string + organization?: string + countryCode?: string + state?: string + locality?: string + validityDays?: number +} + +export async function generateCA (opts: CAOptions = {}): Promise { + // Certificate Attributes: https://git.io/fptna + const attributes = [ + opts.commonName && { name: 'commonName', value: opts.commonName }, + opts.countryCode && { name: 'countryName', value: opts.countryCode }, + opts.state && { name: 'stateOrProvinceName', value: opts.state }, + opts.locality && { name: 'localityName', value: opts.locality }, + opts.organization && { name: 'organizationName', value: opts.organization } + ].filter(Boolean) as {name: string, value: string }[] + + // Required certificate extensions for a certificate authority + const extensions = [ + { name: 'basicConstraints', cA: true, critical: true }, + { name: 'keyUsage', keyCertSign: true, critical: true } + ] + + return await generateCert({ + subject: attributes, + issuer: attributes, + extensions, + validityDays: opts.validityDays || 365 + }) +} + +// Cert + +interface CertOptions { + subject: forge.pki.CertificateField[] + issuer: forge.pki.CertificateField[] + extensions: any[] + validityDays: number + signWith?: string +} + +export async function generateCert (opts: CertOptions): Promise { + // Create serial from and integer between 50000 and 99999 + const serial = Math.floor((Math.random() * 95000) + 50000).toString() + const generateKeyPair = promisify(forge.pki.rsa.generateKeyPair.bind(forge.pki.rsa)) + const keyPair = await generateKeyPair({ bits: 2048, workers: 4 }) + const cert = forge.pki.createCertificate() + + cert.publicKey = keyPair.publicKey + cert.serialNumber = Buffer.from(serial).toString('hex') // serial number must be hex encoded + cert.validity.notBefore = new Date() + cert.validity.notAfter = new Date() + cert.validity.notAfter.setDate(cert.validity.notAfter.getDate() + opts.validityDays) + cert.setSubject(opts.subject) + cert.setIssuer(opts.issuer) + cert.setExtensions(opts.extensions) + + // Sign the certificate with it's own private key if no separate signing key is provided + const signWith = opts.signWith ? forge.pki.privateKeyFromPem(opts.signWith) : keyPair.privateKey + cert.sign(signWith, forge.md.sha256.create()) + + return { + key: forge.pki.privateKeyToPem(keyPair.privateKey), + cert: forge.pki.certificateToPem(cert) + } +} diff --git a/src/index.ts b/src/index.ts index fffa378..a4aa5ed 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,3 @@ -import './shim' import http from 'http' import https from 'https' import { promisify } from 'util' @@ -6,7 +5,6 @@ import { promises as fs } from 'fs' import { networkInterfaces } from 'os' import { joinURL } from 'ufo' import { cyan, gray, underline, bold } from 'colorette' -import type { SelfsignedOptions } from 'selfsigned' import { getPort, GetPortInput } from 'get-port-please' import addShutdown from 'http-shutdown' import { defu } from 'defu' @@ -17,21 +15,21 @@ export interface Certificate { cert: string } -export interface CertificateInput { - key: string +export interface HTTPSOptions { cert: string + key: string + domains?: string[] + validityDays?: number } export interface ListenOptions { name: string port?: GetPortInput, - hostname?: string, - https?: boolean - selfsigned?: SelfsignedOptions + hostname: string, showURL: boolean baseURL: string open: boolean - certificate: Certificate + https: boolean | HTTPSOptions clipboard: boolean isTest: Boolean isProd: Boolean @@ -62,11 +60,7 @@ export async function listen (handle: http.RequestListener, opts: Partial { - const key = await fs.readFile(input.key, 'utf-8') - const cert = await fs.readFile(input.cert, 'utf-8') - return { key, cert } -} +async function resolveCert (opts: HTTPSOptions): Promise { + // Use cert if provided + if (opts.key && opts.cert) { + const isInline = (s: string = '') => s.startsWith('--') + const r = (s: string) => isInline(s) ? s : fs.readFile(s, 'utf-8') + return { + key: await r(opts.key), + cert: await r(opts.cert) + } + } -async function getSelfSignedCert (opts: SelfsignedOptions = {}): Promise { - // @ts-ignore - const { generate } = await import('selfsigned') - // @ts-ignore - const { private: key, cert } = await promisify(generate)(opts.attrs, opts) - return { key, cert } + // Use auto generated cert + const { generateCA, generateSSLCert } = await import('./cert') + const ca = await generateCA() + const cert = await generateSSLCert({ + caCert: ca.cert, + caKey: ca.key, + domains: opts.domains || ['localhost', '127.0.0.1', '::1'], + validityDays: opts.validityDays || 1 + }) + return cert } function getExternalIps (): string[] { diff --git a/src/shim.ts b/src/shim.ts deleted file mode 100644 index fb32c6f..0000000 --- a/src/shim.ts +++ /dev/null @@ -1,18 +0,0 @@ -// https://github.com/jfromaniello/selfsigned - -declare module 'selfsigned' { - export interface SelfsignedOptions { - attrs?: any - keySize?: number, - days?: number, - algorithm?: string, - extensions?: any[], - pkcs7?: boolean, - clientCertificate?: undefined, - clientCertificateCN?: string - } - - export interface GenerateResult { private: string, public: string, cert: string } - - export function generate(attrs?: any, opts?: SelfsignedOptions, cb?: (err: undefined | Error, result: GenerateResult) => any): any -} diff --git a/test/fixture/app.ts b/test/fixture/app.ts index e3b11a5..69754b2 100644 --- a/test/fixture/app.ts +++ b/test/fixture/app.ts @@ -3,5 +3,6 @@ import { listen } from '../../src' listen((_req, res) => { res.end('works!') }, { - open: process.argv.some(arg => arg === '-o' || arg === '--open') + open: process.argv.some(arg => arg === '-o' || arg === '--open'), + https: process.argv.includes('--https') }) diff --git a/test/index.test.ts b/test/index.test.ts index 1224805..041dd02 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -45,8 +45,7 @@ describe('listhen', () => { test('listen (https - custom)', async () => { listener = await listen(handle, { - https: true, - certificate: { + https: { key: resolve(__dirname, 'fixture/cert/key.pem'), cert: resolve(__dirname, 'fixture/cert/cert.pem') }