143 lines
4.9 KiB
TypeScript
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));
|
|
}
|