feat(server): add an embedded ACME directory server and certificate authority with challenge, order, and certificate endpoints

This commit is contained in:
2026-03-19 09:19:15 +00:00
parent 77d40985f3
commit 74ad7cd6c4
26 changed files with 11257 additions and 4906 deletions

View File

@@ -1,5 +1,13 @@
# Changelog
## 2026-03-19 - 9.2.0 - feat(server)
add an embedded ACME directory server and certificate authority with challenge, order, and certificate endpoints
- exports a new server module with AcmeServer, AcmeServerCA, server error types, and related interfaces
- implements in-memory account and order storage, nonce management, JWS verification, routing, challenge validation, and CSR signing for RFC 8555 style flows
- adds end-to-end tests for account creation, order processing, challenge handling, certificate issuance, and error scenarios
- updates the build configuration to include tsfolders and package file patterns for ts_* sources
## 2026-02-16 - 9.1.3 - fix(smartacme)
Include base domain alongside wildcard when building identifiers for wildcard certificate requests

6852
deno.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,7 @@
"type": "module",
"scripts": {
"test": "(tstest test/ --verbose --logfile --timeout 600)",
"build": "(tsbuild)",
"build": "(tsbuild tsfolders)",
"buildDocs": "tsdoc"
},
"repository": {
@@ -41,28 +41,28 @@
"dependencies": {
"@apiclient.xyz/cloudflare": "^7.1.0",
"@peculiar/x509": "^1.14.3",
"@push.rocks/lik": "^6.2.2",
"@push.rocks/smartdata": "^7.0.15",
"@push.rocks/lik": "^6.3.1",
"@push.rocks/smartdata": "^7.1.0",
"@push.rocks/smartdelay": "^3.0.5",
"@push.rocks/smartdns": "^7.8.1",
"@push.rocks/smartlog": "^3.1.10",
"@push.rocks/smartdns": "^7.9.0",
"@push.rocks/smartlog": "^3.2.1",
"@push.rocks/smartnetwork": "^4.4.0",
"@push.rocks/smartstring": "^4.1.0",
"@push.rocks/smarttime": "^4.2.3",
"@push.rocks/smartunique": "^3.0.9",
"@push.rocks/taskbuffer": "^6.1.2",
"@tsclass/tsclass": "^9.3.0"
"@tsclass/tsclass": "^9.5.0"
},
"devDependencies": {
"@git.zone/tsbuild": "^4.1.2",
"@git.zone/tsbuild": "^4.3.0",
"@git.zone/tsrun": "^2.0.1",
"@git.zone/tstest": "^3.1.8",
"@git.zone/tstest": "^3.4.0",
"@push.rocks/qenv": "^6.1.3",
"@types/node": "^25.2.3"
"@types/node": "^25.5.0"
},
"files": [
"ts/**/*",
"ts_web/**/*",
"ts_*/**/*",
"dist/**/*",
"dist_*/**/*",
"dist_ts/**/*",

7448
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,336 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as http from 'node:http';
import * as crypto from 'node:crypto';
import { AcmeServer } from '../ts_server/index.js';
import { AcmeCrypto } from '../ts/acme/acme.classes.crypto.js';
const TEST_PORT = 14567;
const BASE_URL = `http://localhost:${TEST_PORT}`;
let server: AcmeServer;
let accountKeyPem: string;
let accountUrl: string;
let nonce: string;
// Helper: simple HTTP request
function httpRequest(
url: string,
method: string,
body?: string,
headers?: Record<string, string>,
): Promise<{ status: number; headers: Record<string, string>; data: any }> {
return new Promise((resolve, reject) => {
const urlObj = new URL(url);
const options: http.RequestOptions = {
hostname: urlObj.hostname,
port: urlObj.port,
path: urlObj.pathname + urlObj.search,
method,
headers: {
...headers,
...(body ? { 'Content-Length': Buffer.byteLength(body).toString() } : {}),
},
};
const req = http.request(options, (res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf-8');
const responseHeaders: Record<string, string> = {};
for (const [key, value] of Object.entries(res.headers)) {
if (typeof value === 'string') {
responseHeaders[key.toLowerCase()] = value;
} else if (Array.isArray(value)) {
responseHeaders[key.toLowerCase()] = value[0];
}
}
let data: any;
const ct = responseHeaders['content-type'] || '';
if (ct.includes('json') || ct.includes('problem')) {
try { data = JSON.parse(raw); } catch { data = raw; }
} else {
data = raw;
}
resolve({ status: res.statusCode || 0, headers: responseHeaders, data });
});
});
req.on('error', reject);
req.setTimeout(10000, () => req.destroy(new Error('Timeout')));
if (body) req.write(body);
req.end();
});
}
// Helper: signed ACME request
async function signedRequest(
url: string,
payload: any | null,
options?: { useJwk?: boolean },
): Promise<{ status: number; headers: Record<string, string>; data: any }> {
const jwsOptions: { nonce: string; kid?: string; jwk?: Record<string, string> } = { nonce };
if (options?.useJwk) {
jwsOptions.jwk = AcmeCrypto.getJwk(accountKeyPem);
} else if (accountUrl) {
jwsOptions.kid = accountUrl;
} else {
jwsOptions.jwk = AcmeCrypto.getJwk(accountKeyPem);
}
const jws = AcmeCrypto.createJws(accountKeyPem, url, payload, jwsOptions);
const body = JSON.stringify(jws);
const response = await httpRequest(url, 'POST', body, {
'Content-Type': 'application/jose+json',
});
// Save nonce from response
if (response.headers['replay-nonce']) {
nonce = response.headers['replay-nonce'];
}
return response;
}
// Helper: get a fresh nonce
async function fetchNonce(): Promise<string> {
const res = await httpRequest(`${BASE_URL}/new-nonce`, 'HEAD');
return res.headers['replay-nonce'];
}
// ============================================================================
// Tests
// ============================================================================
tap.test('server: start ACME server', async () => {
server = new AcmeServer({
port: TEST_PORT,
baseUrl: BASE_URL,
challengeVerification: false, // Auto-approve challenges for testing
caOptions: {
commonName: 'Test ACME CA',
certValidityDays: 30,
},
});
await server.start();
});
tap.test('server: GET /directory returns valid directory', async () => {
const res = await httpRequest(`${BASE_URL}/directory`, 'GET');
expect(res.status).toEqual(200);
expect(res.data.newNonce).toEqual(`${BASE_URL}/new-nonce`);
expect(res.data.newAccount).toEqual(`${BASE_URL}/new-account`);
expect(res.data.newOrder).toEqual(`${BASE_URL}/new-order`);
expect(res.data.meta).toBeTruthy();
});
tap.test('server: HEAD /new-nonce returns Replay-Nonce header', async () => {
const res = await httpRequest(`${BASE_URL}/new-nonce`, 'HEAD');
expect(res.status).toEqual(200);
expect(res.headers['replay-nonce']).toBeTruthy();
});
tap.test('server: GET /new-nonce returns 204 with Replay-Nonce header', async () => {
const res = await httpRequest(`${BASE_URL}/new-nonce`, 'GET');
expect(res.status).toEqual(204);
expect(res.headers['replay-nonce']).toBeTruthy();
});
tap.test('server: POST /new-account registers an account', async () => {
accountKeyPem = AcmeCrypto.createRsaPrivateKey();
nonce = await fetchNonce();
const res = await signedRequest(`${BASE_URL}/new-account`, {
termsOfServiceAgreed: true,
contact: ['mailto:test@example.com'],
}, { useJwk: true });
expect(res.status).toEqual(201);
expect(res.headers['location']).toBeTruthy();
expect(res.data.status).toEqual('valid');
accountUrl = res.headers['location'];
});
tap.test('server: POST /new-account returns existing account', async () => {
const res = await signedRequest(`${BASE_URL}/new-account`, {
termsOfServiceAgreed: true,
contact: ['mailto:test@example.com'],
}, { useJwk: true });
expect(res.status).toEqual(200);
expect(res.headers['location']).toEqual(accountUrl);
});
tap.test('server: full certificate issuance flow', async () => {
// 1. Create order
const orderRes = await signedRequest(`${BASE_URL}/new-order`, {
identifiers: [{ type: 'dns', value: 'example.com' }],
});
expect(orderRes.status).toEqual(201);
expect(orderRes.data.status).toEqual('pending');
expect(orderRes.data.authorizations).toBeTypeOf('object');
expect(orderRes.data.finalize).toBeTruthy();
const orderUrl = orderRes.headers['location'];
const authzUrl = orderRes.data.authorizations[0];
const finalizeUrl = orderRes.data.finalize;
// 2. Get authorization
const authzRes = await signedRequest(authzUrl, null);
expect(authzRes.status).toEqual(200);
expect(authzRes.data.identifier.value).toEqual('example.com');
expect(authzRes.data.challenges.length).toBeGreaterThan(0);
// Pick a challenge (prefer dns-01 since it's always available)
const challenge = authzRes.data.challenges.find((c: any) => c.type === 'dns-01') || authzRes.data.challenges[0];
expect(challenge.status).toEqual('pending');
// 3. Trigger challenge (auto-approved since challengeVerification is false)
const challengeRes = await signedRequest(challenge.url, {});
expect(challengeRes.status).toEqual(200);
expect(challengeRes.data.status).toEqual('valid');
// 4. Poll order — should now be ready
const orderPollRes = await signedRequest(orderUrl, null);
expect(orderPollRes.status).toEqual(200);
expect(orderPollRes.data.status).toEqual('ready');
// 5. Create CSR and finalize
const [keyPem, csrPem] = await AcmeCrypto.createCsr({
commonName: 'example.com',
altNames: ['example.com'],
});
const csrDer = AcmeCrypto.pemToBuffer(csrPem);
const csrB64url = csrDer.toString('base64url');
const finalizeRes = await signedRequest(finalizeUrl, { csr: csrB64url });
expect(finalizeRes.status).toEqual(200);
expect(finalizeRes.data.status).toEqual('valid');
expect(finalizeRes.data.certificate).toBeTruthy();
// 6. Download certificate
const certUrl = finalizeRes.data.certificate;
const certRes = await signedRequest(certUrl, null);
expect(certRes.status).toEqual(200);
expect(certRes.headers['content-type']).toEqual('application/pem-certificate-chain');
expect(certRes.data).toInclude('BEGIN CERTIFICATE');
// Verify it's a valid PEM chain (at least 2 certs: end-entity + CA)
const certMatches = (certRes.data as string).match(/-----BEGIN CERTIFICATE-----/g);
expect(certMatches!.length).toBeGreaterThan(1);
});
tap.test('server: wildcard certificate issuance', async () => {
// Create order with wildcard
const orderRes = await signedRequest(`${BASE_URL}/new-order`, {
identifiers: [
{ type: 'dns', value: 'test.org' },
{ type: 'dns', value: '*.test.org' },
],
});
expect(orderRes.status).toEqual(201);
expect(orderRes.data.authorizations.length).toEqual(2);
const orderUrl = orderRes.headers['location'];
// Complete all challenges
for (const authzUrl of orderRes.data.authorizations) {
const authzRes = await signedRequest(authzUrl, null);
// For wildcard, only dns-01 should be available
const challenge = authzRes.data.challenges.find((c: any) => c.type === 'dns-01');
expect(challenge).toBeTruthy();
await signedRequest(challenge.url, {});
}
// Poll order
const orderPollRes = await signedRequest(orderUrl, null);
expect(orderPollRes.data.status).toEqual('ready');
// Finalize
const [keyPem, csrPem] = await AcmeCrypto.createCsr({
commonName: 'test.org',
altNames: ['test.org', '*.test.org'],
});
const csrDer = AcmeCrypto.pemToBuffer(csrPem);
const finalizeRes = await signedRequest(orderPollRes.data.finalize, {
csr: csrDer.toString('base64url'),
});
expect(finalizeRes.data.status).toEqual('valid');
// Download cert
const certRes = await signedRequest(finalizeRes.data.certificate, null);
expect(certRes.data).toInclude('BEGIN CERTIFICATE');
});
tap.test('server: rejects invalid JWS signature', async () => {
// Create JWS with one key but sign URL intended for a different key
const otherKey = AcmeCrypto.createRsaPrivateKey();
const otherNonce = await fetchNonce();
const jws = AcmeCrypto.createJws(otherKey, `${BASE_URL}/new-order`, { identifiers: [] }, {
nonce: otherNonce,
kid: accountUrl, // Use our account URL but sign with a different key
});
const res = await httpRequest(`${BASE_URL}/new-order`, 'POST', JSON.stringify(jws), {
'Content-Type': 'application/jose+json',
});
// Save nonce for next request
if (res.headers['replay-nonce']) {
nonce = res.headers['replay-nonce'];
}
expect(res.status).toEqual(403);
});
tap.test('server: rejects expired/bad nonce', async () => {
const jws = AcmeCrypto.createJws(
accountKeyPem,
`${BASE_URL}/new-order`,
{ identifiers: [{ type: 'dns', value: 'example.com' }] },
{ nonce: 'definitely-not-a-valid-nonce', kid: accountUrl },
);
const res = await httpRequest(`${BASE_URL}/new-order`, 'POST', JSON.stringify(jws), {
'Content-Type': 'application/jose+json',
});
if (res.headers['replay-nonce']) {
nonce = res.headers['replay-nonce'];
}
expect(res.status).toEqual(400);
expect(res.data.type).toEqual('urn:ietf:params:acme:error:badNonce');
});
tap.test('server: finalize rejects order not in ready state', async () => {
// Create a new order but don't complete challenges
const orderRes = await signedRequest(`${BASE_URL}/new-order`, {
identifiers: [{ type: 'dns', value: 'pending.example.com' }],
});
const [keyPem, csrPem] = await AcmeCrypto.createCsr({
commonName: 'pending.example.com',
});
const csrDer = AcmeCrypto.pemToBuffer(csrPem);
const finalizeRes = await signedRequest(orderRes.data.finalize, {
csr: csrDer.toString('base64url'),
});
expect(finalizeRes.status).toEqual(403);
expect(finalizeRes.data.type).toEqual('urn:ietf:params:acme:error:orderNotReady');
});
tap.test('server: getCaCertPem returns root CA certificate', async () => {
const caPem = server.getCaCertPem();
expect(caPem).toInclude('BEGIN CERTIFICATE');
});
tap.test('server: stop ACME server', async () => {
await server.stop();
});
export default tap.start();

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartacme',
version: '9.1.3',
version: '9.2.0',
description: 'A TypeScript-based ACME client for LetsEncrypt certificate management with a focus on simplicity and power.'
}

View File

@@ -175,7 +175,7 @@ export class AcmeCrypto {
/**
* Determine JWS algorithm from key type
*/
private static getAlg(keyPem: string): string {
static getAlg(keyPem: string): string {
const keyObj = crypto.createPrivateKey(keyPem);
const keyType = keyObj.asymmetricKeyType;
if (keyType === 'rsa') return 'RS256';

View File

@@ -10,5 +10,9 @@ export { certmanagers };
import * as handlers from './handlers/index.js';
export { handlers };
// server (ACME Directory Server / CA)
import * as server from '../ts_server/index.js';
export { server };
// re-export taskbuffer event types for consumers
export type { ITaskEvent, ITaskMetadata } from '@push.rocks/taskbuffer';

12
ts_server/index.ts Normal file
View File

@@ -0,0 +1,12 @@
export { AcmeServer } from './server.classes.acmeserver.js';
export { AcmeServerError } from './server.classes.jws.verifier.js';
export { AcmeServerCA } from './server.classes.ca.js';
export type {
IAcmeServerOptions,
IServerAccountStore,
IServerOrderStore,
IServerAccount,
IServerOrder,
IServerAuthorization,
IServerChallenge,
} from './server.interfaces.js';

View File

@@ -0,0 +1,27 @@
import type { IServerAccountStore, IServerAccount } from './server.interfaces.js';
/**
* In-memory account storage for the ACME server.
*/
export class MemoryAccountStore implements IServerAccountStore {
private accounts = new Map<string, IServerAccount>();
private byThumbprint = new Map<string, string>();
private byUrl = new Map<string, string>();
async create(account: IServerAccount): Promise<IServerAccount> {
this.accounts.set(account.id, account);
this.byThumbprint.set(account.thumbprint, account.id);
this.byUrl.set(account.url, account.id);
return account;
}
async getByThumbprint(thumbprint: string): Promise<IServerAccount | null> {
const id = this.byThumbprint.get(thumbprint);
return id ? this.accounts.get(id) || null : null;
}
async getByUrl(url: string): Promise<IServerAccount | null> {
const id = this.byUrl.get(url);
return id ? this.accounts.get(id) || null : null;
}
}

View File

@@ -0,0 +1,128 @@
import * as http from 'node:http';
import type { IAcmeServerOptions } from './server.interfaces.js';
import { NonceManager } from './server.classes.nonce.js';
import { JwsVerifier } from './server.classes.jws.verifier.js';
import { MemoryAccountStore } from './server.classes.account.store.js';
import { MemoryOrderStore } from './server.classes.order.store.js';
import { AcmeServerCA } from './server.classes.ca.js';
import { ChallengeVerifier } from './server.classes.challenge.verifier.js';
import { AcmeRouter } from './server.classes.router.js';
import { createDirectoryHandler } from './server.handlers.directory.js';
import { createNonceHeadHandler, createNonceGetHandler } from './server.handlers.nonce.js';
import { createAccountHandler } from './server.handlers.account.js';
import { createNewOrderHandler, createOrderPollHandler } from './server.handlers.order.js';
import { createAuthzHandler } from './server.handlers.authz.js';
import { createChallengeHandler } from './server.handlers.challenge.js';
import { createFinalizeHandler } from './server.handlers.finalize.js';
import { createCertHandler } from './server.handlers.cert.js';
/**
* ACME Directory Server — a self-contained RFC 8555 Certificate Authority.
*
* Usage:
* ```ts
* const server = new AcmeServer({ port: 14000 });
* await server.start();
* console.log(server.getDirectoryUrl()); // http://localhost:14000/directory
* ```
*/
export class AcmeServer {
private options: Required<Pick<IAcmeServerOptions, 'port' | 'hostname'>> & IAcmeServerOptions;
private httpServer: http.Server | null = null;
private ca: AcmeServerCA;
private baseUrl: string;
constructor(options: IAcmeServerOptions = {}) {
this.options = {
port: options.port ?? 14000,
hostname: options.hostname ?? '0.0.0.0',
...options,
};
this.baseUrl = options.baseUrl ?? `http://localhost:${this.options.port}`;
this.ca = new AcmeServerCA(options.caOptions);
}
async start(): Promise<void> {
// Initialize CA
await this.ca.init();
// Create stores
const accountStore = new MemoryAccountStore();
const orderStore = new MemoryOrderStore();
// Create managers
const nonceManager = new NonceManager();
const jwsVerifier = new JwsVerifier(nonceManager, accountStore);
const challengeVerifier = new ChallengeVerifier(this.options.challengeVerification ?? true);
// Create router and register routes
const router = new AcmeRouter(nonceManager);
// Directory
router.addRoute('GET', '/directory', createDirectoryHandler(this.baseUrl));
// Nonce
router.addRoute('HEAD', '/new-nonce', createNonceHeadHandler());
router.addRoute('GET', '/new-nonce', createNonceGetHandler());
// Account
router.addRoute('POST', '/new-account', createAccountHandler(this.baseUrl, jwsVerifier, accountStore));
// Order
router.addRoute('POST', '/new-order', createNewOrderHandler(this.baseUrl, jwsVerifier, orderStore));
router.addRoute('POST', '/order/:id', createOrderPollHandler(this.baseUrl, jwsVerifier, orderStore));
// Authorization
router.addRoute('POST', '/authz/:id', createAuthzHandler(this.baseUrl, jwsVerifier, orderStore));
// Challenge
router.addRoute('POST', '/challenge/:id', createChallengeHandler(
this.baseUrl,
jwsVerifier,
orderStore,
accountStore,
challengeVerifier,
));
// Finalize
router.addRoute('POST', '/finalize/:id', createFinalizeHandler(this.baseUrl, jwsVerifier, orderStore, this.ca));
// Certificate
router.addRoute('POST', '/cert/:id', createCertHandler(this.baseUrl, jwsVerifier, orderStore));
// Start HTTP server
this.httpServer = http.createServer((req, res) => {
router.handle(req, res).catch((err) => {
if (!res.headersSent) {
res.writeHead(500, { 'Content-Type': 'application/problem+json' });
res.end(JSON.stringify({
type: 'urn:ietf:params:acme:error:serverInternal',
detail: err instanceof Error ? err.message : 'Unknown error',
status: 500,
}));
}
});
});
await new Promise<void>((resolve) => {
this.httpServer!.listen(this.options.port, this.options.hostname, () => resolve());
});
}
async stop(): Promise<void> {
if (this.httpServer) {
await new Promise<void>((resolve, reject) => {
this.httpServer!.close((err) => (err ? reject(err) : resolve()));
});
this.httpServer = null;
}
}
getDirectoryUrl(): string {
return `${this.baseUrl}/directory`;
}
getCaCertPem(): string {
return this.ca.getCaCertPem();
}
}

View 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');
}
}

View File

@@ -0,0 +1,61 @@
import * as http from 'node:http';
/**
* Verifies ACME challenges by making HTTP requests or DNS lookups.
*/
export class ChallengeVerifier {
private verificationEnabled: boolean;
constructor(verificationEnabled = true) {
this.verificationEnabled = verificationEnabled;
}
/**
* Verify an HTTP-01 challenge by fetching the token from the domain.
*/
async verifyHttp01(domain: string, token: string, expectedKeyAuth: string): Promise<boolean> {
if (!this.verificationEnabled) {
return true;
}
try {
const url = `http://${domain}/.well-known/acme-challenge/${token}`;
const body = await this.httpGet(url);
return body.trim() === expectedKeyAuth.trim();
} catch {
return false;
}
}
/**
* Verify a DNS-01 challenge by looking up the TXT record.
*/
async verifyDns01(domain: string, expectedHash: string): Promise<boolean> {
if (!this.verificationEnabled) {
return true;
}
try {
const { promises: dns } = await import('node:dns');
const records = await dns.resolveTxt(`_acme-challenge.${domain}`);
const flatRecords = records.map((r) => r.join(''));
return flatRecords.some((r) => r === expectedHash);
} catch {
return false;
}
}
private httpGet(url: string): Promise<string> {
return new Promise((resolve, reject) => {
const req = http.get(url, { timeout: 10000 }, (res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
});
req.on('error', reject);
req.on('timeout', () => {
req.destroy(new Error('HTTP-01 verification timeout'));
});
});
}
}

View File

@@ -0,0 +1,153 @@
import * as crypto from 'node:crypto';
import { AcmeCrypto } from '../ts/acme/acme.classes.crypto.js';
import type { NonceManager } from './server.classes.nonce.js';
import type { IServerAccountStore } from './server.interfaces.js';
export interface IJwsVerifyResult {
jwk: Record<string, string>;
kid: string | null;
thumbprint: string;
payload: any;
}
/**
* Verifies JWS-signed ACME requests.
* This is the inverse of AcmeCrypto.createJws().
*/
export class JwsVerifier {
private nonceManager: NonceManager;
private accountStore: IServerAccountStore;
constructor(nonceManager: NonceManager, accountStore: IServerAccountStore) {
this.nonceManager = nonceManager;
this.accountStore = accountStore;
}
async verify(
body: { protected: string; payload: string; signature: string },
expectedUrl: string,
): Promise<IJwsVerifyResult> {
if (!body || !body.protected || body.signature === undefined) {
throw new AcmeServerError(400, 'urn:ietf:params:acme:error:malformed', 'Invalid JWS structure');
}
// 1. Decode protected header
const headerJson = Buffer.from(body.protected, 'base64url').toString('utf-8');
let header: Record<string, any>;
try {
header = JSON.parse(headerJson);
} catch {
throw new AcmeServerError(400, 'urn:ietf:params:acme:error:malformed', 'Invalid JWS protected header');
}
// 2. Validate required fields
const { alg, nonce, url, jwk, kid } = header;
if (!alg) {
throw new AcmeServerError(400, 'urn:ietf:params:acme:error:malformed', 'Missing alg in protected header');
}
if (!nonce) {
throw new AcmeServerError(400, 'urn:ietf:params:acme:error:badNonce', 'Missing nonce in protected header');
}
if (!url) {
throw new AcmeServerError(400, 'urn:ietf:params:acme:error:malformed', 'Missing url in protected header');
}
// 3. Validate URL matches
if (url !== expectedUrl) {
throw new AcmeServerError(
400,
'urn:ietf:params:acme:error:malformed',
`URL mismatch: expected ${expectedUrl}, got ${url}`,
);
}
// 4. Validate and consume nonce
if (!this.nonceManager.consume(nonce)) {
throw new AcmeServerError(400, 'urn:ietf:params:acme:error:badNonce', 'Invalid or expired nonce');
}
// 5. Must have exactly one of jwk or kid
if (jwk && kid) {
throw new AcmeServerError(400, 'urn:ietf:params:acme:error:malformed', 'JWS must have jwk or kid, not both');
}
if (!jwk && !kid) {
throw new AcmeServerError(400, 'urn:ietf:params:acme:error:malformed', 'JWS must have jwk or kid');
}
// 6. Resolve the public key
let resolvedJwk: Record<string, string>;
if (jwk) {
resolvedJwk = jwk;
} else {
// Look up account by kid (account URL)
const account = await this.accountStore.getByUrl(kid);
if (!account) {
throw new AcmeServerError(
400,
'urn:ietf:params:acme:error:accountDoesNotExist',
'Account not found for kid',
);
}
resolvedJwk = account.jwk;
}
// 7. Reconstruct public key and verify signature
const publicKey = crypto.createPublicKey({ key: resolvedJwk, format: 'jwk' });
const signingInput = `${body.protected}.${body.payload}`;
const signatureBuffer = Buffer.from(body.signature, 'base64url');
const supportedAlgs = ['RS256', 'ES256', 'ES384'];
if (!supportedAlgs.includes(alg)) {
throw new AcmeServerError(400, 'urn:ietf:params:acme:error:badSignatureAlgorithm', `Unsupported algorithm: ${alg}`);
}
let valid: boolean;
if (alg.startsWith('RS')) {
valid = crypto.verify('sha256', Buffer.from(signingInput), publicKey, signatureBuffer);
} else {
valid = crypto.verify('sha256', Buffer.from(signingInput), { key: publicKey, dsaEncoding: 'ieee-p1363' }, signatureBuffer);
}
if (!valid) {
throw new AcmeServerError(403, 'urn:ietf:params:acme:error:unauthorized', 'Invalid JWS signature');
}
// 8. Decode payload
let payload: any;
if (body.payload === '') {
payload = null; // POST-as-GET
} else {
try {
payload = JSON.parse(Buffer.from(body.payload, 'base64url').toString('utf-8'));
} catch {
throw new AcmeServerError(400, 'urn:ietf:params:acme:error:malformed', 'Invalid JWS payload');
}
}
const thumbprint = AcmeCrypto.getJwkThumbprint(resolvedJwk);
return {
jwk: resolvedJwk,
kid: kid || null,
thumbprint,
payload,
};
}
}
/**
* Simple error class for ACME server errors that maps to RFC 8555 problem responses.
*/
export class AcmeServerError extends Error {
public readonly status: number;
public readonly type: string;
public readonly detail: string;
constructor(status: number, type: string, detail: string) {
super(`${type}: ${detail}`);
this.name = 'AcmeServerError';
this.status = status;
this.type = type;
this.detail = detail;
}
}

View File

@@ -0,0 +1,36 @@
import * as crypto from 'node:crypto';
/**
* Manages ACME replay nonces.
* Each nonce is single-use: consumed on verification, fresh one issued with every response.
*/
export class NonceManager {
private nonces = new Set<string>();
private nonceQueue: string[] = [];
private maxSize: number;
constructor(maxSize = 10000) {
this.maxSize = maxSize;
}
generate(): string {
const nonce = crypto.randomBytes(16).toString('base64url');
if (this.nonces.size >= this.maxSize) {
const oldest = this.nonceQueue.shift();
if (oldest) {
this.nonces.delete(oldest);
}
}
this.nonces.add(nonce);
this.nonceQueue.push(nonce);
return nonce;
}
consume(nonce: string): boolean {
if (this.nonces.has(nonce)) {
this.nonces.delete(nonce);
return true;
}
return false;
}
}

View File

@@ -0,0 +1,72 @@
import type {
IServerOrderStore,
IServerOrder,
IServerAuthorization,
IServerChallenge,
} from './server.interfaces.js';
/**
* In-memory order/authorization/challenge/certificate storage for the ACME server.
*/
export class MemoryOrderStore implements IServerOrderStore {
private orders = new Map<string, IServerOrder>();
private authorizations = new Map<string, IServerAuthorization>();
private challenges = new Map<string, IServerChallenge>();
private certPems = new Map<string, string>();
async createOrder(order: IServerOrder): Promise<IServerOrder> {
this.orders.set(order.id, order);
return order;
}
async getOrder(id: string): Promise<IServerOrder | null> {
return this.orders.get(id) || null;
}
async updateOrder(id: string, updates: Partial<IServerOrder>): Promise<void> {
const order = this.orders.get(id);
if (order) {
Object.assign(order, updates);
}
}
async createAuthorization(authz: IServerAuthorization): Promise<IServerAuthorization> {
this.authorizations.set(authz.id, authz);
return authz;
}
async getAuthorization(id: string): Promise<IServerAuthorization | null> {
return this.authorizations.get(id) || null;
}
async updateAuthorization(id: string, updates: Partial<IServerAuthorization>): Promise<void> {
const authz = this.authorizations.get(id);
if (authz) {
Object.assign(authz, updates);
}
}
async createChallenge(challenge: IServerChallenge): Promise<IServerChallenge> {
this.challenges.set(challenge.id, challenge);
return challenge;
}
async getChallenge(id: string): Promise<IServerChallenge | null> {
return this.challenges.get(id) || null;
}
async updateChallenge(id: string, updates: Partial<IServerChallenge>): Promise<void> {
const challenge = this.challenges.get(id);
if (challenge) {
Object.assign(challenge, updates);
}
}
async storeCertPem(orderId: string, pem: string): Promise<void> {
this.certPems.set(orderId, pem);
}
async getCertPem(orderId: string): Promise<string | null> {
return this.certPems.get(orderId) || null;
}
}

View File

@@ -0,0 +1,116 @@
import type * as http from 'node:http';
import type { TRouteHandler } from './server.interfaces.js';
import type { NonceManager } from './server.classes.nonce.js';
import { AcmeServerError } from './server.classes.jws.verifier.js';
interface IRoute {
method: string;
pattern: string;
segments: string[];
handler: TRouteHandler;
}
/**
* Minimal HTTP router for the ACME server.
* Supports parameterized paths like /order/:id.
*/
export class AcmeRouter {
private routes: IRoute[] = [];
private nonceManager: NonceManager;
constructor(nonceManager: NonceManager) {
this.nonceManager = nonceManager;
}
addRoute(method: string, pattern: string, handler: TRouteHandler): void {
this.routes.push({
method: method.toUpperCase(),
pattern,
segments: pattern.split('/').filter(Boolean),
handler,
});
}
async handle(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
const method = (req.method || 'GET').toUpperCase();
const pathSegments = url.pathname.split('/').filter(Boolean);
// Always add a fresh nonce to every response
res.setHeader('Replay-Nonce', this.nonceManager.generate());
res.setHeader('Cache-Control', 'no-store');
// Find matching route
for (const route of this.routes) {
if (route.method !== method) continue;
const params = this.matchPath(route.segments, pathSegments);
if (params === null) continue;
try {
const body = method === 'POST' ? await this.parseBody(req) : undefined;
await route.handler(req, res, params, body);
} catch (err) {
this.sendError(res, err);
}
return;
}
// No route found
this.sendError(res, new AcmeServerError(404, 'urn:ietf:params:acme:error:malformed', 'Not found'));
}
private matchPath(
routeSegments: string[],
pathSegments: string[],
): Record<string, string> | null {
if (routeSegments.length !== pathSegments.length) return null;
const params: Record<string, string> = {};
for (let i = 0; i < routeSegments.length; i++) {
if (routeSegments[i].startsWith(':')) {
params[routeSegments[i].slice(1)] = pathSegments[i];
} else if (routeSegments[i] !== pathSegments[i]) {
return null;
}
}
return params;
}
private parseBody(req: http.IncomingMessage): Promise<any> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer) => chunks.push(chunk));
req.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf-8');
if (!raw) {
resolve(undefined);
return;
}
try {
resolve(JSON.parse(raw));
} catch {
reject(new AcmeServerError(400, 'urn:ietf:params:acme:error:malformed', 'Invalid JSON body'));
}
});
req.on('error', reject);
});
}
private sendError(res: http.ServerResponse, err: unknown): void {
if (err instanceof AcmeServerError) {
res.writeHead(err.status, { 'Content-Type': 'application/problem+json' });
res.end(JSON.stringify({
type: err.type,
detail: err.detail,
status: err.status,
}));
} else {
const message = err instanceof Error ? err.message : 'Internal server error';
res.writeHead(500, { 'Content-Type': 'application/problem+json' });
res.end(JSON.stringify({
type: 'urn:ietf:params:acme:error:serverInternal',
detail: message,
status: 500,
}));
}
}
}

View File

@@ -0,0 +1,85 @@
import type * as http from 'node:http';
import * as crypto from 'node:crypto';
import { AcmeCrypto } from '../ts/acme/acme.classes.crypto.js';
import type { JwsVerifier } from './server.classes.jws.verifier.js';
import { AcmeServerError } from './server.classes.jws.verifier.js';
import type { IServerAccountStore } from './server.interfaces.js';
/**
* POST /new-account — Register or retrieve an ACME account.
* Expects JWS with JWK in protected header (not kid).
*/
export function createAccountHandler(
baseUrl: string,
jwsVerifier: JwsVerifier,
accountStore: IServerAccountStore,
) {
return async (
req: http.IncomingMessage,
res: http.ServerResponse,
_params: Record<string, string>,
body: any,
): Promise<void> => {
const requestUrl = `${baseUrl}/new-account`;
const verified = await jwsVerifier.verify(body, requestUrl);
// Account creation must use JWK, not kid
if (verified.kid) {
throw new AcmeServerError(
400,
'urn:ietf:params:acme:error:malformed',
'newAccount requests must use JWK, not kid',
);
}
const { payload, jwk, thumbprint } = verified;
// Check if account already exists
const existing = await accountStore.getByThumbprint(thumbprint);
if (existing) {
// If onlyReturnExisting, or just returning the existing account
res.writeHead(200, {
'Content-Type': 'application/json',
'Location': existing.url,
});
res.end(JSON.stringify({
status: existing.status,
contact: existing.contact,
orders: `${baseUrl}/account/${existing.id}/orders`,
}));
return;
}
// onlyReturnExisting = true but no account found
if (payload?.onlyReturnExisting) {
throw new AcmeServerError(
400,
'urn:ietf:params:acme:error:accountDoesNotExist',
'Account does not exist',
);
}
// Create new account
const id = crypto.randomBytes(16).toString('hex');
const accountUrl = `${baseUrl}/account/${id}`;
const account = await accountStore.create({
id,
thumbprint,
url: accountUrl,
jwk,
status: 'valid',
contact: payload?.contact || [],
createdAt: new Date().toISOString(),
});
res.writeHead(201, {
'Content-Type': 'application/json',
'Location': accountUrl,
});
res.end(JSON.stringify({
status: account.status,
contact: account.contact,
orders: `${baseUrl}/account/${id}/orders`,
}));
};
}

View File

@@ -0,0 +1,58 @@
import type * as http from 'node:http';
import type { JwsVerifier } from './server.classes.jws.verifier.js';
import { AcmeServerError } from './server.classes.jws.verifier.js';
import type { IServerOrderStore } from './server.interfaces.js';
/**
* POST /authz/:id — Return authorization with embedded challenges (POST-as-GET).
*/
export function createAuthzHandler(
baseUrl: string,
jwsVerifier: JwsVerifier,
orderStore: IServerOrderStore,
) {
return async (
req: http.IncomingMessage,
res: http.ServerResponse,
params: Record<string, string>,
body: any,
): Promise<void> => {
const authzId = params.id;
const requestUrl = `${baseUrl}/authz/${authzId}`;
await jwsVerifier.verify(body, requestUrl);
const authz = await orderStore.getAuthorization(authzId);
if (!authz) {
throw new AcmeServerError(404, 'urn:ietf:params:acme:error:malformed', 'Authorization not found');
}
// Build challenge objects
const challenges = [];
for (const challengeId of authz.challengeIds) {
const challenge = await orderStore.getChallenge(challengeId);
if (challenge) {
challenges.push({
type: challenge.type,
url: `${baseUrl}/challenge/${challenge.id}`,
status: challenge.status,
token: challenge.token,
...(challenge.validated ? { validated: challenge.validated } : {}),
});
}
}
const responseBody: Record<string, any> = {
identifier: authz.identifier,
status: authz.status,
expires: authz.expires,
challenges,
};
if (authz.wildcard) {
responseBody.wildcard = true;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(responseBody));
};
}

View File

@@ -0,0 +1,32 @@
import type * as http from 'node:http';
import type { JwsVerifier } from './server.classes.jws.verifier.js';
import { AcmeServerError } from './server.classes.jws.verifier.js';
import type { IServerOrderStore } from './server.interfaces.js';
/**
* POST /cert/:id — Download certificate chain (POST-as-GET).
*/
export function createCertHandler(
baseUrl: string,
jwsVerifier: JwsVerifier,
orderStore: IServerOrderStore,
) {
return async (
req: http.IncomingMessage,
res: http.ServerResponse,
params: Record<string, string>,
body: any,
): Promise<void> => {
const orderId = params.id;
const requestUrl = `${baseUrl}/cert/${orderId}`;
await jwsVerifier.verify(body, requestUrl);
const certPem = await orderStore.getCertPem(orderId);
if (!certPem) {
throw new AcmeServerError(404, 'urn:ietf:params:acme:error:malformed', 'Certificate not found');
}
res.writeHead(200, { 'Content-Type': 'application/pem-certificate-chain' });
res.end(certPem);
};
}

View File

@@ -0,0 +1,142 @@
import type * as http from 'node:http';
import * as crypto from 'node:crypto';
import { AcmeCrypto } from '../ts/acme/acme.classes.crypto.js';
import type { JwsVerifier } from './server.classes.jws.verifier.js';
import { AcmeServerError } from './server.classes.jws.verifier.js';
import type { IServerOrderStore, IServerAccountStore } from './server.interfaces.js';
import type { ChallengeVerifier } from './server.classes.challenge.verifier.js';
/**
* POST /challenge/:id — Trigger or poll an ACME challenge.
* - POST with `{}` payload: trigger challenge validation
* - POST-as-GET (null payload): return current challenge state
*/
export function createChallengeHandler(
baseUrl: string,
jwsVerifier: JwsVerifier,
orderStore: IServerOrderStore,
accountStore: IServerAccountStore,
challengeVerifier: ChallengeVerifier,
) {
return async (
req: http.IncomingMessage,
res: http.ServerResponse,
params: Record<string, string>,
body: any,
): Promise<void> => {
const challengeId = params.id;
const requestUrl = `${baseUrl}/challenge/${challengeId}`;
const verified = await jwsVerifier.verify(body, requestUrl);
const challenge = await orderStore.getChallenge(challengeId);
if (!challenge) {
throw new AcmeServerError(404, 'urn:ietf:params:acme:error:malformed', 'Challenge not found');
}
// POST-as-GET: just return current state
if (verified.payload === null) {
sendChallengeResponse(res, challenge, baseUrl);
return;
}
// Trigger validation (payload should be `{}`)
if (challenge.status !== 'pending') {
// Already processing or completed
sendChallengeResponse(res, challenge, baseUrl);
return;
}
// Set to processing
await orderStore.updateChallenge(challengeId, { status: 'processing' });
// Get the authorization to find the domain
const authz = await orderStore.getAuthorization(challenge.authorizationId);
if (!authz) {
throw new AcmeServerError(500, 'urn:ietf:params:acme:error:serverInternal', 'Authorization not found');
}
// Resolve the account's JWK for key authorization computation
const account = await accountStore.getByUrl(verified.kid!);
if (!account) {
throw new AcmeServerError(400, 'urn:ietf:params:acme:error:accountDoesNotExist', 'Account not found');
}
const thumbprint = AcmeCrypto.getJwkThumbprint(account.jwk);
const keyAuth = `${challenge.token}.${thumbprint}`;
// Verify the challenge
let valid = false;
const domain = authz.identifier.value;
if (challenge.type === 'http-01') {
valid = await challengeVerifier.verifyHttp01(domain, challenge.token, keyAuth);
} else if (challenge.type === 'dns-01') {
const hash = crypto.createHash('sha256').update(keyAuth).digest('base64url');
valid = await challengeVerifier.verifyDns01(domain, hash);
}
if (valid) {
await orderStore.updateChallenge(challengeId, {
status: 'valid',
validated: new Date().toISOString(),
});
challenge.status = 'valid';
challenge.validated = new Date().toISOString();
// Check if all challenges for this authorization's required type are valid
// One valid challenge is enough to validate the authorization
await orderStore.updateAuthorization(authz.id, { status: 'valid' });
// Check if all authorizations for the order are now valid
const order = await orderStore.getOrder(authz.orderId);
if (order && order.status === 'pending') {
let allValid = true;
for (const authzId of order.authorizationIds) {
const a = await orderStore.getAuthorization(authzId);
if (!a || a.status !== 'valid') {
allValid = false;
break;
}
}
if (allValid) {
await orderStore.updateOrder(order.id, { status: 'ready' });
}
}
} else {
await orderStore.updateChallenge(challengeId, {
status: 'invalid',
error: {
type: 'urn:ietf:params:acme:error:incorrectResponse',
detail: `Challenge verification failed for ${domain}`,
},
});
challenge.status = 'invalid';
// Mark authorization as invalid too
await orderStore.updateAuthorization(authz.id, { status: 'invalid' });
}
sendChallengeResponse(res, challenge, baseUrl);
};
}
function sendChallengeResponse(
res: http.ServerResponse,
challenge: { id: string; type: string; status: string; token: string; validated?: string },
baseUrl: string,
): void {
const responseBody: Record<string, any> = {
type: challenge.type,
url: `${baseUrl}/challenge/${challenge.id}`,
status: challenge.status,
token: challenge.token,
};
if (challenge.validated) {
responseBody.validated = challenge.validated;
}
res.writeHead(200, {
'Content-Type': 'application/json',
'Link': `<${baseUrl}/directory>;rel="index"`,
});
res.end(JSON.stringify(responseBody));
}

View File

@@ -0,0 +1,30 @@
import type * as http from 'node:http';
import type { IAcmeDirectory } from '../ts/acme/acme.interfaces.js';
/**
* GET /directory — Returns the ACME directory object with all endpoint URLs.
*/
export function createDirectoryHandler(baseUrl: string) {
return async (
_req: http.IncomingMessage,
res: http.ServerResponse,
_params: Record<string, string>,
_body: any,
): Promise<void> => {
const directory: IAcmeDirectory = {
newNonce: `${baseUrl}/new-nonce`,
newAccount: `${baseUrl}/new-account`,
newOrder: `${baseUrl}/new-order`,
revokeCert: `${baseUrl}/revoke-cert`,
keyChange: `${baseUrl}/key-change`,
meta: {
termsOfService: `${baseUrl}/terms`,
website: `${baseUrl}`,
caaIdentities: [],
externalAccountRequired: false,
},
};
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(directory));
};
}

View File

@@ -0,0 +1,93 @@
import type * as http from 'node:http';
import type { JwsVerifier } from './server.classes.jws.verifier.js';
import { AcmeServerError } from './server.classes.jws.verifier.js';
import type { IServerOrderStore } from './server.interfaces.js';
import type { AcmeServerCA } from './server.classes.ca.js';
/**
* POST /finalize/:id — Submit CSR and issue certificate.
*/
export function createFinalizeHandler(
baseUrl: string,
jwsVerifier: JwsVerifier,
orderStore: IServerOrderStore,
ca: AcmeServerCA,
) {
return async (
req: http.IncomingMessage,
res: http.ServerResponse,
params: Record<string, string>,
body: any,
): Promise<void> => {
const orderId = params.id;
const requestUrl = `${baseUrl}/finalize/${orderId}`;
const verified = await jwsVerifier.verify(body, requestUrl);
if (!verified.kid) {
throw new AcmeServerError(400, 'urn:ietf:params:acme:error:malformed', 'Finalize requires kid');
}
const order = await orderStore.getOrder(orderId);
if (!order) {
throw new AcmeServerError(404, 'urn:ietf:params:acme:error:malformed', 'Order not found');
}
// Check all authorizations are valid and update order status if needed
if (order.status === 'pending') {
let allValid = true;
for (const authzId of order.authorizationIds) {
const authz = await orderStore.getAuthorization(authzId);
if (!authz || authz.status !== 'valid') {
allValid = false;
break;
}
}
if (allValid) {
await orderStore.updateOrder(orderId, { status: 'ready' });
order.status = 'ready';
}
}
if (order.status !== 'ready') {
throw new AcmeServerError(
403,
'urn:ietf:params:acme:error:orderNotReady',
`Order is in "${order.status}" state, expected "ready"`,
);
}
const { payload } = verified;
if (!payload?.csr) {
throw new AcmeServerError(400, 'urn:ietf:params:acme:error:malformed', 'Missing CSR in finalize request');
}
// Transition to processing
await orderStore.updateOrder(orderId, { status: 'processing' });
// Sign the certificate
const certPem = await ca.signCsr(payload.csr);
// Store certificate and update order
const certUrl = `${baseUrl}/cert/${orderId}`;
await orderStore.storeCertPem(orderId, certPem);
await orderStore.updateOrder(orderId, {
status: 'valid',
certificate: certUrl,
});
const responseBody = {
status: 'valid',
expires: order.expires,
identifiers: order.identifiers,
authorizations: order.authorizationIds.map((id) => `${baseUrl}/authz/${id}`),
finalize: order.finalize,
certificate: certUrl,
};
res.writeHead(200, {
'Content-Type': 'application/json',
'Location': `${baseUrl}/order/${orderId}`,
});
res.end(JSON.stringify(responseBody));
};
}

View File

@@ -0,0 +1,29 @@
import type * as http from 'node:http';
/**
* HEAD /new-nonce — Returns 200 with Replay-Nonce header (added by router).
* GET /new-nonce — Returns 204 with Replay-Nonce header (added by router).
*/
export function createNonceHeadHandler() {
return async (
_req: http.IncomingMessage,
res: http.ServerResponse,
_params: Record<string, string>,
_body: any,
): Promise<void> => {
res.writeHead(200, { 'Content-Type': 'application/octet-stream' });
res.end();
};
}
export function createNonceGetHandler() {
return async (
_req: http.IncomingMessage,
res: http.ServerResponse,
_params: Record<string, string>,
_body: any,
): Promise<void> => {
res.writeHead(204);
res.end();
};
}

View File

@@ -0,0 +1,177 @@
import type * as http from 'node:http';
import * as crypto from 'node:crypto';
import type { JwsVerifier } from './server.classes.jws.verifier.js';
import { AcmeServerError } from './server.classes.jws.verifier.js';
import type { IServerOrderStore, IServerAccountStore } from './server.interfaces.js';
import type { IAcmeIdentifier } from '../ts/acme/acme.interfaces.js';
/**
* POST /new-order — Create a new ACME order.
*/
export function createNewOrderHandler(
baseUrl: string,
jwsVerifier: JwsVerifier,
orderStore: IServerOrderStore,
) {
return async (
req: http.IncomingMessage,
res: http.ServerResponse,
_params: Record<string, string>,
body: any,
): Promise<void> => {
const requestUrl = `${baseUrl}/new-order`;
const verified = await jwsVerifier.verify(body, requestUrl);
if (!verified.kid) {
throw new AcmeServerError(400, 'urn:ietf:params:acme:error:malformed', 'newOrder requires kid');
}
const { payload } = verified;
const identifiers: IAcmeIdentifier[] = payload?.identifiers;
if (!identifiers || !Array.isArray(identifiers) || identifiers.length === 0) {
throw new AcmeServerError(
400,
'urn:ietf:params:acme:error:malformed',
'Order must include at least one identifier',
);
}
const orderId = crypto.randomBytes(16).toString('hex');
const expires = new Date();
expires.setDate(expires.getDate() + 7);
// Create authorizations and challenges for each identifier
const authorizationIds: string[] = [];
for (const identifier of identifiers) {
const authzId = crypto.randomBytes(16).toString('hex');
const isWildcard = identifier.value.startsWith('*.');
const domain = isWildcard ? identifier.value.slice(2) : identifier.value;
// Create challenges for this authorization
const challengeIds: string[] = [];
// HTTP-01 challenge (not for wildcards)
if (!isWildcard) {
const http01Id = crypto.randomBytes(16).toString('hex');
const http01Token = crypto.randomBytes(32).toString('base64url');
await orderStore.createChallenge({
id: http01Id,
authorizationId: authzId,
type: 'http-01',
token: http01Token,
status: 'pending',
});
challengeIds.push(http01Id);
}
// DNS-01 challenge (always)
const dns01Id = crypto.randomBytes(16).toString('hex');
const dns01Token = crypto.randomBytes(32).toString('base64url');
await orderStore.createChallenge({
id: dns01Id,
authorizationId: authzId,
type: 'dns-01',
token: dns01Token,
status: 'pending',
});
challengeIds.push(dns01Id);
await orderStore.createAuthorization({
id: authzId,
orderId,
identifier: { type: 'dns', value: domain },
status: 'pending',
expires: expires.toISOString(),
challengeIds,
wildcard: isWildcard || undefined,
});
authorizationIds.push(authzId);
}
const order = await orderStore.createOrder({
id: orderId,
accountUrl: verified.kid,
status: 'pending',
identifiers,
authorizationIds,
expires: expires.toISOString(),
finalize: `${baseUrl}/finalize/${orderId}`,
});
const responseBody = {
status: order.status,
expires: order.expires,
identifiers: order.identifiers,
authorizations: order.authorizationIds.map((id) => `${baseUrl}/authz/${id}`),
finalize: order.finalize,
};
res.writeHead(201, {
'Content-Type': 'application/json',
'Location': `${baseUrl}/order/${orderId}`,
});
res.end(JSON.stringify(responseBody));
};
}
/**
* POST /order/:id — Poll order status (POST-as-GET).
*/
export function createOrderPollHandler(
baseUrl: string,
jwsVerifier: JwsVerifier,
orderStore: IServerOrderStore,
) {
return async (
req: http.IncomingMessage,
res: http.ServerResponse,
params: Record<string, string>,
body: any,
): Promise<void> => {
const orderId = params.id;
const requestUrl = `${baseUrl}/order/${orderId}`;
await jwsVerifier.verify(body, requestUrl);
const order = await orderStore.getOrder(orderId);
if (!order) {
throw new AcmeServerError(404, 'urn:ietf:params:acme:error:malformed', 'Order not found');
}
// Check if all authorizations are valid → transition to ready
if (order.status === 'pending') {
let allValid = true;
for (const authzId of order.authorizationIds) {
const authz = await orderStore.getAuthorization(authzId);
if (!authz || authz.status !== 'valid') {
allValid = false;
break;
}
}
if (allValid) {
await orderStore.updateOrder(orderId, { status: 'ready' });
order.status = 'ready';
}
}
const responseBody: Record<string, any> = {
status: order.status,
expires: order.expires,
identifiers: order.identifiers,
authorizations: order.authorizationIds.map((id) => `${baseUrl}/authz/${id}`),
finalize: order.finalize,
};
if (order.certificate) {
responseBody.certificate = order.certificate;
}
res.writeHead(200, {
'Content-Type': 'application/json',
'Location': requestUrl,
});
res.end(JSON.stringify(responseBody));
};
}

View File

@@ -0,0 +1,98 @@
import type { IAcmeIdentifier } from '../ts/acme/acme.interfaces.js';
// ============================================================================
// Server configuration
// ============================================================================
export interface IAcmeServerOptions {
port?: number;
hostname?: string;
baseUrl?: string;
/** When false, challenges auto-approve on trigger (useful for testing) */
challengeVerification?: boolean;
caOptions?: {
commonName?: string;
validityDays?: number;
certValidityDays?: number;
};
}
// ============================================================================
// Pluggable storage interfaces
// ============================================================================
export interface IServerAccountStore {
create(account: IServerAccount): Promise<IServerAccount>;
getByThumbprint(thumbprint: string): Promise<IServerAccount | null>;
getByUrl(url: string): Promise<IServerAccount | null>;
}
export interface IServerOrderStore {
createOrder(order: IServerOrder): Promise<IServerOrder>;
getOrder(id: string): Promise<IServerOrder | null>;
updateOrder(id: string, updates: Partial<IServerOrder>): Promise<void>;
createAuthorization(authz: IServerAuthorization): Promise<IServerAuthorization>;
getAuthorization(id: string): Promise<IServerAuthorization | null>;
updateAuthorization(id: string, updates: Partial<IServerAuthorization>): Promise<void>;
createChallenge(challenge: IServerChallenge): Promise<IServerChallenge>;
getChallenge(id: string): Promise<IServerChallenge | null>;
updateChallenge(id: string, updates: Partial<IServerChallenge>): Promise<void>;
storeCertPem(orderId: string, pem: string): Promise<void>;
getCertPem(orderId: string): Promise<string | null>;
}
// ============================================================================
// Internal server models
// ============================================================================
export interface IServerAccount {
id: string;
thumbprint: string;
url: string;
jwk: Record<string, string>;
status: string;
contact: string[];
createdAt: string;
}
export interface IServerOrder {
id: string;
accountUrl: string;
status: string;
identifiers: IAcmeIdentifier[];
authorizationIds: string[];
expires: string;
finalize: string;
certificate?: string;
}
export interface IServerAuthorization {
id: string;
orderId: string;
identifier: IAcmeIdentifier;
status: string;
expires: string;
challengeIds: string[];
wildcard?: boolean;
}
export interface IServerChallenge {
id: string;
authorizationId: string;
type: string;
token: string;
status: string;
validated?: string;
error?: { type: string; detail: string };
}
// ============================================================================
// Route handler type
// ============================================================================
export type TRouteHandler = (
req: import('node:http').IncomingMessage,
res: import('node:http').ServerResponse,
params: Record<string, string>,
body: any,
) => Promise<void>;