feat(server): add an embedded ACME directory server and certificate authority with challenge, order, and certificate endpoints
This commit is contained in:
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