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, body: any, ): Promise => { 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 = { 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)); }