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..fbee179b3 --- /dev/null +++ b/backend/src/db/migrations/20241107112632_skip-bootstrap-cert-validation-est.ts @@ -0,0 +1,35 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasDisableBootstrapCertValidationCol = await knex.schema.hasColumn( + TableName.CertificateTemplateEstConfig, + "disableBootstrapCertValidation" + ); + + const hasCaChainCol = await knex.schema.hasColumn(TableName.CertificateTemplateEstConfig, "encryptedCaChain"); + + await knex.schema.alterTable(TableName.CertificateTemplateEstConfig, (t) => { + if (!hasDisableBootstrapCertValidationCol) { + t.boolean("disableBootstrapCertValidation").defaultTo(false).notNullable(); + } + + if (hasCaChainCol) { + t.binary("encryptedCaChain").nullable().alter(); + } + }); +} + +export async function down(knex: Knex): Promise { + const hasDisableBootstrapCertValidationCol = await knex.schema.hasColumn( + TableName.CertificateTemplateEstConfig, + "disableBootstrapCertValidation" + ); + + await knex.schema.alterTable(TableName.CertificateTemplateEstConfig, (t) => { + if (hasDisableBootstrapCertValidationCol) { + t.dropColumn("disableBootstrapCertValidation"); + } + }); +} diff --git a/backend/src/db/schemas/certificate-template-est-configs.ts b/backend/src/db/schemas/certificate-template-est-configs.ts index 654b5413a..262f22b06 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(), + disableBootstrapCertValidation: 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..5790c8d5a 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.disableBootstrapCertValidation) { + 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..681e40e0d 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, + disableBootstrapCertValidation: 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), + disableBootstrapCertValidation: z.boolean().default(false) + }) + .refine( + ({ caChain, disableBootstrapCertValidation }) => + disableBootstrapCertValidation || (!disableBootstrapCertValidation && 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(), + disableBootstrapCertValidation: 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..ead3e85cb 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, + disableBootstrapCertValidation }: 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, + disableBootstrapCertValidation }); return { ...estConfig, projectId: certTemplate.projectId }; @@ -312,7 +319,8 @@ export const certificateTemplateServiceFactory = ({ actorId, actorAuthMethod, actor, - actorOrgId + actorOrgId, + disableBootstrapCertValidation }: TUpdateEstConfigurationDTO) => { const plan = await licenseService.getPlan(actorOrgId); if (!plan.pkiEst) { @@ -360,7 +368,8 @@ export const certificateTemplateServiceFactory = ({ }); const updatedData: TCertificateTemplateEstConfigsUpdate = { - isEnabled + isEnabled, + disableBootstrapCertValidation }; 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, + disableBootstrapCertValidation: estConfig.disableBootstrapCertValidation }; }; diff --git a/backend/src/services/certificate-template/certificate-template-types.ts b/backend/src/services/certificate-template/certificate-template-types.ts index 6d6488f2c..cdccb6a2d 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; + disableBootstrapCertValidation: boolean; } & Omit; export type TUpdateEstConfigurationDTO = { @@ -44,6 +45,7 @@ export type TUpdateEstConfigurationDTO = { caChain?: string; passphrase?: string; isEnabled?: boolean; + disableBootstrapCertValidation?: boolean; } & Omit; export type TGetEstConfigurationDTO = diff --git a/docs/documentation/platform/pki/est.mdx b/docs/documentation/platform/pki/est.mdx index a31a5672e..ecbd98dd9 100644 --- a/docs/documentation/platform/pki/est.mdx +++ b/docs/documentation/platform/pki/est.mdx @@ -35,6 +35,7 @@ These endpoints are exposed on port 8443 under the .well-known/est path e.g. ![est enrollment modal create](/images/platform/pki/est/template-enrollment-modal.png) + - **Disable Bootstrap Certificate Validation** - Enable this if your devices are not configured with a bootstrap certificate. - **Certificate Authority Chain** - This is the certificate chain used to validate your devices' manufacturing/pre-installed certificates. This will be used to authenticate your devices with Infisical's EST server. - **Passphrase** - This is also used to authenticate your devices with Infisical's EST server. When configuring the clients, use the value defined here as the EST password. diff --git a/docs/images/platform/pki/est/template-enrollment-modal.png b/docs/images/platform/pki/est/template-enrollment-modal.png index c7fed648e..60ed273d7 100644 Binary files a/docs/images/platform/pki/est/template-enrollment-modal.png and b/docs/images/platform/pki/est/template-enrollment-modal.png differ diff --git a/frontend/src/hooks/api/certificateTemplates/types.ts b/frontend/src/hooks/api/certificateTemplates/types.ts index e1ea5ed81..14367a280 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; + disableBootstrapCertValidation: boolean; }; export type TUpdateEstConfigDTO = { @@ -56,11 +57,13 @@ export type TUpdateEstConfigDTO = { caChain?: string; passphrase?: string; isEnabled?: boolean; + disableBootstrapCertValidation?: boolean; }; export type TEstConfig = { id: string; certificateTemplateId: string; caChain: string; - isEnabled: false; + isEnabled: boolean; + disableBootstrapCertValidation: 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..72be07e2a 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(), + disableBootstrapCertValidation: 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 disableBootstrapCertValidation = watch("disableBootstrapCertValidation"); + + useEffect(() => { + if (disableBootstrapCertValidation) { + setValue("caChain", ""); + } + }, [disableBootstrapCertValidation]); + useEffect(() => { if (data) { reset({ caChain: data.caChain, - isEnabled: data.isEnabled + isEnabled: data.isEnabled, + disableBootstrapCertValidation: data.disableBootstrapCertValidation }); } else { reset({ caChain: "", - isEnabled: false + isEnabled: false, + disableBootstrapCertValidation: false }); } }, [data]); @@ -83,7 +96,8 @@ export const CertificateTemplateEnrollmentModal = ({ popUp, handlePopUpToggle }: certificateTemplateId, caChain, passphrase, - isEnabled + isEnabled, + disableBootstrapCertValidation }); } else { if (!passphrase) { @@ -95,7 +109,8 @@ export const CertificateTemplateEnrollmentModal = ({ popUp, handlePopUpToggle }: certificateTemplateId, caChain, passphrase, - isEnabled + isEnabled, + disableBootstrapCertValidation }); } @@ -152,22 +167,43 @@ export const CertificateTemplateEnrollmentModal = ({ popUp, handlePopUpToggle }: )} ( - -