Skip to content

Commit

Permalink
feat: allow creating cert for multiple domains
Browse files Browse the repository at this point in the history
  • Loading branch information
chrisbbreuer committed Dec 19, 2024
1 parent f6b5e9f commit 182414b
Show file tree
Hide file tree
Showing 3 changed files with 156 additions and 26 deletions.
109 changes: 85 additions & 24 deletions src/certificate.ts
Original file line number Diff line number Diff line change
@@ -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<string>()

// 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
Expand Down Expand Up @@ -49,18 +90,32 @@ 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,
critical: true,
...(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',
Expand All @@ -69,22 +124,14 @@ function generateCertificateExtensions(options: CertificateOptions) {
})
}

// Extended Key Usage
// Add extended key usage if specified
if (options.extKeyUsage) {
extensions.push({
name: 'extKeyUsage',
...options.extKeyUsage,
})
}

// Subject Alt Names
if (options.subjectAltNames && options.subjectAltNames.length > 0) {
extensions.push({
name: 'subjectAltName',
altNames: options.subjectAltNames,
})
}

return extensions
}

Expand Down Expand Up @@ -145,10 +192,20 @@ export async function createRootCA(options: CAOptions = {}): Promise<Certificate
}
}

/**
* Generates a certificate for one or multiple domains
* @param options Certificate generation options
* @returns Generated certificate
*/
export async function generateCertificate(options: CertificateOptions): Promise<Certificate> {
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')
}
Expand All @@ -158,30 +215,34 @@ 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({
validityDays: options.validityDays,
verbose: options.verbose,
})

// Generate certificate
const cert = pki.createCertificate()
cert.publicKey = publicKey
cert.serialNumber = generateRandomSerial(options.verbose)
cert.validity.notBefore = notBefore
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())

Expand Down
53 changes: 52 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export interface TlsConfig {
hostCertCN: string
domain: string
domains?: string[]
altNameIPs: string[]
altNameURIs: string[]
validityDays: number
Expand Down Expand Up @@ -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[]
Expand Down Expand Up @@ -132,3 +134,52 @@ export interface CAOptions extends TlsOption {
export type DeepPartial<T> = {
[P in keyof T]?: DeepPartial<T[P]>
}

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
20 changes: 19 additions & 1 deletion src/utils.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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')
}

0 comments on commit 182414b

Please sign in to comment.