From 611d7555cfc5f3fe206d0805f7ffeb14f9e10341 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 12 Nov 2024 10:54:21 +0100 Subject: [PATCH] chore: several updates chore: wip --- .vscode/dictionary.txt | 3 + bin/cli.ts | 16 ++-- build.ts | 16 ++-- docs/index.md | 6 +- src/{keys.ts => certificate.ts} | 136 ++++++++++++++------------------ src/config.ts | 4 +- src/index.ts | 4 +- src/types.ts | 12 +-- src/utils.ts | 64 ++++++++++++--- test/tlsx.test.ts | 6 +- tls.config.ts | 6 ++ 11 files changed, 151 insertions(+), 122 deletions(-) rename src/{keys.ts => certificate.ts} (73%) diff --git a/.vscode/dictionary.txt b/.vscode/dictionary.txt index b79d07c..c7ca051 100644 --- a/.vscode/dictionary.txt +++ b/.vscode/dictionary.txt @@ -1,3 +1,4 @@ +addstore antfu biomejs booleanish @@ -15,10 +16,12 @@ dbaeumer degit deps destructurable +dtsx entrypoints heroicons keychain Keychains +Keypair lockb mkcert openweb diff --git a/bin/cli.ts b/bin/cli.ts index 1877e6c..d66269e 100644 --- a/bin/cli.ts +++ b/bin/cli.ts @@ -2,7 +2,7 @@ import os from 'node:os' import { log } from '@stacksjs/cli' import { CAC } from 'cac' import { version } from '../package.json' -import { addCertToSystemTrustStoreAndSaveCerts, createRootCA, generateCert } from '../src' +import { addCertToSystemTrustStoreAndSaveCerts, createRootCA, generateCert } from '../src/certificate' import { config } from '../src/config' const cli = new CAC('tlsx') @@ -27,22 +27,22 @@ cli .usage('tlsx secure [options]') .example('tlsx secure example.com --output /etc/ssl') .action(async (domain: string, options?: Options) => { - domain = domain ?? config?.ssl?.altNameURIs[0] + domain = domain ?? config?.altNameURIs[0] log.info(`Generating a self-signed SSL certificate for: ${domain}`) log.debug('Options:', options) - const CAcert = await createRootCA() - const HostCert = await generateCert({ - hostCertCN: config?.ssl?.commonName ?? domain, + const caCert = await createRootCA() + const hostCert = await generateCert({ + hostCertCN: config?.commonName ?? domain, domain, rootCAObject: { - certificate: CAcert.certificate, - privateKey: CAcert.privateKey, + certificate: caCert.certificate, + privateKey: caCert.privateKey, }, }) - await addCertToSystemTrustStoreAndSaveCerts(HostCert, CAcert.certificate) + await addCertToSystemTrustStoreAndSaveCerts(hostCert, caCert.certificate) log.success('Certificate generated') }) diff --git a/build.ts b/build.ts index 4de0ef7..8f831b0 100644 --- a/build.ts +++ b/build.ts @@ -1,5 +1,6 @@ import { $ } from 'bun' -import { dts } from 'bun-plugin-dts-auto' +import process from 'node:process' +import { dts } from 'bun-plugin-dtsx' console.log('Building...') @@ -10,15 +11,9 @@ const result = await Bun.build({ outdir: './dist', format: 'esm', target: 'bun', - // minify: true, - // sourcemap: 'inline', - // external: ['bun-plugin-dts-auto'], - // plugins: [ - // dts({ - // root: './src', - // outdir: './dist', - // }), - // ], + plugins: [ + dts(), + ], }) if (!result.success) { @@ -32,7 +27,6 @@ if (!result.success) { process.exit(1) } -// eslint-disable-next-line no-console console.log('Build complete') await $`cp ./dist/src/index.js ./dist/index.js` diff --git a/docs/index.md b/docs/index.md index c7a7353..e6d82d2 100644 --- a/docs/index.md +++ b/docs/index.md @@ -3,9 +3,9 @@ layout: home hero: - name: "Reverse Proxy" - text: "A better developer environment." - tagline: My great project tagline + name: "tlsx" + text: "HTTPS for your dev environment" + tagline: Automatically ensure your local development environment uses HTTPS actions: - theme: brand text: Markdown Examples diff --git a/src/keys.ts b/src/certificate.ts similarity index 73% rename from src/keys.ts rename to src/certificate.ts index 79ec9ec..4c2538d 100644 --- a/src/keys.ts +++ b/src/certificate.ts @@ -1,16 +1,17 @@ +import type { AddCertOption, CertOption, GenerateCertReturn } from './types' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import { log, runCommand } from '@stacksjs/cli' import forge, { pki, tls } from 'node-forge' import { config } from './config' -import type { AddCertOption, CertOption, GenerateCertReturn } from './types' +import { findFoldersWithFile, makeNumberPositive } from './utils' /** * Generate a random serial number for the Certificate * @returns The serial number for the Certificate */ -export const randomSerialNumber = (): string => { +export function randomSerialNumber(): string { return makeNumberPositive(forge.util.bytesToHex(forge.random.getBytesSync(20))) } @@ -18,7 +19,7 @@ export const randomSerialNumber = (): string => { * Get the Not Before Date for a Certificate (will be valid from 2 days ago) * @returns The Not Before Date for the Certificate */ -export const getCertNotBefore = (): Date => { +export function getCertNotBefore(): Date { const twoDaysAgo = new Date(Date.now() - 60 * 60 * 24 * 2 * 1000) const year = twoDaysAgo.getFullYear() const month = (twoDaysAgo.getMonth() + 1).toString().padStart(2, '0') @@ -31,7 +32,7 @@ export const getCertNotBefore = (): Date => { * @param notBefore - The Not Before Date for the Certificate * @returns The Not After Date for the Certificate */ -export const getCertNotAfter = (notBefore: Date): Date => { +export function getCertNotAfter(notBefore: Date): Date { const ninetyDaysLater = new Date(notBefore.getTime() + 60 * 60 * 24 * 90 * 1000) const year = ninetyDaysLater.getFullYear() const month = (ninetyDaysLater.getMonth() + 1).toString().padStart(2, '0') @@ -45,7 +46,7 @@ export const getCertNotAfter = (notBefore: Date): Date => { * @param notBefore - The Not Before Date for the CA * @returns The Not After Date for the CA */ -export const getCANotAfter = (notBefore: Date): Date => { +export function getCANotAfter(notBefore: Date): Date { const year = notBefore.getFullYear() + 100 const month = (notBefore.getMonth() + 1).toString().padStart(2, '0') const day = notBefore.getDate().toString().padStart(2, '0') @@ -56,7 +57,7 @@ export const getCANotAfter = (notBefore: Date): Date => { export const DEFAULT_C = 'US' export const DEFAULT_ST = 'California' export const DEFAULT_L = 'Playa Vista' -export const DEFAULT_O: string = config?.ssl?.organizationName ?? 'Stacks.js' +export const DEFAULT_O: string = config?.organizationName ?? 'stacksjs.org' /** * Create a new Root CA Certificate @@ -80,30 +81,30 @@ export async function createRootCA(): Promise { ] // Create an empty Certificate - const CAcert = pki.createCertificate() + const caCert = pki.createCertificate() // Set the Certificate attributes for the new Root CA - CAcert.publicKey = publicKey - CAcert.serialNumber = randomSerialNumber() - CAcert.validity.notBefore = getCertNotBefore() - CAcert.validity.notAfter = getCANotAfter(CAcert.validity.notBefore) - CAcert.setSubject(attributes) - CAcert.setIssuer(attributes) - CAcert.setExtensions(extensions) + caCert.publicKey = publicKey + caCert.serialNumber = randomSerialNumber() + caCert.validity.notBefore = getCertNotBefore() + caCert.validity.notAfter = getCANotAfter(caCert.validity.notBefore) + caCert.setSubject(attributes) + caCert.setIssuer(attributes) + caCert.setExtensions(extensions) // Self-sign the Certificate - CAcert.sign(privateKey, forge.md.sha512.create()) + caCert.sign(privateKey, forge.md.sha512.create()) // Convert to PEM format - const pemCert = pki.certificateToPem(CAcert) + const pemCert = pki.certificateToPem(caCert) const pemKey = pki.privateKeyToPem(privateKey) // Return the PEM encoded cert and private key return { certificate: pemCert, privateKey: pemKey, - notBefore: CAcert.validity.notBefore, - notAfter: CAcert.validity.notAfter, + notBefore: caCert.validity.notBefore, + notAfter: caCert.validity.notAfter, } } @@ -115,9 +116,10 @@ export async function createRootCA(): Promise { export async function generateCert(options?: CertOption): Promise { log.debug('generateCert', options) - if (!options?.hostCertCN?.trim()) throw new Error('"hostCertCN" must be a String') - if (!options.domain?.trim()) throw new Error('"domain" must be a String') - + if (!options?.hostCertCN?.trim()) + throw new Error('"hostCertCN" must be a String') + if (!options.domain?.trim()) + throw new Error('"domain" must be a String') if (!options.rootCAObject || !options.rootCAObject.certificate || !options.rootCAObject.privateKey) throw new Error('"rootCAObject" must be an Object with the properties "certificate" & "privateKey"') @@ -127,6 +129,7 @@ export async function generateCert(options?: CertOption): Promise { const certPath = storeCert(cert, options) - const CAcertPath = storeCACert(CAcert, options) + const caCertPath = storeCACert(CAcert, options) const platform = os.platform() const args = 'TC, C, C' @@ -185,12 +188,14 @@ export async function addCertToSystemTrustStoreAndSaveCerts( if (platform === 'darwin') { // macOS await runCommand( - `sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain ${CAcertPath}`, + `sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain ${caCertPath}`, ) - } else if (platform === 'win32') { + } + else if (platform === 'win32') { // Windows - await runCommand(`certutil -f -v -addstore -enterprise Root ${CAcertPath}`) - } else if (platform === 'linux') { + await runCommand(`certutil -f -v -addstore -enterprise Root ${caCertPath}`) + } + else if (platform === 'linux') { // Linux (This might vary based on the distro) // for Ubuntu/Debian based systems @@ -202,13 +207,14 @@ export async function addCertToSystemTrustStoreAndSaveCerts( try { // delete existing cert from system trust store await runCommand(`certutil -d sql:${folder} -D -n ${DEFAULT_O}`) - } catch (error) { + } + catch (error) { // ignore error if no cert exists console.warn(`Error deleting existing cert: ${error}`) } // add new cert to system trust store - await runCommand(`certutil -d sql:${folder} -A -t ${args} -n ${DEFAULT_O} -i ${CAcertPath}`) + await runCommand(`certutil -d sql:${folder} -A -t ${args} -n ${DEFAULT_O} -i ${caCertPath}`) log.info(`Cert added to ${folder}`) } @@ -217,37 +223,44 @@ export async function addCertToSystemTrustStoreAndSaveCerts( // `sudo cp ${certPath} /usr/local/share/ca-certificates/`, // // add new cert to system trust store - // `certutil -d sql:${os.homedir()}/.pki/nssdb -A -t ${args} -n ${DEFAULT_O} -i ${CAcertPath}`, + // `certutil -d sql:${os.homedir()}/.pki/nssdb -A -t ${args} -n ${DEFAULT_O} -i ${caCertPath}`, // // add new cert to system trust store for Brave - // `certutil -d sql:${os.homedir()}/snap/brave/411/.pki/nssdb -A -t ${args} -n ${DEFAULT_O} -i ${CAcertPath}`, + // `certutil -d sql:${os.homedir()}/snap/brave/411/.pki/nssdb -A -t ${args} -n ${DEFAULT_O} -i ${caCertPath}`, // // add new cert to system trust store for Firefox - // `certutil -d sql:${os.homedir()}/snap/firefox/common/.mozilla/firefox/3l148raz.default -A -t ${args} -n ${DEFAULT_O} -i ${CAcertPath}`, + // `certutil -d sql:${os.homedir()}/snap/firefox/common/.mozilla/firefox/3l148raz.default -A -t ${args} -n ${DEFAULT_O} -i ${caCertPath}`, // // reload system trust store // `sudo update-ca-certificates`, // ]).catch((err) => { // throw new Error(err) // }) - } else throw new Error(`Unsupported platform: ${platform}`) + } + else { + throw new Error(`Unsupported platform: ${platform}`) + } return certPath } -export function storeCert(cert: { certificate: string; privateKey: string }, options?: AddCertOption): string { +export function storeCert(cert: { certificate: string, privateKey: string }, options?: AddCertOption): string { // Construct the path using os.homedir() and path.join() - const certPath = options?.customCertPath || path.join(os.homedir(), '.stacks', 'ssl', `stacks.localhost.crt`) - const certKeyPath = options?.customCertPath || path.join(os.homedir(), '.stacks', 'ssl', `stacks.localhost.crt.key`) + const certPath = options?.customCertPath || path.join(os.homedir(), '.stacks', 'ssl', `tlsx.localhost.crt`) + const certKeyPath = options?.customCertPath || path.join(os.homedir(), '.stacks', 'ssl', `tlsx.localhost.crt.key`) // Ensure the directory exists before writing the file const certDir = path.dirname(certPath) - if (!fs.existsSync(certDir)) fs.mkdirSync(certDir, { recursive: true }) + if (!fs.existsSync(certDir)) + fs.mkdirSync(certDir, { recursive: true }) + fs.writeFileSync(certPath, cert.certificate) // Ensure the directory exists before writing the file const certKeyDir = path.dirname(certKeyPath) - if (!fs.existsSync(certKeyDir)) fs.mkdirSync(certKeyDir, { recursive: true }) + if (!fs.existsSync(certKeyDir)) + fs.mkdirSync(certKeyDir, { recursive: true }) + fs.writeFileSync(certKeyPath, cert.privateKey) return certPath @@ -261,49 +274,16 @@ export function storeCert(cert: { certificate: string; privateKey: string }, opt */ export function storeCACert(CAcert: string, options?: AddCertOption): string { // Construct the path using os.homedir() and path.join() - const CAcertPath = options?.customCertPath || path.join(os.homedir(), '.stacks', 'ssl', `tlsx.localhost.ca.crt`) + const caCertPath = options?.customCertPath || path.join(os.homedir(), '.stacks', 'ssl', `tlsx.localhost.ca.crt`) // Ensure the directory exists before writing the file - const CacertDir = path.dirname(CAcertPath) - if (!fs.existsSync(CacertDir)) fs.mkdirSync(CacertDir, { recursive: true }) - fs.writeFileSync(CAcertPath, CAcert) - - return CAcertPath -} - -function findFoldersWithFile(rootDir: string, fileName: string): string[] { - const result: string[] = [] - - function search(dir: string) { - try { - const files = fs.readdirSync(dir) - - for (const file of files) { - const filePath = path.join(dir, file) - const stats = fs.lstatSync(filePath) - - if (stats.isDirectory()) { - search(filePath) - } else if (file === fileName) { - result.push(dir) - } - } - } catch (error) { - console.warn(`Error reading directory ${dir}: ${error}`) - } - } - - search(rootDir) - return result -} - -const makeNumberPositive = (hexString: string): string => { - let mostSignificativeHexDigitAsInt = Number.parseInt(hexString[0], 16) + const caCertDir = path.dirname(caCertPath) + if (!fs.existsSync(caCertDir)) + fs.mkdirSync(caCertDir, { recursive: true }) - if (mostSignificativeHexDigitAsInt < 8) return hexString + fs.writeFileSync(caCertPath, CAcert) - mostSignificativeHexDigitAsInt -= 8 - return mostSignificativeHexDigitAsInt.toString() + hexString.substring(1) + return caCertPath } -export { tls, pki, forge } +export { forge, pki, tls } diff --git a/src/config.ts b/src/config.ts index 94267f1..7742ff7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,8 @@ import type { TlsConfig } from './types' +import process from 'node:process' import { loadConfig } from 'bun-config' +// eslint-disable-next-line antfu/no-top-level-await export const config: TlsConfig = await loadConfig({ name: 'tls', cwd: process.cwd(), @@ -16,5 +18,5 @@ export const config: TlsConfig = await loadConfig({ hostCertCN: 'stacks.localhost', domain: 'localhost', rootCAObject: { certificate: '', privateKey: '' }, - } + }, }) diff --git a/src/index.ts b/src/index.ts index 118b97e..58b1b0d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ +export * from './certificate' export { config } from './config' -export * from './keys' -export * from './utils' export * from './types' +export * from './utils' diff --git a/src/types.ts b/src/types.ts index 39eab17..035a0d4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,11 +1,7 @@ -export type DeepPartial = { - [P in keyof T]?: DeepPartial -} - export interface TlsConfig { hostCertCN: string domain: string - rootCAObject: { certificate: string; privateKey: string } + rootCAObject: { certificate: string, privateKey: string } altNameIPs: string[] altNameURIs: string[] commonName: string @@ -19,7 +15,7 @@ export interface TlsConfig { export interface CertOption { hostCertCN: string domain: string - rootCAObject: { certificate: string; privateKey: string } + rootCAObject: { certificate: string, privateKey: string } altNameIPs?: string[] altNameURIs?: string[] validityDays?: number @@ -50,3 +46,7 @@ export interface GenerateCertReturn { } export type TlsOption = DeepPartial + +export type DeepPartial = { + [P in keyof T]?: DeepPartial +} diff --git a/src/utils.ts b/src/utils.ts index 9e3e7c5..f083d85 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,8 +1,8 @@ +import type { CertDetails } from './types' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import { pki } from 'node-forge' -import type { CertDetails } from './types' /** * Checks if a certificate is valid for a given domain. @@ -75,7 +75,8 @@ export function getCertificateFromCertPemOrPath(certPemOrPath: string): pki.Cert if (certPemOrPath.startsWith('-----BEGIN CERTIFICATE-----')) { // If the input is a PEM string certPem = certPemOrPath - } else { + } + else { // If the input is a path to the certificate file certPem = readCertFromFile(certPemOrPath) } @@ -98,32 +99,75 @@ export function listCertsInDirectory(dirPath?: string): string[] { if (platform === 'darwin') { // macOS default certificate directory defaultDir = '/etc/ssl/certs' - } else if (platform === 'win32') { + } + else if (platform === 'win32') { // Windows default certificate directory defaultDir = 'C:\\Windows\\System32\\certsrv\\CertEnroll' - } else if (platform === 'linux') { + } + else if (platform === 'linux') { // Linux default certificate directory defaultDir = '/etc/ssl/certs' - } else { + } + else { throw new Error(`Unsupported platform: ${platform}`) } - } else { + } + else { defaultDir = dirPath } const certFiles = fs .readdirSync(defaultDir) - .filter((file) => file.endsWith('.crt')) - .map((file) => path.join(defaultDir, file)) + .filter(file => file.endsWith('.crt')) + .map(file => path.join(defaultDir, file)) // If no certificates are found in the default directory, check the fallback path const stacksDir = path.join(os.homedir(), '.stacks', 'ssl') certFiles.push( ...fs .readdirSync(stacksDir) - .filter((file) => file.endsWith('.crt')) - .map((file) => path.join(stacksDir, file)), + .filter(file => file.endsWith('.crt')) + .map(file => path.join(stacksDir, file)), ) return certFiles } + +export function makeNumberPositive(hexString: string): string { + let mostSignificativeHexDigitAsInt = Number.parseInt(hexString[0], 16) + + if (mostSignificativeHexDigitAsInt < 8) + return hexString + + mostSignificativeHexDigitAsInt -= 8 + + return mostSignificativeHexDigitAsInt.toString() + hexString.substring(1) +} + +export function findFoldersWithFile(rootDir: string, fileName: string): string[] { + const result: string[] = [] + + function search(dir: string) { + try { + const files = fs.readdirSync(dir) + + for (const file of files) { + const filePath = path.join(dir, file) + const stats = fs.lstatSync(filePath) + + if (stats.isDirectory()) { + search(filePath) + } + else if (file === fileName) { + result.push(dir) + } + } + } + catch (error) { + console.warn(`Error reading directory ${dir}: ${error}`) + } + } + + search(rootDir) + return result +} diff --git a/test/tlsx.test.ts b/test/tlsx.test.ts index 9da3fe3..86ef498 100644 --- a/test/tlsx.test.ts +++ b/test/tlsx.test.ts @@ -1,6 +1,6 @@ +import type { CertOption } from '../src/types' import { describe, expect, it } from 'bun:test' import { createRootCA, generateCert, isCertExpired, isCertValidForDomain, parseCertDetails } from '../src' -import type { CertOptions } from '../src/types' describe('@stacksjs/tlsx', () => { it('should create a Root CA certificate', async () => { @@ -13,7 +13,7 @@ describe('@stacksjs/tlsx', () => { it('should generate a host certificate', async () => { const rootCA = await createRootCA() - const options: CertOptions = { + const options: CertOption = { hostCertCN: 'localhost', domain: 'localhost', rootCAObject: { @@ -30,7 +30,7 @@ describe('@stacksjs/tlsx', () => { it('should validate a certificate for a domain', async () => { const rootCA = await createRootCA() - const options: CertOptions = { + const options: CertOption = { hostCertCN: 'localhost', domain: 'localhost', rootCAObject: { diff --git a/tls.config.ts b/tls.config.ts index 2977458..4e81511 100644 --- a/tls.config.ts +++ b/tls.config.ts @@ -1,6 +1,12 @@ import type { TlsConfig } from './src/types' const config: TlsConfig = { + domain: 'localhost', + hostCertCN: 'stacks.localhost', + rootCAObject: { + certificate: '', + privateKey: '', + }, altNameIPs: ['127.0.0.1'], altNameURIs: ['localhost'], organizationName: 'stacksjs.org',