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

View File

@@ -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");
}
});
}

View File

@@ -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<typeof CertificateTemplateEstConfigsSchema>;

View File

@@ -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({

View File

@@ -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: {

View File

@@ -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
};
};

View File

@@ -34,9 +34,10 @@ export type TDeleteCertTemplateDTO = {
export type TCreateEstConfigurationDTO = {
certificateTemplateId: string;
caChain: string;
caChain?: string;
passphrase: string;
isEnabled: boolean;
disableBootstrapCertValidation: boolean;
} & Omit<TProjectPermission, "projectId">;
export type TUpdateEstConfigurationDTO = {
@@ -44,6 +45,7 @@ export type TUpdateEstConfigurationDTO = {
caChain?: string;
passphrase?: string;
isEnabled?: boolean;
disableBootstrapCertValidation?: boolean;
} & Omit<TProjectPermission, "projectId">;
export type TGetEstConfigurationDTO =

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)
- **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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 612 KiB

After

Width:  |  Height:  |  Size: 507 KiB

View File

@@ -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;
};

View File

@@ -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<typeof schema>;
@@ -53,6 +54,8 @@ export const CertificateTemplateEnrollmentModal = ({ popUp, handlePopUpToggle }:
handleSubmit,
reset,
setError,
watch,
setValue,
formState: { isSubmitting }
} = useForm<FormData>({
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 }:
)}
<Controller
control={control}
name="caChain"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Certificate Authority Chain"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<TextArea
{...field}
className="min-h-[15rem] border-none bg-mineshaft-900 text-gray-400"
reSize="none"
/>
</FormControl>
)}
name="disableBootstrapCertValidation"
render={({ field, fieldState: { error } }) => {
return (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Switch
id="skip-bootstrap-cert-validation"
onCheckedChange={(value) => field.onChange(value)}
isChecked={field.value}
>
<p className="ml-1 w-full">Disable Bootstrap Certificate Validation</p>
</Switch>
</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
control={control}
name="passphrase"