From b4bd05cbd0d43746f53609f15b6203f725e85bd8 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 31 Oct 2025 20:16:26 -0700 Subject: [PATCH] issuing cert --- .../bdd/features/pki/acme/challenge.feature | 1 + .../migrations/20251029234547_add-pki-acme.ts | 9 ++++ .../pki-acme/pki-acme-challenge-service.ts | 3 -- .../ee/services/pki-acme/pki-acme-errors.ts | 21 +++++++++ .../services/pki-acme/pki-acme-order-dal.ts | 10 +++++ .../ee/services/pki-acme/pki-acme-service.ts | 44 +++++++++++++++++-- 6 files changed, 82 insertions(+), 6 deletions(-) diff --git a/backend/bdd/features/pki/acme/challenge.feature b/backend/bdd/features/pki/acme/challenge.feature index 8e7dad523..960842ebf 100644 --- a/backend/bdd/features/pki/acme/challenge.feature +++ b/backend/bdd/features/pki/acme/challenge.feature @@ -19,3 +19,4 @@ Feature: Challenge Then I select challenge with type http-01 for domain localhost from order at order as challenge Then I serve challenge response for challenge at localhost Then I tell ACME server that challenge is ready to be verified + Then I poll and finalize the ACME order order diff --git a/backend/src/db/migrations/20251029234547_add-pki-acme.ts b/backend/src/db/migrations/20251029234547_add-pki-acme.ts index c91a179a8..ee5ba9f36 100644 --- a/backend/src/db/migrations/20251029234547_add-pki-acme.ts +++ b/backend/src/db/migrations/20251029234547_add-pki-acme.ts @@ -85,6 +85,12 @@ export async function up(knex: Knex): Promise { t.timestamp("expiresAt").notNullable(); + t.string("csr").nullable(); + t.string("certificate").nullable(); + t.string("certificateChain").nullable(); + + t.string("error").nullable(); + // Order status t.string("status").notNullable(); // pending, ready, processing, valid, invalid @@ -160,6 +166,9 @@ export async function up(knex: Knex): Promise { // Challenge status t.string("status").notNullable(); // pending, processing, valid, invalid + // Error message when the challenge fails + t.string("error").nullable(); + // Validation timestamp t.timestamp("validatedAt").nullable(); diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts index fa34000cc..3b858e427 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts @@ -1,14 +1,12 @@ import { getConfig } from "@app/lib/config/env"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; -import { TPkiAcmeAuthDALFactory } from "./pki-acme-auth-dal"; import { TPkiAcmeChallengeDALFactory } from "./pki-acme-challenge-dal"; import { AcmeConnectionError, AcmeDnsFailureError, AcmeIncorrectResponseError } from "./pki-acme-errors"; import { AcmeAuthStatus, AcmeChallengeStatus, AcmeChallengeType } from "./pki-acme-schemas"; import { TPkiAcmeChallengeServiceFactory } from "./pki-acme-types"; type TPkiAcmeChallengeServiceFactoryDep = { - acmeAuthDAL: Pick; acmeChallengeDAL: Pick< TPkiAcmeChallengeDALFactory, "transaction" | "findByIdForChallengeValidation" | "markAsValidCascadeById" | "markAsInvalidCascadeById" @@ -16,7 +14,6 @@ type TPkiAcmeChallengeServiceFactoryDep = { }; export const pkiAcmeChallengeServiceFactory = ({ - acmeAuthDAL, acmeChallengeDAL }: TPkiAcmeChallengeServiceFactoryDep): TPkiAcmeChallengeServiceFactory => { const appCfg = getConfig(); diff --git a/backend/src/ee/services/pki-acme/pki-acme-errors.ts b/backend/src/ee/services/pki-acme/pki-acme-errors.ts index 8b5a19149..924a7b0ca 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-errors.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-errors.ts @@ -507,3 +507,24 @@ export class AcmeDnsFailureError extends AcmeError { this.name = "AcmeDnsFailureError"; } } + +export class AcmeOrderNotReadyError extends AcmeError { + constructor({ + detail = "The order is not ready", + error, + message + }: { + detail?: string; + error?: unknown; + message?: string; + } = {}) { + super({ + type: AcmeErrorType.OrderNotReady, + detail, + status: 403, + error, + message + }); + this.name = "AcmeOrderNotReadyError"; + } +} diff --git a/backend/src/ee/services/pki-acme/pki-acme-order-dal.ts b/backend/src/ee/services/pki-acme/pki-acme-order-dal.ts index 47d72a261..8600079df 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-order-dal.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-order-dal.ts @@ -10,6 +10,15 @@ export type TPkiAcmeOrderDALFactory = ReturnType; export const pkiAcmeOrderDALFactory = (db: TDbClient) => { const pkiAcmeOrderOrm = ormify(db, TableName.PkiAcmeOrder); + const findByIdForFinalization = async (id: string, tx?: Knex) => { + try { + const order = await (tx || db)(TableName.PkiAcmeOrder).forUpdate().where({ id }).first(); + return order || null; + } catch (error) { + throw new DatabaseError({ error, name: "Find PKI ACME order by id for finalization" }); + } + }; + const findByAccountAndOrderIdWithAuthorizations = async (accountId: string, orderId: string, tx?: Knex) => { try { const rows = await (tx || db)(TableName.PkiAcmeOrder) @@ -53,6 +62,7 @@ export const pkiAcmeOrderDALFactory = (db: TDbClient) => { return { ...pkiAcmeOrderOrm, + findByIdForFinalization, findByAccountAndOrderIdWithAuthorizations }; }; 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 f1d60b241..1e4af96e5 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -6,6 +6,7 @@ import { NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; +import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service"; import { EnrollmentType, TCertificateProfileWithConfigs @@ -27,6 +28,7 @@ import { AcmeBadPublicKeyError, AcmeError, AcmeMalformedError, + AcmeOrderNotReadyError, AcmeServerInternalError, AcmeUnauthorizedError, AcmeUnsupportedIdentifierError @@ -64,11 +66,15 @@ import { type TPkiAcmeServiceFactoryDep = { certificateProfileDAL: Pick; + internalCertificateAuthorityService: Pick; acmeAccountDAL: Pick< TPkiAcmeAccountDALFactory, "findByProjectIdAndAccountId" | "findByProfileIdAndPublicKeyThumbprintAndAlg" | "create" >; - acmeOrderDAL: Pick; + acmeOrderDAL: Pick< + TPkiAcmeOrderDALFactory, + "create" | "transaction" | "updateById" | "findByAccountAndOrderIdWithAuthorizations" | "findByIdForFinalization" + >; acmeAuthDAL: Pick; acmeOrderAuthDAL: Pick; acmeChallengeDAL: Pick< @@ -80,6 +86,7 @@ type TPkiAcmeServiceFactoryDep = { export const pkiAcmeServiceFactory = ({ certificateProfileDAL, + internalCertificateAuthorityService, acmeAccountDAL, acmeOrderDAL, acmeAuthDAL, @@ -497,8 +504,39 @@ export const pkiAcmeServiceFactory = ({ if (!order) { throw new NotFoundError({ message: "ACME order not found" }); } - const { csr } = payload; - // FIXME: Implement ACME finalize order + if (order.status === AcmeOrderStatus.Ready) { + await acmeOrderDAL.transaction(async (tx) => { + const order = (await acmeOrderDAL.findByIdForFinalization(orderId, tx))!; + const profile = (await certificateProfileDAL.findById(profileId, tx))!; + if (order.status !== AcmeOrderStatus.Ready) { + throw new AcmeOrderNotReadyError({ message: "ACME order is not ready" }); + } + if (order.expiresAt < new Date()) { + throw new AcmeOrderNotReadyError({ message: "ACME order has expired" }); + } + const { csr } = payload; + // TODO: validate the CSR and return badCSR error if it's invalid + const { certificate, certificateChain } = await internalCertificateAuthorityService.signCertFromCa({ + isInternal: true, + certificateTemplateId: profile.certificateTemplateId, + csr, + notBefore: order.notBefore?.toISOString(), + notAfter: order.notAfter?.toISOString() + }); + await acmeOrderDAL.updateById( + orderId, + { + status: AcmeOrderStatus.Valid, + csr, + certificate, + certificateChain + }, + tx + ); + }); + } else if (order.status !== AcmeOrderStatus.Valid) { + throw new AcmeOrderNotReadyError({ message: "ACME order is not ready" }); + } return { status: 200, body: buildAcmeOrderResource({ profileId, order }),