143 lines
4.6 KiB
TypeScript
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');
|
|
}
|
|
}
|