Merge pull request #2704 from Infisical/feat/add-support-for-no-bootstrap-cert-est

feat: add support for EST device enrollment without bootstrap certs
This commit is contained in:
Sheen
2024-11-12 02:40:37 +08:00
committed by GitHub
10 changed files with 186 additions and 82 deletions
@@ -0,0 +1,35 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
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<void> {
const hasDisableBootstrapCertValidationCol = await knex.schema.hasColumn(
TableName.CertificateTemplateEstConfig,
"disableBootstrapCertValidation"
);
await knex.schema.alterTable(TableName.CertificateTemplateEstConfig, (t) => {
if (hasDisableBootstrapCertValidationCol) {
t.dropColumn("disableBootstrapCertValidation");
}
});
}
@@ -12,11 +12,12 @@ import { TImmutableDBKeys } from "./models";
export const CertificateTemplateEstConfigsSchema = z.object({ export const CertificateTemplateEstConfigsSchema = z.object({
id: z.string().uuid(), id: z.string().uuid(),
certificateTemplateId: z.string().uuid(), certificateTemplateId: z.string().uuid(),
encryptedCaChain: zodBuffer, encryptedCaChain: zodBuffer.nullable().optional(),
hashedPassphrase: z.string(), hashedPassphrase: z.string(),
isEnabled: z.boolean(), isEnabled: z.boolean(),
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date() updatedAt: z.date(),
disableBootstrapCertValidation: z.boolean().default(false)
}); });
export type TCertificateTemplateEstConfigs = z.infer<typeof CertificateTemplateEstConfigsSchema>; export type TCertificateTemplateEstConfigs = z.infer<typeof CertificateTemplateEstConfigsSchema>;
@@ -171,27 +171,29 @@ export const certificateEstServiceFactory = ({
}); });
} }
const caCerts = estConfig.caChain if (!estConfig.disableBootstrapCertValidation) {
.match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) const caCerts = estConfig.caChain
?.map((cert) => { .match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g)
return new x509.X509Certificate(cert); ?.map((cert) => {
}); return new x509.X509Certificate(cert);
});
if (!caCerts) { if (!caCerts) {
throw new BadRequestError({ message: "Failed to parse certificate chain" }); throw new BadRequestError({ message: "Failed to parse certificate chain" });
} }
const leafCertificate = decodeURIComponent(sslClientCert).match( const leafCertificate = decodeURIComponent(sslClientCert).match(
/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g /-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g
)?.[0]; )?.[0];
if (!leafCertificate) { if (!leafCertificate) {
throw new BadRequestError({ message: "Missing client certificate" }); throw new BadRequestError({ message: "Missing client certificate" });
} }
const certObj = new x509.X509Certificate(leafCertificate); const certObj = new x509.X509Certificate(leafCertificate);
if (!(await isCertChainValid([certObj, ...caCerts]))) { if (!(await isCertChainValid([certObj, ...caCerts]))) {
throw new BadRequestError({ message: "Invalid certificate chain" }); throw new BadRequestError({ message: "Invalid certificate chain" });
}
} }
const { certificate } = await certificateAuthorityService.signCertFromCa({ const { certificate } = await certificateAuthorityService.signCertFromCa({
@@ -14,7 +14,8 @@ import { validateTemplateRegexField } from "@app/services/certificate-template/c
const sanitizedEstConfig = CertificateTemplateEstConfigsSchema.pick({ const sanitizedEstConfig = CertificateTemplateEstConfigsSchema.pick({
id: true, id: true,
certificateTemplateId: true, certificateTemplateId: true,
isEnabled: true isEnabled: true,
disableBootstrapCertValidation: true
}); });
export const registerCertificateTemplateRouter = async (server: FastifyZodProvider) => { export const registerCertificateTemplateRouter = async (server: FastifyZodProvider) => {
@@ -241,11 +242,18 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid
params: z.object({ params: z.object({
certificateTemplateId: z.string().trim() certificateTemplateId: z.string().trim()
}), }),
body: z.object({ body: z
caChain: z.string().trim().min(1), .object({
passphrase: z.string().min(1), caChain: z.string().trim().optional(),
isEnabled: z.boolean().default(true) 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: { response: {
200: sanitizedEstConfig 200: sanitizedEstConfig
} }
@@ -289,8 +297,9 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid
certificateTemplateId: z.string().trim() certificateTemplateId: z.string().trim()
}), }),
body: z.object({ body: z.object({
caChain: z.string().trim().min(1).optional(), caChain: z.string().trim().optional(),
passphrase: z.string().min(1).optional(), passphrase: z.string().min(1).optional(),
disableBootstrapCertValidation: z.boolean().optional(),
isEnabled: z.boolean().optional() isEnabled: z.boolean().optional()
}), }),
response: { response: {
@@ -235,7 +235,8 @@ export const certificateTemplateServiceFactory = ({
actorId, actorId,
actorAuthMethod, actorAuthMethod,
actor, actor,
actorOrgId actorOrgId,
disableBootstrapCertValidation
}: TCreateEstConfigurationDTO) => { }: TCreateEstConfigurationDTO) => {
const plan = await licenseService.getPlan(actorOrgId); const plan = await licenseService.getPlan(actorOrgId);
if (!plan.pkiEst) { if (!plan.pkiEst) {
@@ -266,39 +267,45 @@ export const certificateTemplateServiceFactory = ({
const appCfg = getConfig(); const appCfg = getConfig();
const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ let encryptedCaChain: Buffer | undefined;
projectId: certTemplate.projectId, if (caChain) {
projectDAL, const certificateManagerKmsId = await getProjectKmsCertificateKeyId({
kmsService projectId: certTemplate.projectId,
}); projectDAL,
kmsService
});
// validate CA chain // validate CA chain
const certificates = caChain const certificates = caChain
.match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) .match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g)
?.map((cert) => new x509.X509Certificate(cert)); ?.map((cert) => new x509.X509Certificate(cert));
if (!certificates) { if (!certificates) {
throw new BadRequestError({ message: "Failed to parse certificate chain" }); 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 hashedPassphrase = await bcrypt.hash(passphrase, appCfg.SALT_ROUNDS);
const estConfig = await certificateTemplateEstConfigDAL.create({ const estConfig = await certificateTemplateEstConfigDAL.create({
certificateTemplateId, certificateTemplateId,
hashedPassphrase, hashedPassphrase,
encryptedCaChain, encryptedCaChain,
isEnabled isEnabled,
disableBootstrapCertValidation
}); });
return { ...estConfig, projectId: certTemplate.projectId }; return { ...estConfig, projectId: certTemplate.projectId };
@@ -312,7 +319,8 @@ export const certificateTemplateServiceFactory = ({
actorId, actorId,
actorAuthMethod, actorAuthMethod,
actor, actor,
actorOrgId actorOrgId,
disableBootstrapCertValidation
}: TUpdateEstConfigurationDTO) => { }: TUpdateEstConfigurationDTO) => {
const plan = await licenseService.getPlan(actorOrgId); const plan = await licenseService.getPlan(actorOrgId);
if (!plan.pkiEst) { if (!plan.pkiEst) {
@@ -360,7 +368,8 @@ export const certificateTemplateServiceFactory = ({
}); });
const updatedData: TCertificateTemplateEstConfigsUpdate = { const updatedData: TCertificateTemplateEstConfigsUpdate = {
isEnabled isEnabled,
disableBootstrapCertValidation
}; };
if (caChain) { if (caChain) {
@@ -442,18 +451,24 @@ export const certificateTemplateServiceFactory = ({
kmsId: certificateManagerKmsId kmsId: certificateManagerKmsId
}); });
const decryptedCaChain = await kmsDecryptor({ let decryptedCaChain = "";
cipherTextBlob: estConfig.encryptedCaChain if (estConfig.encryptedCaChain) {
}); decryptedCaChain = (
await kmsDecryptor({
cipherTextBlob: estConfig.encryptedCaChain
})
).toString();
}
return { return {
certificateTemplateId, certificateTemplateId,
id: estConfig.id, id: estConfig.id,
isEnabled: estConfig.isEnabled, isEnabled: estConfig.isEnabled,
caChain: decryptedCaChain.toString(), caChain: decryptedCaChain,
hashedPassphrase: estConfig.hashedPassphrase, hashedPassphrase: estConfig.hashedPassphrase,
projectId: certTemplate.projectId, projectId: certTemplate.projectId,
orgId: certTemplate.orgId orgId: certTemplate.orgId,
disableBootstrapCertValidation: estConfig.disableBootstrapCertValidation
}; };
}; };
@@ -34,9 +34,10 @@ export type TDeleteCertTemplateDTO = {
export type TCreateEstConfigurationDTO = { export type TCreateEstConfigurationDTO = {
certificateTemplateId: string; certificateTemplateId: string;
caChain: string; caChain?: string;
passphrase: string; passphrase: string;
isEnabled: boolean; isEnabled: boolean;
disableBootstrapCertValidation: boolean;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TUpdateEstConfigurationDTO = { export type TUpdateEstConfigurationDTO = {
@@ -44,6 +45,7 @@ export type TUpdateEstConfigurationDTO = {
caChain?: string; caChain?: string;
passphrase?: string; passphrase?: string;
isEnabled?: boolean; isEnabled?: boolean;
disableBootstrapCertValidation?: boolean;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TGetEstConfigurationDTO = export type TGetEstConfigurationDTO =
+1
View File
@@ -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) ![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. - **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. - **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.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 612 KiB

After

Width:  |  Height:  |  Size: 507 KiB

@@ -46,9 +46,10 @@ export type TDeleteCertificateTemplateDTO = {
export type TCreateEstConfigDTO = { export type TCreateEstConfigDTO = {
certificateTemplateId: string; certificateTemplateId: string;
caChain: string; caChain?: string;
passphrase: string; passphrase: string;
isEnabled: boolean; isEnabled: boolean;
disableBootstrapCertValidation: boolean;
}; };
export type TUpdateEstConfigDTO = { export type TUpdateEstConfigDTO = {
@@ -56,11 +57,13 @@ export type TUpdateEstConfigDTO = {
caChain?: string; caChain?: string;
passphrase?: string; passphrase?: string;
isEnabled?: boolean; isEnabled?: boolean;
disableBootstrapCertValidation?: boolean;
}; };
export type TEstConfig = { export type TEstConfig = {
id: string; id: string;
certificateTemplateId: string; certificateTemplateId: string;
caChain: string; caChain: string;
isEnabled: false; isEnabled: boolean;
disableBootstrapCertValidation: boolean;
}; };
@@ -33,9 +33,10 @@ type Props = {
const schema = z.object({ const schema = z.object({
method: z.nativeEnum(EnrollmentMethod), method: z.nativeEnum(EnrollmentMethod),
caChain: z.string(), caChain: z.string().optional(),
passphrase: z.string().optional(), passphrase: z.string().optional(),
isEnabled: z.boolean() isEnabled: z.boolean(),
disableBootstrapCertValidation: z.boolean().optional().default(false)
}); });
export type FormData = z.infer<typeof schema>; export type FormData = z.infer<typeof schema>;
@@ -53,6 +54,8 @@ export const CertificateTemplateEnrollmentModal = ({ popUp, handlePopUpToggle }:
handleSubmit, handleSubmit,
reset, reset,
setError, setError,
watch,
setValue,
formState: { isSubmitting } formState: { isSubmitting }
} = useForm<FormData>({ } = useForm<FormData>({
resolver: zodResolver(schema) resolver: zodResolver(schema)
@@ -62,16 +65,26 @@ export const CertificateTemplateEnrollmentModal = ({ popUp, handlePopUpToggle }:
const { mutateAsync: updateEstConfig } = useUpdateEstConfig(); const { mutateAsync: updateEstConfig } = useUpdateEstConfig();
const [isPassphraseFocused, setIsPassphraseFocused] = useToggle(false); const [isPassphraseFocused, setIsPassphraseFocused] = useToggle(false);
const disableBootstrapCertValidation = watch("disableBootstrapCertValidation");
useEffect(() => {
if (disableBootstrapCertValidation) {
setValue("caChain", "");
}
}, [disableBootstrapCertValidation]);
useEffect(() => { useEffect(() => {
if (data) { if (data) {
reset({ reset({
caChain: data.caChain, caChain: data.caChain,
isEnabled: data.isEnabled isEnabled: data.isEnabled,
disableBootstrapCertValidation: data.disableBootstrapCertValidation
}); });
} else { } else {
reset({ reset({
caChain: "", caChain: "",
isEnabled: false isEnabled: false,
disableBootstrapCertValidation: false
}); });
} }
}, [data]); }, [data]);
@@ -83,7 +96,8 @@ export const CertificateTemplateEnrollmentModal = ({ popUp, handlePopUpToggle }:
certificateTemplateId, certificateTemplateId,
caChain, caChain,
passphrase, passphrase,
isEnabled isEnabled,
disableBootstrapCertValidation
}); });
} else { } else {
if (!passphrase) { if (!passphrase) {
@@ -95,7 +109,8 @@ export const CertificateTemplateEnrollmentModal = ({ popUp, handlePopUpToggle }:
certificateTemplateId, certificateTemplateId,
caChain, caChain,
passphrase, passphrase,
isEnabled isEnabled,
disableBootstrapCertValidation
}); });
} }
@@ -152,22 +167,43 @@ export const CertificateTemplateEnrollmentModal = ({ popUp, handlePopUpToggle }:
)} )}
<Controller <Controller
control={control} control={control}
name="caChain" name="disableBootstrapCertValidation"
render={({ field, fieldState: { error } }) => ( render={({ field, fieldState: { error } }) => {
<FormControl return (
label="Certificate Authority Chain" <FormControl isError={Boolean(error)} errorText={error?.message}>
isError={Boolean(error)} <Switch
errorText={error?.message} id="skip-bootstrap-cert-validation"
isRequired onCheckedChange={(value) => field.onChange(value)}
> isChecked={field.value}
<TextArea >
{...field} <p className="ml-1 w-full">Disable Bootstrap Certificate Validation</p>
className="min-h-[15rem] border-none bg-mineshaft-900 text-gray-400" </Switch>
reSize="none" </FormControl>
/> );
</FormControl> }}
)}
/> />
{!disableBootstrapCertValidation && (
<Controller
control={control}
name="caChain"
disabled={disableBootstrapCertValidation}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Certificate Authority Chain"
isError={Boolean(error)}
errorText={error?.message}
isRequired={!disableBootstrapCertValidation}
>
<TextArea
{...field}
isDisabled={disableBootstrapCertValidation}
className="min-h-[15rem] border-none bg-mineshaft-900 text-gray-400"
reSize="none"
/>
</FormControl>
)}
/>
)}
<Controller <Controller
control={control} control={control}
name="passphrase" name="passphrase"