diff --git a/backend/src/db/migrations/20241107112632_skip-bootstrap-cert-validation-est.ts b/backend/src/db/migrations/20241107112632_skip-bootstrap-cert-validation-est.ts new file mode 100644 index 000000000..69b1193ed --- /dev/null +++ b/backend/src/db/migrations/20241107112632_skip-bootstrap-cert-validation-est.ts @@ -0,0 +1,41 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasSkipBootstrapCertValidationCol = await knex.schema.hasColumn( + TableName.CertificateTemplateEstConfig, + "skipBootstrapCertValidation" + ); + + const hasCaChainCol = await knex.schema.hasColumn(TableName.CertificateTemplateEstConfig, "encryptedCaChain"); + + await knex.schema.alterTable(TableName.CertificateTemplateEstConfig, (t) => { + if (!hasSkipBootstrapCertValidationCol) { + t.boolean("skipBootstrapCertValidation").defaultTo(false).notNullable(); + } + + if (hasCaChainCol) { + t.binary("encryptedCaChain").nullable().alter(); + } + }); +} + +export async function down(knex: Knex): Promise { + const hasSkipBootstrapCertValidationCol = await knex.schema.hasColumn( + TableName.CertificateTemplateEstConfig, + "skipBootstrapCertValidation" + ); + + const hasCaChainCol = await knex.schema.hasColumn(TableName.CertificateTemplateEstConfig, "encryptedCaChain"); + + await knex.schema.alterTable(TableName.CertificateTemplateEstConfig, (t) => { + if (hasSkipBootstrapCertValidationCol) { + t.dropColumn("skipBootstrapCertValidation"); + } + + if (hasCaChainCol) { + t.binary("encryptedCaChain").notNullable().alter(); + } + }); +} diff --git a/backend/src/db/schemas/certificate-template-est-configs.ts b/backend/src/db/schemas/certificate-template-est-configs.ts index 654b5413a..a24089c54 100644 --- a/backend/src/db/schemas/certificate-template-est-configs.ts +++ b/backend/src/db/schemas/certificate-template-est-configs.ts @@ -12,11 +12,12 @@ import { TImmutableDBKeys } from "./models"; export const CertificateTemplateEstConfigsSchema = z.object({ id: z.string().uuid(), certificateTemplateId: z.string().uuid(), - encryptedCaChain: zodBuffer, + encryptedCaChain: zodBuffer.nullable().optional(), hashedPassphrase: z.string(), isEnabled: z.boolean(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + skipBootstrapCertValidation: z.boolean().default(false) }); export type TCertificateTemplateEstConfigs = z.infer; diff --git a/backend/src/ee/services/certificate-est/certificate-est-service.ts b/backend/src/ee/services/certificate-est/certificate-est-service.ts index ce3821ae0..83e5845fd 100644 --- a/backend/src/ee/services/certificate-est/certificate-est-service.ts +++ b/backend/src/ee/services/certificate-est/certificate-est-service.ts @@ -171,27 +171,29 @@ export const certificateEstServiceFactory = ({ }); } - const caCerts = estConfig.caChain - .match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) - ?.map((cert) => { - return new x509.X509Certificate(cert); - }); + if (!estConfig.skipBootstrapCertValidation) { + const caCerts = estConfig.caChain + .match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) + ?.map((cert) => { + return new x509.X509Certificate(cert); + }); - if (!caCerts) { - throw new BadRequestError({ message: "Failed to parse certificate chain" }); - } + if (!caCerts) { + throw new BadRequestError({ message: "Failed to parse certificate chain" }); + } - const leafCertificate = decodeURIComponent(sslClientCert).match( - /-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g - )?.[0]; + const leafCertificate = decodeURIComponent(sslClientCert).match( + /-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g + )?.[0]; - if (!leafCertificate) { - throw new BadRequestError({ message: "Missing client certificate" }); - } + if (!leafCertificate) { + throw new BadRequestError({ message: "Missing client certificate" }); + } - const certObj = new x509.X509Certificate(leafCertificate); - if (!(await isCertChainValid([certObj, ...caCerts]))) { - throw new BadRequestError({ message: "Invalid certificate chain" }); + const certObj = new x509.X509Certificate(leafCertificate); + if (!(await isCertChainValid([certObj, ...caCerts]))) { + throw new BadRequestError({ message: "Invalid certificate chain" }); + } } const { certificate } = await certificateAuthorityService.signCertFromCa({ diff --git a/backend/src/server/routes/v1/certificate-template-router.ts b/backend/src/server/routes/v1/certificate-template-router.ts index 54ce571a2..ede482778 100644 --- a/backend/src/server/routes/v1/certificate-template-router.ts +++ b/backend/src/server/routes/v1/certificate-template-router.ts @@ -14,7 +14,8 @@ import { validateTemplateRegexField } from "@app/services/certificate-template/c const sanitizedEstConfig = CertificateTemplateEstConfigsSchema.pick({ id: true, certificateTemplateId: true, - isEnabled: true + isEnabled: true, + skipBootstrapCertValidation: true }); export const registerCertificateTemplateRouter = async (server: FastifyZodProvider) => { @@ -241,11 +242,18 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid params: z.object({ certificateTemplateId: z.string().trim() }), - body: z.object({ - caChain: z.string().trim().min(1), - passphrase: z.string().min(1), - isEnabled: z.boolean().default(true) - }), + body: z + .object({ + caChain: z.string().trim().optional(), + passphrase: z.string().min(1), + isEnabled: z.boolean().default(true), + skipBootstrapCertValidation: z.boolean().default(false) + }) + .refine( + ({ caChain, skipBootstrapCertValidation }) => + skipBootstrapCertValidation || (!skipBootstrapCertValidation && caChain), + "CA chain is required" + ), response: { 200: sanitizedEstConfig } @@ -289,8 +297,9 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid certificateTemplateId: z.string().trim() }), body: z.object({ - caChain: z.string().trim().min(1).optional(), + caChain: z.string().trim().optional(), passphrase: z.string().min(1).optional(), + skipBootstrapCertValidation: z.boolean().optional(), isEnabled: z.boolean().optional() }), response: { diff --git a/backend/src/services/certificate-template/certificate-template-service.ts b/backend/src/services/certificate-template/certificate-template-service.ts index cbe893719..483296b87 100644 --- a/backend/src/services/certificate-template/certificate-template-service.ts +++ b/backend/src/services/certificate-template/certificate-template-service.ts @@ -235,7 +235,8 @@ export const certificateTemplateServiceFactory = ({ actorId, actorAuthMethod, actor, - actorOrgId + actorOrgId, + skipBootstrapCertValidation }: TCreateEstConfigurationDTO) => { const plan = await licenseService.getPlan(actorOrgId); if (!plan.pkiEst) { @@ -266,39 +267,45 @@ export const certificateTemplateServiceFactory = ({ const appCfg = getConfig(); - const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ - projectId: certTemplate.projectId, - projectDAL, - kmsService - }); + let encryptedCaChain: Buffer | undefined; + if (caChain) { + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId: certTemplate.projectId, + projectDAL, + kmsService + }); - // validate CA chain - const certificates = caChain - .match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) - ?.map((cert) => new x509.X509Certificate(cert)); + // validate CA chain + const certificates = caChain + .match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) + ?.map((cert) => new x509.X509Certificate(cert)); - if (!certificates) { - throw new BadRequestError({ message: "Failed to parse certificate chain" }); + if (!certificates) { + throw new BadRequestError({ message: "Failed to parse certificate chain" }); + } + + if (!(await isCertChainValid(certificates))) { + throw new BadRequestError({ message: "Invalid certificate chain" }); + } + + const kmsEncryptor = await kmsService.encryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + const { cipherTextBlob } = await kmsEncryptor({ + plainText: Buffer.from(caChain) + }); + + encryptedCaChain = cipherTextBlob; } - if (!(await isCertChainValid(certificates))) { - throw new BadRequestError({ message: "Invalid certificate chain" }); - } - - const kmsEncryptor = await kmsService.encryptWithKmsKey({ - kmsId: certificateManagerKmsId - }); - - const { cipherTextBlob: encryptedCaChain } = await kmsEncryptor({ - plainText: Buffer.from(caChain) - }); - const hashedPassphrase = await bcrypt.hash(passphrase, appCfg.SALT_ROUNDS); const estConfig = await certificateTemplateEstConfigDAL.create({ certificateTemplateId, hashedPassphrase, encryptedCaChain, - isEnabled + isEnabled, + skipBootstrapCertValidation }); return { ...estConfig, projectId: certTemplate.projectId }; @@ -312,7 +319,8 @@ export const certificateTemplateServiceFactory = ({ actorId, actorAuthMethod, actor, - actorOrgId + actorOrgId, + skipBootstrapCertValidation }: TUpdateEstConfigurationDTO) => { const plan = await licenseService.getPlan(actorOrgId); if (!plan.pkiEst) { @@ -360,7 +368,8 @@ export const certificateTemplateServiceFactory = ({ }); const updatedData: TCertificateTemplateEstConfigsUpdate = { - isEnabled + isEnabled, + skipBootstrapCertValidation }; if (caChain) { @@ -442,18 +451,24 @@ export const certificateTemplateServiceFactory = ({ kmsId: certificateManagerKmsId }); - const decryptedCaChain = await kmsDecryptor({ - cipherTextBlob: estConfig.encryptedCaChain - }); + let decryptedCaChain = ""; + if (estConfig.encryptedCaChain) { + decryptedCaChain = ( + await kmsDecryptor({ + cipherTextBlob: estConfig.encryptedCaChain + }) + ).toString(); + } return { certificateTemplateId, id: estConfig.id, isEnabled: estConfig.isEnabled, - caChain: decryptedCaChain.toString(), + caChain: decryptedCaChain, hashedPassphrase: estConfig.hashedPassphrase, projectId: certTemplate.projectId, - orgId: certTemplate.orgId + orgId: certTemplate.orgId, + skipBootstrapCertValidation: estConfig.skipBootstrapCertValidation }; }; diff --git a/backend/src/services/certificate-template/certificate-template-types.ts b/backend/src/services/certificate-template/certificate-template-types.ts index 6d6488f2c..db50a54ba 100644 --- a/backend/src/services/certificate-template/certificate-template-types.ts +++ b/backend/src/services/certificate-template/certificate-template-types.ts @@ -34,9 +34,10 @@ export type TDeleteCertTemplateDTO = { export type TCreateEstConfigurationDTO = { certificateTemplateId: string; - caChain: string; + caChain?: string; passphrase: string; isEnabled: boolean; + skipBootstrapCertValidation: boolean; } & Omit; export type TUpdateEstConfigurationDTO = { @@ -44,6 +45,7 @@ export type TUpdateEstConfigurationDTO = { caChain?: string; passphrase?: string; isEnabled?: boolean; + skipBootstrapCertValidation?: boolean; } & Omit; export type TGetEstConfigurationDTO = diff --git a/frontend/src/hooks/api/certificateTemplates/types.ts b/frontend/src/hooks/api/certificateTemplates/types.ts index e1ea5ed81..af8093895 100644 --- a/frontend/src/hooks/api/certificateTemplates/types.ts +++ b/frontend/src/hooks/api/certificateTemplates/types.ts @@ -46,9 +46,10 @@ export type TDeleteCertificateTemplateDTO = { export type TCreateEstConfigDTO = { certificateTemplateId: string; - caChain: string; + caChain?: string; passphrase: string; isEnabled: boolean; + skipBootstrapCertValidation: boolean; }; export type TUpdateEstConfigDTO = { @@ -56,11 +57,13 @@ export type TUpdateEstConfigDTO = { caChain?: string; passphrase?: string; isEnabled?: boolean; + skipBootstrapCertValidation?: boolean; }; export type TEstConfig = { id: string; certificateTemplateId: string; caChain: string; - isEnabled: false; + isEnabled: boolean; + skipBootstrapCertValidation: boolean; }; diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateTemplateEnrollmentModal.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateTemplateEnrollmentModal.tsx index 9384cfeec..4df8c1359 100644 --- a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateTemplateEnrollmentModal.tsx +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateTemplateEnrollmentModal.tsx @@ -33,9 +33,10 @@ type Props = { const schema = z.object({ method: z.nativeEnum(EnrollmentMethod), - caChain: z.string(), + caChain: z.string().optional(), passphrase: z.string().optional(), - isEnabled: z.boolean() + isEnabled: z.boolean(), + skipBootstrapCertValidation: z.boolean().optional().default(false) }); export type FormData = z.infer; @@ -53,6 +54,8 @@ export const CertificateTemplateEnrollmentModal = ({ popUp, handlePopUpToggle }: handleSubmit, reset, setError, + watch, + setValue, formState: { isSubmitting } } = useForm({ resolver: zodResolver(schema) @@ -62,16 +65,26 @@ export const CertificateTemplateEnrollmentModal = ({ popUp, handlePopUpToggle }: const { mutateAsync: updateEstConfig } = useUpdateEstConfig(); const [isPassphraseFocused, setIsPassphraseFocused] = useToggle(false); + const skipBootstrapCertValidation = watch("skipBootstrapCertValidation"); + + useEffect(() => { + if (skipBootstrapCertValidation) { + setValue("caChain", ""); + } + }, [skipBootstrapCertValidation]); + useEffect(() => { if (data) { reset({ caChain: data.caChain, - isEnabled: data.isEnabled + isEnabled: data.isEnabled, + skipBootstrapCertValidation: data.skipBootstrapCertValidation }); } else { reset({ caChain: "", - isEnabled: false + isEnabled: false, + skipBootstrapCertValidation: false }); } }, [data]); @@ -83,7 +96,8 @@ export const CertificateTemplateEnrollmentModal = ({ popUp, handlePopUpToggle }: certificateTemplateId, caChain, passphrase, - isEnabled + isEnabled, + skipBootstrapCertValidation }); } else { if (!passphrase) { @@ -95,7 +109,8 @@ export const CertificateTemplateEnrollmentModal = ({ popUp, handlePopUpToggle }: certificateTemplateId, caChain, passphrase, - isEnabled + isEnabled, + skipBootstrapCertValidation }); } @@ -152,22 +167,43 @@ export const CertificateTemplateEnrollmentModal = ({ popUp, handlePopUpToggle }: )} ( - -