Skip to content

Commit dfe9138

Browse files
chore: Generate CA Cert add save CA to System Trust
1 parent 01ed0db commit dfe9138

File tree

4 files changed

+230
-53
lines changed

4 files changed

+230
-53
lines changed

bin/cli.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,11 @@ import os from 'node:os'
22
import { log } from '@stacksjs/logging'
33
import { CAC } from 'cac'
44
import { version } from '../package.json'
5-
import { addCertToSystemTrustStore, generateCert } from '../src'
5+
import { CreateRootCA, addCertToSystemTrustStoreAndSaveCerts, generateCert } from '../src'
66

77
const cli = new CAC('tlsx')
88

99
interface Options {
10-
domain: string
1110
output: string
1211
key: string
1312
cert: string
@@ -24,13 +23,18 @@ cli
2423
.option('--verbose', 'Enable verbose logging', { default: false })
2524
.usage('tlsx secure <domain> [options]')
2625
.example('tlsx secure example.com --output /etc/ssl')
27-
.action(async (domain?: string, options?: Options) => {
26+
.action(async (domain: string, options?: Options) => {
2827
log.debug(`Generating a self-signed SSL certificate for domain: ${domain}`)
2928
log.debug('Options:', options)
30-
await addCertToSystemTrustStore((await generateCert()).cert) // TODO: domain
31-
// Generate a keypair and create an X.509v3 certificate for the domain
32-
// await generateAndSaveCertificates()
33-
// await addRootCAToSystemTrust()
29+
30+
// Create a new Root CA
31+
const CAcert = await CreateRootCA()
32+
33+
// await generateCert()
34+
const HostCert = await generateCert('Tlsx Stacks RootCA', domain, CAcert, options)
35+
36+
// await addCertToSystemTrustStoreAndSaveCerts()
37+
await addCertToSystemTrustStoreAndSaveCerts(HostCert.certificate, CAcert.certificate)
3438
})
3539

3640
cli.version(version)

bun.lockb

0 Bytes
Binary file not shown.

src/keys.ts

Lines changed: 214 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -7,72 +7,228 @@ import forge, { pki, tls } from 'node-forge'
77
import { resolveConfig } from './config'
88
import type { GenerateCertOptions } from './types'
99

10-
export async function generateCert(options?: GenerateCertOptions) {
11-
log.debug('generateCert', options)
10+
const makeNumberPositive = (hexString: string) => {
11+
let mostSignificativeHexDigitAsInt = Number.parseInt(hexString[0], 16)
1212

13-
const opts = await resolveConfig(options)
14-
const keys = pki.rsa.generateKeyPair(2048)
15-
const cert = pki.createCertificate()
16-
cert.publicKey = keys.publicKey
13+
if (mostSignificativeHexDigitAsInt < 8) return hexString
14+
15+
mostSignificativeHexDigitAsInt -= 8
16+
return mostSignificativeHexDigitAsInt.toString() + hexString.substring(1)
17+
}
18+
19+
// Generate a random serial number for the Certificate
20+
const randomSerialNumber = () => {
21+
return makeNumberPositive(forge.util.bytesToHex(forge.random.getBytesSync(20)))
22+
}
1723

18-
// NOTE: serialNumber is the hex encoded value of an ASN.1 INTEGER.
19-
// Conforming CAs should ensure serialNumber is:
20-
// - no more than 20 octets
21-
// - non-negative (prefix a '00' if your value starts with a '1' bit)
22-
cert.serialNumber = `01${crypto.randomBytes(19).toString('hex')}` // 1 octet = 8 bits = 1 byte = 2 hex chars
23-
cert.validity.notBefore = new Date()
24-
cert.validity.notAfter = new Date(new Date().getTime() + 1000 * 60 * 60 * 24 * (opts.validityDays ?? 1))
24+
// Get the Not Before Date for a Certificate (will be valid from 2 days ago)
25+
const getCertNotBefore = () => {
26+
const twoDaysAgo = new Date(Date.now() - 60 * 60 * 24 * 2 * 1000)
27+
const year = twoDaysAgo.getFullYear()
28+
const month = (twoDaysAgo.getMonth() + 1).toString().padStart(2, '0')
29+
const day = twoDaysAgo.getDate()
30+
return new Date(`${year}-${month}-${day} 00:00:00Z`)
31+
}
2532

26-
const attrs = [
33+
// Get Certificate Expiration Date (Valid for 90 Days)
34+
const getCertNotAfter = (notBefore: any) => {
35+
const ninetyDaysLater = new Date(notBefore.getTime() + 60 * 60 * 24 * 90 * 1000)
36+
const year = ninetyDaysLater.getFullYear()
37+
const month = (ninetyDaysLater.getMonth() + 1).toString().padStart(2, '0')
38+
const day = ninetyDaysLater.getDate()
39+
return new Date(`${year}-${month}-${day} 23:59:59Z`)
40+
}
41+
42+
// Get CA Expiration Date (Valid for 100 Years)
43+
const getCANotAfter = (notBefore: any) => {
44+
const year = notBefore.getFullYear() + 100
45+
const month = (notBefore.getMonth() + 1).toString().padStart(2, '0')
46+
const day = notBefore.getDate()
47+
return new Date(`${year}-${month}-${day} 23:59:59Z`)
48+
}
49+
50+
const DEFAULT_C = 'US'
51+
const DEFAULT_ST = 'California'
52+
const DEFAULT_L = 'Melbourne'
53+
const DEFAULT_O = 'Tlsx Stacks RootCA'
54+
55+
// Generate a new Root CA Certificate
56+
export async function CreateRootCA() {
57+
// Create a new Keypair for the Root CA
58+
const { privateKey, publicKey } = forge.pki.rsa.generateKeyPair(2048)
59+
60+
// Define the attributes for the new Root CA
61+
const attributes = [
2762
{
28-
name: 'countryName',
29-
value: opts.countryName ?? 'US',
63+
shortName: 'C',
64+
value: DEFAULT_C,
3065
},
3166
{
3267
shortName: 'ST',
33-
value: opts.stateName ?? 'California',
68+
value: DEFAULT_ST,
69+
},
70+
{
71+
shortName: 'L',
72+
value: DEFAULT_L,
3473
},
3574
{
36-
name: 'organizationName',
37-
value: opts.organizationName ?? 'tlsx stacks.localhost', // simply for a recognizable name
75+
shortName: 'CN',
76+
value: DEFAULT_O,
3877
},
3978
]
4079

41-
cert.setSubject(attrs)
42-
cert.setIssuer(attrs)
80+
const extensions = [
81+
{
82+
name: 'basicConstraints',
83+
cA: true,
84+
},
85+
{
86+
name: 'keyUsage',
87+
keyCertSign: true,
88+
cRLSign: true,
89+
},
90+
]
91+
92+
// Create an empty Certificate
93+
const cert = forge.pki.createCertificate()
94+
95+
// Set the Certificate attributes for the new Root CA
96+
cert.publicKey = publicKey
97+
cert.privateKey = privateKey
98+
cert.serialNumber = randomSerialNumber()
99+
cert.validity.notBefore = getCertNotBefore()
100+
cert.validity.notAfter = getCANotAfter(cert.validity.notBefore)
101+
cert.setSubject(attributes)
102+
cert.setIssuer(attributes)
103+
cert.setExtensions(extensions)
104+
105+
// Self-sign the Certificate
106+
cert.sign(privateKey, forge.md.sha512.create())
107+
108+
// Convert to PEM format
109+
const pemCert = forge.pki.certificateToPem(cert)
110+
const pemKey = forge.pki.privateKeyToPem(privateKey)
111+
112+
// Return the PEM encoded cert and private key
113+
return {
114+
certificate: pemCert,
115+
privateKey: pemKey,
116+
notBefore: cert.validity.notBefore,
117+
notAfter: cert.validity.notAfter,
118+
}
119+
}
43120

44-
// add alt names so that the browser won't complain
45-
cert.setExtensions([
121+
export async function generateCert(
122+
hostCertCN: string,
123+
domain: string,
124+
rootCAObject: { certificate: string; privateKey: string },
125+
options?: GenerateCertOptions,
126+
) {
127+
log.debug('generateCert', options)
128+
129+
if (!hostCertCN.toString().trim()) throw new Error('"hostCertCN" must be a String')
130+
if (!domain.toString().trim()) throw new Error('"validDomain" must be a String')
131+
132+
if (!rootCAObject || !rootCAObject.certificate || !rootCAObject.privateKey)
133+
throw new Error('"rootCAObject" must be an Object with the properties "certificate" & "privateKey"')
134+
135+
const opts = await resolveConfig(options)
136+
// Convert the Root CA PEM details, to a forge Object
137+
const caCert = pki.certificateFromPem(rootCAObject.certificate)
138+
const caKey = pki.privateKeyFromPem(rootCAObject.privateKey)
139+
140+
// Create a new Keypair for the Host Certificate
141+
const hostKeys = pki.rsa.generateKeyPair(2048)
142+
// Define the attributes/properties for the Host Certificate
143+
const attributes = [
46144
{
47-
name: 'subjectAltName',
48-
altNames: [
49-
...(opts.altNameURIs !== undefined ? opts.altNameURIs.map((uri) => ({ type: 6, value: uri })) : []),
145+
shortName: 'C',
146+
value: DEFAULT_C,
147+
},
148+
{
149+
shortName: 'ST',
150+
value: DEFAULT_ST,
151+
},
152+
{
153+
shortName: 'L',
154+
value: DEFAULT_L,
155+
},
156+
{
157+
shortName: 'CN',
158+
value: hostCertCN,
159+
},
160+
]
50161

51-
...(opts.altNameIPs !== undefined ? opts.altNameIPs.map((uri) => ({ type: 7, ip: uri })) : []),
52-
],
162+
const extensions = [
163+
// {
164+
// name: 'basicConstraints',
165+
// cA: true
166+
// },
167+
{
168+
name: 'nsCertType',
169+
server: true,
170+
},
171+
{
172+
name: 'subjectKeyIdentifier',
173+
},
174+
{
175+
name: 'authorityKeyIdentifier',
176+
authorityCertIssuer: true,
177+
serialNumber: caCert.serialNumber,
178+
},
179+
{
180+
name: 'keyUsage',
181+
digitalSignature: true,
182+
nonRepudiation: true,
183+
keyEncipherment: true,
184+
},
185+
{
186+
name: 'extKeyUsage',
187+
serverAuth: true,
188+
},
189+
{
190+
name: 'subjectAltName',
191+
altNames: { type: 2, value: domain },
53192
},
54-
])
193+
]
194+
195+
// Create an empty Certificate
196+
const newHostCert = forge.pki.createCertificate()
197+
newHostCert.publicKey = hostKeys.publicKey
55198

56-
// self-sign certificate
57-
cert.sign(keys.privateKey)
199+
// Set the attributes for the new Host Certificate
200+
newHostCert.publicKey = hostKeys.publicKey
201+
newHostCert.serialNumber = randomSerialNumber()
202+
newHostCert.validity.notBefore = getCertNotBefore()
203+
newHostCert.validity.notAfter = getCertNotAfter(newHostCert.validity.notBefore)
204+
newHostCert.setSubject(attributes)
205+
newHostCert.setIssuer(caCert.subject.attributes)
206+
newHostCert.setExtensions(extensions)
58207

59-
// convert a Forge certificate and private key to PEM
60-
const pem = pki.certificateToPem(cert)
61-
const privateKey = pki.privateKeyToPem(keys.privateKey)
208+
// Sign the new Host Certificate using the CA
209+
newHostCert.sign(caKey, forge.md.sha512.create())
210+
211+
// Convert to PEM format
212+
const pemHostCert = pki.certificateToPem(newHostCert)
213+
const pemHostKey = pki.privateKeyToPem(hostKeys.privateKey)
62214

63215
return {
64-
cert: pem,
65-
privateKey,
216+
certificate: pemHostCert,
217+
privateKey: pemHostKey,
66218
}
67219
}
68220

69221
export interface AddCertOptions {
70222
customCertPath?: string
71223
}
72224

73-
export async function addCertToSystemTrustStore(cert: string, options?: AddCertOptions) {
225+
export async function addCertToSystemTrustStoreAndSaveCerts(cert: string, CAcert: string, options?: AddCertOptions) {
226+
74227
const certPath = storeCert(cert, options)
228+
const CAcertPath = storeCACert(CAcert, options)
229+
75230
const platform = os.platform()
231+
const args = 'TC, C, C'
76232

77233
if (platform === 'darwin')
78234
// macOS
@@ -84,7 +240,14 @@ export async function addCertToSystemTrustStore(cert: string, options?: AddCertO
84240
else if (platform === 'linux')
85241
// Linux (This might vary based on the distro)
86242
// for Ubuntu/Debian based systems
87-
await runCommands([`sudo cp ${certPath} /usr/local/share/ca-certificates/`, `sudo update-ca-certificates`])
243+
244+
await runCommands([
245+
`sudo cp ${certPath} /usr/local/share/ca-certificates/`,
246+
247+
`certutil -d sql:${os.homedir()}/.pki/nssdb -A -t ${args} -n ${DEFAULT_O} -i ${CAcertPath}`,
248+
249+
`sudo update-ca-certificates`,
250+
])
88251
else throw new Error(`Unsupported platform: ${platform}`)
89252
return certPath
90253
}
@@ -102,4 +265,17 @@ export function storeCert(cert: string, options?: AddCertOptions) {
102265
return certPath
103266
}
104267

268+
export function storeCACert(CAcert: string, options?: AddCertOptions) {
269+
// Construct the path using os.homedir() and path.join()
270+
const certPath = options?.customCertPath || path.join(os.homedir(), '.stacks', 'ssl', `stacks.localhost.ca.crt`)
271+
272+
// Ensure the directory exists before writing the file
273+
const certDir = path.dirname(certPath)
274+
if (!fs.existsSync(certDir)) fs.mkdirSync(certDir, { recursive: true })
275+
276+
fs.writeFileSync(certPath, CAcert)
277+
278+
return certPath
279+
}
280+
105281
export { tls, pki, forge }

src/types.ts

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,11 @@ export interface TlsOptions {
1616
}
1717

1818
export interface GenerateCertOptions {
19-
altNameIPs?: string[]
20-
altNameURIs?: string[]
21-
validityDays?: number
22-
organizationName?: string
23-
countryName?: string
24-
stateName?: string
25-
localityName?: string
26-
commonName?: string
19+
output: string
20+
key: string
21+
cert: string
22+
ca: string
23+
verbose: boolean
2724
}
2825

2926
export type TlsConfig = DeepPartial<TlsOptions>

0 commit comments

Comments
 (0)