From 0961d2f1c65ed8ff0c0e4c8b86c1a1b23ba72263 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Fri, 16 Aug 2024 17:24:33 +0800 Subject: [PATCH] feat: subject alternative name policy enforcement --- ...20240815045235_add-certificate-template.ts | 1 + .../src/db/schemas/certificate-templates.ts | 1 + .../routes/v1/certificate-template-router.ts | 3 + .../certificate-authority-service.ts | 65 ++++++++++--------- .../certificate-template-fns.ts | 16 +++-- .../certificate-template-service.ts | 4 ++ .../certificate-template-types.ts | 2 + .../hooks/api/certificateTemplates/types.ts | 3 + .../components/CertificateModal.tsx | 6 ++ .../components/CertificateTemplateModal.tsx | 51 +++++++++++---- 10 files changed, 106 insertions(+), 46 deletions(-) diff --git a/backend/src/db/migrations/20240815045235_add-certificate-template.ts b/backend/src/db/migrations/20240815045235_add-certificate-template.ts index f6a691f38..efe0daae3 100644 --- a/backend/src/db/migrations/20240815045235_add-certificate-template.ts +++ b/backend/src/db/migrations/20240815045235_add-certificate-template.ts @@ -12,6 +12,7 @@ export async function up(knex: Knex): Promise { tb.foreign("caId").references("id").inTable(TableName.CertificateAuthority).onDelete("CASCADE"); tb.string("name").notNullable(); tb.string("commonName").notNullable(); + tb.string("subjectAlternativeName").notNullable(); tb.string("ttl").notNullable(); tb.timestamps(true, true, true); }); diff --git a/backend/src/db/schemas/certificate-templates.ts b/backend/src/db/schemas/certificate-templates.ts index 988147362..d969d95fb 100644 --- a/backend/src/db/schemas/certificate-templates.ts +++ b/backend/src/db/schemas/certificate-templates.ts @@ -12,6 +12,7 @@ export const CertificateTemplatesSchema = z.object({ caId: z.string().uuid(), name: z.string(), commonName: z.string(), + subjectAlternativeName: z.string(), ttl: z.string(), createdAt: z.date(), updatedAt: z.date() diff --git a/backend/src/server/routes/v1/certificate-template-router.ts b/backend/src/server/routes/v1/certificate-template-router.ts index 668dde2c0..90eab4521 100644 --- a/backend/src/server/routes/v1/certificate-template-router.ts +++ b/backend/src/server/routes/v1/certificate-template-router.ts @@ -12,6 +12,7 @@ const sanitizedCertificateTemplate = CertificateTemplatesSchema.pick({ caId: true, name: true, commonName: true, + subjectAlternativeName: true, ttl: true }); @@ -62,6 +63,7 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid caId: z.string(), name: z.string().min(1), commonName: validateTemplateRegexField, + subjectAlternativeName: validateTemplateRegexField, ttl: z.string().refine((val) => ms(val) > 0, "TTL must be a positive number") }), response: { @@ -95,6 +97,7 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid caId: z.string().optional(), name: z.string().min(1).optional(), commonName: validateTemplateRegexField.optional(), + subjectAlternativeName: validateTemplateRegexField.optional(), ttl: z .string() .refine((val) => ms(val) > 0, "TTL must be a positive number") diff --git a/backend/src/services/certificate-authority/certificate-authority-service.ts b/backend/src/services/certificate-authority/certificate-authority-service.ts index 91f841221..1f1ffe9e1 100644 --- a/backend/src/services/certificate-authority/certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/certificate-authority-service.ts @@ -1096,17 +1096,6 @@ export const certificateAuthorityServiceFactory = ({ throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" }); } - if (certificateTemplate) { - validateCertificateDetailsAgainstTemplate( - { - commonName, - notBeforeDate, - notAfterDate - }, - certificateTemplate - ); - } - const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm); const leafKeys = await crypto.subtle.generateKey(alg, true, ["sign", "verify"]); @@ -1136,11 +1125,13 @@ export const certificateAuthorityServiceFactory = ({ await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey) ]; + let altNamesArray: { + type: "email" | "dns"; + value: string; + }[] = []; + if (altNames) { - const altNamesArray: { - type: "email" | "dns"; - value: string; - }[] = altNames + altNamesArray = altNames .split(",") .map((name) => name.trim()) .map((altName) => { @@ -1168,6 +1159,18 @@ export const certificateAuthorityServiceFactory = ({ extensions.push(altNamesExtension); } + if (certificateTemplate) { + validateCertificateDetailsAgainstTemplate( + { + commonName, + notBeforeDate, + notAfterDate, + altNames: altNamesArray.map((entry) => entry.value) + }, + certificateTemplate + ); + } + const serialNumber = crypto.randomBytes(32).toString("hex"); const leafCert = await x509.X509CertificateGenerator.create({ serialNumber, @@ -1345,17 +1348,6 @@ export const certificateAuthorityServiceFactory = ({ message: "A common name (CN) is required in the CSR or as a parameter to this endpoint" }); - if (certificateTemplate) { - validateCertificateDetailsAgainstTemplate( - { - commonName: cn, - notBeforeDate, - notAfterDate - }, - certificateTemplate - ); - } - const { caPrivateKey } = await getCaCredentials({ caId: ca.id, certificateAuthorityDAL, @@ -1371,11 +1363,12 @@ export const certificateAuthorityServiceFactory = ({ await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey) ]; + let altNamesArray: { + type: "email" | "dns"; + value: string; + }[] = []; if (altNames) { - const altNamesArray: { - type: "email" | "dns"; - value: string; - }[] = altNames + altNamesArray = altNames .split(",") .map((name) => name.trim()) .map((altName) => { @@ -1403,6 +1396,18 @@ export const certificateAuthorityServiceFactory = ({ extensions.push(altNamesExtension); } + if (certificateTemplate) { + validateCertificateDetailsAgainstTemplate( + { + commonName: cn, + notBeforeDate, + notAfterDate, + altNames: altNamesArray.map((entry) => entry.value) + }, + certificateTemplate + ); + } + const serialNumber = crypto.randomBytes(32).toString("hex"); const leafCert = await x509.X509CertificateGenerator.create({ serialNumber, diff --git a/backend/src/services/certificate-template/certificate-template-fns.ts b/backend/src/services/certificate-template/certificate-template-fns.ts index 8b62020ca..597be7eb2 100644 --- a/backend/src/services/certificate-template/certificate-template-fns.ts +++ b/backend/src/services/certificate-template/certificate-template-fns.ts @@ -1,5 +1,6 @@ import ms from "ms"; +import { TCertificateTemplates } from "@app/db/schemas"; import { BadRequestError } from "@app/lib/errors"; export const validateCertificateDetailsAgainstTemplate = ( @@ -7,11 +8,9 @@ export const validateCertificateDetailsAgainstTemplate = ( commonName: string; notBeforeDate: Date; notAfterDate: Date; + altNames: string[]; }, - template: { - commonName: string; - ttl: string; - } + template: TCertificateTemplates ) => { const commonNameRegex = new RegExp(template.commonName); if (!commonNameRegex.test(cert.commonName)) { @@ -25,4 +24,13 @@ export const validateCertificateDetailsAgainstTemplate = ( message: "Invalid validity date based on template policy" }); } + + const subjectAlternativeNameRegex = new RegExp(template.subjectAlternativeName); + cert.altNames.forEach((altName) => { + if (!subjectAlternativeNameRegex.test(altName)) { + throw new BadRequestError({ + message: "Invalid subject alternative name based on template policy" + }); + } + }); }; diff --git a/backend/src/services/certificate-template/certificate-template-service.ts b/backend/src/services/certificate-template/certificate-template-service.ts index a294fee81..3b2a5d0ed 100644 --- a/backend/src/services/certificate-template/certificate-template-service.ts +++ b/backend/src/services/certificate-template/certificate-template-service.ts @@ -30,6 +30,7 @@ export const certificateTemplateServiceFactory = ({ caId, name, commonName, + subjectAlternativeName, ttl, actorId, actorAuthMethod, @@ -59,6 +60,7 @@ export const certificateTemplateServiceFactory = ({ caId, name, commonName, + subjectAlternativeName, ttl }); @@ -70,6 +72,7 @@ export const certificateTemplateServiceFactory = ({ caId, name, commonName, + subjectAlternativeName, ttl, actorId, actorAuthMethod, @@ -108,6 +111,7 @@ export const certificateTemplateServiceFactory = ({ const updatedCertTemplate = await certificateTemplateDAL.updateById(certTemplate.id, { caId, commonName, + subjectAlternativeName, name, ttl }); diff --git a/backend/src/services/certificate-template/certificate-template-types.ts b/backend/src/services/certificate-template/certificate-template-types.ts index 556215790..498a4cf59 100644 --- a/backend/src/services/certificate-template/certificate-template-types.ts +++ b/backend/src/services/certificate-template/certificate-template-types.ts @@ -4,6 +4,7 @@ export type TCreateCertTemplateDTO = { caId: string; name: string; commonName: string; + subjectAlternativeName: string; ttl: string; } & Omit; @@ -12,6 +13,7 @@ export type TUpdateCertTemplateDTO = { caId?: string; name?: string; commonName?: string; + subjectAlternativeName?: string; ttl?: string; } & Omit; diff --git a/frontend/src/hooks/api/certificateTemplates/types.ts b/frontend/src/hooks/api/certificateTemplates/types.ts index c440ea6fb..7b7d3a15f 100644 --- a/frontend/src/hooks/api/certificateTemplates/types.ts +++ b/frontend/src/hooks/api/certificateTemplates/types.ts @@ -10,6 +10,7 @@ export type TCertificateTemplate = { caId: string; name: string; commonName: string; + subjectAlternativeName: string; ttl: string; }; @@ -17,6 +18,7 @@ export type TCreateCertificateTemplateDTO = { caId: string; name: string; commonName: string; + subjectAlternativeName: string; ttl: string; projectId: string; }; @@ -26,6 +28,7 @@ export type TUpdateCertificateTemplateDTO = { caId?: string; name?: string; commonName?: string; + subjectAlternativeName?: string; ttl?: string; projectId: string; }; diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateModal.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateModal.tsx index 86009c6b6..c3275c547 100644 --- a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateModal.tsx +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateModal.tsx @@ -110,6 +110,12 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => { } }, [cert]); + useEffect(() => { + if (!cert && selectedCertTemplate) { + setValue("ttl", selectedCertTemplate.ttl); + } + }, [selectedCertTemplate, cert]); + const onFormSubmit = async ({ caId, friendlyName, commonName, altNames, ttl }: FormData) => { try { if (!currentWorkspace?.slug) return; diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateTemplateModal.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateTemplateModal.tsx index 2d811a26e..b8ef59dfa 100644 --- a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateTemplateModal.tsx +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateTemplateModal.tsx @@ -24,18 +24,21 @@ import { import { caTypeToNameMap } from "@app/hooks/api/ca/constants"; import { UsePopUpState } from "@app/hooks/usePopUp"; +const validateTemplateRegexField = z + .string() + .trim() + .min(1) + .max(100) + .regex(/^[a-zA-Z0-9 *@\-\\.\\]+$/, { + message: + "Invalid pattern: only alphanumeric characters, spaces, *, ., @, -, and \\ are allowed." + }); + const schema = z.object({ caId: z.string(), name: z.string().min(1), - commonName: z - .string() - .trim() - .min(1) - .max(100) - .regex(/^[a-zA-Z0-9 *@\-\\.\\]+$/, { - message: - "Invalid pattern: only alphanumeric characters, spaces, *, ., @, -, and \\ are allowed." - }), + commonName: validateTemplateRegexField, + subjectAlternativeName: validateTemplateRegexField, ttl: z.string().trim().min(1) }); @@ -78,6 +81,7 @@ export const CertificateTemplateModal = ({ popUp, handlePopUpToggle }: Props) => caId: certTemplate.caId, name: certTemplate.name, commonName: certTemplate.commonName, + subjectAlternativeName: certTemplate.subjectAlternativeName, ttl: certTemplate.ttl }); } else { @@ -90,7 +94,13 @@ export const CertificateTemplateModal = ({ popUp, handlePopUpToggle }: Props) => } }, [certTemplate]); - const onFormSubmit = async ({ caId, name, commonName, ttl }: FormData) => { + const onFormSubmit = async ({ + caId, + name, + commonName, + subjectAlternativeName, + ttl + }: FormData) => { if (!currentWorkspace?.id) { return; } @@ -103,6 +113,7 @@ export const CertificateTemplateModal = ({ popUp, handlePopUpToggle }: Props) => caId, name, commonName, + subjectAlternativeName, ttl }); @@ -116,6 +127,7 @@ export const CertificateTemplateModal = ({ popUp, handlePopUpToggle }: Props) => caId, name, commonName, + subjectAlternativeName, ttl }); @@ -199,7 +211,22 @@ export const CertificateTemplateModal = ({ popUp, handlePopUpToggle }: Props) => errorText={error?.message} isRequired > - + + + )} + /> + ( + + )} /> @@ -208,7 +235,7 @@ export const CertificateTemplateModal = ({ popUp, handlePopUpToggle }: Props) => name="ttl" render={({ field, fieldState: { error } }) => (