More error handling

This commit is contained in:
Fang-Pen Lin
2025-11-07 09:19:34 -08:00
parent f02adcf70c
commit 9314348b31
3 changed files with 119 additions and 52 deletions
@@ -5,7 +5,7 @@ import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { TPkiAcmeAuthDALFactory } from "./pki-acme-auth-dal"; import { TPkiAcmeAuthDALFactory } from "./pki-acme-auth-dal";
import { TPkiAcmeChallengeDALFactory } from "./pki-acme-challenge-dal"; import { TPkiAcmeChallengeDALFactory } from "./pki-acme-challenge-dal";
import { AcmeIncorrectResponseError } from "./pki-acme-errors"; import { AcmeConnectionError, AcmeDnsFailureError, AcmeIncorrectResponseError } from "./pki-acme-errors";
import { AcmeAuthStatus, AcmeChallengeStatus, AcmeChallengeType } from "./pki-acme-schemas"; import { AcmeAuthStatus, AcmeChallengeStatus, AcmeChallengeType } from "./pki-acme-schemas";
import { TPkiAcmeChallengeServiceFactory } from "./pki-acme-types"; import { TPkiAcmeChallengeServiceFactory } from "./pki-acme-types";
import { TPkiAcmeChallenges } from "@app/db/schemas"; import { TPkiAcmeChallenges } from "@app/db/schemas";
@@ -25,7 +25,7 @@ export const pkiAcmeChallengeServiceFactory = ({
const appCfg = getConfig(); const appCfg = getConfig();
const validateChallengeResponse = async (challengeId: string): Promise<void> => { const validateChallengeResponse = async (challengeId: string): Promise<void> => {
return await acmeChallengeDAL.transaction(async (tx) => { const error = await acmeChallengeDAL.transaction(async (tx) => {
logger.info({ challengeId }, "Validating ACME challenge response"); logger.info({ challengeId }, "Validating ACME challenge response");
const challenge = await acmeChallengeDAL.findByIdForChallengeValidation(challengeId, tx); const challenge = await acmeChallengeDAL.findByIdForChallengeValidation(challengeId, tx);
if (!challenge) { if (!challenge) {
@@ -54,6 +54,7 @@ export const pkiAcmeChallengeServiceFactory = ({
? `${baseUrl}:${appCfg.ACME_DEVELOPMENT_HTTP01_CHALLENGE_PORT}` ? `${baseUrl}:${appCfg.ACME_DEVELOPMENT_HTTP01_CHALLENGE_PORT}`
: baseUrl; : baseUrl;
const challengeUrl = new URL(`/.well-known/acme-challenge/${challenge.auth.token}`, actualBaseUrl); const challengeUrl = new URL(`/.well-known/acme-challenge/${challenge.auth.token}`, actualBaseUrl);
logger.info({ challengeUrl }, "Performing ACME HTTP-01 challenge validation");
try { try {
// Notice: well, we are in a transaction, ideally we should not hold transaction and perform // Notice: well, we are in a transaction, ideally we should not hold transaction and perform
// a long running operation for long time. But assuming we are not performing a tons of // a long running operation for long time. But assuming we are not performing a tons of
@@ -66,17 +67,34 @@ export const pkiAcmeChallengeServiceFactory = ({
const challengeResponseBody = await challengeResponse.text(); const challengeResponseBody = await challengeResponse.text();
const thumbprint = Buffer.from(challenge.auth.account.publicKeyThumbprint, "utf-8").toString("base64url"); const thumbprint = Buffer.from(challenge.auth.account.publicKeyThumbprint, "utf-8").toString("base64url");
const expectedChallengeResponseBody = `${challenge.auth.token}.${thumbprint}`; const expectedChallengeResponseBody = `${challenge.auth.token}.${thumbprint}`;
if (challengeResponseBody !== expectedChallengeResponseBody) { if (challengeResponseBody.trimEnd() !== expectedChallengeResponseBody) {
throw new AcmeIncorrectResponseError({ message: "ACME challenge response is not correct" }); throw new AcmeIncorrectResponseError({ message: "ACME challenge response is not correct" });
} }
await acmeChallengeDAL.markAsValidCascadeById(challengeId, tx); await acmeChallengeDAL.markAsValidCascadeById(challengeId, tx);
} catch (error) { } catch (error) {
logger.error(error, "Error validating ACME challenge response");
// TODO: we should retry the challenge validation a few times, but let's keep it simple for now // TODO: we should retry the challenge validation a few times, but let's keep it simple for now
await acmeChallengeDAL.markAsValidCascadeById(challengeId, tx); await acmeChallengeDAL.markAsValidCascadeById(challengeId, tx);
throw error; // Properly type and inspect the error
if (error instanceof TypeError && error.message.includes("fetch failed")) {
const cause = error.cause as AggregateError;
if (cause?.errors?.[0]?.code === "ECONNREFUSED") {
logger.error(error, "Connection refused.");
return new AcmeConnectionError({ message: "Connection refused." });
} else if (cause?.errors?.[0]?.code === "ENOTFOUND") {
logger.error(error, "Hostname could not be resolved (DNS failure).");
return new AcmeDnsFailureError({ message: "Hostname could not be resolved (DNS failure)." });
}
} else if (error instanceof Error) {
logger.error(error, "Error validating ACME challenge response");
} else {
logger.error(error, "Unknown error validating ACME challenge response");
}
return error;
} }
}); });
if (error) {
throw error;
}
}; };
return { validateChallengeResponse }; return { validateChallengeResponse };
@@ -3,15 +3,43 @@
* https://datatracker.ietf.org/doc/html/rfc8555#section-6.2 * https://datatracker.ietf.org/doc/html/rfc8555#section-6.2
*/ */
// RFC 8555 Section 6.7 - Error Types
export enum AcmeErrorType {
AccountDoesNotExist = "accountDoesNotExist",
AlreadyRevoked = "alreadyRevoked",
BadCsr = "badCSR",
BadNonce = "badNonce",
BadPublicKey = "badPublicKey",
BadRevocationReason = "badRevocationReason",
BadSignatureAlgorithm = "badSignatureAlgorithm",
CAA = "CAA",
Compound = "compound",
Connection = "connection",
DNS = "DNS",
ExternalAccountRequired = "externalAccountRequired",
IncorrectResponse = "incorrectResponse",
IncorrectContact = "incorrectContact",
Malformed = "malformed",
OrderNotReady = "orderNotReady",
RateLimited = "rateLimited",
RejectedIdentifier = "rejectedIdentifier",
ServerInternal = "serverInternal",
TLS = "tls",
Unauthorized = "unauthorized",
UnsupportedContact = "unsupportedContact",
UnsupportedIdentifier = "unsupportedIdentifier",
UserActionRequired = "userActionRequired"
}
export interface IAcmeError { export interface IAcmeError {
type: string; type: AcmeErrorType;
detail: string; detail: string;
status: number; status: number;
subproblems?: Array<{ type: string; detail: string; identifier?: { type: string; value: string } }>; subproblems?: Array<{ type: string; detail: string; identifier?: { type: string; value: string } }>;
} }
export class AcmeError extends Error implements IAcmeError { export class AcmeError extends Error implements IAcmeError {
type: string; type: AcmeErrorType;
detail: string; detail: string;
@@ -29,7 +57,7 @@ export class AcmeError extends Error implements IAcmeError {
error, error,
message message
}: { }: {
type: string; type: AcmeErrorType;
detail: string; detail: string;
status: number; status: number;
subproblems?: Array<{ type: string; detail: string; identifier?: { type: string; value: string } }>; subproblems?: Array<{ type: string; detail: string; identifier?: { type: string; value: string } }>;
@@ -69,7 +97,7 @@ export class AcmeMalformedError extends AcmeError {
message?: string; message?: string;
} = {}) { } = {}) {
super({ super({
type: "malformed", type: AcmeErrorType.Malformed,
detail, detail,
status: 400, status: 400,
error, error,
@@ -93,7 +121,7 @@ export class AcmeUnauthorizedError extends AcmeError {
message?: string; message?: string;
} = {}) { } = {}) {
super({ super({
type: "unauthorized", type: AcmeErrorType.Unauthorized,
detail, detail,
status: 403, status: 403,
error, error,
@@ -118,7 +146,7 @@ export class AcmeAccountDoesNotExistError extends AcmeError {
message?: string; message?: string;
} = {}) { } = {}) {
super({ super({
type: "accountDoesNotExist", type: AcmeErrorType.AccountDoesNotExist,
detail, detail,
status: 400, status: 400,
error, error,
@@ -142,7 +170,7 @@ export class AcmeBadNonceError extends AcmeError {
message?: string; message?: string;
} = {}) { } = {}) {
super({ super({
type: "badNonce", type: AcmeErrorType.BadNonce,
detail, detail,
status: 400, status: 400,
error, error,
@@ -153,11 +181,11 @@ export class AcmeBadNonceError extends AcmeError {
} }
/** /**
* badSignature - The JWS signature is invalid (RFC 8555 Section 6.7.5) * badSignatureAlgorithm - The signature algorithm is invalid (RFC 8555 Section 6.7.5)
*/ */
export class AcmeBadSignatureError extends AcmeError { export class AcmeBadSignatureAlgorithmError extends AcmeError {
constructor({ constructor({
detail = "The JWS signature is invalid", detail = "The signature algorithm is invalid",
error, error,
message message
}: { }: {
@@ -166,13 +194,13 @@ export class AcmeBadSignatureError extends AcmeError {
message?: string; message?: string;
} = {}) { } = {}) {
super({ super({
type: "badSignature", type: AcmeErrorType.BadSignatureAlgorithm,
detail, detail,
status: 401, status: 401,
error, error,
message message
}); });
this.name = "AcmeBadSignatureError"; this.name = "AcmeBadSignatureAlgorithmError";
} }
} }
@@ -190,7 +218,7 @@ export class AcmeBadPublicKeyError extends AcmeError {
message?: string; message?: string;
} = {}) { } = {}) {
super({ super({
type: "badPublicKey", type: AcmeErrorType.BadPublicKey,
detail, detail,
status: 400, status: 400,
error, error,
@@ -214,7 +242,7 @@ export class AcmeBadCsrError extends AcmeError {
message?: string; message?: string;
} = {}) { } = {}) {
super({ super({
type: "badCSR", type: AcmeErrorType.BadCsr,
detail, detail,
status: 400, status: 400,
error, error,
@@ -239,7 +267,7 @@ export class AcmeBadRevocationReasonError extends AcmeError {
message?: string; message?: string;
} = {}) { } = {}) {
super({ super({
type: "badRevocationReason", type: AcmeErrorType.BadRevocationReason,
detail, detail,
status: 400, status: 400,
error, error,
@@ -263,7 +291,7 @@ export class AcmeRateLimitedError extends AcmeError {
message?: string; message?: string;
} = {}) { } = {}) {
super({ super({
type: "rateLimited", type: AcmeErrorType.RateLimited,
detail, detail,
status: 429, status: 429,
error, error,
@@ -290,7 +318,7 @@ export class AcmeRejectedIdentifierError extends AcmeError {
message?: string; message?: string;
} = {}) { } = {}) {
super({ super({
type: "rejectedIdentifier", type: AcmeErrorType.RejectedIdentifier,
detail, detail,
status: 400, status: 400,
subproblems, subproblems,
@@ -315,7 +343,7 @@ export class AcmeServerInternalError extends AcmeError {
message?: string; message?: string;
} = {}) { } = {}) {
super({ super({
type: "serverInternal", type: AcmeErrorType.ServerInternal,
detail, detail,
status: 500, status: 500,
error, error,
@@ -325,30 +353,6 @@ export class AcmeServerInternalError extends AcmeError {
} }
} }
/**
* serviceUnavailable - The service is unavailable (RFC 8555 Section 6.7.12)
*/
export class AcmeServiceUnavailableError extends AcmeError {
constructor({
detail = "The service is unavailable",
error,
message
}: {
detail?: string;
error?: unknown;
message?: string;
} = {}) {
super({
type: "serviceUnavailable",
detail,
status: 503,
error,
message
});
this.name = "AcmeServiceUnavailableError";
}
}
/** /**
* unsupportedContact - A contact URL is of an unsupported type (RFC 8555 Section 6.7.13) * unsupportedContact - A contact URL is of an unsupported type (RFC 8555 Section 6.7.13)
*/ */
@@ -363,7 +367,7 @@ export class AcmeUnsupportedContactError extends AcmeError {
message?: string; message?: string;
} = {}) { } = {}) {
super({ super({
type: "unsupportedContact", type: AcmeErrorType.UnsupportedContact,
detail, detail,
status: 400, status: 400,
error, error,
@@ -388,7 +392,7 @@ export class AcmeUnsupportedIdentifierError extends AcmeError {
message?: string; message?: string;
} = {}) { } = {}) {
super({ super({
type: "unsupportedIdentifier", type: AcmeErrorType.UnsupportedIdentifier,
detail, detail,
status: 400, status: 400,
error, error,
@@ -417,7 +421,7 @@ export class AcmeUserActionRequiredError extends AcmeError {
message?: string; message?: string;
} = {}) { } = {}) {
super({ super({
type: "userActionRequired", type: AcmeErrorType.UserActionRequired,
detail, detail,
status: 403, status: 403,
error, error,
@@ -449,7 +453,7 @@ export class AcmeIncorrectResponseError extends AcmeError {
message?: string; message?: string;
} = {}) { } = {}) {
super({ super({
type: "incorrectResponse", type: AcmeErrorType.IncorrectResponse,
detail, detail,
status: 400, status: 400,
error, error,
@@ -458,3 +462,48 @@ export class AcmeIncorrectResponseError extends AcmeError {
this.name = "AcmeIncorrectResponseError"; this.name = "AcmeIncorrectResponseError";
} }
} }
/**
* connectionError - A connection error occurred (RFC 8555 Section 6.7.17)
*/
export class AcmeConnectionError extends AcmeError {
constructor({
detail = "A connection error occurred",
error,
message
}: {
detail?: string;
error?: unknown;
message?: string;
} = {}) {
super({
type: AcmeErrorType.Connection,
detail,
status: 400,
error,
message
});
this.name = "AcmeConnectionError";
}
}
export class AcmeDnsFailureError extends AcmeError {
constructor({
detail = "Hostname could not be resolved (DNS failure)",
error,
message
}: {
detail?: string;
error?: unknown;
message?: string;
} = {}) {
super({
type: AcmeErrorType.DNS,
detail,
status: 400,
error,
message
});
this.name = "AcmeDnsFailureError";
}
}
@@ -158,7 +158,7 @@ export const GetAcmeAuthorizationResponseSchema = z.object({
) )
}); });
export const RespondToAcmeChallengeBodySchema = z.object({}); export const RespondToAcmeChallengeBodySchema = z.object({}).strict();
export const RespondToAcmeChallengeResponseSchema = z.object({ export const RespondToAcmeChallengeResponseSchema = z.object({
type: z.enum(Object.values(AcmeChallengeType) as [string, ...string[]]), type: z.enum(Object.values(AcmeChallengeType) as [string, ...string[]]),