Skip to content

Commit 182414b

Browse files
committed
feat: allow creating cert for multiple domains
1 parent f6b5e9f commit 182414b

File tree

3 files changed

+156
-26
lines changed

3 files changed

+156
-26
lines changed

src/certificate.ts

Lines changed: 85 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,60 @@
1-
import type { CAOptions, Certificate, CertificateOptions, TlsOption } from './types'
1+
import type { CAOptions, Cert, Certificate, CertificateExtension, CertificateOptions, CertPath, RandomSerialNumber, SubjectAltName, TlsOption } from './types'
22
import fs from 'node:fs'
33
import os from 'node:os'
44
import path from 'node:path'
55
import { consola as log } from 'consola'
66
import forge, { pki, tls } from 'node-forge'
77
import { config } from './config'
8-
import { debugLog, findFoldersWithFile, makeNumberPositive, runCommand } from './utils'
8+
import { debugLog, findFoldersWithFile, makeNumberPositive, runCommand, getPrimaryDomain } from './utils'
99

10-
interface Cert {
11-
certificate: string
12-
privateKey: string
13-
}
10+
/**
11+
* Generates Subject Alternative Names for the certificate
12+
* @param options Certificate generation options
13+
* @returns Array of SubjectAltName objects
14+
*/
15+
function generateSubjectAltNames(options: CertificateOptions): SubjectAltName[] {
16+
const altNames: SubjectAltName[] = []
17+
18+
// Add all domains to SAN
19+
const domains = new Set<string>()
20+
21+
// Add primary domain if explicitly set
22+
if (options.domain) {
23+
domains.add(options.domain)
24+
}
25+
26+
// Add all domains from the domains array
27+
if (options.domains?.length) {
28+
options.domains.forEach(domain => domains.add(domain))
29+
}
30+
31+
// Convert domains to SAN entries
32+
for (const domain of domains) {
33+
altNames.push({ type: 2, value: domain })
34+
}
1435

15-
type CertPath = string
16-
type RandomSerialNumber = string
36+
// Add IP addresses if specified
37+
if (options.altNameIPs?.length) {
38+
for (const ip of options.altNameIPs) {
39+
altNames.push({ type: 7, value: ip })
40+
}
41+
}
42+
43+
// Add URIs if specified
44+
if (options.altNameURIs?.length) {
45+
for (const uri of options.altNameURIs) {
46+
altNames.push({ type: 6, value: uri })
47+
}
48+
}
49+
50+
// Add any additional subject alt names
51+
if (options.subjectAltNames?.length) {
52+
altNames.push(...options.subjectAltNames)
53+
}
54+
55+
debugLog('cert', `Generated ${altNames.length} Subject Alternative Names`, options.verbose)
56+
return altNames
57+
}
1758

1859
/**
1960
* Generate a random serial number for the Certificate
@@ -49,18 +90,32 @@ export function calculateValidityDates(options: {
4990
return { notBefore, notAfter }
5091
}
5192

52-
function generateCertificateExtensions(options: CertificateOptions) {
53-
const extensions = []
93+
/**
94+
* Generates certificate extensions including subject alt names
95+
* @param options Certificate generation options
96+
* @returns Array of certificate extensions
97+
*/
98+
function generateCertificateExtensions(options: CertificateOptions): CertificateExtension[] {
99+
const extensions: CertificateExtension[] = []
54100

55-
// Basic Constraints
101+
// Add basic constraints
56102
extensions.push({
57103
name: 'basicConstraints',
58104
cA: options.isCA ?? false,
59105
critical: true,
60106
...(options.basicConstraints || {}),
61107
})
62108

63-
// Key Usage
109+
// Add subject alt names
110+
const altNames = generateSubjectAltNames(options)
111+
if (altNames.length > 0) {
112+
extensions.push({
113+
name: 'subjectAltName',
114+
altNames,
115+
})
116+
}
117+
118+
// Add key usage if specified
64119
if (options.keyUsage) {
65120
extensions.push({
66121
name: 'keyUsage',
@@ -69,22 +124,14 @@ function generateCertificateExtensions(options: CertificateOptions) {
69124
})
70125
}
71126

72-
// Extended Key Usage
127+
// Add extended key usage if specified
73128
if (options.extKeyUsage) {
74129
extensions.push({
75130
name: 'extKeyUsage',
76131
...options.extKeyUsage,
77132
})
78133
}
79134

80-
// Subject Alt Names
81-
if (options.subjectAltNames && options.subjectAltNames.length > 0) {
82-
extensions.push({
83-
name: 'subjectAltName',
84-
altNames: options.subjectAltNames,
85-
})
86-
}
87-
88135
return extensions
89136
}
90137

@@ -145,10 +192,20 @@ export async function createRootCA(options: CAOptions = {}): Promise<Certificate
145192
}
146193
}
147194

195+
/**
196+
* Generates a certificate for one or multiple domains
197+
* @param options Certificate generation options
198+
* @returns Generated certificate
199+
*/
148200
export async function generateCertificate(options: CertificateOptions): Promise<Certificate> {
149201
debugLog('cert', 'Generating new certificate', options.verbose)
150202
debugLog('cert', `Options: ${JSON.stringify(options)}`, options.verbose)
151203

204+
// Validate that at least one domain is specified
205+
if (!options.domain && !options.domains?.length) {
206+
throw new Error('Either domain or domains must be specified')
207+
}
208+
152209
if (!options.rootCA?.certificate || !options.rootCA?.privateKey) {
153210
throw new Error('Root CA certificate and private key are required')
154211
}
@@ -158,30 +215,34 @@ export async function generateCertificate(options: CertificateOptions): Promise<
158215

159216
debugLog('cert', 'Generating 2048-bit RSA key pair for host certificate', options.verbose)
160217
const keySize = 2048
161-
// const keySize = options.keySize || 2048
162218
const { privateKey, publicKey } = pki.rsa.generateKeyPair(keySize)
163219

164-
// Allow for custom certificate attributes
220+
// Use the primary domain for the CN if no specific commonName is provided
221+
const commonName = options.commonName || getPrimaryDomain(options)
222+
165223
const attributes = options.certificateAttributes || [
166224
{ shortName: 'C', value: options.countryName || config.countryName },
167225
{ shortName: 'ST', value: options.stateName || config.stateName },
168226
{ shortName: 'L', value: options.localityName || config.localityName },
169227
{ shortName: 'O', value: options.organizationName || config.organizationName },
170-
{ shortName: 'CN', value: options.commonName || config.commonName },
228+
{ shortName: 'CN', value: commonName },
171229
]
172230

173231
const { notBefore, notAfter } = calculateValidityDates({
174232
validityDays: options.validityDays,
175233
verbose: options.verbose,
176234
})
177235

236+
// Generate certificate
178237
const cert = pki.createCertificate()
179238
cert.publicKey = publicKey
180239
cert.serialNumber = generateRandomSerial(options.verbose)
181240
cert.validity.notBefore = notBefore
182241
cert.validity.notAfter = notAfter
183242
cert.setSubject(attributes)
184243
cert.setIssuer(caCert.subject.attributes)
244+
245+
// Set extensions with proper typing
185246
cert.setExtensions(generateCertificateExtensions(options))
186247
cert.sign(caKey, forge.md.sha256.create())
187248

src/types.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export interface TlsConfig {
22
hostCertCN: string
33
domain: string
4+
domains?: string[]
45
altNameIPs: string[]
56
altNameURIs: string[]
67
validityDays: number
@@ -53,7 +54,8 @@ export interface SubjectAltName {
5354
}
5455

5556
export interface CertificateOptions {
56-
domain: string
57+
domain?: string
58+
domains?: string[]
5759
rootCA: { certificate: string, privateKey: string }
5860
hostCertCN?: string
5961
altNameIPs?: string[]
@@ -132,3 +134,52 @@ export interface CAOptions extends TlsOption {
132134
export type DeepPartial<T> = {
133135
[P in keyof T]?: DeepPartial<T[P]>
134136
}
137+
138+
export interface Cert {
139+
certificate: string
140+
privateKey: string
141+
}
142+
143+
export type CertPath = string
144+
export type RandomSerialNumber = string
145+
146+
export interface BasicConstraintsExtension {
147+
name: 'basicConstraints'
148+
cA: boolean
149+
pathLenConstraint?: number
150+
critical: boolean
151+
}
152+
153+
export interface KeyUsageExtension {
154+
name: 'keyUsage'
155+
critical: boolean
156+
digitalSignature?: boolean
157+
contentCommitment?: boolean
158+
keyEncipherment?: boolean
159+
dataEncipherment?: boolean
160+
keyAgreement?: boolean
161+
keyCertSign?: boolean
162+
cRLSign?: boolean
163+
encipherOnly?: boolean
164+
decipherOnly?: boolean
165+
}
166+
167+
export interface ExtKeyUsageExtension {
168+
name: 'extKeyUsage'
169+
serverAuth?: boolean
170+
clientAuth?: boolean
171+
codeSigning?: boolean
172+
emailProtection?: boolean
173+
timeStamping?: boolean
174+
}
175+
176+
interface SubjectAltNameExtension {
177+
name: 'subjectAltName'
178+
altNames: SubjectAltName[]
179+
}
180+
181+
export type CertificateExtension =
182+
| BasicConstraintsExtension
183+
| KeyUsageExtension
184+
| ExtKeyUsageExtension
185+
| SubjectAltNameExtension

src/utils.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { CertDetails } from './types'
1+
import type { CertDetails, CertificateOptions } from './types'
22
import { exec } from 'node:child_process'
33
import fs from 'node:fs'
44
import os from 'node:os'
@@ -224,3 +224,21 @@ export async function runCommand(
224224
throw enhancedError
225225
}
226226
}
227+
228+
/**
229+
* Gets the primary domain from options
230+
* @param options Certificate generation options
231+
* @throws Error if no domain is specified
232+
* @returns Primary domain
233+
*/
234+
export function getPrimaryDomain(options: CertificateOptions): string {
235+
if (options.domain) {
236+
return options.domain
237+
}
238+
239+
if (options.domains?.length) {
240+
return options.domains[0]
241+
}
242+
243+
throw new Error('Either domain or domains must be specified')
244+
}

0 commit comments

Comments
 (0)