feat(server): add an embedded ACME directory server and certificate authority with challenge, order, and certificate endpoints
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
20
package.json
20
package.json
@@ -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
7448
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
336
test/test.acme-server.node+bun+deno.ts
Normal file
336
test/test.acme-server.node+bun+deno.ts
Normal 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();
|
||||
@@ -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.'
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
12
ts_server/index.ts
Normal 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';
|
||||
27
ts_server/server.classes.account.store.ts
Normal file
27
ts_server/server.classes.account.store.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
128
ts_server/server.classes.acmeserver.ts
Normal file
128
ts_server/server.classes.acmeserver.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
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');
|
||||
}
|
||||
}
|
||||
61
ts_server/server.classes.challenge.verifier.ts
Normal file
61
ts_server/server.classes.challenge.verifier.ts
Normal 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'));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
153
ts_server/server.classes.jws.verifier.ts
Normal file
153
ts_server/server.classes.jws.verifier.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
36
ts_server/server.classes.nonce.ts
Normal file
36
ts_server/server.classes.nonce.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
72
ts_server/server.classes.order.store.ts
Normal file
72
ts_server/server.classes.order.store.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
116
ts_server/server.classes.router.ts
Normal file
116
ts_server/server.classes.router.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
85
ts_server/server.handlers.account.ts
Normal file
85
ts_server/server.handlers.account.ts
Normal 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`,
|
||||
}));
|
||||
};
|
||||
}
|
||||
58
ts_server/server.handlers.authz.ts
Normal file
58
ts_server/server.handlers.authz.ts
Normal 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));
|
||||
};
|
||||
}
|
||||
32
ts_server/server.handlers.cert.ts
Normal file
32
ts_server/server.handlers.cert.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
142
ts_server/server.handlers.challenge.ts
Normal file
142
ts_server/server.handlers.challenge.ts
Normal 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));
|
||||
}
|
||||
30
ts_server/server.handlers.directory.ts
Normal file
30
ts_server/server.handlers.directory.ts
Normal 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));
|
||||
};
|
||||
}
|
||||
93
ts_server/server.handlers.finalize.ts
Normal file
93
ts_server/server.handlers.finalize.ts
Normal 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));
|
||||
};
|
||||
}
|
||||
29
ts_server/server.handlers.nonce.ts
Normal file
29
ts_server/server.handlers.nonce.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
177
ts_server/server.handlers.order.ts
Normal file
177
ts_server/server.handlers.order.ts
Normal 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));
|
||||
};
|
||||
}
|
||||
98
ts_server/server.interfaces.ts
Normal file
98
ts_server/server.interfaces.ts
Normal 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>;
|
||||
Reference in New Issue
Block a user