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 57582655a8..cbcf881680 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -7,6 +7,7 @@ import { importJWK, JWSHeaderParameters } from "jose"; +import { Knex } from "knex"; import { z, ZodError } from "zod"; import { TPkiAcmeAccounts } from "@app/db/schemas/pki-acme-accounts"; @@ -30,6 +31,10 @@ import { import { orderCertificate } from "@app/services/certificate-authority/acme/acme-certificate-authority-fns"; import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; +import { + TCertificateIssuanceQueueFactory, + TIssueCertificateFromProfileJobData +} from "@app/services/certificate-authority/certificate-issuance-queue"; import { TExternalCertificateAuthorityDALFactory } from "@app/services/certificate-authority/external-certificate-authority-dal"; import { extractAlgorithmsFromCSR, @@ -40,6 +45,8 @@ import { EnrollmentType, TCertificateProfileWithConfigs } from "@app/services/certificate-profile/certificate-profile-types"; +import { TCertificateRequestServiceFactory } from "@app/services/certificate-request/certificate-request-service"; +import { CertificateRequestStatus } from "@app/services/certificate-request/certificate-request-types"; import { TCertificateTemplateV2DALFactory } from "@app/services/certificate-template-v2/certificate-template-v2-dal"; import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service"; import { TCertificateV3ServiceFactory } from "@app/services/certificate-v3/certificate-v3-service"; @@ -100,13 +107,9 @@ import { type TPkiAcmeServiceFactoryDep = { projectDAL: Pick; - appConnectionDAL: Pick; - certificateDAL: Pick; certificateAuthorityDAL: Pick; - externalCertificateAuthorityDAL: Pick; certificateProfileDAL: Pick; certificateBodyDAL: Pick; - certificateSecretDAL: Pick; certificateTemplateV2DAL: Pick; acmeAccountDAL: Pick< TPkiAcmeAccountDALFactory, @@ -135,6 +138,8 @@ type TPkiAcmeServiceFactoryDep = { licenseService: Pick; certificateV3Service: Pick; certificateTemplateV2Service: Pick; + certificateRequestService: Pick; + certificateIssuanceQueue: Pick; acmeChallengeService: Pick; pkiAcmeQueueService: Pick; auditLogService: Pick; @@ -142,13 +147,9 @@ type TPkiAcmeServiceFactoryDep = { export const pkiAcmeServiceFactory = ({ projectDAL, - appConnectionDAL, - certificateDAL, certificateAuthorityDAL, - externalCertificateAuthorityDAL, certificateProfileDAL, certificateBodyDAL, - certificateSecretDAL, certificateTemplateV2DAL, acmeAccountDAL, acmeOrderDAL, @@ -160,6 +161,8 @@ export const pkiAcmeServiceFactory = ({ licenseService, certificateV3Service, certificateTemplateV2Service, + certificateRequestService, + certificateIssuanceQueue, acmeChallengeService, pkiAcmeQueueService, auditLogService @@ -744,6 +747,125 @@ export const pkiAcmeServiceFactory = ({ }; }; + const processCertificateIssuanceForOrder = async ({ + caType, + accountId, + actorOrgId, + profileId, + orderId, + csr, + finalizingOrder, + certificateRequest, + profile, + ca, + tx + }: { + caType: CaType; + accountId: string; + actorOrgId: string; + profileId: string; + orderId: string; + csr: string; + finalizingOrder: { + notBefore?: Date | null; + notAfter?: Date | null; + }; + certificateRequest: ReturnType; + profile: TCertificateProfileWithConfigs; + ca: Awaited>; + tx?: Knex; + }): Promise<{ certificateId?: string; certIssuanceJobData?: TIssueCertificateFromProfileJobData }> => { + if (caType === CaType.INTERNAL) { + const result = await certificateV3Service.signCertificateFromProfile({ + actor: ActorType.ACME_ACCOUNT, + actorId: accountId, + actorAuthMethod: null, + actorOrgId, + profileId, + csr, + notBefore: finalizingOrder.notBefore ? new Date(finalizingOrder.notBefore) : undefined, + notAfter: finalizingOrder.notAfter ? new Date(finalizingOrder.notAfter) : undefined, + validity: !finalizingOrder.notAfter + ? { + // 47 days, the default TTL comes with Let's Encrypt + // TODO: read config from the profile to get the expiration time instead + ttl: `${47}d` + } + : // ttl is not used if notAfter is provided + ({ ttl: "0d" } as const), + enrollmentType: EnrollmentType.ACME + }); + return { + certificateId: result.certificateId + }; + } + + const { keyAlgorithm: extractedKeyAlgorithm, signatureAlgorithm: extractedSignatureAlgorithm } = + extractAlgorithmsFromCSR(csr); + const updatedCertificateRequest = { + ...certificateRequest, + keyAlgorithm: extractedKeyAlgorithm, + signatureAlgorithm: extractedSignatureAlgorithm, + validity: finalizingOrder.notAfter + ? (() => { + const notBefore = finalizingOrder.notBefore ? new Date(finalizingOrder.notBefore) : new Date(); + const notAfter = new Date(finalizingOrder.notAfter); + const diffMs = notAfter.getTime() - notBefore.getTime(); + const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24)); + return { ttl: `${diffDays}d` }; + })() + : certificateRequest.validity + }; + + const template = await certificateTemplateV2DAL.findById(profile.certificateTemplateId); + if (!template) { + throw new NotFoundError({ message: "Certificate template not found" }); + } + const validationResult = await certificateTemplateV2Service.validateCertificateRequest( + template.id, + updatedCertificateRequest + ); + if (!validationResult.isValid) { + throw new AcmeBadCSRError({ message: `Invalid CSR: ${validationResult.errors.join(", ")}` }); + } + + const certRequest = await certificateRequestService.createCertificateRequest({ + actor: ActorType.ACME_ACCOUNT, + actorId: accountId, + actorAuthMethod: null, + actorOrgId, + projectId: profile.projectId, + caId: ca.id, + profileId: profile.id, + commonName: updatedCertificateRequest.commonName ?? "", + keyUsages: updatedCertificateRequest.keyUsages?.map((usage) => usage.toString()) ?? [], + extendedKeyUsages: updatedCertificateRequest.extendedKeyUsages?.map((usage) => usage.toString()) ?? [], + keyAlgorithm: updatedCertificateRequest.keyAlgorithm || "", + signatureAlgorithm: updatedCertificateRequest.signatureAlgorithm || "", + altNames: updatedCertificateRequest.subjectAlternativeNames?.map((san) => san.value).join(","), + notBefore: updatedCertificateRequest.notBefore, + notAfter: updatedCertificateRequest.notAfter, + status: CertificateRequestStatus.PENDING, + tx + }); + return { + certIssuanceJobData: { + certificateId: orderId, + profileId: profile.id, + caId: profile.caId || "", + ttl: updatedCertificateRequest.validity?.ttl || "1y", + signatureAlgorithm: updatedCertificateRequest.signatureAlgorithm || "", + keyAlgorithm: updatedCertificateRequest.keyAlgorithm || "", + commonName: updatedCertificateRequest.commonName || "", + altNames: updatedCertificateRequest.subjectAlternativeNames?.map((san) => san.value) || [], + keyUsages: updatedCertificateRequest.keyUsages?.map((usage) => usage.toString()) ?? [], + extendedKeyUsages: updatedCertificateRequest.extendedKeyUsages?.map((usage) => usage.toString()) ?? [], + certificateRequestId: certRequest.id, + csr + } + }; + }; + const finalizeAcmeOrder = async ({ profileId, accountId, @@ -768,7 +890,11 @@ export const pkiAcmeServiceFactory = ({ throw new NotFoundError({ message: "ACME order not found" }); } if (order.status === AcmeOrderStatus.Ready) { - const { order: updatedOrder, error } = await acmeOrderDAL.transaction(async (tx) => { + const { + order: updatedOrder, + error, + certIssuanceJobData + } = await acmeOrderDAL.transaction(async (tx) => { const finalizingOrder = (await acmeOrderDAL.findByIdForFinalization(orderId, tx))!; // TODO: ideally, this should be doen with onRequest: verifyAuth([AuthMode.ACME_JWS_SIGNATURE]), instead? const { ownerOrgId: actorOrgId } = (await certificateProfileDAL.findByIdWithOwnerOrgId(profileId, tx))!; @@ -815,94 +941,33 @@ export const pkiAcmeServiceFactory = ({ } const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL; let errorToReturn: Error | undefined; + let certIssuanceJobDataToReturn: TIssueCertificateFromProfileJobData | undefined; try { - const { certificateId } = await (async () => { - if (caType === CaType.INTERNAL) { - const result = await certificateV3Service.signCertificateFromProfile({ - actor: ActorType.ACME_ACCOUNT, - actorId: accountId, - actorAuthMethod: null, - actorOrgId, - profileId, - csr, - notBefore: finalizingOrder.notBefore ? new Date(finalizingOrder.notBefore) : undefined, - notAfter: finalizingOrder.notAfter ? new Date(finalizingOrder.notAfter) : undefined, - validity: !finalizingOrder.notAfter - ? { - // 47 days, the default TTL comes with Let's Encrypt - // TODO: read config from the profile to get the expiration time instead - ttl: `${47}d` - } - : // ttl is not used if notAfter is provided - ({ ttl: "0d" } as const), - enrollmentType: EnrollmentType.ACME - }); - return { certificateId: result.certificateId }; - } - const { certificateAuthority } = (await certificateProfileDAL.findByIdWithConfigs(profileId, tx))!; - const csrObj = new x509.Pkcs10CertificateRequest(csr); - const csrPem = csrObj.toString("pem"); - - const { keyAlgorithm: extractedKeyAlgorithm, signatureAlgorithm: extractedSignatureAlgorithm } = - extractAlgorithmsFromCSR(csr); - - certificateRequest.keyAlgorithm = extractedKeyAlgorithm; - certificateRequest.signatureAlgorithm = extractedSignatureAlgorithm; - if (finalizingOrder.notAfter) { - const notBefore = finalizingOrder.notBefore ? new Date(finalizingOrder.notBefore) : new Date(); - const notAfter = new Date(finalizingOrder.notAfter); - const diffMs = notAfter.getTime() - notBefore.getTime(); - const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24)); - certificateRequest.validity = { ttl: `${diffDays}d` }; - } - - const template = await certificateTemplateV2DAL.findById(profile.certificateTemplateId); - if (!template) { - throw new NotFoundError({ message: "Certificate template not found" }); - } - const validationResult = await certificateTemplateV2Service.validateCertificateRequest( - template.id, - certificateRequest - ); - if (!validationResult.isValid) { - throw new AcmeBadCSRError({ message: `Invalid CSR: ${validationResult.errors.join(", ")}` }); - } - // TODO: this is pretty slow, and we are holding the transaction open for a long time, - // we should queue the certificate issuance to a background job instead - const cert = await orderCertificate( - { - caId: certificateAuthority!.id, - // It is possible that the CSR does not have a common name, in which case we use an empty string - // (more likely than not for a CSR from a modern ACME client like certbot, cert-manager, etc.) - commonName: certificateRequest.commonName ?? "", - altNames: certificateRequest.subjectAlternativeNames?.map((san) => san.value), - csr: Buffer.from(csrPem), - // TODO: not 100% sure what are these columns for, but let's put the values for common website SSL certs for now - keyUsages: [CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT, CertKeyUsage.KEY_AGREEMENT], - extendedKeyUsages: [CertExtendedKeyUsage.SERVER_AUTH] - }, - { - appConnectionDAL, - certificateAuthorityDAL, - externalCertificateAuthorityDAL, - certificateDAL, - certificateBodyDAL, - certificateSecretDAL, - kmsService, - projectDAL - } - ); - return { certificateId: cert.id }; - })(); - await acmeOrderDAL.updateById( + const result = await processCertificateIssuanceForOrder({ + caType, + accountId, + actorOrgId, + profileId, orderId, - { - status: AcmeOrderStatus.Valid, - csr, - certificateId - }, + csr, + finalizingOrder, + certificateRequest, + profile, + ca, tx - ); + }); + if (result.certificateId) { + await acmeOrderDAL.updateById( + orderId, + { + status: AcmeOrderStatus.Valid, + csr, + certificateId: result.certificateId + }, + tx + ); + } + certIssuanceJobDataToReturn = result.certIssuanceJobData; } catch (exp) { await acmeOrderDAL.updateById( orderId, @@ -920,17 +985,25 @@ export const pkiAcmeServiceFactory = ({ } else if (exp instanceof AcmeError) { errorToReturn = exp; } else { - errorToReturn = new AcmeServerInternalError({ message: "Failed to sign certificate with internal error" }); + errorToReturn = new AcmeServerInternalError({ + message: "Failed to sign certificate with internal error" + }); } } return { order: (await acmeOrderDAL.findByAccountAndOrderIdWithAuthorizations(accountId, orderId, tx))!, - error: errorToReturn + error: errorToReturn, + certIssuanceJobData: certIssuanceJobDataToReturn }; }); if (error) { throw error; } + if (certIssuanceJobData) { + // TODO: ideally, this should be done inside the transaction, but the pg-boss queue doesn't support external transactions + // as it seems to be. + await certificateIssuanceQueue.queueCertificateIssuance(certIssuanceJobData); + } order = updatedOrder; await auditLogService.createAuditLog({ projectId: profile.projectId,