BREAKING CHANGE(acme): Replace external acme-client with a built-in RFC8555-compliant ACME implementation and update public APIs accordingly
This commit is contained in:
220
ts/acme/acme.classes.crypto.ts
Normal file
220
ts/acme/acme.classes.crypto.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import * as crypto from 'node:crypto';
|
||||
import type { IAcmeCsrOptions } from './acme.interfaces.js';
|
||||
|
||||
/**
|
||||
* All cryptographic operations for the ACME protocol.
|
||||
* Uses node:crypto for key gen, JWK, JWS signing.
|
||||
* Uses @peculiar/x509 for CSR generation (no native Node.js CSR API).
|
||||
*/
|
||||
export class AcmeCrypto {
|
||||
/**
|
||||
* Generate an RSA private key in PEM format
|
||||
*/
|
||||
static createRsaPrivateKey(modulusLength = 2048): string {
|
||||
const { privateKey } = crypto.generateKeyPairSync('rsa', {
|
||||
modulusLength,
|
||||
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
||||
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
||||
});
|
||||
return privateKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export public JWK from PEM private key, keys sorted alphabetically per RFC 7638
|
||||
*/
|
||||
static getJwk(keyPem: string): Record<string, string> {
|
||||
const keyObj = crypto.createPublicKey(keyPem);
|
||||
const jwk = keyObj.export({ format: 'jwk' }) as Record<string, any>;
|
||||
if (jwk.kty === 'RSA') {
|
||||
return { e: jwk.e, kty: jwk.kty, n: jwk.n };
|
||||
} else if (jwk.kty === 'EC') {
|
||||
return { crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y };
|
||||
}
|
||||
throw new Error(`Unsupported key type: ${jwk.kty}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute JWK Thumbprint (SHA-256, base64url) per RFC 7638
|
||||
*/
|
||||
static getJwkThumbprint(jwk: Record<string, string>): string {
|
||||
let canonical: string;
|
||||
if (jwk.kty === 'RSA') {
|
||||
canonical = JSON.stringify({ e: jwk.e, kty: jwk.kty, n: jwk.n });
|
||||
} else if (jwk.kty === 'EC') {
|
||||
canonical = JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y });
|
||||
} else {
|
||||
throw new Error(`Unsupported key type: ${jwk.kty}`);
|
||||
}
|
||||
const hash = crypto.createHash('sha256').update(canonical).digest();
|
||||
return hash.toString('base64url');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a flattened JWS for ACME requests (RFC 7515)
|
||||
* payload=null means POST-as-GET (empty string payload)
|
||||
*/
|
||||
static createJws(
|
||||
keyPem: string,
|
||||
url: string,
|
||||
payload: any | null,
|
||||
options: { nonce: string; kid?: string; jwk?: Record<string, string> },
|
||||
): { protected: string; payload: string; signature: string } {
|
||||
const header: Record<string, any> = {
|
||||
alg: AcmeCrypto.getAlg(keyPem),
|
||||
nonce: options.nonce,
|
||||
url,
|
||||
};
|
||||
if (options.kid) {
|
||||
header.kid = options.kid;
|
||||
} else if (options.jwk) {
|
||||
header.jwk = options.jwk;
|
||||
} else {
|
||||
header.jwk = AcmeCrypto.getJwk(keyPem);
|
||||
}
|
||||
|
||||
const protectedB64 = Buffer.from(JSON.stringify(header)).toString('base64url');
|
||||
const payloadB64 =
|
||||
payload !== null ? Buffer.from(JSON.stringify(payload)).toString('base64url') : '';
|
||||
|
||||
const signingInput = `${protectedB64}.${payloadB64}`;
|
||||
const keyObj = crypto.createPrivateKey(keyPem);
|
||||
const alg = AcmeCrypto.getAlg(keyPem);
|
||||
|
||||
let signature: Buffer;
|
||||
if (alg.startsWith('RS')) {
|
||||
signature = crypto.sign('sha256', Buffer.from(signingInput), keyObj);
|
||||
} else if (alg.startsWith('ES')) {
|
||||
signature = crypto.sign('sha256', Buffer.from(signingInput), {
|
||||
key: keyObj,
|
||||
dsaEncoding: 'ieee-p1363',
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Unsupported algorithm: ${alg}`);
|
||||
}
|
||||
|
||||
return {
|
||||
protected: protectedB64,
|
||||
payload: payloadB64,
|
||||
signature: signature.toString('base64url'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a CSR (PKCS#10) via @peculiar/x509
|
||||
* Returns [privateKeyPem, csrPem]
|
||||
*/
|
||||
static async createCsr(
|
||||
options: IAcmeCsrOptions,
|
||||
existingKeyPem?: string,
|
||||
): Promise<[string, string]> {
|
||||
const x509 = await import('@peculiar/x509');
|
||||
const { webcrypto } = crypto;
|
||||
x509.cryptoProvider.set(webcrypto as any);
|
||||
|
||||
let keys: CryptoKeyPair;
|
||||
let keyPem: string;
|
||||
|
||||
if (existingKeyPem) {
|
||||
keys = await AcmeCrypto.importKeyPairToWebCrypto(existingKeyPem, webcrypto);
|
||||
keyPem = existingKeyPem;
|
||||
} else {
|
||||
keys = (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 pkcs8 = await webcrypto.subtle.exportKey('pkcs8', keys.privateKey);
|
||||
const b64 = Buffer.from(pkcs8).toString('base64');
|
||||
const lines = b64.match(/.{1,64}/g)!;
|
||||
keyPem = `-----BEGIN PRIVATE KEY-----\n${lines.join('\n')}\n-----END PRIVATE KEY-----\n`;
|
||||
}
|
||||
|
||||
// Collect all DNS names for SAN (CN is always included)
|
||||
const sanNames: string[] = [options.commonName];
|
||||
if (options.altNames) {
|
||||
for (const name of options.altNames) {
|
||||
if (!sanNames.includes(name)) {
|
||||
sanNames.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const csr = await x509.Pkcs10CertificateRequestGenerator.create({
|
||||
name: `CN=${options.commonName}`,
|
||||
keys,
|
||||
signingAlgorithm: { name: 'RSASSA-PKCS1-v1_5' },
|
||||
extensions: [
|
||||
new x509.SubjectAlternativeNameExtension(
|
||||
sanNames.map((name) => ({ type: 'dns' as const, value: name })),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
// Convert to PEM
|
||||
const csrPem = csr.toString('pem');
|
||||
|
||||
return [keyPem, csrPem];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert PEM to raw DER Buffer (strip headers, decode base64)
|
||||
*/
|
||||
static pemToBuffer(pem: string): Buffer {
|
||||
const lines = pem
|
||||
.split('\n')
|
||||
.filter((line) => !line.startsWith('-----') && line.trim().length > 0);
|
||||
return Buffer.from(lines.join(''), 'base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine JWS algorithm from key type
|
||||
*/
|
||||
private static getAlg(keyPem: string): string {
|
||||
const keyObj = crypto.createPrivateKey(keyPem);
|
||||
const keyType = keyObj.asymmetricKeyType;
|
||||
if (keyType === 'rsa') return 'RS256';
|
||||
if (keyType === 'ec') {
|
||||
const details = keyObj.asymmetricKeyDetails;
|
||||
if (details?.namedCurve === 'prime256v1' || details?.namedCurve === 'P-256') return 'ES256';
|
||||
if (details?.namedCurve === 'secp384r1' || details?.namedCurve === 'P-384') return 'ES384';
|
||||
return 'ES256';
|
||||
}
|
||||
throw new Error(`Unsupported key type: ${keyType}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a PEM private key into WebCrypto as a CryptoKeyPair
|
||||
*/
|
||||
private static async importKeyPairToWebCrypto(
|
||||
keyPem: string,
|
||||
wc: typeof crypto.webcrypto,
|
||||
): Promise<CryptoKeyPair> {
|
||||
const keyObj = crypto.createPrivateKey(keyPem);
|
||||
const pkcs8Der = keyObj.export({ type: 'pkcs8', format: 'der' });
|
||||
const privateKey = await wc.subtle.importKey(
|
||||
'pkcs8',
|
||||
pkcs8Der,
|
||||
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
|
||||
true,
|
||||
['sign'],
|
||||
);
|
||||
|
||||
const pubKeyObj = crypto.createPublicKey(keyPem);
|
||||
const spkiDer = pubKeyObj.export({ type: 'spki', format: 'der' });
|
||||
const publicKey = await wc.subtle.importKey(
|
||||
'spki',
|
||||
spkiDer,
|
||||
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
|
||||
true,
|
||||
['verify'],
|
||||
);
|
||||
|
||||
return { privateKey: privateKey as unknown as CryptoKey, publicKey: publicKey as unknown as CryptoKey };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user