feat(server): add an embedded ACME directory server and certificate authority with challenge, order, and certificate endpoints
This commit is contained in:
142
ts_server/server.classes.ca.ts
Normal file
142
ts_server/server.classes.ca.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user