178 lines
5.3 KiB
TypeScript
178 lines
5.3 KiB
TypeScript
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));
|
|
};
|
|
}
|