From 182414b8a2f0c09a5cf865cc115862d998d9e554 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 19 Dec 2024 22:45:59 +0100 Subject: [PATCH] feat: allow creating cert for multiple domains --- src/certificate.ts | 109 +++++++++++++++++++++++++++++++++++---------- src/types.ts | 53 +++++++++++++++++++++- src/utils.ts | 20 ++++++++- 3 files changed, 156 insertions(+), 26 deletions(-) diff --git a/src/certificate.ts b/src/certificate.ts index d71a351..d427bb9 100644 --- a/src/certificate.ts +++ b/src/certificate.ts @@ -1,19 +1,60 @@ -import type { CAOptions, Certificate, CertificateOptions, TlsOption } from './types' +import type { CAOptions, Cert, Certificate, CertificateExtension, CertificateOptions, CertPath, RandomSerialNumber, SubjectAltName, TlsOption } from './types' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import { consola as log } from 'consola' import forge, { pki, tls } from 'node-forge' import { config } from './config' -import { debugLog, findFoldersWithFile, makeNumberPositive, runCommand } from './utils' +import { debugLog, findFoldersWithFile, makeNumberPositive, runCommand, getPrimaryDomain } from './utils' -interface Cert { - certificate: string - privateKey: string -} +/** + * Generates Subject Alternative Names for the certificate + * @param options Certificate generation options + * @returns Array of SubjectAltName objects + */ +function generateSubjectAltNames(options: CertificateOptions): SubjectAltName[] { + const altNames: SubjectAltName[] = [] + + // Add all domains to SAN + const domains = new Set() + + // Add primary domain if explicitly set + if (options.domain) { + domains.add(options.domain) + } + + // Add all domains from the domains array + if (options.domains?.length) { + options.domains.forEach(domain => domains.add(domain)) + } + + // Convert domains to SAN entries + for (const domain of domains) { + altNames.push({ type: 2, value: domain }) + } -type CertPath = string -type RandomSerialNumber = string + // Add IP addresses if specified + if (options.altNameIPs?.length) { + for (const ip of options.altNameIPs) { + altNames.push({ type: 7, value: ip }) + } + } + + // Add URIs if specified + if (options.altNameURIs?.length) { + for (const uri of options.altNameURIs) { + altNames.push({ type: 6, value: uri }) + } + } + + // Add any additional subject alt names + if (options.subjectAltNames?.length) { + altNames.push(...options.subjectAltNames) + } + + debugLog('cert', `Generated ${altNames.length} Subject Alternative Names`, options.verbose) + return altNames +} /** * Generate a random serial number for the Certificate @@ -49,10 +90,15 @@ export function calculateValidityDates(options: { return { notBefore, notAfter } } -function generateCertificateExtensions(options: CertificateOptions) { - const extensions = [] +/** + * Generates certificate extensions including subject alt names + * @param options Certificate generation options + * @returns Array of certificate extensions + */ +function generateCertificateExtensions(options: CertificateOptions): CertificateExtension[] { + const extensions: CertificateExtension[] = [] - // Basic Constraints + // Add basic constraints extensions.push({ name: 'basicConstraints', cA: options.isCA ?? false, @@ -60,7 +106,16 @@ function generateCertificateExtensions(options: CertificateOptions) { ...(options.basicConstraints || {}), }) - // Key Usage + // Add subject alt names + const altNames = generateSubjectAltNames(options) + if (altNames.length > 0) { + extensions.push({ + name: 'subjectAltName', + altNames, + }) + } + + // Add key usage if specified if (options.keyUsage) { extensions.push({ name: 'keyUsage', @@ -69,7 +124,7 @@ function generateCertificateExtensions(options: CertificateOptions) { }) } - // Extended Key Usage + // Add extended key usage if specified if (options.extKeyUsage) { extensions.push({ name: 'extKeyUsage', @@ -77,14 +132,6 @@ function generateCertificateExtensions(options: CertificateOptions) { }) } - // Subject Alt Names - if (options.subjectAltNames && options.subjectAltNames.length > 0) { - extensions.push({ - name: 'subjectAltName', - altNames: options.subjectAltNames, - }) - } - return extensions } @@ -145,10 +192,20 @@ export async function createRootCA(options: CAOptions = {}): Promise { debugLog('cert', 'Generating new certificate', options.verbose) debugLog('cert', `Options: ${JSON.stringify(options)}`, options.verbose) + // Validate that at least one domain is specified + if (!options.domain && !options.domains?.length) { + throw new Error('Either domain or domains must be specified') + } + if (!options.rootCA?.certificate || !options.rootCA?.privateKey) { throw new Error('Root CA certificate and private key are required') } @@ -158,16 +215,17 @@ export async function generateCertificate(options: CertificateOptions): Promise< debugLog('cert', 'Generating 2048-bit RSA key pair for host certificate', options.verbose) const keySize = 2048 - // const keySize = options.keySize || 2048 const { privateKey, publicKey } = pki.rsa.generateKeyPair(keySize) - // Allow for custom certificate attributes + // Use the primary domain for the CN if no specific commonName is provided + const commonName = options.commonName || getPrimaryDomain(options) + const attributes = options.certificateAttributes || [ { shortName: 'C', value: options.countryName || config.countryName }, { shortName: 'ST', value: options.stateName || config.stateName }, { shortName: 'L', value: options.localityName || config.localityName }, { shortName: 'O', value: options.organizationName || config.organizationName }, - { shortName: 'CN', value: options.commonName || config.commonName }, + { shortName: 'CN', value: commonName }, ] const { notBefore, notAfter } = calculateValidityDates({ @@ -175,6 +233,7 @@ export async function generateCertificate(options: CertificateOptions): Promise< verbose: options.verbose, }) + // Generate certificate const cert = pki.createCertificate() cert.publicKey = publicKey cert.serialNumber = generateRandomSerial(options.verbose) @@ -182,6 +241,8 @@ export async function generateCertificate(options: CertificateOptions): Promise< cert.validity.notAfter = notAfter cert.setSubject(attributes) cert.setIssuer(caCert.subject.attributes) + + // Set extensions with proper typing cert.setExtensions(generateCertificateExtensions(options)) cert.sign(caKey, forge.md.sha256.create()) diff --git a/src/types.ts b/src/types.ts index 3332d5b..5fcbd50 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,7 @@ export interface TlsConfig { hostCertCN: string domain: string + domains?: string[] altNameIPs: string[] altNameURIs: string[] validityDays: number @@ -53,7 +54,8 @@ export interface SubjectAltName { } export interface CertificateOptions { - domain: string + domain?: string + domains?: string[] rootCA: { certificate: string, privateKey: string } hostCertCN?: string altNameIPs?: string[] @@ -132,3 +134,52 @@ export interface CAOptions extends TlsOption { export type DeepPartial = { [P in keyof T]?: DeepPartial } + +export interface Cert { + certificate: string + privateKey: string +} + +export type CertPath = string +export type RandomSerialNumber = string + +export interface BasicConstraintsExtension { + name: 'basicConstraints' + cA: boolean + pathLenConstraint?: number + critical: boolean +} + +export interface KeyUsageExtension { + name: 'keyUsage' + critical: boolean + digitalSignature?: boolean + contentCommitment?: boolean + keyEncipherment?: boolean + dataEncipherment?: boolean + keyAgreement?: boolean + keyCertSign?: boolean + cRLSign?: boolean + encipherOnly?: boolean + decipherOnly?: boolean +} + +export interface ExtKeyUsageExtension { + name: 'extKeyUsage' + serverAuth?: boolean + clientAuth?: boolean + codeSigning?: boolean + emailProtection?: boolean + timeStamping?: boolean +} + +interface SubjectAltNameExtension { + name: 'subjectAltName' + altNames: SubjectAltName[] +} + +export type CertificateExtension = + | BasicConstraintsExtension + | KeyUsageExtension + | ExtKeyUsageExtension + | SubjectAltNameExtension diff --git a/src/utils.ts b/src/utils.ts index f8221bc..b016da2 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,4 +1,4 @@ -import type { CertDetails } from './types' +import type { CertDetails, CertificateOptions } from './types' import { exec } from 'node:child_process' import fs from 'node:fs' import os from 'node:os' @@ -224,3 +224,21 @@ export async function runCommand( throw enhancedError } } + +/** + * Gets the primary domain from options + * @param options Certificate generation options + * @throws Error if no domain is specified + * @returns Primary domain + */ +export function getPrimaryDomain(options: CertificateOptions): string { + if (options.domain) { + return options.domain + } + + if (options.domains?.length) { + return options.domains[0] + } + + throw new Error('Either domain or domains must be specified') +}