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 73f17e061..329cb1989 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-errors.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-errors.ts @@ -549,3 +549,24 @@ export class AcmeBadCSRError extends AcmeError { this.name = "AcmeBadCSRError"; } } + +export class AcmeExternalAccountRequiredError extends AcmeError { + constructor({ + detail = "External account binding is required", + error, + message + }: { + detail?: string; + error?: unknown; + message?: string; + } = {}) { + super({ + type: AcmeErrorType.ExternalAccountRequired, + detail, + status: 400, + error, + message + }); + this.name = "AcmeExternalAccountRequiredError"; + } +} 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 7298dd944..1d2f95fdf 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-schemas.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-schemas.ts @@ -66,13 +66,7 @@ export const CreateAcmeAccountBodySchema = z.object({ contact: z.array(z.string()).optional(), termsOfServiceAgreed: z.boolean().optional(), onlyReturnExisting: z.boolean().optional(), - externalAccountBinding: z - .object({ - protected: z.string(), - payload: z.string(), - signature: z.string() - }) - .optional() + externalAccountBinding: RawJwsPayloadSchema.optional() }); // New Account endpoint 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 a478a1721..16d917fae 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -2,7 +2,7 @@ import { TPkiAcmeAccounts } from "@app/db/schemas/pki-acme-accounts"; import { TPkiAcmeAuths } from "@app/db/schemas/pki-acme-auths"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; -import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { BadRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; @@ -12,6 +12,9 @@ import { TCertificateProfileWithConfigs } from "@app/services/certificate-profile/certificate-profile-types"; import { TCertificateV3ServiceFactory } from "@app/services/certificate-v3/certificate-v3-service"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; import { calculateJwkThumbprint, errors, @@ -29,6 +32,7 @@ import { AcmeBadCSRError, AcmeBadPublicKeyError, AcmeError, + AcmeExternalAccountRequiredError, AcmeMalformedError, AcmeOrderNotReadyError, AcmeServerInternalError, @@ -67,8 +71,8 @@ import { } from "./pki-acme-types"; type TPkiAcmeServiceFactoryDep = { - certificateProfileDAL: Pick; - certificateV3Service: Pick; + projectDAL: Pick; + certificateProfileDAL: Pick; acmeAccountDAL: Pick< TPkiAcmeAccountDALFactory, "findByProjectIdAndAccountId" | "findByProfileIdAndPublicKeyThumbprintAndAlg" | "create" @@ -83,21 +87,25 @@ type TPkiAcmeServiceFactoryDep = { TPkiAcmeChallengeDALFactory, "create" | "transaction" | "updateById" | "findByAccountAuthAndChallengeId" | "findByIdForChallengeValidation" >; + kmsService: Pick; + certificateV3Service: Pick; acmeChallengeService: TPkiAcmeChallengeServiceFactory; }; export const pkiAcmeServiceFactory = ({ + projectDAL, certificateProfileDAL, - certificateV3Service, acmeAccountDAL, acmeOrderDAL, acmeAuthDAL, acmeOrderAuthDAL, acmeChallengeDAL, + kmsService, + certificateV3Service, acmeChallengeService }: TPkiAcmeServiceFactoryDep): TPkiAcmeServiceFactory => { const validateAcmeProfile = async (profileId: string): Promise => { - const profile = await certificateProfileDAL.findById(profileId); + const profile = await certificateProfileDAL.findByIdWithConfigs(profileId); if (!profile) { throw new NotFoundError({ message: "Certificate profile not found" }); } @@ -304,7 +312,7 @@ export const pkiAcmeServiceFactory = ({ profileId, alg, jwk, - payload: { onlyReturnExisting, contact } + payload: { onlyReturnExisting, contact, externalAccountBinding } }: { profileId: string; alg: string; @@ -312,6 +320,44 @@ export const pkiAcmeServiceFactory = ({ payload: TCreateAcmeAccountPayload; }): Promise> => { const profile = await validateAcmeProfile(profileId); + if (!externalAccountBinding) { + throw new AcmeExternalAccountRequiredError({ detail: "External account binding is required" }); + } + + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId: profile.projectId, + projectDAL, + kmsService + }); + + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + const eabSecret = await kmsDecryptor({ cipherTextBlob: profile.acmeConfig!.encryptedEabSecret }); + const encodedSecret = new TextEncoder().encode(eabSecret.toString()); + try { + const { payload: eabPayload, protectedHeader: eabProtectedHeader } = await flattenedVerify( + externalAccountBinding, + encodedSecret + ); + const alg = eabProtectedHeader!.alg!; + if (!["HS256", "HS384", "HS512"].includes(alg)) { + throw new AcmeMalformedError({ detail: "Invalid algorithm for external account binding JWS payload" }); + } + if ((eabPayload as unknown as { kid: string }).kid !== profile.id) { + throw new UnauthorizedError({ message: "External account binding KID mismatch" }); + } + } catch (error) { + if (error instanceof errors.JWSInvalid) { + throw new AcmeMalformedError({ detail: "Invalid external account binding JWS payload" }); + } + if (error instanceof AcmeError) { + throw error; + } + logger.error(error, "Unexpected error while verifying EAB JWS payload"); + throw new AcmeServerInternalError({ detail: "Failed to verify EAB JWS payload" }); + } + const publicKeyThumbprint = await calculateJwkThumbprint(jwk, "sha256"); const existingAccount: TPkiAcmeAccounts | null = await acmeAccountDAL.findByProfileIdAndPublicKeyThumbprintAndAlg( profileId,