221 lines
6.8 KiB
TypeScript
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 };
|
||
|
|
}
|
||
|
|
}
|