Files
smartacme/ts_server/server.classes.ca.ts

143 lines
4.6 KiB
TypeScript

import * as crypto from 'node:crypto';
/**
* Certificate Authority for the ACME server.
* Generates a self-signed root CA and signs certificates from CSRs.
* Uses @peculiar/x509 (already a project dependency).
*/
export class AcmeServerCA {
private caKeyPair!: CryptoKeyPair;
private caCert!: InstanceType<typeof import('@peculiar/x509').X509Certificate>;
private caCertPem!: string;
private certValidityDays: number;
constructor(private options: { commonName?: string; validityDays?: number; certValidityDays?: number } = {}) {
this.certValidityDays = options.certValidityDays ?? 90;
}
async init(): Promise<void> {
const x509 = await import('@peculiar/x509');
const { webcrypto } = crypto;
x509.cryptoProvider.set(webcrypto as any);
// Generate RSA key pair for the CA
this.caKeyPair = await webcrypto.subtle.generateKey(
{
name: 'RSASSA-PKCS1-v1_5',
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]),
hash: 'SHA-256',
},
true,
['sign', 'verify'],
) as CryptoKeyPair;
const cn = this.options.commonName ?? 'SmartACME Test CA';
const validityDays = this.options.validityDays ?? 3650;
const notBefore = new Date();
const notAfter = new Date();
notAfter.setDate(notAfter.getDate() + validityDays);
// Create self-signed root CA certificate
this.caCert = await x509.X509CertificateGenerator.createSelfSigned({
serialNumber: this.randomSerialNumber(),
name: `CN=${cn}`,
notBefore,
notAfter,
signingAlgorithm: { name: 'RSASSA-PKCS1-v1_5' },
keys: this.caKeyPair,
extensions: [
new x509.BasicConstraintsExtension(true, undefined, true),
new x509.KeyUsagesExtension(
x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign,
true,
),
await x509.SubjectKeyIdentifierExtension.create(this.caKeyPair.publicKey),
],
});
this.caCertPem = this.caCert.toString('pem');
}
/**
* Sign a CSR and return a PEM certificate chain (end-entity + root CA).
* @param csrDerBase64url - The CSR in base64url-encoded DER format (as sent by ACME clients)
*/
async signCsr(csrDerBase64url: string): Promise<string> {
const x509 = await import('@peculiar/x509');
const { webcrypto } = crypto;
x509.cryptoProvider.set(webcrypto as any);
// Parse the CSR
const csrDer = Buffer.from(csrDerBase64url, 'base64url');
const csr = new x509.Pkcs10CertificateRequest(csrDer);
// Extract Subject Alternative Names from CSR extensions
const sanNames: { type: 'dns'; value: string }[] = [];
const sanExt = csr.extensions?.find(
(ext) => ext.type === '2.5.29.17', // OID for SubjectAlternativeName
);
if (sanExt) {
const san = new x509.SubjectAlternativeNameExtension(sanExt.rawData);
if (san.names) {
const jsonNames = san.names.toJSON();
for (const name of jsonNames) {
if (name.type === 'dns') {
sanNames.push({ type: 'dns', value: name.value });
}
}
}
}
// If no SAN found, use CN from subject
if (sanNames.length === 0) {
const cnMatch = csr.subject.match(/CN=([^,]+)/);
if (cnMatch) {
sanNames.push({ type: 'dns', value: cnMatch[1] });
}
}
const notBefore = new Date();
const notAfter = new Date();
notAfter.setDate(notAfter.getDate() + this.certValidityDays);
// Sign the certificate
const cert = await x509.X509CertificateGenerator.create({
serialNumber: this.randomSerialNumber(),
subject: csr.subject,
issuer: this.caCert.subject,
notBefore,
notAfter,
signingAlgorithm: { name: 'RSASSA-PKCS1-v1_5' },
publicKey: csr.publicKey,
signingKey: this.caKeyPair.privateKey,
extensions: [
new x509.BasicConstraintsExtension(false, undefined, true),
new x509.KeyUsagesExtension(
x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment,
true,
),
new x509.ExtendedKeyUsageExtension(
['1.3.6.1.5.5.7.3.1'], // serverAuth
true,
),
new x509.SubjectAlternativeNameExtension(sanNames),
await x509.AuthorityKeyIdentifierExtension.create(this.caKeyPair.publicKey),
],
});
// Return PEM chain: end-entity cert + root CA cert
const certPem = cert.toString('pem');
return `${certPem}\n${this.caCertPem}`;
}
getCaCertPem(): string {
return this.caCertPem;
}
private randomSerialNumber(): string {
return crypto.randomBytes(16).toString('hex');
}
}