From b5aa650899bdb2588213844f83b0a5e167228cac Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Mon, 24 Jun 2024 14:07:15 -0700 Subject: [PATCH 1/3] Add cert support for alt names --- .../20240624163425_certificate-alt-names.ts | 24 +++++++++ backend/src/db/schemas/certificates.ts | 3 +- backend/src/db/schemas/projects.ts | 4 +- backend/src/lib/api-docs/constants.ts | 2 + .../routes/v1/certificate-authority-router.ts | 6 ++- .../certificate-authority-service.ts | 51 ++++++++++++++++--- .../certificate-authority-types.ts | 1 + .../certificate-authority-validators.ts | 26 ++++++++++ .../platform/pki/certificates.mdx | 4 +- frontend/src/hooks/api/ca/types.ts | 1 + frontend/src/hooks/api/certificates/types.ts | 1 + .../components/CertificateModal.tsx | 24 ++++++++- 12 files changed, 134 insertions(+), 13 deletions(-) create mode 100644 backend/src/db/migrations/20240624163425_certificate-alt-names.ts diff --git a/backend/src/db/migrations/20240624163425_certificate-alt-names.ts b/backend/src/db/migrations/20240624163425_certificate-alt-names.ts new file mode 100644 index 0000000000..fa076b7b4b --- /dev/null +++ b/backend/src/db/migrations/20240624163425_certificate-alt-names.ts @@ -0,0 +1,24 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.Certificate)) { + const hasAltNamesColumn = await knex.schema.hasColumn(TableName.Certificate, "altNames"); + if (!hasAltNamesColumn) { + await knex.schema.alterTable(TableName.Certificate, (t) => { + t.string("altNames").defaultTo(""); + }); + } + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.Certificate)) { + if (await knex.schema.hasColumn(TableName.Certificate, "altNames")) { + await knex.schema.alterTable(TableName.Certificate, (t) => { + t.dropColumn("altNames"); + }); + } + } +} diff --git a/backend/src/db/schemas/certificates.ts b/backend/src/db/schemas/certificates.ts index b635420d51..833396fb16 100644 --- a/backend/src/db/schemas/certificates.ts +++ b/backend/src/db/schemas/certificates.ts @@ -19,7 +19,8 @@ export const CertificatesSchema = z.object({ notBefore: z.date(), notAfter: z.date(), revokedAt: z.date().nullable().optional(), - revocationReason: z.number().nullable().optional() + revocationReason: z.number().nullable().optional(), + altNames: z.string().default("").nullable().optional() }); export type TCertificates = z.infer; diff --git a/backend/src/db/schemas/projects.ts b/backend/src/db/schemas/projects.ts index 91035ab8e0..211626b7c5 100644 --- a/backend/src/db/schemas/projects.ts +++ b/backend/src/db/schemas/projects.ts @@ -17,8 +17,8 @@ export const ProjectsSchema = z.object({ updatedAt: z.date(), version: z.number().default(1), upgradeStatus: z.string().nullable().optional(), - kmsCertificateKeyId: z.string().uuid().nullable().optional(), - pitVersionLimit: z.number().default(10) + pitVersionLimit: z.number().default(10), + kmsCertificateKeyId: z.string().uuid().nullable().optional() }); export type TProjects = z.infer; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index d768066fa7..4b53f12b0d 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -804,6 +804,8 @@ export const CERTIFICATE_AUTHORITIES = { caId: "The ID of the CA to issue the certificate from", friendlyName: "A friendly name for the certificate", commonName: "The common name (CN) for the certificate", + altNames: + "A comma-delimited list of Subject Alternative Names (SANs) for the certificate; these can be host names or email addresses.", ttl: "The time to live for the certificate such as 1m, 1h, 1d, 1y, ...", notBefore: "The date and time when the certificate becomes valid in YYYY-MM-DDTHH:mm:ss.sssZ format", notAfter: "The date and time when the certificate expires in YYYY-MM-DDTHH:mm:ss.sssZ format", diff --git a/backend/src/server/routes/v1/certificate-authority-router.ts b/backend/src/server/routes/v1/certificate-authority-router.ts index 7573c0bd29..533f249f2b 100644 --- a/backend/src/server/routes/v1/certificate-authority-router.ts +++ b/backend/src/server/routes/v1/certificate-authority-router.ts @@ -9,7 +9,10 @@ import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; import { CaStatus, CaType } from "@app/services/certificate-authority/certificate-authority-types"; -import { validateCaDateField } from "@app/services/certificate-authority/certificate-authority-validators"; +import { + validateAltNamesField, + validateCaDateField +} from "@app/services/certificate-authority/certificate-authority-validators"; export const registerCaRouter = async (server: FastifyZodProvider) => { server.route({ @@ -452,6 +455,7 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { .object({ friendlyName: z.string().optional().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.friendlyName), commonName: z.string().trim().min(1).describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.commonName), + altNames: validateAltNamesField.describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.altNames), 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 2345180a3f..5a3243ee8b 100644 --- a/backend/src/services/certificate-authority/certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/certificate-authority-service.ts @@ -3,6 +3,7 @@ import { ForbiddenError } from "@casl/ability"; import * as x509 from "@peculiar/x509"; import crypto, { KeyObject } from "crypto"; import ms from "ms"; +import { z } from "zod"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; @@ -38,6 +39,7 @@ import { TSignIntermediateDTO, TUpdateCaDTO } from "./certificate-authority-types"; +import { hostnameRegex } from "./certificate-authority-validators"; type TCertificateAuthorityServiceFactoryDep = { certificateAuthorityDAL: Pick< @@ -653,6 +655,7 @@ export const certificateAuthorityServiceFactory = ({ caId, friendlyName, commonName, + altNames, ttl, notBefore, notAfter, @@ -738,6 +741,46 @@ export const certificateAuthorityServiceFactory = ({ kmsService }); + const extensions: x509.Extension[] = [ + new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment, true), + new x509.BasicConstraintsExtension(false), + await x509.AuthorityKeyIdentifierExtension.create(caCertObj, false), + await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey) + ]; + + if (altNames) { + const altNamesArray: { + type: "email" | "dns"; + value: string; + }[] = altNames + .split(",") + .map((name) => name.trim()) + .map((altName) => { + // check if the altName is a valid email + if (z.string().email().safeParse(altName).success) { + return { + type: "email", + value: altName + }; + } + + // check if the altName is a valid hostname + if (hostnameRegex.test(altName)) { + return { + type: "dns", + value: altName + }; + } + + // If altName is neither a valid email nor a valid hostname, throw an error or handle it accordingly + throw new Error(`Invalid altName: ${altName}`); + }); + + console.log("the altNamesArray: ", altNamesArray); + const altNamesExtension = new x509.SubjectAlternativeNameExtension(altNamesArray, false); + extensions.push(altNamesExtension); + } + const serialNumber = crypto.randomBytes(32).toString("hex"); const leafCert = await x509.X509CertificateGenerator.create({ serialNumber, @@ -748,12 +791,7 @@ export const certificateAuthorityServiceFactory = ({ signingKey: caPrivateKey, publicKey: csrObj.publicKey, signingAlgorithm: alg, - extensions: [ - new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment, true), - new x509.BasicConstraintsExtension(false), - await x509.AuthorityKeyIdentifierExtension.create(caCertObj, false), - await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey) - ] + extensions }); const skLeafObj = KeyObject.from(leafKeys.privateKey); @@ -771,6 +809,7 @@ export const certificateAuthorityServiceFactory = ({ status: CertStatus.ACTIVE, friendlyName: friendlyName || commonName, commonName, + altNames, serialNumber, notBefore: notBeforeDate, notAfter: notAfterDate diff --git a/backend/src/services/certificate-authority/certificate-authority-types.ts b/backend/src/services/certificate-authority/certificate-authority-types.ts index 3ba7624c0f..8af8b679c6 100644 --- a/backend/src/services/certificate-authority/certificate-authority-types.ts +++ b/backend/src/services/certificate-authority/certificate-authority-types.ts @@ -75,6 +75,7 @@ export type TIssueCertFromCaDTO = { caId: string; friendlyName?: string; commonName: string; + altNames: string; ttl: string; notBefore?: string; notAfter?: string; diff --git a/backend/src/services/certificate-authority/certificate-authority-validators.ts b/backend/src/services/certificate-authority/certificate-authority-validators.ts index 77bf9ad2fc..a6d6c8c230 100644 --- a/backend/src/services/certificate-authority/certificate-authority-validators.ts +++ b/backend/src/services/certificate-authority/certificate-authority-validators.ts @@ -6,3 +6,29 @@ const isValidDate = (dateString: string) => { }; export const validateCaDateField = z.string().trim().refine(isValidDate, { message: "Invalid date format" }); + +export const hostnameRegex = /^(?!:\/\/)([a-zA-Z0-9-_]{1,63}\.?)+(?!:\/\/)([a-zA-Z]{2,63})$/; +export const validateAltNamesField = z + .string() + .trim() + .default("") + .transform((data) => { + if (data === "") return ""; + // Trim each alt name and join with ', ' to ensure formatting + return data + .split(",") + .map((id) => id.trim()) + .join(", "); + }) + .refine( + (data) => { + if (data === "") return true; + // Split and validate each alt name + return data.split(", ").every((name) => { + return hostnameRegex.test(name) || z.string().email().safeParse(name).success; + }); + }, + { + message: "Each alt name must be a valid hostname or email address" + } + ); diff --git a/docs/documentation/platform/pki/certificates.mdx b/docs/documentation/platform/pki/certificates.mdx index fe55466811..7107d3cb86 100644 --- a/docs/documentation/platform/pki/certificates.mdx +++ b/docs/documentation/platform/pki/certificates.mdx @@ -56,9 +56,9 @@ In the following steps, we explore how to issue a X.509 certificate under a CA. - Issuing CA: The CA under which to issue the certificate. - Friendly Name: A friendly name for the certificate; this is only for display and defaults to the common name of the certificate if left empty. - - Common Name (CN): The (common) name of the certificate. + - Common Name (CN): The (common) name for the certificate like `service.acme.com`. + - Alternative Names (SANs): A comma-delimited list of Subject Alternative Names (SANs) for the certificate; these can be host names or email addresses. - TTL: The lifetime of the certificate in seconds. - - Valid Until: The date until which the certificate is valid in the date time string format specified [here](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#date_time_string_format). For example, the following formats would be valid: `YYYY`, `YYYY-MM`, `YYYY-MM-DD`, `YYYY-MM-DDTHH:mm:ss.sssZ`. diff --git a/frontend/src/hooks/api/ca/types.ts b/frontend/src/hooks/api/ca/types.ts index 64511253bd..7cb5dbf429 100644 --- a/frontend/src/hooks/api/ca/types.ts +++ b/frontend/src/hooks/api/ca/types.ts @@ -81,6 +81,7 @@ export type TCreateCertificateDTO = { caId: string; friendlyName?: string; commonName: string; + altNames: string; // sans ttl: string; // string compatible with ms notBefore?: string; notAfter?: string; diff --git a/frontend/src/hooks/api/certificates/types.ts b/frontend/src/hooks/api/certificates/types.ts index 2dd6001877..d1ba46910a 100644 --- a/frontend/src/hooks/api/certificates/types.ts +++ b/frontend/src/hooks/api/certificates/types.ts @@ -6,6 +6,7 @@ export type TCertificate = { status: CertStatus; friendlyName: string; commonName: string; + altNames: string; serialNumber: string; notBefore: string; notAfter: 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 03e70a80c0..12bd8c47cd 100644 --- a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateModal.tsx +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateModal.tsx @@ -24,6 +24,7 @@ const schema = z.object({ caId: z.string(), friendlyName: z.string(), commonName: z.string().trim().min(1), + altNames: z.string(), ttl: z.string().trim() }); @@ -71,6 +72,7 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => { caId: cert.caId, friendlyName: cert.friendlyName, commonName: cert.commonName, + altNames: cert.altNames, ttl: "" }); } else { @@ -78,12 +80,13 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => { caId: "", friendlyName: "", commonName: "", + altNames: "", ttl: "" }); } }, [cert]); - const onFormSubmit = async ({ caId, friendlyName, commonName, ttl }: FormData) => { + const onFormSubmit = async ({ caId, friendlyName, commonName, altNames, ttl }: FormData) => { try { if (!currentWorkspace?.slug) return; @@ -92,6 +95,7 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => { caId, friendlyName, commonName, + altNames, ttl }); @@ -192,6 +196,24 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => { )} /> + ( + + + + )} + /> Date: Mon, 24 Jun 2024 14:14:51 -0700 Subject: [PATCH 2/3] Update altName example in docs --- docs/documentation/platform/pki/certificates.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/documentation/platform/pki/certificates.mdx b/docs/documentation/platform/pki/certificates.mdx index 7107d3cb86..7dc3bca880 100644 --- a/docs/documentation/platform/pki/certificates.mdx +++ b/docs/documentation/platform/pki/certificates.mdx @@ -57,7 +57,7 @@ In the following steps, we explore how to issue a X.509 certificate under a CA. - Issuing CA: The CA under which to issue the certificate. - Friendly Name: A friendly name for the certificate; this is only for display and defaults to the common name of the certificate if left empty. - Common Name (CN): The (common) name for the certificate like `service.acme.com`. - - Alternative Names (SANs): A comma-delimited list of Subject Alternative Names (SANs) for the certificate; these can be host names or email addresses. + - Alternative Names (SANs): A comma-delimited list of Subject Alternative Names (SANs) for the certificate; these can be host names or email addresses like `app1.acme.com, app2.acme.com`. - TTL: The lifetime of the certificate in seconds. From d69267a3ca2dfb0a0e0ef4a9269bf257e7b6cd65 Mon Sep 17 00:00:00 2001 From: Maidul Islam Date: Mon, 24 Jun 2024 17:27:02 -0400 Subject: [PATCH 3/3] remove console.log --- .../certificate-authority/certificate-authority-service.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/src/services/certificate-authority/certificate-authority-service.ts b/backend/src/services/certificate-authority/certificate-authority-service.ts index 5a3243ee8b..7d87545e24 100644 --- a/backend/src/services/certificate-authority/certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/certificate-authority-service.ts @@ -776,7 +776,6 @@ export const certificateAuthorityServiceFactory = ({ throw new Error(`Invalid altName: ${altName}`); }); - console.log("the altNamesArray: ", altNamesArray); const altNamesExtension = new x509.SubjectAlternativeNameExtension(altNamesArray, false); extensions.push(altNamesExtension); }