feat: add support for no bootstrap cert EST

This commit is contained in:
Sheen Capadngan
2024-11-08 01:42:47 +08:00
parent 6bf4b4a380
commit e761e65322
8 changed files with 191 additions and 82 deletions
@@ -0,0 +1,41 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
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<void> {
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();
}
});
}
@@ -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(),
skipBootstrapCertValidation: 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.skipBootstrapCertValidation) {
.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,
skipBootstrapCertValidation: 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),
skipBootstrapCertValidation: z.boolean().default(false)
})
.refine(
({ caChain, skipBootstrapCertValidation }) =>
skipBootstrapCertValidation || (!skipBootstrapCertValidation && 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(),
skipBootstrapCertValidation: 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,
skipBootstrapCertValidation
}: 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,
skipBootstrapCertValidation
}); });
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,
skipBootstrapCertValidation
}: 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,
skipBootstrapCertValidation
}; };
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,
skipBootstrapCertValidation: estConfig.skipBootstrapCertValidation
}; };
}; };
@@ -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;
skipBootstrapCertValidation: 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;
skipBootstrapCertValidation?: boolean;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TGetEstConfigurationDTO = export type TGetEstConfigurationDTO =
@@ -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;
skipBootstrapCertValidation: 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;
skipBootstrapCertValidation?: boolean;
}; };
export type TEstConfig = { export type TEstConfig = {
id: string; id: string;
certificateTemplateId: string; certificateTemplateId: string;
caChain: string; caChain: string;
isEnabled: false; isEnabled: boolean;
skipBootstrapCertValidation: 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(),
skipBootstrapCertValidation: 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 skipBootstrapCertValidation = watch("skipBootstrapCertValidation");
useEffect(() => {
if (skipBootstrapCertValidation) {
setValue("caChain", "");
}
}, [skipBootstrapCertValidation]);
useEffect(() => { useEffect(() => {
if (data) { if (data) {
reset({ reset({
caChain: data.caChain, caChain: data.caChain,
isEnabled: data.isEnabled isEnabled: data.isEnabled,
skipBootstrapCertValidation: data.skipBootstrapCertValidation
}); });
} else { } else {
reset({ reset({
caChain: "", caChain: "",
isEnabled: false isEnabled: false,
skipBootstrapCertValidation: false
}); });
} }
}, [data]); }, [data]);
@@ -83,7 +96,8 @@ export const CertificateTemplateEnrollmentModal = ({ popUp, handlePopUpToggle }:
certificateTemplateId, certificateTemplateId,
caChain, caChain,
passphrase, passphrase,
isEnabled isEnabled,
skipBootstrapCertValidation
}); });
} else { } else {
if (!passphrase) { if (!passphrase) {
@@ -95,7 +109,8 @@ export const CertificateTemplateEnrollmentModal = ({ popUp, handlePopUpToggle }:
certificateTemplateId, certificateTemplateId,
caChain, caChain,
passphrase, passphrase,
isEnabled isEnabled,
skipBootstrapCertValidation
}); });
} }
@@ -152,22 +167,43 @@ export const CertificateTemplateEnrollmentModal = ({ popUp, handlePopUpToggle }:
)} )}
<Controller <Controller
control={control} control={control}
name="caChain" name="skipBootstrapCertValidation"
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">Skip Bootstrap Certificate Validation</p>
className="min-h-[15rem] border-none bg-mineshaft-900 text-gray-400" </Switch>
reSize="none" </FormControl>
/> );
</FormControl> }}
)}
/> />
{!skipBootstrapCertValidation && (
<Controller
control={control}
name="caChain"
disabled={skipBootstrapCertValidation}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Certificate Authority Chain"
isError={Boolean(error)}
errorText={error?.message}
isRequired={!skipBootstrapCertValidation}
>
<TextArea
{...field}
isDisabled={skipBootstrapCertValidation}
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"