Files
smartacme/ts_server/server.handlers.challenge.ts

143 lines
4.9 KiB
TypeScript

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