Skip to content

Commit

Permalink
feat!: improved ssl support
Browse files Browse the repository at this point in the history
BREAKING CHANGE: `certificate` and `selfsigned` options are merged with `https`
  • Loading branch information
pi0 committed Sep 15, 2022
1 parent 35b4a7a commit 71256e6
Show file tree
Hide file tree
Showing 8 changed files with 190 additions and 61 deletions.
18 changes: 13 additions & 5 deletions README.md
Expand Up @@ -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`
Expand Down
4 changes: 3 additions & 1 deletion package.json
Expand Up @@ -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",
Expand Down
26 changes: 17 additions & 9 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

126 changes: 126 additions & 0 deletions 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> {
// 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> {
// 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<Certificate> {
// 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)
}
}
53 changes: 28 additions & 25 deletions src/index.ts
@@ -1,12 +1,10 @@
import './shim'
import http from 'http'
import https from 'https'
import { promisify } from 'util'
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'
Expand All @@ -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
Expand Down Expand Up @@ -62,11 +60,7 @@ export async function listen (handle: http.RequestListener, opts: Partial<Listen
clipboard: false,
isTest: process.env.NODE_ENV === 'test',
isProd: process.env.NODE_ENV === 'production',
autoClose: true,
selfsigned: {
// https://github.com/jfromaniello/selfsigned/issues/33
keySize: 2048
}
autoClose: true
})

if (opts.isTest) {
Expand All @@ -91,7 +85,7 @@ export async function listen (handle: http.RequestListener, opts: Partial<Listen
const displayHost = isExternal ? 'localhost' : opts.hostname

if (opts.https) {
const { key, cert } = opts.certificate ? await resolveCert(opts.certificate) : await getSelfSignedCert(opts.selfsigned)
const { key, cert } = await resolveCert({ ...opts.https as any })
server = https.createServer({ key, cert }, handle)
addShutdown(server)
// @ts-ignore
Expand Down Expand Up @@ -158,18 +152,27 @@ export async function listen (handle: http.RequestListener, opts: Partial<Listen
}
}

async function resolveCert (input: CertificateInput): Promise<Certificate> {
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<Certificate> {
// 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<Certificate> {
// @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[] {
Expand Down
18 changes: 0 additions & 18 deletions src/shim.ts

This file was deleted.

3 changes: 2 additions & 1 deletion test/fixture/app.ts
Expand Up @@ -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')
})
3 changes: 1 addition & 2 deletions test/index.test.ts
Expand Up @@ -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')
}
Expand Down

0 comments on commit 71256e6

Please sign in to comment.