Files
smartacme/ts/acme/acme.classes.crypto.ts

221 lines
6.8 KiB
TypeScript

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 };
}
}