feat(server): add an embedded ACME directory server and certificate authority with challenge, order, and certificate endpoints
This commit is contained in:
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));
|
||||
}
|
||||
Reference in New Issue
Block a user