From efabf9ee05b78f61ab0de2522e591930fe4901ed Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 28 Oct 2025 13:55:41 -0700 Subject: [PATCH] Add more scheme --- .../ee/services/pki-acme/pki-acme-errors.ts | 436 ++++++++++++++++++ .../ee/services/pki-acme/pki-acme-schemas.ts | 20 + .../ee/services/pki-acme/pki-acme-service.ts | 54 ++- .../ee/services/pki-acme/pki-acme-types.ts | 7 + 4 files changed, 501 insertions(+), 16 deletions(-) create mode 100644 backend/src/ee/services/pki-acme/pki-acme-errors.ts diff --git a/backend/src/ee/services/pki-acme/pki-acme-errors.ts b/backend/src/ee/services/pki-acme/pki-acme-errors.ts new file mode 100644 index 000000000..85de5cb1c --- /dev/null +++ b/backend/src/ee/services/pki-acme/pki-acme-errors.ts @@ -0,0 +1,436 @@ +/** + * ACME Error Classes based on RFC 8555 Section 6.2 + * https://datatracker.ietf.org/doc/html/rfc8555#section-6.2 + */ + +export interface IAcmeError { + type: string; + detail: string; + status: number; + subproblems?: Array<{ type: string; detail: string; identifier?: { type: string; value: string } }>; +} + +export class AcmeError extends Error implements IAcmeError { + type: string; + + detail: string; + + status: number; + + subproblems?: Array<{ type: string; detail: string; identifier?: { type: string; value: string } }>; + + error?: unknown; + + constructor({ + type, + detail, + status, + subproblems, + error, + message + }: { + type: string; + detail: string; + status: number; + subproblems?: Array<{ type: string; detail: string; identifier?: { type: string; value: string } }>; + error?: unknown; + message?: string; + }) { + super(message || detail); + this.type = type; + this.detail = detail; + this.status = status; + this.subproblems = subproblems; + this.error = error; + this.name = "AcmeError"; + } + + toAcmeResponse(): IAcmeError { + return { + type: this.type, + detail: this.detail, + status: this.status, + subproblems: this.subproblems + }; + } +} + +/** + * malformed - The request message was malformed (RFC 8555 Section 6.7.1) + */ +export class AcmeMalformedError extends AcmeError { + constructor({ + detail = "The request message was malformed", + error, + message + }: { + detail?: string; + error?: unknown; + message?: string; + } = {}) { + super({ + type: "malformed", + detail, + status: 400, + error, + message + }); + this.name = "AcmeMalformedError"; + } +} + +/** + * unauthorized - The client lacks sufficient authorization (RFC 8555 Section 6.7.2) + */ +export class AcmeUnauthorizedError extends AcmeError { + constructor({ + detail = "The client lacks sufficient authorization", + error, + message + }: { + detail?: string; + error?: unknown; + message?: string; + } = {}) { + super({ + type: "unauthorized", + detail, + status: 403, + error, + message + }); + this.name = "AcmeUnauthorizedError"; + } +} + +/** + * accountDoesNotExist - The request specified an account that does not exist + * (RFC 8555 Section 6.7.3) + */ +export class AcmeAccountDoesNotExistError extends AcmeError { + constructor({ + detail = "The request specified an account that does not exist", + error, + message + }: { + detail?: string; + error?: unknown; + message?: string; + } = {}) { + super({ + type: "accountDoesNotExist", + detail, + status: 400, + error, + message + }); + this.name = "AcmeAccountDoesNotExistError"; + } +} + +/** + * badNonce - The client sent an unacceptable anti-replay nonce (RFC 8555 Section 6.7.4) + */ +export class AcmeBadNonceError extends AcmeError { + constructor({ + detail = "The client sent an unacceptable anti-replay nonce", + error, + message + }: { + detail?: string; + error?: unknown; + message?: string; + } = {}) { + super({ + type: "badNonce", + detail, + status: 400, + error, + message + }); + this.name = "AcmeBadNonceError"; + } +} + +/** + * badSignature - The JWS signature is invalid (RFC 8555 Section 6.7.5) + */ +export class AcmeBadSignatureError extends AcmeError { + constructor({ + detail = "The JWS signature is invalid", + error, + message + }: { + detail?: string; + error?: unknown; + message?: string; + } = {}) { + super({ + type: "badSignature", + detail, + status: 401, + error, + message + }); + this.name = "AcmeBadSignatureError"; + } +} + +/** + * badPublicKey - The public key is not acceptable (RFC 8555 Section 6.7.6) + */ +export class AcmeBadPublicKeyError extends AcmeError { + constructor({ + detail = "The public key is not acceptable", + error, + message + }: { + detail?: string; + error?: unknown; + message?: string; + } = {}) { + super({ + type: "badPublicKey", + detail, + status: 400, + error, + message + }); + this.name = "AcmeBadPublicKeyError"; + } +} + +/** + * badCSR - The CSR is unacceptable (RFC 8555 Section 6.7.7) + */ +export class AcmeBadCsrError extends AcmeError { + constructor({ + detail = "The CSR is unacceptable", + error, + message + }: { + detail?: string; + error?: unknown; + message?: string; + } = {}) { + super({ + type: "badCSR", + detail, + status: 400, + error, + message + }); + this.name = "AcmeBadCsrError"; + } +} + +/** + * badRevocationReason - The revocation reason provided is not allowed + * (RFC 8555 Section 6.7.8) + */ +export class AcmeBadRevocationReasonError extends AcmeError { + constructor({ + detail = "The revocation reason provided is not allowed", + error, + message + }: { + detail?: string; + error?: unknown; + message?: string; + } = {}) { + super({ + type: "badRevocationReason", + detail, + status: 400, + error, + message + }); + this.name = "AcmeBadRevocationReasonError"; + } +} + +/** + * rateLimited - The client has exceeded a rate limit (RFC 8555 Section 6.7.9) + */ +export class AcmeRateLimitedError extends AcmeError { + constructor({ + detail = "The client has exceeded a rate limit", + error, + message + }: { + detail?: string; + error?: unknown; + message?: string; + } = {}) { + super({ + type: "rateLimited", + detail, + status: 429, + error, + message + }); + this.name = "AcmeRateLimitedError"; + } +} + +/** + * rejectedIdentifier - The server will not issue certificates for the identifier + * (RFC 8555 Section 6.7.10) + */ +export class AcmeRejectedIdentifierError extends AcmeError { + constructor({ + detail = "The server will not issue certificates for the identifier", + subproblems, + error, + message + }: { + detail?: string; + subproblems?: Array<{ type: string; detail: string; identifier?: { type: string; value: string } }>; + error?: unknown; + message?: string; + } = {}) { + super({ + type: "rejectedIdentifier", + detail, + status: 400, + subproblems, + error, + message + }); + this.name = "AcmeRejectedIdentifierError"; + } +} + +/** + * serverInternal - An internal error occurred (RFC 8555 Section 6.7.11) + */ +export class AcmeServerInternalError extends AcmeError { + constructor({ + detail = "An internal error occurred", + error, + message + }: { + detail?: string; + error?: unknown; + message?: string; + } = {}) { + super({ + type: "serverInternal", + detail, + status: 500, + error, + message + }); + this.name = "AcmeServerInternalError"; + } +} + +/** + * 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) + */ +export class AcmeUnsupportedContactError extends AcmeError { + constructor({ + detail = "A contact URL is of an unsupported type", + error, + message + }: { + detail?: string; + error?: unknown; + message?: string; + } = {}) { + super({ + type: "unsupportedContact", + detail, + status: 400, + error, + message + }); + this.name = "AcmeUnsupportedContactError"; + } +} + +/** + * unsupportedIdentifier - An identifier is of an unsupported type + * (RFC 8555 Section 6.7.14) + */ +export class AcmeUnsupportedIdentifierError extends AcmeError { + constructor({ + detail = "An identifier is of an unsupported type", + error, + message + }: { + detail?: string; + error?: unknown; + message?: string; + } = {}) { + super({ + type: "unsupportedIdentifier", + detail, + status: 400, + error, + message + }); + this.name = "AcmeUnsupportedIdentifierError"; + } +} + +/** + * userActionRequired - Visit the "instance" URL and take actions specified there + * (RFC 8555 Section 6.7.15) + */ +export class AcmeUserActionRequiredError extends AcmeError { + instance?: string; + + constructor({ + detail = "Visit the instance URL and take actions specified there", + instance, + error, + message + }: { + detail?: string; + instance?: string; + error?: unknown; + message?: string; + } = {}) { + super({ + type: "userActionRequired", + detail, + status: 403, + error, + message + }); + this.instance = instance; + this.name = "AcmeUserActionRequiredError"; + } + + toAcmeResponse(): IAcmeError & { instance?: string } { + return { + ...super.toAcmeResponse(), + instance: this.instance + }; + } +} diff --git a/backend/src/ee/services/pki-acme/pki-acme-schemas.ts b/backend/src/ee/services/pki-acme/pki-acme-schemas.ts index cdce6e9b8..8530ad16f 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-schemas.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-schemas.ts @@ -1,5 +1,25 @@ import { z } from "zod"; +export const ProtectedHeaderSchema = z.object({ + alg: z.string(), + nonce: z.string(), + url: z.string(), + kid: z.string().optional(), + jwk: z.record(z.string(), z.string()).optional() +}); + +// Raw JWS payload schema before parsing and verification +export const RawJwsPayloadSchema = z.object({ + protected: z.string(), + payload: z.string(), + signature: z.string() +}); + +export const JwsPayloadSchema = z.object({ + protectedHeader: ProtectedHeaderSchema, + payload: z.any() +}); + // Directory endpoint export const GetAcmeDirectorySchema = z.object({ params: z.object({ diff --git a/backend/src/ee/services/pki-acme/pki-acme-service.ts b/backend/src/ee/services/pki-acme/pki-acme-service.ts index d95f1f11a..2ce87b37a 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -1,8 +1,10 @@ import { getConfig } from "@app/lib/config/env"; -import { NotFoundError } from "@app/lib/errors"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; +import { AcmeMalformedError, AcmeBadPublicKeyError } from "./pki-acme-errors"; + import { EnrollmentType, TCertificateProfileWithConfigs @@ -19,18 +21,20 @@ import { TGetAcmeAuthorizationResponse, TGetAcmeDirectoryResponse, TGetAcmeOrderResponse, + TRawJwsPayload, TListAcmeOrdersResponse, TPkiAcmeServiceFactory, - TRespondToAcmeChallengeResponse + TRespondToAcmeChallengeResponse, + TJwsPayload, + TProtectedHeader } from "./pki-acme-types"; +import { flattenedVerify, importJWK, JWK, JWSHeaderParameters } from "jose"; type TPkiAcmeServiceFactoryDep = { certificateProfileDAL: Pick; }; export const pkiAcmeServiceFactory = ({ certificateProfileDAL }: TPkiAcmeServiceFactoryDep): TPkiAcmeServiceFactory => { - const appCfg = getConfig(); - const validateAcmeProfile = async (profileId: string): Promise => { const profile = await certificateProfileDAL.findById(profileId); if (!profile) { @@ -43,18 +47,35 @@ export const pkiAcmeServiceFactory = ({ certificateProfileDAL }: TPkiAcmeService }; const buildUrl = (path: string): string => { + const appCfg = getConfig(); const baseUrl = appCfg.SITE_URL ?? ""; return `${baseUrl}${path}`; }; + const validateCreateAcmeAccountJwsPayload = async (rawPayload: TRawJwsPayload): Promise => { + const { payload, protectedHeader } = await flattenedVerify( + rawPayload, + async (protectedHeader: JWSHeaderParameters | undefined) => { + if (protectedHeader === undefined) { + throw new AcmeMalformedError({ detail: "Protected header is required" }); + } + if (protectedHeader.jwk === undefined) { + throw new AcmeBadPublicKeyError({ detail: "JWK is required in the protected header" }); + } + // For the create account request, the JWK is provided in the protected header. + // Let use it to verify the signature. + const imported = await importJWK(protectedHeader.jwk as JWK, protectedHeader.alg); + return imported; + } + ); + const decoder = new TextDecoder(); + const parsedPayload = JSON.parse(decoder.decode(payload)) as TCreateAcmeAccountPayload; + // TODO: also consume the nonce here + return { payload: parsedPayload, protectedHeader: protectedHeader as TProtectedHeader }; + }; + const getAcmeDirectory = async (profileId: string): Promise => { - // FIXME: Implement ACME directory endpoint - // Validate profile exists and is for ACME enrollment - const profile = await validateAcmeProfile(profileId); - - // FIXME: Validate profile is configured for ACME enrollment - - // Return absolute URLs using SITE_URL + await validateAcmeProfile(profileId); return { newNonce: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/new-nonce`), newAccount: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/new-account`), @@ -71,7 +92,7 @@ export const pkiAcmeServiceFactory = ({ certificateProfileDAL }: TPkiAcmeService const createAcmeAccount = async ( profileId: string, - body: TCreateAcmeAccountPayload + payload: TCreateAcmeAccountPayload ): Promise => { const profile = await validateAcmeProfile(profileId); // FIXME: Implement ACME new account registration @@ -88,7 +109,7 @@ export const pkiAcmeServiceFactory = ({ certificateProfileDAL }: TPkiAcmeService const createAcmeOrder = async ( profileId: string, - body: TCreateAcmeOrderPayload + payload: TCreateAcmeOrderPayload ): Promise => { const profile = await validateAcmeProfile(profileId); // FIXME: Implement ACME new order creation @@ -105,7 +126,7 @@ export const pkiAcmeServiceFactory = ({ certificateProfileDAL }: TPkiAcmeService const deactivateAcmeAccount = async ( profileId: string, accountId: string, - body?: TDeactivateAcmeAccountPayload + payload?: TDeactivateAcmeAccountPayload ): Promise => { const profile = await validateAcmeProfile(profileId); // FIXME: Implement ACME account deactivation @@ -137,10 +158,10 @@ export const pkiAcmeServiceFactory = ({ certificateProfileDAL }: TPkiAcmeService const finalizeAcmeOrder = async ( profileId: string, orderId: string, - body: TFinalizeAcmeOrderPayload + payload: TFinalizeAcmeOrderPayload ): Promise => { const profile = await validateAcmeProfile(profileId); - const { csr } = body; + const { csr } = payload; // FIXME: Implement ACME finalize order return { status: "processing", @@ -196,6 +217,7 @@ export const pkiAcmeServiceFactory = ({ certificateProfileDAL }: TPkiAcmeService }; return { + validateCreateAcmeAccountJwsPayload, getAcmeDirectory, getAcmeNewNonce, createAcmeAccount, diff --git a/backend/src/ee/services/pki-acme/pki-acme-types.ts b/backend/src/ee/services/pki-acme/pki-acme-types.ts index f004e5102..ffe5df0d3 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-types.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-types.ts @@ -12,7 +12,10 @@ import { GetAcmeAuthorizationResponseSchema, GetAcmeDirectoryResponseSchema, GetAcmeOrderResponseSchema, + JwsPayloadSchema, ListAcmeOrdersResponseSchema, + ProtectedHeaderSchema, + RawJwsPayloadSchema, RespondToAcmeChallengeResponseSchema } from "./pki-acme-schemas"; @@ -28,12 +31,16 @@ export type TGetAcmeAuthorizationResponse = z.infer; // Payload types +export type TRawJwsPayload = z.infer; +export type TJwsPayload = z.infer; +export type TProtectedHeader = z.infer; export type TCreateAcmeAccountPayload = z.infer; export type TCreateAcmeOrderPayload = z.infer; export type TDeactivateAcmeAccountPayload = z.infer; export type TFinalizeAcmeOrderPayload = z.infer; export type TPkiAcmeServiceFactory = { + validateCreateAcmeAccountJwsPayload(body: TRawJwsPayload): Promise; getAcmeDirectory: (profileId: string) => Promise; getAcmeNewNonce: (profileId: string) => Promise; createAcmeAccount: (profileId: string, body: TCreateAcmeAccountPayload) => Promise;