From 0df31813081ecf03fbd5dd70a8757b236b10dc9f Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Sat, 18 Oct 2025 03:16:36 -0300 Subject: [PATCH] PKI revamp EST improvements and fixes --- .../20251007133321_pki-v3-tables.ts | 2 +- .../db/schemas/pki-est-enrollment-configs.ts | 2 +- .../ee/routes/est/certificate-est-router.ts | 68 +- backend/src/server/routes/index.ts | 1 + .../routes/v1/certificate-profiles-router.ts | 2 +- .../server/routes/v3/certificates-router.ts | 187 +++-- .../certificate-est-v3-service.test.ts | 734 ++++++++++++++++++ .../certificate-est-v3-service.ts | 105 ++- .../certificate-profile-service.ts | 84 +- .../certificate-template-v2-service.ts | 160 ++-- .../certificate-v3/certificate-v3-service.ts | 32 +- .../enrollment-config-types.ts | 2 +- .../permissions/project-permissions.mdx | 10 + .../hooks/api/certificateProfiles/types.ts | 4 +- .../api/certificateTemplates/mutations.tsx | 38 +- .../api/certificateTemplates/queries.tsx | 6 +- .../hooks/api/certificateTemplates/types.ts | 8 +- .../components/AlgorithmSelectors.tsx | 90 +++ .../components/CertificateIssuanceModal.tsx | 690 ++++------------ .../components/KeyUsageSection.tsx | 78 ++ .../components/SubjectAltNamesField.tsx | 101 +++ .../components/certificateUtils.ts | 88 +++ .../components/useCertificateTemplate.ts | 181 +++++ .../CreateProfileModal.tsx | 44 +- .../CertificateProfilesTab/ProfileRow.tsx | 2 +- .../CertificateTemplatesV2Tab.tsx | 13 +- .../CreateTemplateModal.tsx | 81 +- .../TemplateList.tsx | 6 +- .../shared/schemas.ts | 14 +- 29 files changed, 1994 insertions(+), 839 deletions(-) create mode 100644 backend/src/services/certificate-est-v3/certificate-est-v3-service.test.ts create mode 100644 frontend/src/pages/cert-manager/CertificatesPage/components/AlgorithmSelectors.tsx create mode 100644 frontend/src/pages/cert-manager/CertificatesPage/components/KeyUsageSection.tsx create mode 100644 frontend/src/pages/cert-manager/CertificatesPage/components/SubjectAltNamesField.tsx create mode 100644 frontend/src/pages/cert-manager/CertificatesPage/components/certificateUtils.ts create mode 100644 frontend/src/pages/cert-manager/CertificatesPage/components/useCertificateTemplate.ts diff --git a/backend/src/db/migrations/20251007133321_pki-v3-tables.ts b/backend/src/db/migrations/20251007133321_pki-v3-tables.ts index 1564ff0b4..713953f14 100644 --- a/backend/src/db/migrations/20251007133321_pki-v3-tables.ts +++ b/backend/src/db/migrations/20251007133321_pki-v3-tables.ts @@ -34,7 +34,7 @@ export async function up(knex: Knex): Promise { t.boolean("disableBootstrapCaValidation").defaultTo(false); t.text("hashedPassphrase").notNullable(); - t.binary("encryptedCaChain").notNullable(); + t.binary("encryptedCaChain"); t.timestamps(true, true, true); }); diff --git a/backend/src/db/schemas/pki-est-enrollment-configs.ts b/backend/src/db/schemas/pki-est-enrollment-configs.ts index f54eb0561..4a3b16eeb 100644 --- a/backend/src/db/schemas/pki-est-enrollment-configs.ts +++ b/backend/src/db/schemas/pki-est-enrollment-configs.ts @@ -13,7 +13,7 @@ export const PkiEstEnrollmentConfigsSchema = z.object({ id: z.string().uuid(), disableBootstrapCaValidation: z.boolean().default(false).nullable().optional(), hashedPassphrase: z.string(), - encryptedCaChain: zodBuffer, + encryptedCaChain: zodBuffer.nullable().optional(), createdAt: z.date(), updatedAt: z.date() }); diff --git a/backend/src/ee/routes/est/certificate-est-router.ts b/backend/src/ee/routes/est/certificate-est-router.ts index 2f9c6ca51..d5876782d 100644 --- a/backend/src/ee/routes/est/certificate-est-router.ts +++ b/backend/src/ee/routes/est/certificate-est-router.ts @@ -4,41 +4,22 @@ import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; -import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; export const registerCertificateEstRouter = async (server: FastifyZodProvider) => { const appCfg = getConfig(); - const getIdentifierType = async ({ - identifier, - actor, - actorId, - actorAuthMethod, - actorOrgId - }: { - identifier: string; - actor: ActorType; - actorId: string; - actorAuthMethod: ActorAuthMethod; - actorOrgId: string; - }): Promise<"template" | "profile" | null> => { + const getIdentifierType = async (identifier: string): Promise<"template" | "profile" | null> => { try { + // Try to find as profile first using internal access await server.services.certificateProfile.getEstConfigurationByProfile({ - actor, - actorId, - actorAuthMethod, - actorOrgId, - profileId: identifier + profileId: identifier, + isInternal: true }); return "profile"; } catch (profileError) { try { await server.services.certificateTemplate.getEstConfiguration({ - isInternal: false, - actor, - actorId, - actorAuthMethod, - actorOrgId, + isInternal: true, certificateTemplateId: identifier }); return "template"; @@ -109,13 +90,7 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) = const identifier = urlFragments.slice(-2)[0]; - const identifierType = await getIdentifierType({ - identifier, - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId - }); + const identifierType = await getIdentifierType(identifier); if (!identifierType) { res.raw.statusCode = 404; res.raw.setHeader("Content-Type", "text/plain"); @@ -127,11 +102,8 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) = let estConfig; if (identifierType === "profile") { estConfig = await server.services.certificateProfile.getEstConfigurationByProfile({ - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - profileId: identifier + profileId: identifier, + isInternal: true }); } else { estConfig = await server.services.certificateTemplate.getEstConfiguration({ @@ -188,13 +160,7 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) = void res.header("Content-Transfer-Encoding", "base64"); const { identifier } = req.params; - const identifierType = await getIdentifierType({ - identifier, - actor: req.permission.type, - actorId: req.permission.id, - actorOrgId: req.permission.orgId, - actorAuthMethod: req.permission.authMethod - }); + const identifierType = await getIdentifierType(identifier); if (!identifierType) { throw new BadRequestError({ message: "Certificate template or profile not found" }); @@ -235,13 +201,7 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) = void res.header("Content-Transfer-Encoding", "base64"); const { identifier } = req.params; - const identifierType = await getIdentifierType({ - identifier, - actor: req.permission.type, - actorId: req.permission.id, - actorOrgId: req.permission.orgId, - actorAuthMethod: req.permission.authMethod - }); + const identifierType = await getIdentifierType(identifier); if (!identifierType) { throw new BadRequestError({ message: "Certificate template or profile not found" }); @@ -281,13 +241,7 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) = void res.header("Content-Transfer-Encoding", "base64"); const { identifier } = req.params; - const identifierType = await getIdentifierType({ - identifier, - actor: req.permission.type, - actorId: req.permission.id, - actorOrgId: req.permission.orgId, - actorAuthMethod: req.permission.authMethod - }); + const identifierType = await getIdentifierType(identifier); if (!identifierType) { throw new BadRequestError({ message: "Certificate template or profile not found" }); diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index d7b66eb0c..4e0b49c5b 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -2126,6 +2126,7 @@ export const registerRoutes = async ( const certificateEstV3Service = certificateEstV3ServiceFactory({ internalCertificateAuthorityService, certificateTemplateDAL, + certificateTemplateV2Service, certificateAuthorityDAL, certificateAuthorityCertDAL, projectDAL, diff --git a/backend/src/server/routes/v1/certificate-profiles-router.ts b/backend/src/server/routes/v1/certificate-profiles-router.ts index a63a98d58..da800475c 100644 --- a/backend/src/server/routes/v1/certificate-profiles-router.ts +++ b/backend/src/server/routes/v1/certificate-profiles-router.ts @@ -36,7 +36,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid .object({ disableBootstrapCaValidation: z.boolean().default(false), passphraseInput: z.string().min(1), - encryptedCaChain: z.string() + encryptedCaChain: z.string().optional() }) .optional(), apiConfig: z diff --git a/backend/src/server/routes/v3/certificates-router.ts b/backend/src/server/routes/v3/certificates-router.ts index 4cbc0d2eb..de8d2bef1 100644 --- a/backend/src/server/routes/v3/certificates-router.ts +++ b/backend/src/server/routes/v3/certificates-router.ts @@ -1,30 +1,55 @@ -import RE2 from "re2"; +import * as x509 from "@peculiar/x509"; import { z } from "zod"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { ApiDocsTags } from "@app/lib/api-docs"; +import { BadRequestError } from "@app/lib/errors"; import { ms } from "@app/lib/ms"; import { writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; import { ACMESANType, + CertExtendedKeyUsageOIDToName, CertificateOrderStatus, CertKeyAlgorithm, - CertSignatureAlgorithm + CertKeyUsage, + CertSignatureAlgorithm, + mapLegacyAltNameType, + TAltNameMapping } from "@app/services/certificate/certificate-types"; +import { parseDistinguishedName } from "@app/services/certificate-authority/certificate-authority-fns"; import { - validateAltNamesField, validateAndMapAltNameType, validateCaDateField } from "@app/services/certificate-authority/certificate-authority-validators"; import { CertExtendedKeyUsageType, CertKeyUsageType, - CertSubjectAlternativeNameType + CertSubjectAlternativeNameType, + mapLegacyExtendedKeyUsageToStandard, + mapLegacyKeyUsageToStandard } from "@app/services/certificate-common/certificate-constants"; import { mapEnumsForValidation } from "@app/services/certificate-common/certificate-utils"; import { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators"; +import { TCertificateRequest } from "@app/services/certificate-template-v2/certificate-template-v2-types"; + +interface CertificateRequestForService { + commonName?: string; + keyUsages?: CertKeyUsageType[]; + extendedKeyUsages?: CertExtendedKeyUsageType[]; + altNames?: Array<{ + type: CertSubjectAlternativeNameType; + value: string; + }>; + validity: { + ttl: string; + }; + notBefore?: Date; + notAfter?: Date; + signatureAlgorithm?: string; + keyAlgorithm?: string; +} const validateTtlAndDateFields = (data: { notBefore?: string; notAfter?: string; ttl?: string }) => { const hasDateFields = data.notBefore || data.notAfter; @@ -41,6 +66,67 @@ const validateDateOrder = (data: { notBefore?: string; notAfter?: string }) => { return true; }; +const extractCertificateRequestFromCSR = (csr: string): TCertificateRequest => { + let csrPem = csr; + if (!csr.includes("-----BEGIN CERTIFICATE REQUEST-----")) { + try { + csrPem = Buffer.from(csr, "base64").toString("utf8"); + } catch (error) { + throw new BadRequestError({ message: "Invalid base64 CSR encoding" }); + } + } + + const csrObj = new x509.Pkcs10CertificateRequest(csrPem); + const subject = parseDistinguishedName(csrObj.subject); + + const certificateRequest: TCertificateRequest = { + commonName: subject.commonName, + organization: subject.organization, + organizationUnit: subject.ou, + locality: subject.locality, + state: subject.province, + country: subject.country + }; + + const csrKeyUsageExtension = csrObj.getExtension("2.5.29.15") as x509.KeyUsagesExtension; + if (csrKeyUsageExtension) { + const csrKeyUsages = Object.values(CertKeyUsage).filter( + // eslint-disable-next-line no-bitwise + (keyUsage) => (x509.KeyUsageFlags[keyUsage] & csrKeyUsageExtension.usages) !== 0 + ); + certificateRequest.keyUsages = csrKeyUsages.map(mapLegacyKeyUsageToStandard); + } + + const csrExtendedKeyUsageExtension = csrObj.getExtension("2.5.29.37") as x509.ExtendedKeyUsageExtension; + if (csrExtendedKeyUsageExtension) { + const csrExtendedKeyUsages = csrExtendedKeyUsageExtension.usages + .map((ekuOid) => CertExtendedKeyUsageOIDToName[ekuOid as string]) + .filter((eku) => eku !== undefined); + certificateRequest.extendedKeyUsages = csrExtendedKeyUsages.map(mapLegacyExtendedKeyUsageToStandard); + } + + const sanExtension = csrObj.extensions.find((ext) => ext.type === "2.5.29.17"); + if (sanExtension) { + const sanNames = new x509.GeneralNames(sanExtension.value); + const altNamesArray: TAltNameMapping[] = sanNames.items + .filter((value) => value.type === "email" || value.type === "dns" || value.type === "url" || value.type === "ip") + .map((name): TAltNameMapping => { + const altNameType = validateAndMapAltNameType(name.value); + if (!altNameType) { + throw new BadRequestError({ message: `Invalid altName from CSR: ${name.value}` }); + } + return altNameType; + }); + + certificateRequest.subjectAlternativeNames = altNamesArray.map((altName) => ({ + type: mapLegacyAltNameType(altName.type), + value: altName.value + })); + } + + return certificateRequest; +}; + export const registerCertificatesRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", @@ -54,6 +140,7 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => body: z .object({ profileId: z.string().uuid(), + csr: z.string().trim().optional(), commonName: validateTemplateRegexField.optional(), ttl: z .string() @@ -64,7 +151,14 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => extendedKeyUsages: z.nativeEnum(CertExtendedKeyUsageType).array().optional(), notBefore: validateCaDateField.optional(), notAfter: validateCaDateField.optional(), - subjectAltNames: validateAltNamesField.optional(), + altNames: z + .array( + z.object({ + type: z.nativeEnum(CertSubjectAlternativeNameType), + value: z.string().min(1, "SAN value cannot be empty") + }) + ) + .optional(), signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm).optional(), keyAlgorithm: z.nativeEnum(CertKeyAlgorithm).optional() }) @@ -88,44 +182,45 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const rawCertificateRequest = { - commonName: req.body.commonName, - keyUsages: req.body.keyUsages, - extendedKeyUsages: req.body.extendedKeyUsages, - altNames: req.body.subjectAltNames - ? (() => { - const splitRegex = new RE2("[,;]+"); - return req.body.subjectAltNames - .split(splitRegex) - .map((name) => name.trim()) - .filter((name) => name.length > 0) - .map((name) => { - const mappedType = validateAndMapAltNameType(name); - if (!mappedType) return null; - const typeMapping = { - dns: "dns_name", - ip: "ip_address", - email: "email", - url: "uri" - } as const; - return { - type: typeMapping[mappedType.type] as CertSubjectAlternativeNameType, - value: mappedType.value - }; - }) - .filter((item): item is NonNullable => item !== null); - })() - : undefined, - validity: { - ttl: req.body.ttl - }, - notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined, - notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined, - signatureAlgorithm: req.body.signatureAlgorithm, - keyAlgorithm: req.body.keyAlgorithm - }; + let certificateRequestForService: CertificateRequestForService; - const mappedCertificateRequest = mapEnumsForValidation(rawCertificateRequest); + if (req.body.csr) { + try { + const csrData = extractCertificateRequestFromCSR(req.body.csr); + + certificateRequestForService = { + commonName: csrData.commonName, + keyUsages: csrData.keyUsages, + extendedKeyUsages: csrData.extendedKeyUsages, + altNames: csrData.subjectAlternativeNames, + validity: { + ttl: req.body.ttl + }, + notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined, + notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined, + signatureAlgorithm: req.body.signatureAlgorithm, + keyAlgorithm: req.body.keyAlgorithm + }; + } catch (error) { + throw new BadRequestError({ message: `Invalid CSR: ${(error as Error).message}` }); + } + } else { + certificateRequestForService = { + commonName: req.body.commonName, + keyUsages: req.body.keyUsages, + extendedKeyUsages: req.body.extendedKeyUsages, + altNames: req.body.altNames, + validity: { + ttl: req.body.ttl + }, + notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined, + notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined, + signatureAlgorithm: req.body.signatureAlgorithm, + keyAlgorithm: req.body.keyAlgorithm + }; + } + + const mappedCertificateRequest = mapEnumsForValidation(certificateRequestForService); const data = await server.services.certificateV3.issueCertificateFromProfile({ actor: req.permission.type, @@ -173,7 +268,9 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => .min(1, "TTL cannot be empty") .refine((val) => ms(val) > 0, "TTL must be a positive number"), notBefore: validateCaDateField.optional(), - notAfter: validateCaDateField.optional() + notAfter: validateCaDateField.optional(), + signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm).optional(), + keyAlgorithm: z.nativeEnum(CertKeyAlgorithm).optional() }) .refine(validateTtlAndDateFields, { message: @@ -205,7 +302,9 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => ttl: req.body.ttl }, notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined, - notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined + notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined, + signatureAlgorithm: req.body.signatureAlgorithm, + keyAlgorithm: req.body.keyAlgorithm }); await server.services.auditLog.createAuditLog({ diff --git a/backend/src/services/certificate-est-v3/certificate-est-v3-service.test.ts b/backend/src/services/certificate-est-v3/certificate-est-v3-service.test.ts new file mode 100644 index 000000000..c5f8b9759 --- /dev/null +++ b/backend/src/services/certificate-est-v3/certificate-est-v3-service.test.ts @@ -0,0 +1,734 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable no-bitwise */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { BadRequestError, NotFoundError } from "@app/lib/errors"; + +import { EnrollmentType } from "../certificate-profile/certificate-profile-types"; +import { certificateEstV3ServiceFactory, TCertificateEstV3ServiceFactory } from "./certificate-est-v3-service"; + +// Mock the x509 module +vi.mock("@peculiar/x509", () => ({ + Pkcs10CertificateRequest: vi.fn(), + GeneralNames: vi.fn(), + KeyUsagesExtension: vi.fn(), + ExtendedKeyUsageExtension: vi.fn(), + X509Certificate: vi.fn(), + KeyUsageFlags: { + digitalSignature: 1, + nonRepudiation: 2, + keyEncipherment: 4, + dataEncipherment: 8, + keyAgreement: 16, + keyCertSign: 32, + cRLSign: 64, + encipherOnly: 128, + decipherOnly: 256 + } +})); + +// Mock other dependencies +vi.mock("@app/services/certificate-authority/certificate-authority-fns", () => ({ + parseDistinguishedName: vi.fn((subject: string) => { + const parts = subject.split(","); + const result: any = {}; + parts.forEach((part) => { + const [key, value] = part.split("="); + switch (key.trim()) { + case "CN": + result.commonName = value; + break; + case "O": + result.organization = value; + break; + case "OU": + result.ou = value; + break; + case "L": + result.locality = value; + break; + case "ST": + result.province = value; + break; + case "C": + result.country = value; + break; + default: + break; + } + }); + return result; + }) +})); + +vi.mock("@app/services/certificate-authority/certificate-authority-validators", () => ({ + validateAndMapAltNameType: vi.fn((value: string) => { + if (value.includes(".") && !value.match(/^\d+\.\d+\.\d+\.\d+$/)) { + return { type: "dns", value }; + } + if (value.match(/^\d+\.\d+\.\d+\.\d+$/)) { + return { type: "ip", value }; + } + return null; + }) +})); + +vi.mock("@app/services/certificate-common/certificate-constants", () => ({ + mapLegacyKeyUsageToStandard: vi.fn((usage: string) => { + const mapping: Record = { + digitalSignature: "digital_signature", + keyEncipherment: "key_encipherment", + keyCertSign: "key_cert_sign" + }; + return mapping[usage] || usage; + }), + mapLegacyExtendedKeyUsageToStandard: vi.fn((usage: string) => { + const mapping: Record = { + clientAuth: "client_auth", + serverAuth: "server_auth", + codeSigning: "code_signing" + }; + return mapping[usage] || usage; + }), + CertKeyUsageType: { + DIGITAL_SIGNATURE: "digital_signature", + KEY_ENCIPHERMENT: "key_encipherment", + KEY_CERT_SIGN: "key_cert_sign" + }, + CertExtendedKeyUsageType: { + CLIENT_AUTH: "client_auth", + SERVER_AUTH: "server_auth", + CODE_SIGNING: "code_signing" + }, + CertSubjectAlternativeNameType: { + DNS_NAME: "dns_name", + IP_ADDRESS: "ip_address", + RFC822_NAME: "rfc822_name", + UNIFORM_RESOURCE_IDENTIFIER: "uniform_resource_identifier" + } +})); + +vi.mock("@app/services/certificate/certificate-types", () => ({ + mapLegacyAltNameType: vi.fn((type: string) => { + const mapping: Record = { + dns: "dns_name", + ip: "ip_address", + email: "rfc822_name", + url: "uniform_resource_identifier" + }; + return mapping[type] || type; + }), + CertExtendedKeyUsageOIDToName: { + "1.3.6.1.5.5.7.3.1": "serverAuth", + "1.3.6.1.5.5.7.3.2": "clientAuth", + "1.3.6.1.5.5.7.3.3": "codeSigning" + }, + CertKeyUsage: { + DIGITAL_SIGNATURE: "digitalSignature", + KEY_ENCIPHERMENT: "keyEncipherment", + KEY_CERT_SIGN: "keyCertSign", + NON_REPUDIATION: "nonRepudiation", + DATA_ENCIPHERMENT: "dataEncipherment", + KEY_AGREEMENT: "keyAgreement", + CRL_SIGN: "cRLSign", + ENCIPHER_ONLY: "encipherOnly", + DECIPHER_ONLY: "decipherOnly" + }, + CertExtendedKeyUsage: { + CLIENT_AUTH: "clientAuth", + SERVER_AUTH: "serverAuth", + CODE_SIGNING: "codeSigning" + } +})); + +vi.mock("@app/services/certificate-common/certificate-utils", () => ({ + mapEnumsForValidation: vi.fn((req: any) => req) +})); + +vi.mock("../../ee/services/certificate-est/certificate-est-fns", () => ({ + convertRawCertsToPkcs7: vi.fn(() => "mocked-pkcs7-response") +})); + +describe("CertificateEstV3Service Security Fix", () => { + let service: TCertificateEstV3ServiceFactory; + + const mockInternalCertificateAuthorityService = { + signCertFromCa: vi.fn() + }; + + const mockCertificateTemplateDAL = { + findById: vi.fn() + }; + + const mockCertificateTemplateV2Service = { + validateCertificateRequest: vi.fn() + }; + + const mockCertificateAuthorityDAL = { + findById: vi.fn(), + findByIdWithAssociatedCa: vi.fn() + }; + + const mockCertificateAuthorityCertDAL = { + find: vi.fn(), + findById: vi.fn() + }; + + const mockProjectDAL = { + findOne: vi.fn(), + updateById: vi.fn(), + transaction: vi.fn() + }; + + const mockKmsService = { + decryptWithKmsKey: vi.fn(), + generateKmsKey: vi.fn() + }; + + const mockLicenseService = { + getPlan: vi.fn() + }; + + const mockCertificateProfileDAL = { + findByIdWithConfigs: vi.fn() + }; + + const mockEstEnrollmentConfigDAL = { + findById: vi.fn() + }; + + const mockProfile = { + id: "profile-123", + projectId: "project-123", + caId: "ca-123", + certificateTemplateId: "template-v2-123", + enrollmentType: EnrollmentType.EST, + estConfigId: "est-config-123" + }; + + const mockEstConfig = { + id: "est-config-123", + disableBootstrapCaValidation: true + }; + + const mockProject = { + id: "project-123", + orgId: "org-123" + }; + + const mockPlan = { + pkiEst: true + }; + + beforeEach(async () => { + const { Pkcs10CertificateRequest, GeneralNames } = await import("@peculiar/x509"); + + service = certificateEstV3ServiceFactory({ + internalCertificateAuthorityService: mockInternalCertificateAuthorityService, + certificateTemplateDAL: mockCertificateTemplateDAL, + certificateTemplateV2Service: mockCertificateTemplateV2Service, + certificateAuthorityDAL: mockCertificateAuthorityDAL, + certificateAuthorityCertDAL: mockCertificateAuthorityCertDAL, + projectDAL: mockProjectDAL, + kmsService: mockKmsService, + licenseService: mockLicenseService, + certificateProfileDAL: mockCertificateProfileDAL, + estEnrollmentConfigDAL: mockEstEnrollmentConfigDAL + }); + + mockCertificateProfileDAL.findByIdWithConfigs.mockResolvedValue(mockProfile); + mockEstEnrollmentConfigDAL.findById.mockResolvedValue(mockEstConfig); + mockProjectDAL.findOne.mockResolvedValue(mockProject); + mockLicenseService.getPlan.mockResolvedValue(mockPlan); + + // Set up the default CSR parsing behavior + (Pkcs10CertificateRequest as any).mockImplementation((csr: string) => { + const parsed = JSON.parse(csr); + const mockExtensions: any[] = []; + + if (parsed.sans && parsed.sans.length > 0) { + mockExtensions.push({ type: "2.5.29.17", value: "mock-san-value" }); + } + + return { + subject: parsed.subject, + extensions: mockExtensions, + getExtension: vi.fn((oid: string) => { + if (oid === "2.5.29.15" && parsed.keyUsages && parsed.keyUsages.length > 0) { + // Calculate usages as bitwise OR of key usage flags + let usages = 0; + parsed.keyUsages.forEach((usage: string) => { + switch (usage) { + case "digital_signature": + usages |= 1; // KeyUsageFlags.digitalSignature + break; + case "key_encipherment": + usages |= 4; // KeyUsageFlags.keyEncipherment + break; + case "key_cert_sign": + usages |= 32; // KeyUsageFlags.keyCertSign + break; + default: + break; + } + }); + return { usages }; + } + if (oid === "2.5.29.37" && parsed.extendedKeyUsages && parsed.extendedKeyUsages.length > 0) { + const ekuOids = parsed.extendedKeyUsages.map((eku: string) => { + switch (eku) { + case "client_auth": + return "1.3.6.1.5.5.7.3.2"; + case "server_auth": + return "1.3.6.1.5.5.7.3.1"; + case "code_signing": + return "1.3.6.1.5.5.7.3.3"; + default: + return "1.3.6.1.5.5.7.3.1"; + } + }); + return { usages: ekuOids }; + } + return undefined; + }) + }; + }); + + (GeneralNames as any).mockImplementation(() => ({ + items: [ + { type: "dns", value: "test.example.com" }, + { type: "ip", value: "192.168.1.1" } + ] + })); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + const createMockCSR = ( + options: { + subject?: string; + keyUsages?: string[]; + extendedKeyUsages?: string[]; + sans?: Array<{ type: string; value: string }>; + } = {} + ) => { + const { + subject = "CN=test.example.com,O=Test Org,C=US", + keyUsages = [], + extendedKeyUsages = [], + sans = [] + } = options; + + return JSON.stringify({ + subject, + keyUsages, + extendedKeyUsages, + sans + }); + }; + + describe("CSR Extraction and Template Validation", () => { + it("should extract subject attributes from CSR", async () => { + const csr = createMockCSR({ + subject: "CN=test.example.com,O=Test Organization,OU=IT Department,L=San Francisco,ST=California,C=US" + }); + + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + + mockInternalCertificateAuthorityService.signCertFromCa.mockResolvedValue({ + certificate: { rawData: new ArrayBuffer(0) } + }); + + await service.simpleEnrollByProfile({ + csr, + profileId: "profile-123", + sslClientCert: "" + }); + + expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalledWith( + "template-v2-123", + expect.objectContaining({ + commonName: "test.example.com", + organization: "Test Organization", + organizationUnit: "IT Department", + locality: "San Francisco", + state: "California", + country: "US" + }) + ); + }); + + it("should extract key usages from CSR", async () => { + const csr = createMockCSR({ + keyUsages: ["digital_signature", "key_encipherment"] + }); + + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + + mockInternalCertificateAuthorityService.signCertFromCa.mockResolvedValue({ + certificate: { rawData: new ArrayBuffer(0) } + }); + + await service.simpleEnrollByProfile({ + csr, + profileId: "profile-123", + sslClientCert: "" + }); + + expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalledWith( + "template-v2-123", + expect.objectContaining({ + keyUsages: expect.arrayContaining(["digital_signature", "key_encipherment"]) + }) + ); + }); + + it("should extract extended key usages from CSR", async () => { + const csr = createMockCSR({ + extendedKeyUsages: ["client_auth", "server_auth"] + }); + + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + + mockInternalCertificateAuthorityService.signCertFromCa.mockResolvedValue({ + certificate: { rawData: new ArrayBuffer(0) } + }); + + await service.simpleEnrollByProfile({ + csr, + profileId: "profile-123", + sslClientCert: "" + }); + + expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalledWith( + "template-v2-123", + expect.objectContaining({ + extendedKeyUsages: expect.arrayContaining(["client_auth", "server_auth"]) + }) + ); + }); + + it("should extract Subject Alternative Names from CSR", async () => { + const { GeneralNames } = await import("@peculiar/x509"); + + const csr = createMockCSR({ + sans: [ + { type: "dns", value: "test.example.com" }, + { type: "ip", value: "192.168.1.1" } + ] + }); + + (GeneralNames as any).mockImplementation(() => ({ + items: [ + { type: "dns", value: "test.example.com" }, + { type: "ip", value: "192.168.1.1" } + ] + })); + + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + + mockInternalCertificateAuthorityService.signCertFromCa.mockResolvedValue({ + certificate: { rawData: new ArrayBuffer(0) } + }); + + await service.simpleEnrollByProfile({ + csr, + profileId: "profile-123", + sslClientCert: "" + }); + + expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalledWith( + "template-v2-123", + expect.objectContaining({ + subjectAlternativeNames: expect.arrayContaining([ + expect.objectContaining({ + type: "dns_name", + value: "test.example.com" + }), + expect.objectContaining({ + type: "ip_address", + value: "192.168.1.1" + }) + ]) + }) + ); + }); + }); + + describe("Template Validation Enforcement", () => { + const basicCSR = createMockCSR(); + + it("should enforce template validation and reject invalid requests", async () => { + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: false, + errors: ["Common name 'test.example.com' is not allowed", "Key usage 'digital_signature' is denied"], + warnings: [] + }); + + await expect( + service.simpleEnrollByProfile({ + csr: basicCSR, + profileId: "profile-123", + sslClientCert: "" + }) + ).rejects.toThrow(BadRequestError); + + expect(mockInternalCertificateAuthorityService.signCertFromCa).not.toHaveBeenCalled(); + }); + + it("should allow valid requests that pass template validation", async () => { + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + + mockInternalCertificateAuthorityService.signCertFromCa.mockResolvedValue({ + certificate: { rawData: new ArrayBuffer(0) } + }); + + await service.simpleEnrollByProfile({ + csr: basicCSR, + profileId: "profile-123", + sslClientCert: "" + }); + + expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalledWith( + "template-v2-123", + expect.any(Object) + ); + expect(mockInternalCertificateAuthorityService.signCertFromCa).toHaveBeenCalledWith({ + isInternal: true, + caId: "ca-123", + csr: basicCSR + }); + }); + + it("should validate template for both simpleEnrollByProfile and simpleReenrollByProfile", async () => { + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: false, + errors: ["SAN value 'evil.com' is denied"], + warnings: [] + }); + + await expect( + service.simpleEnrollByProfile({ + csr: basicCSR, + profileId: "profile-123", + sslClientCert: "" + }) + ).rejects.toThrow(BadRequestError); + + expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalled(); + expect(mockInternalCertificateAuthorityService.signCertFromCa).not.toHaveBeenCalled(); + }); + }); + + describe("Policy Bypass Prevention", () => { + const maliciousCSR = createMockCSR({ + subject: "CN=evil.com,O=Evil Corp,C=XX", + keyUsages: ["key_cert_sign"], + sans: [ + { type: "dns", value: "*.example.com" }, + { type: "ip", value: "127.0.0.1" } + ] + }); + + it("should block attempts to bypass subject attribute policies", async () => { + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: false, + errors: ["Organization 'Evil Corp' is denied", "Country 'XX' is not allowed"], + warnings: [] + }); + + await expect( + service.simpleEnrollByProfile({ + csr: maliciousCSR, + profileId: "profile-123", + sslClientCert: "" + }) + ).rejects.toThrow(BadRequestError); + + expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalledWith( + "template-v2-123", + expect.objectContaining({ + commonName: "evil.com", + organization: "Evil Corp", + country: "XX" + }) + ); + }); + + it("should block attempts to bypass key usage policies", async () => { + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: false, + errors: ["Key usage 'key_cert_sign' is denied - certificate authority privileges not allowed"], + warnings: [] + }); + + await expect( + service.simpleEnrollByProfile({ + csr: maliciousCSR, + profileId: "profile-123", + sslClientCert: "" + }) + ).rejects.toThrow(BadRequestError); + + expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalledWith( + "template-v2-123", + expect.objectContaining({ + keyUsages: expect.arrayContaining(["key_cert_sign"]) + }) + ); + }); + + it("should block attempts to bypass SAN policies", async () => { + const { GeneralNames } = await import("@peculiar/x509"); + + (GeneralNames as any).mockImplementation(() => ({ + items: [ + { type: "dns", value: "*.example.com" }, + { type: "ip", value: "127.0.0.1" } + ] + })); + + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: false, + errors: ["SAN value '*.example.com' matches denied wildcard pattern", "SAN value '127.0.0.1' is denied"], + warnings: [] + }); + + await expect( + service.simpleEnrollByProfile({ + csr: maliciousCSR, + profileId: "profile-123", + sslClientCert: "" + }) + ).rejects.toThrow(BadRequestError); + }); + }); + + describe("Error Handling", () => { + const basicCSR = createMockCSR(); + + it("should handle profile not found", async () => { + mockCertificateProfileDAL.findByIdWithConfigs.mockResolvedValue(null); + + await expect( + service.simpleEnrollByProfile({ + csr: basicCSR, + profileId: "nonexistent", + sslClientCert: "" + }) + ).rejects.toThrow(NotFoundError); + }); + + it("should handle non-EST enrollment type", async () => { + mockCertificateProfileDAL.findByIdWithConfigs.mockResolvedValue({ + ...mockProfile, + enrollmentType: EnrollmentType.API + }); + + await expect( + service.simpleEnrollByProfile({ + csr: basicCSR, + profileId: "profile-123", + sslClientCert: "" + }) + ).rejects.toThrow(BadRequestError); + }); + + it("should handle template validation service errors", async () => { + mockCertificateTemplateV2Service.validateCertificateRequest.mockRejectedValue( + new Error("Template validation service unavailable") + ); + + await expect( + service.simpleEnrollByProfile({ + csr: basicCSR, + profileId: "profile-123", + sslClientCert: "" + }) + ).rejects.toThrow("Template validation service unavailable"); + }); + }); + + describe("Integration with existing flow", () => { + const basicCSR = createMockCSR(); + + beforeEach(() => { + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + }); + + it("should call internal CA service with correct parameters after validation", async () => { + mockInternalCertificateAuthorityService.signCertFromCa.mockResolvedValue({ + certificate: { rawData: new ArrayBuffer(0) } + }); + + await service.simpleEnrollByProfile({ + csr: basicCSR, + profileId: "profile-123", + sslClientCert: "" + }); + + expect(mockInternalCertificateAuthorityService.signCertFromCa).toHaveBeenCalledWith({ + isInternal: true, + caId: "ca-123", + csr: basicCSR + }); + }); + + it("should use profile's CA ID instead of template ID to avoid v1/v2 mismatch", async () => { + mockInternalCertificateAuthorityService.signCertFromCa.mockResolvedValue({ + certificate: { rawData: new ArrayBuffer(0) } + }); + + await service.simpleEnrollByProfile({ + csr: basicCSR, + profileId: "profile-123", + sslClientCert: "" + }); + + // Verify it uses caId from profile, not certificateTemplateId + expect(mockInternalCertificateAuthorityService.signCertFromCa).toHaveBeenCalledWith( + expect.objectContaining({ + caId: "ca-123" + }) + ); + + // Verify it does NOT pass certificateTemplateId to avoid v1/v2 confusion + expect(mockInternalCertificateAuthorityService.signCertFromCa).toHaveBeenCalledWith( + expect.not.objectContaining({ + certificateTemplateId: expect.anything() + }) + ); + }); + }); +}); diff --git a/backend/src/services/certificate-est-v3/certificate-est-v3-service.ts b/backend/src/services/certificate-est-v3/certificate-est-v3-service.ts index f287614a0..6c052b0ac 100644 --- a/backend/src/services/certificate-est-v3/certificate-est-v3-service.ts +++ b/backend/src/services/certificate-est-v3/certificate-est-v3-service.ts @@ -3,13 +3,31 @@ import * as x509 from "@peculiar/x509"; import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate"; import { BadRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { isCertChainValid } from "@app/services/certificate/certificate-fns"; +import { + CertExtendedKeyUsageOIDToName, + CertKeyUsage, + mapLegacyAltNameType, + TAltNameMapping +} from "@app/services/certificate/certificate-types"; import { TCertificateAuthorityCertDALFactory } from "@app/services/certificate-authority/certificate-authority-cert-dal"; import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; -import { getCaCertChain, getCaCertChains } from "@app/services/certificate-authority/certificate-authority-fns"; +import { + getCaCertChain, + getCaCertChains, + parseDistinguishedName +} from "@app/services/certificate-authority/certificate-authority-fns"; +import { validateAndMapAltNameType } from "@app/services/certificate-authority/certificate-authority-validators"; import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service"; +import { + mapLegacyExtendedKeyUsageToStandard, + mapLegacyKeyUsageToStandard +} from "@app/services/certificate-common/certificate-constants"; +import { mapEnumsForValidation } from "@app/services/certificate-common/certificate-utils"; import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types"; import { TCertificateTemplateDALFactory } from "@app/services/certificate-template/certificate-template-dal"; +import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service"; +import { TCertificateRequest } from "@app/services/certificate-template-v2/certificate-template-v2-types"; import { TEstEnrollmentConfigDALFactory } from "@app/services/enrollment-config/est-enrollment-config-dal"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TProjectDALFactory } from "@app/services/project/project-dal"; @@ -21,6 +39,7 @@ import { TLicenseServiceFactory } from "../../ee/services/license/license-servic type TCertificateEstV3ServiceFactoryDep = { internalCertificateAuthorityService: Pick; certificateTemplateDAL: Pick; + certificateTemplateV2Service: Pick; certificateAuthorityDAL: Pick; certificateAuthorityCertDAL: Pick; projectDAL: Pick; @@ -35,6 +54,7 @@ export type TCertificateEstV3ServiceFactory = ReturnType { + const extractCertificateRequestFromCSR = (csr: string): TCertificateRequest => { + const csrObj = new x509.Pkcs10CertificateRequest(csr); + const subject = parseDistinguishedName(csrObj.subject); + + const certificateRequest: TCertificateRequest = { + commonName: subject.commonName, + organization: subject.organization, + organizationUnit: subject.ou, + locality: subject.locality, + state: subject.province, + country: subject.country + }; + + const csrKeyUsageExtension = csrObj.getExtension("2.5.29.15") as x509.KeyUsagesExtension; + if (csrKeyUsageExtension) { + const csrKeyUsages = Object.values(CertKeyUsage).filter( + // eslint-disable-next-line no-bitwise + (keyUsage) => (x509.KeyUsageFlags[keyUsage] & csrKeyUsageExtension.usages) !== 0 + ); + certificateRequest.keyUsages = csrKeyUsages.map(mapLegacyKeyUsageToStandard); + } + + const csrExtendedKeyUsageExtension = csrObj.getExtension("2.5.29.37") as x509.ExtendedKeyUsageExtension; + if (csrExtendedKeyUsageExtension) { + const csrExtendedKeyUsages = csrExtendedKeyUsageExtension.usages.map( + (ekuOid) => CertExtendedKeyUsageOIDToName[ekuOid as string] + ); + certificateRequest.extendedKeyUsages = csrExtendedKeyUsages.map(mapLegacyExtendedKeyUsageToStandard); + } + + const sanExtension = csrObj.extensions.find((ext) => ext.type === "2.5.29.17"); + if (sanExtension) { + const sanNames = new x509.GeneralNames(sanExtension.value); + const altNamesArray: TAltNameMapping[] = sanNames.items + .filter( + (value) => value.type === "email" || value.type === "dns" || value.type === "url" || value.type === "ip" + ) + .map((name): TAltNameMapping => { + const altNameType = validateAndMapAltNameType(name.value); + if (!altNameType) { + throw new BadRequestError({ message: `Invalid altName from CSR: ${name.value}` }); + } + return altNameType; + }); + + certificateRequest.subjectAlternativeNames = altNamesArray.map((altName) => ({ + type: mapLegacyAltNameType(altName.type), + value: altName.value + })); + } + + return certificateRequest; + }; const simpleEnrollByProfile = async ({ csr, profileId, @@ -122,9 +195,22 @@ export const certificateEstV3ServiceFactory = ({ } } + const certificateRequest = extractCertificateRequestFromCSR(csr); + const mappedCertificateRequest = mapEnumsForValidation(certificateRequest); + const validationResult = await certificateTemplateV2Service.validateCertificateRequest( + profile.certificateTemplateId, + mappedCertificateRequest + ); + + if (!validationResult.isValid) { + throw new BadRequestError({ + message: `Certificate request validation failed: ${validationResult.errors.join(", ")}` + }); + } + const { certificate } = await internalCertificateAuthorityService.signCertFromCa({ isInternal: true, - certificateTemplateId: profile.certificateTemplateId, + caId: profile.caId, csr }); @@ -235,9 +321,22 @@ export const certificateEstV3ServiceFactory = ({ }); } + const certificateRequest = extractCertificateRequestFromCSR(csr); + const mappedCertificateRequest = mapEnumsForValidation(certificateRequest); + const validationResult = await certificateTemplateV2Service.validateCertificateRequest( + profile.certificateTemplateId, + mappedCertificateRequest + ); + + if (!validationResult.isValid) { + throw new BadRequestError({ + message: `Certificate request validation failed: ${validationResult.errors.join(", ")}` + }); + } + const { certificate } = await internalCertificateAuthorityService.signCertFromCa({ isInternal: true, - certificateTemplateId: profile.certificateTemplateId, + caId: profile.caId, csr }); diff --git a/backend/src/services/certificate-profile/certificate-profile-service.ts b/backend/src/services/certificate-profile/certificate-profile-service.ts index ccad3fccb..232a9516c 100644 --- a/backend/src/services/certificate-profile/certificate-profile-service.ts +++ b/backend/src/services/certificate-profile/certificate-profile-service.ts @@ -142,17 +142,19 @@ export const certificateProfileServiceFactory = ({ // Hash the passphrase const hashedPassphrase = await crypto.hashing().createHash(data.estConfig.passphraseInput, appCfg.SALT_ROUNDS); - let encryptedCaChainBuffer: Buffer; - try { - if (!data.estConfig.encryptedCaChain || typeof data.estConfig.encryptedCaChain !== "string") { - throw new BadRequestError({ message: "Invalid or missing CA chain data" }); + let encryptedCaChainBuffer: Buffer | null = null; + if (!data.estConfig.disableBootstrapCaValidation) { + try { + if (!data.estConfig.encryptedCaChain || typeof data.estConfig.encryptedCaChain !== "string") { + throw new BadRequestError({ message: "Invalid or missing CA chain data" }); + } + encryptedCaChainBuffer = Buffer.from(data.estConfig.encryptedCaChain, "base64"); + if (encryptedCaChainBuffer.toString("base64") !== data.estConfig.encryptedCaChain) { + throw new BadRequestError({ message: "Invalid Base64 encoding in CA chain data" }); + } + } catch (error) { + throw new BadRequestError({ message: "Failed to decode CA chain data: Invalid Base64 format" }); } - encryptedCaChainBuffer = Buffer.from(data.estConfig.encryptedCaChain, "base64"); - if (encryptedCaChainBuffer.toString("base64") !== data.estConfig.encryptedCaChain) { - throw new BadRequestError({ message: "Invalid Base64 encoding in CA chain data" }); - } - } catch (error) { - throw new BadRequestError({ message: "Failed to decode CA chain data: Invalid Base64 format" }); } const estConfig = await estEnrollmentConfigDAL.create( @@ -614,37 +616,49 @@ export const certificateProfileServiceFactory = ({ return metrics; }; - const getEstConfigurationByProfile = async ({ - actor, - actorId, - actorAuthMethod, - actorOrgId, - profileId - }: { - actor: ActorType; - actorId: string; - actorAuthMethod: ActorAuthMethod; - actorOrgId: string | undefined; - profileId: string; - }) => { + const getEstConfigurationByProfile = async ( + params: + | { + profileId: string; + isInternal: true; + } + | { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string | undefined; + profileId: string; + isInternal?: false; + } + ) => { + const { profileId, isInternal = false } = params; const profile = await certificateProfileDAL.findByIdWithConfigs(profileId); if (!profile) { throw new NotFoundError({ message: "Certificate profile not found" }); } - const { permission } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId: profile.projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.CertificateManager - }); + if (!isInternal) { + const { actor, actorId, actorAuthMethod, actorOrgId } = params as { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string | undefined; + }; - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionCertificateProfileActions.Read, - ProjectPermissionSub.CertificateProfiles - ); + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: profile.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.Read, + ProjectPermissionSub.CertificateProfiles + ); + } if (profile.enrollmentType !== EnrollmentType.EST) { throw new ForbiddenRequestError({ diff --git a/backend/src/services/certificate-template-v2/certificate-template-v2-service.ts b/backend/src/services/certificate-template-v2/certificate-template-v2-service.ts index 8873e0587..7531049cd 100644 --- a/backend/src/services/certificate-template-v2/certificate-template-v2-service.ts +++ b/backend/src/services/certificate-template-v2/certificate-template-v2-service.ts @@ -27,12 +27,34 @@ type TCertificateTemplateV2ServiceFactoryDep = { permissionService: Pick; }; -export type TCertificateTemplateV2ServiceFactory = ReturnType; - export const certificateTemplateV2ServiceFactory = ({ certificateTemplateV2DAL, permissionService }: TCertificateTemplateV2ServiceFactoryDep) => { + const consolidateAttributeArray = < + T extends { type: string; allowed?: string[]; required?: string[]; denied?: string[] } + >( + attributes: T[] + ): T[] => { + const consolidated = new Map(); + + attributes.forEach((attr) => { + const existing = consolidated.get(attr.type); + if (existing) { + consolidated.set(attr.type, { + ...attr, + allowed: [...new Set([...(existing.allowed || []), ...(attr.allowed || [])])], + required: [...new Set([...(existing.required || []), ...(attr.required || [])])], + denied: [...new Set([...(existing.denied || []), ...(attr.denied || [])])] + } as T); + } else { + consolidated.set(attr.type, attr); + } + }); + + return Array.from(consolidated.values()); + }; + const parseTTL = (ttl: string): number => { const regex = new RE2("^(\\d+)([dmyh])$"); const match = regex.exec(ttl); @@ -298,44 +320,14 @@ export const certificateTemplateV2ServiceFactory = ({ if (request.country) requestAttributes.set(CertSubjectAttributeType.COUNTRY, request.country); if (subjectPolicies && subjectPolicies.length > 0) { - // Validate each template subject attribute policy for (const attrPolicy of subjectPolicies) { const requestValue = requestAttributes.get(attrPolicy.type); - // Check denied values first - if (requestValue && attrPolicy.denied && attrPolicy.denied.length > 0) { - const validation = validateValueAgainstConstraints(requestValue, attrPolicy.denied, attrPolicy.type); - if (validation.isValid) { - errors.push(`${attrPolicy.type} value '${requestValue}' is denied by template policy`); - // Skip further validation for this attribute if it's denied - } else if (requestValue && attrPolicy.allowed && attrPolicy.allowed.length > 0) { - // Check allowed values if present and not denied - const allowedValidation = validateValueAgainstConstraints( - requestValue, - attrPolicy.allowed, - attrPolicy.type - ); - if (!allowedValidation.isValid && allowedValidation.error) { - errors.push(allowedValidation.error); - } - } - } else if (requestValue && attrPolicy.allowed && attrPolicy.allowed.length > 0) { - // Check allowed values if present and not denied - const allowedValidation = validateValueAgainstConstraints(requestValue, attrPolicy.allowed, attrPolicy.type); - if (!allowedValidation.isValid && allowedValidation.error) { - errors.push(allowedValidation.error); - } - } - } - - // Check for required subject attributes - for (const attrPolicy of subjectPolicies) { if (attrPolicy.required && attrPolicy.required.length > 0) { - const requestValue = requestAttributes.get(attrPolicy.type); if (!requestValue) { errors.push(`Missing required ${attrPolicy.type} attribute`); } else { - // Validate that the request value matches at least one required pattern + // Validate that the request value matches the required pattern const hasMatchingRequired = attrPolicy.required.some((requiredValue) => { const validation = validateValueAgainstConstraints(requestValue, [requiredValue], attrPolicy.type); return validation.isValid; @@ -347,6 +339,38 @@ export const certificateTemplateV2ServiceFactory = ({ } } } + + if (requestValue) { + let isValueDenied = false; + if (attrPolicy.denied && attrPolicy.denied.length > 0) { + const validation = validateValueAgainstConstraints(requestValue, attrPolicy.denied, attrPolicy.type); + if (validation.isValid) { + errors.push(`${attrPolicy.type} value '${requestValue}' is denied by template policy`); + isValueDenied = true; + } + } + + if (!isValueDenied && attrPolicy.allowed && attrPolicy.allowed.length > 0) { + let satisfiesRequired = false; + if (attrPolicy.required && attrPolicy.required.length > 0) { + satisfiesRequired = attrPolicy.required.some((requiredValue) => { + const validation = validateValueAgainstConstraints(requestValue, [requiredValue], attrPolicy.type); + return validation.isValid; + }); + } + + if (!satisfiesRequired) { + const allowedValidation = validateValueAgainstConstraints( + requestValue, + attrPolicy.allowed, + attrPolicy.type + ); + if (!allowedValidation.isValid && allowedValidation.error) { + errors.push(allowedValidation.error); + } + } + } + } } // Check if any request attributes are not covered by template policies @@ -409,9 +433,19 @@ export const certificateTemplateV2ServiceFactory = ({ // Check ALLOWED values - if present, all SANs must match at least one allowed pattern if (sanPolicy.allowed && sanPolicy.allowed.length > 0 && requestSans.length > 0) { for (const sanValue of requestSans) { - const validation = validateValueAgainstConstraints(sanValue, sanPolicy.allowed, `${sanPolicy.type} SAN`); - if (!validation.isValid && validation.error) { - errors.push(validation.error); + let satisfiesRequired = false; + if (sanPolicy.required && sanPolicy.required.length > 0) { + satisfiesRequired = sanPolicy.required.some((requiredValue) => { + const validation = validateValueAgainstConstraints(sanValue, [requiredValue], `${sanPolicy.type} SAN`); + return validation.isValid; + }); + } + + if (!satisfiesRequired) { + const validation = validateValueAgainstConstraints(sanValue, sanPolicy.allowed, `${sanPolicy.type} SAN`); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } } } } @@ -615,20 +649,26 @@ export const certificateTemplateV2ServiceFactory = ({ throw new Error("Template data is required"); } - if (data.subject) { - validateSubjectAttributePolicy(data.subject); + const consolidatedData = { + ...data, + subject: data.subject ? consolidateAttributeArray(data.subject) : undefined, + sans: data.sans ? consolidateAttributeArray(data.sans) : undefined + }; + + if (consolidatedData.subject) { + validateSubjectAttributePolicy(consolidatedData.subject); } - if (data.sans) { - validateSanPolicy(data.sans); + if (consolidatedData.sans) { + validateSanPolicy(consolidatedData.sans); } - if (data.keyUsages) { - validateKeyUsagePolicy(data.keyUsages); + if (consolidatedData.keyUsages) { + validateKeyUsagePolicy(consolidatedData.keyUsages); } - if (data.extendedKeyUsages) { - validateExtendedKeyUsagePolicy(data.extendedKeyUsages); + if (consolidatedData.extendedKeyUsages) { + validateExtendedKeyUsagePolicy(consolidatedData.extendedKeyUsages); } // Generate slug from name and ensure it's unique within project @@ -640,7 +680,7 @@ export const certificateTemplateV2ServiceFactory = ({ const uniqueSlug = await ensureUniqueSlug(projectId, slug); const template = await certificateTemplateV2DAL.create({ - ...data, + ...consolidatedData, name: uniqueSlug, projectId }); @@ -682,23 +722,29 @@ export const certificateTemplateV2ServiceFactory = ({ ProjectPermissionSub.CertificateTemplates ); - if (data.subject) { - validateSubjectAttributePolicy(data.subject); + const consolidatedData = { + ...data, + subject: data.subject ? consolidateAttributeArray(data.subject) : undefined, + sans: data.sans ? consolidateAttributeArray(data.sans) : undefined + }; + + if (consolidatedData.subject) { + validateSubjectAttributePolicy(consolidatedData.subject); } - if (data.sans) { - validateSanPolicy(data.sans); + if (consolidatedData.sans) { + validateSanPolicy(consolidatedData.sans); } - if (data.keyUsages) { - validateKeyUsagePolicy(data.keyUsages); + if (consolidatedData.keyUsages) { + validateKeyUsagePolicy(consolidatedData.keyUsages); } - if (data.extendedKeyUsages) { - validateExtendedKeyUsagePolicy(data.extendedKeyUsages); + if (consolidatedData.extendedKeyUsages) { + validateExtendedKeyUsagePolicy(consolidatedData.extendedKeyUsages); } - const updateData = { ...data }; + const updateData = { ...consolidatedData }; if (data.name && typeof data.name === "string") { const newSlug = generateTemplateSlug(data.name); if (newSlug !== existingTemplate.name) { @@ -871,7 +917,9 @@ export const certificateTemplateV2ServiceFactory = ({ const isInUse = await certificateTemplateV2DAL.isTemplateInUse(templateId); if (isInUse) { const profilesUsingTemplate = await certificateTemplateV2DAL.getProfilesUsingTemplate(templateId); - const profileNames = profilesUsingTemplate.map((profile) => profile.slug || profile.id).join(", "); + const profileNames = profilesUsingTemplate + .map((profile: { slug?: string; id: string }) => profile.slug || profile.id) + .join(", "); throw new ForbiddenRequestError({ message: @@ -910,3 +958,5 @@ export const certificateTemplateV2ServiceFactory = ({ validateCertificateRequest }; }; + +export type TCertificateTemplateV2ServiceFactory = ReturnType; diff --git a/backend/src/services/certificate-v3/certificate-v3-service.ts b/backend/src/services/certificate-v3/certificate-v3-service.ts index d0eaa6b5d..bcdc33a53 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.ts @@ -192,6 +192,26 @@ export const certificateV3ServiceFactory = ({ ...certificateRequest, subjectAlternativeNames: certificateRequest.altNames }); + + let template; + try { + template = await certificateTemplateV2Service.getTemplateV2ById({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + templateId: profile.certificateTemplateId + }); + } catch (error) { + throw new BadRequestError({ + message: `Certificate profile is using a legacy template (${profile.certificateTemplateId}) that doesn't support security validation policies. Please migrate to a template v2 for proper security enforcement.` + }); + } + + if (!template) { + throw new NotFoundError({ message: "Certificate template not found for this profile" }); + } + const validationResult = await certificateTemplateV2Service.validateCertificateRequest( profile.certificateTemplateId, mappedCertificateRequest @@ -214,18 +234,6 @@ export const certificateV3ServiceFactory = ({ throw new BadRequestError({ message: "Authentication method is required for certificate issuance" }); } - const template = await certificateTemplateV2Service.getTemplateV2ById({ - actor, - actorId, - actorAuthMethod, - actorOrgId, - templateId: profile.certificateTemplateId - }); - - if (!template) { - throw new NotFoundError({ message: "Certificate template not found for this profile" }); - } - validateAlgorithmCompatibility(ca, template); const effectiveSignatureAlgorithm = certificateRequest.signatureAlgorithm as CertSignatureAlgorithm | undefined; diff --git a/backend/src/services/enrollment-config/enrollment-config-types.ts b/backend/src/services/enrollment-config/enrollment-config-types.ts index 8ab55a7d4..2a9591984 100644 --- a/backend/src/services/enrollment-config/enrollment-config-types.ts +++ b/backend/src/services/enrollment-config/enrollment-config-types.ts @@ -20,7 +20,7 @@ export type TApiEnrollmentConfigUpdate = TPkiApiEnrollmentConfigsUpdate; export interface TEstConfigData { disableBootstrapCaValidation: boolean; passphraseInput: string; - encryptedCaChain: string; + encryptedCaChain?: string; } export interface TApiConfigData { diff --git a/docs/internals/permissions/project-permissions.mdx b/docs/internals/permissions/project-permissions.mdx index 3a4dd11e6..0f4736d1a 100644 --- a/docs/internals/permissions/project-permissions.mdx +++ b/docs/internals/permissions/project-permissions.mdx @@ -291,6 +291,16 @@ Supports conditions and permission inversion | `create` | Issue new certificates | | `delete` | Revoke or remove certificates | +#### Subject: `certificate-profiles` + +| Action | Description | +| -------- | -------------------------------- | +| `read` | View certificate profiles | +| `create` | Create new certificate profiles | +| `edit` | Modify profile configurations | +| `delete` | Remove certificate profiles | +| `issue-cert` | Issue new certificates | + #### Subject: `certificate-templates` | Action | Description | diff --git a/frontend/src/hooks/api/certificateProfiles/types.ts b/frontend/src/hooks/api/certificateProfiles/types.ts index 75b7e6a82..72e06d06e 100644 --- a/frontend/src/hooks/api/certificateProfiles/types.ts +++ b/frontend/src/hooks/api/certificateProfiles/types.ts @@ -48,8 +48,8 @@ export type TCreateCertificateProfileDTO = { enrollmentType: "api" | "est"; estConfig?: { disableBootstrapCaValidation?: boolean; - passphrase: string; - caChain: string; + passphraseInput: string; + caChain?: string; }; apiConfig?: { autoRenew?: boolean; diff --git a/frontend/src/hooks/api/certificateTemplates/mutations.tsx b/frontend/src/hooks/api/certificateTemplates/mutations.tsx index 69c446050..0355d9d87 100644 --- a/frontend/src/hooks/api/certificateTemplates/mutations.tsx +++ b/frontend/src/hooks/api/certificateTemplates/mutations.tsx @@ -7,17 +7,17 @@ import { projectKeys } from "../projects"; import { certTemplateKeys } from "./queries"; import { TCertificateTemplate, - TCertificateTemplateV2New, + TCertificateTemplateV2WithPolicies, TCreateCertificateTemplateDTO, TCreateCertificateTemplateV2DTO, - TCreateCertificateTemplateV2NewDTO, + TCreateCertificateTemplateV2WithPoliciesDTO, TCreateEstConfigDTO, TDeleteCertificateTemplateDTO, TDeleteCertificateTemplateV2DTO, - TDeleteCertificateTemplateV2NewDTO, + TDeleteCertificateTemplateV2WithPoliciesDTO, TUpdateCertificateTemplateDTO, TUpdateCertificateTemplateV2DTO, - TUpdateCertificateTemplateV2NewDTO, + TUpdateCertificateTemplateV2WithPoliciesDTO, TUpdateEstConfigDTO } from "./types"; @@ -168,12 +168,16 @@ export const useUpdateEstConfig = () => { }); }; -export const useCreateCertificateTemplateV2New = () => { +export const useCreateCertificateTemplateV2WithPolicies = () => { const queryClient = useQueryClient(); - return useMutation({ + return useMutation< + TCertificateTemplateV2WithPolicies, + object, + TCreateCertificateTemplateV2WithPoliciesDTO + >({ mutationFn: async (data) => { const { data: response } = await apiRequest.post<{ - certificateTemplate: TCertificateTemplateV2New; + certificateTemplate: TCertificateTemplateV2WithPolicies; }>("/api/v2/certificate-templates", data); return response.certificateTemplate; }, @@ -185,12 +189,16 @@ export const useCreateCertificateTemplateV2New = () => { }); }; -export const useUpdateCertificateTemplateV2New = () => { +export const useUpdateCertificateTemplateV2WithPolicies = () => { const queryClient = useQueryClient(); - return useMutation({ + return useMutation< + TCertificateTemplateV2WithPolicies, + object, + TUpdateCertificateTemplateV2WithPoliciesDTO + >({ mutationFn: async ({ templateId, ...data }) => { const { data: response } = await apiRequest.patch<{ - certificateTemplate: TCertificateTemplateV2New; + certificateTemplate: TCertificateTemplateV2WithPolicies; }>(`/api/v2/certificate-templates/${templateId}`, data); return response.certificateTemplate; }, @@ -205,12 +213,16 @@ export const useUpdateCertificateTemplateV2New = () => { }); }; -export const useDeleteCertificateTemplateV2New = () => { +export const useDeleteCertificateTemplateV2WithPolicies = () => { const queryClient = useQueryClient(); - return useMutation({ + return useMutation< + TCertificateTemplateV2WithPolicies, + object, + TDeleteCertificateTemplateV2WithPoliciesDTO + >({ mutationFn: async ({ templateId }) => { const { data: response } = await apiRequest.delete<{ - certificateTemplate: TCertificateTemplateV2New; + certificateTemplate: TCertificateTemplateV2WithPolicies; }>(`/api/v2/certificate-templates/${templateId}`); return response.certificateTemplate; }, diff --git a/frontend/src/hooks/api/certificateTemplates/queries.tsx b/frontend/src/hooks/api/certificateTemplates/queries.tsx index bef7aa67d..383f4ed71 100644 --- a/frontend/src/hooks/api/certificateTemplates/queries.tsx +++ b/frontend/src/hooks/api/certificateTemplates/queries.tsx @@ -5,7 +5,7 @@ import { apiRequest } from "@app/config/request"; import { TCertificateTemplate, TCertificateTemplateV2, - TCertificateTemplateV2New, + TCertificateTemplateV2WithPolicies, TEstConfig, TGetCertificateTemplateV2ByIdDTO, TListCertificateTemplatesDTO, @@ -90,7 +90,7 @@ export const useListCertificateTemplatesV2 = ({ queryKey: certTemplateKeys.listTemplatesV2({ projectId, limit, offset }), queryFn: async () => { const { data } = await apiRequest.get<{ - certificateTemplates: TCertificateTemplateV2New[]; + certificateTemplates: TCertificateTemplateV2WithPolicies[]; totalCount: number; }>("/api/v2/certificate-templates", { params: { @@ -112,7 +112,7 @@ export const useGetCertificateTemplateV2ById = ({ queryKey: certTemplateKeys.getTemplateV2ById(templateId), queryFn: async () => { const { data } = await apiRequest.get<{ - certificateTemplate: TCertificateTemplateV2New; + certificateTemplate: TCertificateTemplateV2WithPolicies; }>(`/api/v2/certificate-templates/${templateId}`); return data.certificateTemplate; }, diff --git a/frontend/src/hooks/api/certificateTemplates/types.ts b/frontend/src/hooks/api/certificateTemplates/types.ts index 08fc2f53e..373bbef2a 100644 --- a/frontend/src/hooks/api/certificateTemplates/types.ts +++ b/frontend/src/hooks/api/certificateTemplates/types.ts @@ -156,7 +156,7 @@ export type TCertificateTemplateV2Policy = { }; }; -export type TCertificateTemplateV2New = { +export type TCertificateTemplateV2WithPolicies = { id: string; projectId: string; name: string; @@ -171,7 +171,7 @@ export type TCertificateTemplateV2New = { updatedAt: string; }; -export type TCreateCertificateTemplateV2NewDTO = { +export type TCreateCertificateTemplateV2WithPoliciesDTO = { projectId: string; name: string; description?: string; @@ -183,7 +183,7 @@ export type TCreateCertificateTemplateV2NewDTO = { validity?: TCertificateTemplateV2Policy["validity"]; }; -export type TUpdateCertificateTemplateV2NewDTO = { +export type TUpdateCertificateTemplateV2WithPoliciesDTO = { templateId: string; name?: string; description?: string; @@ -195,7 +195,7 @@ export type TUpdateCertificateTemplateV2NewDTO = { validity?: TCertificateTemplateV2Policy["validity"]; }; -export type TDeleteCertificateTemplateV2NewDTO = { +export type TDeleteCertificateTemplateV2WithPoliciesDTO = { templateId: string; }; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/AlgorithmSelectors.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/AlgorithmSelectors.tsx new file mode 100644 index 000000000..25ec819ea --- /dev/null +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/AlgorithmSelectors.tsx @@ -0,0 +1,90 @@ +import { Control, Controller } from "react-hook-form"; + +import { FormControl, Select, SelectItem } from "@app/components/v2"; + +type AlgorithmOption = { + value: string; + label: string; +}; + +type AlgorithmSelectorsProps = { + control: Control; + availableSignatureAlgorithms: AlgorithmOption[]; + availableKeyAlgorithms: AlgorithmOption[]; + signatureError?: string; + keyError?: string; +}; + +export const AlgorithmSelectors = ({ + control, + availableSignatureAlgorithms, + availableKeyAlgorithms, + signatureError, + keyError +}: AlgorithmSelectorsProps) => { + return ( +
+
+ ( + + + + )} + /> +
+ +
+ ( + + + + )} + /> +
+
+ ); +}; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal.tsx index 561e9ad79..a45f00ead 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Controller, useForm } from "react-hook-form"; -import { faPlus, faQuestionCircle, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; @@ -8,14 +8,9 @@ import { z } from "zod"; import { createNotification } from "@app/components/notifications"; import { Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, Button, - Checkbox, FormControl, FormLabel, - IconButton, Input, Modal, ModalContent, @@ -24,21 +19,24 @@ import { Tooltip } from "@app/components/v2"; import { useProject } from "@app/context"; -import { useCreateCertificateV3, useGetCert } from "@app/hooks/api"; +import { useGetCert } from "@app/hooks/api"; +import { useCreateCertificateV3 } from "@app/hooks/api/ca"; import { useListCertificateProfiles } from "@app/hooks/api/certificateProfiles"; -import { - EXTENDED_KEY_USAGES_OPTIONS, - KEY_USAGES_OPTIONS -} from "@app/hooks/api/certificates/constants"; import { CertExtendedKeyUsage, CertKeyUsage } from "@app/hooks/api/certificates/enums"; import { useGetCertificateTemplateV2ById } from "@app/hooks/api/certificateTemplates/queries"; import { UsePopUpState } from "@app/hooks/usePopUp"; -import { - mapTemplateKeyAlgorithmToApi, - mapTemplateSignatureAlgorithmToApi -} from "@app/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/certificate-constants"; +import { AlgorithmSelectors } from "./AlgorithmSelectors"; import { CertificateContent } from "./CertificateContent"; +import { + filterUsages, + formatSubjectAltNames, + FrontendSanType, + getAttributeValue +} from "./certificateUtils"; +import { KeyUsageSection } from "./KeyUsageSection"; +import { SubjectAltNamesField } from "./SubjectAltNamesField"; +import { useCertificateTemplate } from "./useCertificateTemplate"; const createSchema = (shouldShowSubjectSection: boolean) => { return z.object({ @@ -63,7 +61,7 @@ const createSchema = (shouldShowSubjectSection: boolean) => { subjectAltNames: z .array( z.object({ - type: z.enum(["dns", "ip", "email", "uri"]), + type: z.nativeEnum(FrontendSanType), value: z.string().min(1, "Value is required") }) ) @@ -71,25 +69,29 @@ const createSchema = (shouldShowSubjectSection: boolean) => { ttl: z.string().trim().min(1, "TTL is required"), signatureAlgorithm: z.string().min(1, "Signature algorithm is required"), keyAlgorithm: z.string().min(1, "Key algorithm is required"), - keyUsages: z.object({ - [CertKeyUsage.DIGITAL_SIGNATURE]: z.boolean().optional(), - [CertKeyUsage.KEY_ENCIPHERMENT]: z.boolean().optional(), - [CertKeyUsage.NON_REPUDIATION]: z.boolean().optional(), - [CertKeyUsage.DATA_ENCIPHERMENT]: z.boolean().optional(), - [CertKeyUsage.KEY_AGREEMENT]: z.boolean().optional(), - [CertKeyUsage.KEY_CERT_SIGN]: z.boolean().optional(), - [CertKeyUsage.CRL_SIGN]: z.boolean().optional(), - [CertKeyUsage.ENCIPHER_ONLY]: z.boolean().optional(), - [CertKeyUsage.DECIPHER_ONLY]: z.boolean().optional() - }), - extendedKeyUsages: z.object({ - [CertExtendedKeyUsage.CLIENT_AUTH]: z.boolean().optional(), - [CertExtendedKeyUsage.CODE_SIGNING]: z.boolean().optional(), - [CertExtendedKeyUsage.EMAIL_PROTECTION]: z.boolean().optional(), - [CertExtendedKeyUsage.OCSP_SIGNING]: z.boolean().optional(), - [CertExtendedKeyUsage.SERVER_AUTH]: z.boolean().optional(), - [CertExtendedKeyUsage.TIMESTAMPING]: z.boolean().optional() - }) + keyUsages: z + .object({ + [CertKeyUsage.DIGITAL_SIGNATURE]: z.boolean().optional(), + [CertKeyUsage.KEY_ENCIPHERMENT]: z.boolean().optional(), + [CertKeyUsage.NON_REPUDIATION]: z.boolean().optional(), + [CertKeyUsage.DATA_ENCIPHERMENT]: z.boolean().optional(), + [CertKeyUsage.KEY_AGREEMENT]: z.boolean().optional(), + [CertKeyUsage.KEY_CERT_SIGN]: z.boolean().optional(), + [CertKeyUsage.CRL_SIGN]: z.boolean().optional(), + [CertKeyUsage.ENCIPHER_ONLY]: z.boolean().optional(), + [CertKeyUsage.DECIPHER_ONLY]: z.boolean().optional() + }) + .default({}), + extendedKeyUsages: z + .object({ + [CertExtendedKeyUsage.CLIENT_AUTH]: z.boolean().optional(), + [CertExtendedKeyUsage.CODE_SIGNING]: z.boolean().optional(), + [CertExtendedKeyUsage.EMAIL_PROTECTION]: z.boolean().optional(), + [CertExtendedKeyUsage.OCSP_SIGNING]: z.boolean().optional(), + [CertExtendedKeyUsage.SERVER_AUTH]: z.boolean().optional(), + [CertExtendedKeyUsage.TIMESTAMPING]: z.boolean().optional() + }) + .default({}) }); }; @@ -113,15 +115,6 @@ type TCertificateDetails = { export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }: Props) => { const [certificateDetails, setCertificateDetails] = useState(null); - const [allowedKeyUsages, setAllowedKeyUsages] = useState([]); - const [allowedExtendedKeyUsages, setAllowedExtendedKeyUsages] = useState([]); - const [requiredKeyUsages, setRequiredKeyUsages] = useState([]); - const [requiredExtendedKeyUsages, setRequiredExtendedKeyUsages] = useState([]); - const [allowedSignatureAlgorithms, setAllowedSignatureAlgorithms] = useState([]); - const [allowedKeyAlgorithms, setAllowedKeyAlgorithms] = useState([]); - const [allowedSanTypes, setAllowedSanTypes] = useState(["dns", "ip", "email", "uri"]); - const [shouldShowSanSection, setShouldShowSanSection] = useState(true); - const [shouldShowSubjectSection, setShouldShowSubjectSection] = useState(true); const { currentProject } = useProject(); const inputSerialNumber = @@ -137,44 +130,11 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId } const { mutateAsync: createCertificate } = useCreateCertificateV3(); - const { - control, - handleSubmit, - reset, - watch, - setValue, - formState: { isSubmitting } - } = useForm({ - resolver: zodResolver(createSchema(shouldShowSubjectSection)), - defaultValues: { - profileId: profileId || "", - subjectAttributes: shouldShowSubjectSection - ? [{ type: "common_name", value: "" }] - : undefined, - subjectAltNames: [], - ttl: "30d", - signatureAlgorithm: "", - keyAlgorithm: "", - keyUsages: {}, - extendedKeyUsages: {} - } - }); + const selectedProfileId = useMemo(() => { + const form = document.querySelector('select[name="profileId"]') as HTMLSelectElement; + return form?.value || profileId || ""; + }, [profileId]); - const resetAllState = useCallback(() => { - setCertificateDetails(null); - setAllowedKeyUsages([]); - setAllowedExtendedKeyUsages([]); - setRequiredKeyUsages([]); - setRequiredExtendedKeyUsages([]); - setAllowedSignatureAlgorithms([]); - setAllowedKeyAlgorithms([]); - setAllowedSanTypes(["dns", "ip", "email", "uri"]); - setShouldShowSanSection(true); - setShouldShowSubjectSection(true); - reset(); - }, [reset]); - - const selectedProfileId = watch("profileId"); const selectedProfile = useMemo( () => profilesData?.certificateProfiles?.find((p) => p.id === selectedProfileId), [profilesData?.certificateProfiles, selectedProfileId] @@ -184,135 +144,54 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId } templateId: selectedProfile?.certificateTemplateId || "" }); - const filteredKeyUsages = useMemo(() => { - return KEY_USAGES_OPTIONS.filter(({ value }) => allowedKeyUsages.includes(value)); - }, [allowedKeyUsages]); - - const filteredExtendedKeyUsages = useMemo(() => { - return EXTENDED_KEY_USAGES_OPTIONS.filter(({ value }) => - allowedExtendedKeyUsages.includes(value) - ); - }, [allowedExtendedKeyUsages]); - - const mapBackendSanTypeToFrontend = (backendType: string): string => { - switch (backendType) { - case "dns_name": - return "dns"; - case "ip_address": - return "ip"; - case "email": - return "email"; - case "uri": - return "uri"; - default: - return backendType; + const { + control, + handleSubmit, + reset, + watch, + setValue, + formState, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(createSchema((templateData?.subject?.length || 0) > 0)), + defaultValues: { + profileId: profileId || "", + subjectAttributes: [], + subjectAltNames: [], + ttl: "30d", + signatureAlgorithm: "", + keyAlgorithm: "", + keyUsages: {}, + extendedKeyUsages: {} } - }; + }); - const availableSignatureAlgorithms = useMemo(() => { - return allowedSignatureAlgorithms.map((templateAlgorithm) => { - const apiAlgorithm = mapTemplateSignatureAlgorithmToApi(templateAlgorithm); - return { - value: apiAlgorithm, - label: apiAlgorithm - }; - }); - }, [allowedSignatureAlgorithms]); + const actualSelectedProfileId = watch("profileId"); + const actualSelectedProfile = useMemo( + () => profilesData?.certificateProfiles?.find((p) => p.id === actualSelectedProfileId), + [profilesData?.certificateProfiles, actualSelectedProfileId] + ); - const availableKeyAlgorithms = useMemo(() => { - return allowedKeyAlgorithms.map((templateAlgorithm) => { - const apiAlgorithm = mapTemplateKeyAlgorithmToApi(templateAlgorithm); - return { - value: apiAlgorithm, - label: apiAlgorithm - }; - }); - }, [allowedKeyAlgorithms]); + const { + constraints, + filteredKeyUsages, + filteredExtendedKeyUsages, + availableSignatureAlgorithms, + availableKeyAlgorithms, + resetConstraints + } = useCertificateTemplate( + templateData, + actualSelectedProfile, + popUp?.certificateIssuance?.isOpen || false, + setValue, + watch + ); - useEffect(() => { - if (templateData && selectedProfile && popUp?.certificateIssuance?.isOpen) { - if (templateData.algorithms?.signature && templateData.algorithms.signature.length > 0) { - setAllowedSignatureAlgorithms(templateData.algorithms.signature); - } else { - setAllowedSignatureAlgorithms([]); - } - - if ( - templateData.algorithms?.keyAlgorithm && - templateData.algorithms.keyAlgorithm.length > 0 - ) { - setAllowedKeyAlgorithms(templateData.algorithms.keyAlgorithm); - } else { - setAllowedKeyAlgorithms([]); - } - - if (templateData.validity?.max) { - setValue("ttl", templateData.validity.max); - } - - const keyUsages: string[] = []; - if (templateData.keyUsages?.required) { - keyUsages.push(...templateData.keyUsages.required); - } - if (templateData.keyUsages?.allowed) { - keyUsages.push(...templateData.keyUsages.allowed); - } - setAllowedKeyUsages(keyUsages); - - const extendedKeyUsages: string[] = []; - if (templateData.extendedKeyUsages?.required) { - extendedKeyUsages.push(...templateData.extendedKeyUsages.required); - } - if (templateData.extendedKeyUsages?.allowed) { - extendedKeyUsages.push(...templateData.extendedKeyUsages.allowed); - } - setAllowedExtendedKeyUsages(extendedKeyUsages); - - setRequiredKeyUsages(templateData.keyUsages?.required || []); - setRequiredExtendedKeyUsages(templateData.extendedKeyUsages?.required || []); - - if (templateData.sans && templateData.sans.length > 0) { - const sanTypes: string[] = []; - templateData.sans.forEach((sanPolicy) => { - const frontendType = mapBackendSanTypeToFrontend(sanPolicy.type); - if (!sanTypes.includes(frontendType)) { - sanTypes.push(frontendType); - } - }); - setAllowedSanTypes(sanTypes); - setShouldShowSanSection(true); - } else { - setAllowedSanTypes([]); - setShouldShowSanSection(false); - setValue("subjectAltNames", []); - } - - if (templateData.subject && templateData.subject.length > 0) { - setShouldShowSubjectSection(true); - const currentSubjectAttrs = watch("subjectAttributes"); - if (!currentSubjectAttrs || currentSubjectAttrs.length === 0) { - setValue("subjectAttributes", [{ type: "common_name", value: "" }]); - } - } else { - setShouldShowSubjectSection(false); - setValue("subjectAttributes", undefined); - } - - const initialKeyUsages: Record = {}; - const initialExtendedKeyUsages: Record = {}; - - (templateData.keyUsages?.required || []).forEach((usage: string) => { - initialKeyUsages[usage] = true; - }); - - (templateData.extendedKeyUsages?.required || []).forEach((usage: string) => { - initialExtendedKeyUsages[usage] = true; - }); - - setValue("keyUsages", initialKeyUsages); - setValue("extendedKeyUsages", initialExtendedKeyUsages); - } - }, [templateData, selectedProfile, setValue, watch, popUp?.certificateIssuance?.isOpen]); + const resetAllState = useCallback(() => { + setCertificateDetails(null); + resetConstraints(); + reset(); + }, [reset, resetConstraints]); useEffect(() => { if (cert) { @@ -327,14 +206,16 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId } subjectAltNames: cert.subjectAltNames ? cert.subjectAltNames.split(",").map((name) => { const trimmed = name.trim(); - if (trimmed.includes("@")) return { type: "email" as const, value: trimmed }; + if (trimmed.includes("@")) return { type: FrontendSanType.EMAIL, value: trimmed }; if (trimmed.match(/^\d+\.\d+\.\d+\.\d+$/)) - return { type: "ip" as const, value: trimmed }; - if (trimmed.startsWith("http")) return { type: "uri" as const, value: trimmed }; - return { type: "dns" as const, value: trimmed }; + return { type: FrontendSanType.IP, value: trimmed }; + if (trimmed.startsWith("http")) return { type: FrontendSanType.URI, value: trimmed }; + return { type: FrontendSanType.DNS, value: trimmed }; }) : [], ttl: "", + signatureAlgorithm: "", + keyAlgorithm: "", keyUsages: Object.fromEntries((cert.keyUsages || []).map((name) => [name, true])), extendedKeyUsages: Object.fromEntries( (cert.extendedKeyUsages || []).map((name) => [name, true]) @@ -349,27 +230,6 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId } } }, [popUp?.certificateIssuance?.isOpen, profileId, cert, setValue]); - const getAttributeValue = useCallback( - (subjectAttributes: FormData["subjectAttributes"], type: string) => { - const foundAttr = subjectAttributes?.find((attr) => attr.type === type); - return foundAttr?.value || ""; - }, - [] - ); - - const formatSubjectAltNames = useCallback((subjectAltNames: FormData["subjectAltNames"]) => { - return subjectAltNames - .filter((san) => san.value.trim()) - .map((san) => san.value.trim()) - .join(", "); - }, []); - - const filterUsages = useCallback(>(usages: T) => { - return Object.entries(usages) - .filter(([, value]) => value) - .map(([key]) => key); - }, []); - const onFormSubmit = useCallback( async ({ profileId: formProfileId, @@ -399,7 +259,11 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId } } let commonName = ""; - if (shouldShowSubjectSection && subjectAttributes && subjectAttributes.length > 0) { + if ( + constraints.shouldShowSubjectSection && + subjectAttributes && + subjectAttributes.length > 0 + ) { commonName = getAttributeValue(subjectAttributes, "common_name"); if (!commonName.trim()) { createNotification({ @@ -420,13 +284,13 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId } extendedKeyUsages: filterUsages(extendedKeyUsages) as CertExtendedKeyUsage[] }; - if (shouldShowSubjectSection && commonName) { + if (constraints.shouldShowSubjectSection && commonName) { certificateRequest.commonName = commonName; } - if (shouldShowSanSection && subjectAltNames && subjectAltNames.length > 0) { + if (constraints.shouldShowSanSection && subjectAltNames && subjectAltNames.length > 0) { const formattedSans = formatSubjectAltNames(subjectAltNames); - if (formattedSans) { - certificateRequest.subjectAltNames = formattedSans; + if (formattedSans && formattedSans.length > 0) { + certificateRequest.altNames = formattedSans; } } @@ -459,11 +323,8 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId } [ currentProject?.slug, createCertificate, - shouldShowSubjectSection, - shouldShowSanSection, - getAttributeValue, - formatSubjectAltNames, - filterUsages + constraints.shouldShowSubjectSection, + constraints.shouldShowSanSection ] ); @@ -479,21 +340,6 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId } return "Issue a new certificate using a certificate profile"; }; - const getSanPlaceholder = (sanType: string) => { - switch (sanType) { - case "dns": - return "example.com or *.example.com"; - case "ip": - return "192.168.1.1"; - case "email": - return "admin@example.com"; - case "uri": - return "https://example.com"; - default: - return "Enter value"; - } - }; - return ( )} - {(selectedProfile || profileId) && ( + {(actualSelectedProfile || profileId) && ( <> - {shouldShowSubjectSection && ( + {constraints.shouldShowSubjectSection && ( ( -
- {(value || []).map((attr, index) => ( - // eslint-disable-next-line react/no-array-index-key -
- - { - const newValue = [...(value || [])]; - newValue[index] = { ...attr, value: e.target.value }; - onChange(newValue); - }} - placeholder="example.com" - className="flex-1" - /> - {(value || []).length > 1 && ( - { - const newValue = (value || []).filter((_, i) => i !== index); - onChange(newValue); - }} - > - - - )} -
- ))} - -
+ { + onChange([{ type: "common_name", value: e.target.value }]); + }} + placeholder="example.com" + />
)} /> )} - {shouldShowSanSection && ( - ( - -
- {value.map((san, index) => ( -
- - { - const newValue = [...value]; - newValue[index] = { ...san, value: e.target.value }; - onChange(newValue); - }} - placeholder={getSanPlaceholder(san.type)} - className="flex-1" - /> - { - const newValue = value.filter((_, i) => i !== index); - onChange(newValue); - }} - > - - -
- ))} - -
-
- )} + allowedSanTypes={constraints.allowedSanTypes} + error={formState.errors.subjectAltNames?.message} /> )} @@ -753,158 +470,31 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId } )} /> -
-
- ( - - - - )} - /> -
- -
- ( - - - - )} - /> -
-
+ - {filteredKeyUsages.length > 0 && ( - - Key Usages - -
- {filteredKeyUsages.map(({ label, value }) => { - const isRequired = requiredKeyUsages.includes(value); - return ( - ( -
- { - if (!isRequired) { - field.onChange(checked); - } - }} - isDisabled={isRequired} - /> -
- - {isRequired && (Required)} -
-
- )} - /> - ); - })} -
-
-
- )} - - {filteredExtendedKeyUsages.length > 0 && ( - - Extended Key Usages - -
- {filteredExtendedKeyUsages.map(({ label, value }) => { - const isRequired = requiredExtendedKeyUsages.includes(value); - return ( - ( -
- { - if (!isRequired) { - field.onChange(checked); - } - }} - isDisabled={isRequired} - /> -
- - {isRequired && (Required)} -
-
- )} - /> - ); - })} -
-
-
- )} + +
)} @@ -915,7 +505,7 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId } size="sm" type="submit" isLoading={isSubmitting} - isDisabled={isSubmitting || (!selectedProfile && !profileId)} + isDisabled={isSubmitting || (!actualSelectedProfile && !profileId)} > {cert ? "Update" : "Issue Certificate"} diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/KeyUsageSection.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/KeyUsageSection.tsx new file mode 100644 index 000000000..747c6c09f --- /dev/null +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/KeyUsageSection.tsx @@ -0,0 +1,78 @@ +import { Control, Controller } from "react-hook-form"; + +import { + AccordionContent, + AccordionItem, + AccordionTrigger, + Checkbox, + FormLabel +} from "@app/components/v2"; + +type KeyUsageOption = { + label: string; + value: string; +}; + +type KeyUsageSectionProps = { + control: Control; + title: string; + accordionValue: string; + namePrefix: "keyUsages" | "extendedKeyUsages"; + options: KeyUsageOption[]; + requiredUsages: string[]; +}; + +export const KeyUsageSection = ({ + control, + title, + accordionValue, + namePrefix, + options, + requiredUsages +}: KeyUsageSectionProps) => { + if (options.length === 0) return null; + + return ( + + {title} + +
+ {options.map(({ label, value }) => { + const isRequired = requiredUsages.includes(value); + return ( + ( +
+ { + if (!isRequired) { + field.onChange(checked); + } + }} + isDisabled={isRequired} + /> +
+ + {isRequired && (Required)} +
+
+ )} + /> + ); + })} +
+
+
+ ); +}; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/SubjectAltNamesField.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/SubjectAltNamesField.tsx new file mode 100644 index 000000000..4600a65f4 --- /dev/null +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/SubjectAltNamesField.tsx @@ -0,0 +1,101 @@ +import { Control, Controller } from "react-hook-form"; +import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { Button, FormControl, IconButton, Input, Select, SelectItem } from "@app/components/v2"; + +import { + FrontendSanType, + getSanPlaceholder, + getSanTypeLabels, + SubjectAltName +} from "./certificateUtils"; + +type SubjectAltNamesFieldProps = { + control: Control; + allowedSanTypes: FrontendSanType[]; + error?: string; +}; + +export const SubjectAltNamesField = ({ + control, + allowedSanTypes, + error +}: SubjectAltNamesFieldProps) => { + const sanTypeLabels = getSanTypeLabels(); + + return ( + ( + +
+ {value.map((san: SubjectAltName, index: number) => ( + // eslint-disable-next-line react/no-array-index-key +
+ + { + const newValue = [...value]; + newValue[index] = { ...san, value: e.target.value }; + onChange(newValue); + }} + placeholder={getSanPlaceholder(san.type)} + className="flex-1" + /> + { + const newValue = value.filter((_: any, i: number) => i !== index); + onChange(newValue); + }} + > + + +
+ ))} + +
+
+ )} + /> + ); +}; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/certificateUtils.ts b/frontend/src/pages/cert-manager/CertificatesPage/components/certificateUtils.ts new file mode 100644 index 000000000..794219a86 --- /dev/null +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/certificateUtils.ts @@ -0,0 +1,88 @@ +import { CertSubjectAlternativeNameType } from "@app/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/certificate-constants"; + +export enum FrontendSanType { + DNS = "dns", + IP = "ip", + EMAIL = "email", + URI = "uri" +} + +export const mapBackendSanTypeToFrontend = (backendType: string): FrontendSanType => { + switch (backendType) { + case CertSubjectAlternativeNameType.DNS_NAME: + return FrontendSanType.DNS; + case CertSubjectAlternativeNameType.IP_ADDRESS: + return FrontendSanType.IP; + case CertSubjectAlternativeNameType.EMAIL: + return FrontendSanType.EMAIL; + case CertSubjectAlternativeNameType.URI: + return FrontendSanType.URI; + default: + return backendType as FrontendSanType; + } +}; + +export const mapFrontendSanTypeToBackend = (frontendType: FrontendSanType): string => { + switch (frontendType) { + case FrontendSanType.DNS: + return CertSubjectAlternativeNameType.DNS_NAME; + case FrontendSanType.IP: + return CertSubjectAlternativeNameType.IP_ADDRESS; + case FrontendSanType.EMAIL: + return CertSubjectAlternativeNameType.EMAIL; + case FrontendSanType.URI: + return CertSubjectAlternativeNameType.URI; + default: + return frontendType; + } +}; + +export const getSanPlaceholder = (sanType: FrontendSanType): string => { + switch (sanType) { + case FrontendSanType.DNS: + return "example.com or *.example.com"; + case FrontendSanType.IP: + return "192.168.1.1"; + case FrontendSanType.EMAIL: + return "admin@example.com"; + case FrontendSanType.URI: + return "https://example.com"; + default: + return "Enter value"; + } +}; + +export const getSanTypeLabels = () => ({ + [FrontendSanType.DNS]: "DNS", + [FrontendSanType.IP]: "IP", + [FrontendSanType.EMAIL]: "Email", + [FrontendSanType.URI]: "URI" +}); + +export type SubjectAltName = { + type: FrontendSanType; + value: string; +}; + +export const formatSubjectAltNames = (subjectAltNames: SubjectAltName[]) => { + return subjectAltNames + .filter((san) => san.value.trim()) + .map((san) => ({ + type: mapFrontendSanTypeToBackend(san.type), + value: san.value.trim() + })); +}; + +export const filterUsages = >(usages: T): string[] => { + return Object.entries(usages) + .filter(([, value]) => value) + .map(([key]) => key); +}; + +export const getAttributeValue = ( + subjectAttributes: Array<{ type: string; value: string }> | undefined, + type: string +): string => { + const foundAttr = subjectAttributes?.find((attr) => attr.type === type); + return foundAttr?.value || ""; +}; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/useCertificateTemplate.ts b/frontend/src/pages/cert-manager/CertificatesPage/components/useCertificateTemplate.ts new file mode 100644 index 000000000..9562220b3 --- /dev/null +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/useCertificateTemplate.ts @@ -0,0 +1,181 @@ +import { useEffect, useMemo, useState } from "react"; +import { UseFormSetValue, UseFormWatch } from "react-hook-form"; + +import { + EXTENDED_KEY_USAGES_OPTIONS, + KEY_USAGES_OPTIONS +} from "@app/hooks/api/certificates/constants"; +import { + mapTemplateKeyAlgorithmToApi, + mapTemplateSignatureAlgorithmToApi +} from "@app/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/certificate-constants"; + +import { FrontendSanType, mapBackendSanTypeToFrontend } from "./certificateUtils"; + +export type TemplateConstraints = { + allowedKeyUsages: string[]; + allowedExtendedKeyUsages: string[]; + requiredKeyUsages: string[]; + requiredExtendedKeyUsages: string[]; + allowedSignatureAlgorithms: string[]; + allowedKeyAlgorithms: string[]; + allowedSanTypes: FrontendSanType[]; + shouldShowSanSection: boolean; + shouldShowSubjectSection: boolean; +}; + +export const useCertificateTemplate = ( + templateData: any, + selectedProfile: any, + isModalOpen: boolean, + setValue: UseFormSetValue, + watch: UseFormWatch +) => { + const [constraints, setConstraints] = useState({ + allowedKeyUsages: [], + allowedExtendedKeyUsages: [], + requiredKeyUsages: [], + requiredExtendedKeyUsages: [], + allowedSignatureAlgorithms: [], + allowedKeyAlgorithms: [], + allowedSanTypes: [ + FrontendSanType.DNS, + FrontendSanType.IP, + FrontendSanType.EMAIL, + FrontendSanType.URI + ], + shouldShowSanSection: true, + shouldShowSubjectSection: true + }); + + const filteredKeyUsages = useMemo(() => { + return KEY_USAGES_OPTIONS.filter(({ value }) => constraints.allowedKeyUsages.includes(value)); + }, [constraints.allowedKeyUsages]); + + const filteredExtendedKeyUsages = useMemo(() => { + return EXTENDED_KEY_USAGES_OPTIONS.filter(({ value }) => + constraints.allowedExtendedKeyUsages.includes(value) + ); + }, [constraints.allowedExtendedKeyUsages]); + + const availableSignatureAlgorithms = useMemo(() => { + return constraints.allowedSignatureAlgorithms.map((templateAlgorithm) => { + const apiAlgorithm = mapTemplateSignatureAlgorithmToApi(templateAlgorithm); + return { + value: apiAlgorithm, + label: apiAlgorithm + }; + }); + }, [constraints.allowedSignatureAlgorithms]); + + const availableKeyAlgorithms = useMemo(() => { + return constraints.allowedKeyAlgorithms.map((templateAlgorithm) => { + const apiAlgorithm = mapTemplateKeyAlgorithmToApi(templateAlgorithm); + return { + value: apiAlgorithm, + label: apiAlgorithm + }; + }); + }, [constraints.allowedKeyAlgorithms]); + + const resetConstraints = () => { + setConstraints({ + allowedKeyUsages: [], + allowedExtendedKeyUsages: [], + requiredKeyUsages: [], + requiredExtendedKeyUsages: [], + allowedSignatureAlgorithms: [], + allowedKeyAlgorithms: [], + allowedSanTypes: [ + FrontendSanType.DNS, + FrontendSanType.IP, + FrontendSanType.EMAIL, + FrontendSanType.URI + ], + shouldShowSanSection: true, + shouldShowSubjectSection: true + }); + }; + + useEffect(() => { + if (templateData && selectedProfile && isModalOpen) { + const newConstraints: TemplateConstraints = { + allowedSignatureAlgorithms: templateData.algorithms?.signature || [], + allowedKeyAlgorithms: templateData.algorithms?.keyAlgorithm || [], + allowedKeyUsages: [ + ...(templateData.keyUsages?.required || []), + ...(templateData.keyUsages?.allowed || []) + ], + allowedExtendedKeyUsages: [ + ...(templateData.extendedKeyUsages?.required || []), + ...(templateData.extendedKeyUsages?.allowed || []) + ], + requiredKeyUsages: templateData.keyUsages?.required || [], + requiredExtendedKeyUsages: templateData.extendedKeyUsages?.required || [], + allowedSanTypes: [], + shouldShowSanSection: true, + shouldShowSubjectSection: true + }; + + // Set TTL if available + if (templateData.validity?.max) { + setValue("ttl", templateData.validity.max); + } + + // Handle SAN types + if (templateData.sans && templateData.sans.length > 0) { + const sanTypes: FrontendSanType[] = []; + templateData.sans.forEach((sanPolicy: any) => { + const frontendType = mapBackendSanTypeToFrontend(sanPolicy.type); + if (!sanTypes.includes(frontendType)) { + sanTypes.push(frontendType); + } + }); + newConstraints.allowedSanTypes = sanTypes; + newConstraints.shouldShowSanSection = true; + } else { + newConstraints.allowedSanTypes = []; + newConstraints.shouldShowSanSection = false; + setValue("subjectAltNames", []); + } + + // Handle subject section + if (templateData.subject && templateData.subject.length > 0) { + newConstraints.shouldShowSubjectSection = true; + const currentSubjectAttrs = watch("subjectAttributes"); + if (!currentSubjectAttrs || currentSubjectAttrs.length === 0) { + setValue("subjectAttributes", [{ type: "common_name", value: "" }]); + } + } else { + newConstraints.shouldShowSubjectSection = false; + setValue("subjectAttributes", undefined); + } + + setConstraints(newConstraints); + + // Set initial required usages + const initialKeyUsages: Record = {}; + const initialExtendedKeyUsages: Record = {}; + + (templateData.keyUsages?.required || []).forEach((usage: string) => { + initialKeyUsages[usage] = true; + }); + + (templateData.extendedKeyUsages?.required || []).forEach((usage: string) => { + initialExtendedKeyUsages[usage] = true; + }); + + setValue("keyUsages", initialKeyUsages); + setValue("extendedKeyUsages", initialExtendedKeyUsages); + } + }, [templateData, selectedProfile, setValue, watch, isModalOpen]); + + return { + constraints, + filteredKeyUsages, + filteredExtendedKeyUsages, + availableSignatureAlgorithms, + availableKeyAlgorithms, + resetConstraints + }; +}; diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx index 55262439a..c4b46037e 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx @@ -47,7 +47,7 @@ const createSchema = z estConfig: z .object({ disableBootstrapCaValidation: z.boolean().optional(), - passphrase: z.string().min(1, "EST passphrase is required"), + passphraseInput: z.string().min(1, "EST passphrase is required"), caChain: z.string().min(1, "EST CA chain is required").optional() }) .refine( @@ -58,7 +58,8 @@ const createSchema = z return true; }, { - message: "EST CA chain is required" + message: "EST CA chain is required when bootstrap CA validation is enabled", + path: ["caChain"] } ) .optional(), @@ -106,7 +107,7 @@ const editSchema = z estConfig: z .object({ disableBootstrapCaValidation: z.boolean().optional(), - passphrase: z.string().optional(), + passphraseInput: z.string().optional(), caChain: z.string().optional() }) .optional(), @@ -168,15 +169,22 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" } enrollmentType: profile.enrollmentType, certificateAuthorityId: profile.caId, certificateTemplateId: profile.certificateTemplateId, - estConfig: { - disableBootstrapCaValidation: profile.estConfig?.disableBootstrapCaValidation || false, - passphrase: "", - caChain: undefined - }, - apiConfig: { - autoRenew: profile.apiConfig?.autoRenew || false, - autoRenewDays: profile.apiConfig?.autoRenewDays || 30 - } + estConfig: + profile.enrollmentType === "est" + ? { + disableBootstrapCaValidation: + profile.estConfig?.disableBootstrapCaValidation || false, + passphraseInput: "", + caChain: profile.estConfig?.encryptedCaChain || "" + } + : undefined, + apiConfig: + profile.enrollmentType === "api" + ? { + autoRenew: profile.apiConfig?.autoRenew || false, + autoRenewDays: profile.apiConfig?.autoRenewDays || 30 + } + : undefined } : { slug: "", @@ -229,8 +237,8 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" } if (data.enrollmentType === "est" && data.estConfig) { createData.estConfig = { - passphrase: data.estConfig.passphrase, - caChain: data.estConfig.caChain || "", + passphraseInput: data.estConfig.passphraseInput, + caChain: data.estConfig.caChain || undefined, disableBootstrapCaValidation: data.estConfig.disableBootstrapCaValidation }; } else if (data.enrollmentType === "api" && data.apiConfig) { @@ -346,8 +354,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" } if (watchedEnrollmentType === "est") { setValue("estConfig", { disableBootstrapCaValidation: false, - passphrase: "", - caChain: "" + passphraseInput: "" }); setValue("apiConfig", undefined); } else { @@ -391,8 +398,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" } setValue("apiConfig", undefined); setValue("estConfig", { disableBootstrapCaValidation: false, - passphrase: "", - caChain: "" + passphraseInput: "" }); } else { setValue("estConfig", undefined); @@ -444,7 +450,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" } ( )} - {canIssueCertificate && ( + {canIssueCertificate && profile.enrollmentType === "api" && ( { e.stopPropagation(); diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/CertificateTemplatesV2Tab.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/CertificateTemplatesV2Tab.tsx index b4806a503..ae14f2dea 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/CertificateTemplatesV2Tab.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/CertificateTemplatesV2Tab.tsx @@ -9,8 +9,8 @@ import { ProjectPermissionPkiTemplateActions, ProjectPermissionSub } from "@app/context/ProjectPermissionContext/types"; -import { useDeleteCertificateTemplateV2New } from "@app/hooks/api/certificateTemplates/mutations"; -import { TCertificateTemplateV2New } from "@app/hooks/api/certificateTemplates/types"; +import { useDeleteCertificateTemplateV2WithPolicies } from "@app/hooks/api/certificateTemplates/mutations"; +import { TCertificateTemplateV2WithPolicies } from "@app/hooks/api/certificateTemplates/types"; import { CreateTemplateModal } from "./CreateTemplateModal"; import { TemplateList } from "./TemplateList"; @@ -21,9 +21,10 @@ export const CertificateTemplatesV2Tab = () => { const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - const [selectedTemplate, setSelectedTemplate] = useState(null); + const [selectedTemplate, setSelectedTemplate] = + useState(null); - const deleteTemplateV2 = useDeleteCertificateTemplateV2New(); + const deleteTemplateV2 = useDeleteCertificateTemplateV2WithPolicies(); const canCreateTemplate = permission.can( ProjectPermissionPkiTemplateActions.Create, @@ -34,12 +35,12 @@ export const CertificateTemplatesV2Tab = () => { setIsCreateModalOpen(true); }; - const handleEditTemplate = (template: TCertificateTemplateV2New) => { + const handleEditTemplate = (template: TCertificateTemplateV2WithPolicies) => { setSelectedTemplate(template); setIsEditModalOpen(true); }; - const handleDeleteTemplate = (template: TCertificateTemplateV2New) => { + const handleDeleteTemplate = (template: TCertificateTemplateV2WithPolicies) => { setSelectedTemplate(template); setIsDeleteModalOpen(true); }; diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/CreateTemplateModal.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/CreateTemplateModal.tsx index 909a2e8ac..cb5fabc99 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/CreateTemplateModal.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/CreateTemplateModal.tsx @@ -24,12 +24,12 @@ import { } from "@app/components/v2"; import { useProject } from "@app/context"; import { - useCreateCertificateTemplateV2New, - useUpdateCertificateTemplateV2New + useCreateCertificateTemplateV2WithPolicies, + useUpdateCertificateTemplateV2WithPolicies } from "@app/hooks/api/certificateTemplates/mutations"; import { - TCertificateTemplateV2New, - TCertificateTemplateV2Policy + TCertificateTemplateV2Policy, + TCertificateTemplateV2WithPolicies } from "@app/hooks/api/certificateTemplates/types"; import { @@ -57,7 +57,7 @@ type ValidityTransform = TCertificateTemplateV2Policy["validity"]; interface Props { isOpen: boolean; onClose: () => void; - template?: TCertificateTemplateV2New; + template?: TCertificateTemplateV2WithPolicies; mode?: "create" | "edit"; } @@ -106,12 +106,12 @@ const KEY_ALGORITHMS = [ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" }: Props) => { const { currentProject } = useProject(); - const createTemplate = useCreateCertificateTemplateV2New(); - const updateTemplate = useUpdateCertificateTemplateV2New(); + const createTemplate = useCreateCertificateTemplateV2WithPolicies(); + const updateTemplate = useUpdateCertificateTemplateV2WithPolicies(); const isEdit = mode === "edit" && template; - const convertApiToUiFormat = (templateData: TCertificateTemplateV2New): FormData => { + const convertApiToUiFormat = (templateData: TCertificateTemplateV2WithPolicies): FormData => { const attributes: FormData["attributes"] = []; if (templateData.subject && Array.isArray(templateData.subject)) { templateData.subject.forEach((subj) => { @@ -244,12 +244,10 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" maxDuration: { value: 365, unit: CertDurationUnit.DAYS } }, signatureAlgorithm: { - allowedAlgorithms: [], - defaultAlgorithm: "" + allowedAlgorithms: [] }, keyAlgorithm: { - allowedKeyTypes: [], - defaultKeyType: "" + allowedKeyTypes: [] } }); @@ -275,8 +273,38 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" optionalUsages: [] }; - const transformToNewApiFormat = (data: FormData) => { - const subject = + const consolidateByType = < + T extends { type: string; allowed?: string[]; required?: string[]; denied?: string[] } + >( + items: T[] + ): T[] => { + const consolidated = new Map(); + + items.forEach((item) => { + const existing = consolidated.get(item.type); + if (existing) { + const mergedItem = { + ...item, + allowed: [...new Set([...(existing.allowed || []), ...(item.allowed || [])])], + required: [...new Set([...(existing.required || []), ...(item.required || [])])], + denied: [...new Set([...(existing.denied || []), ...(item.denied || [])])] + } as T; + + if (mergedItem.allowed?.length === 0) delete mergedItem.allowed; + if (mergedItem.required?.length === 0) delete mergedItem.required; + if (mergedItem.denied?.length === 0) delete mergedItem.denied; + + consolidated.set(item.type, mergedItem); + } else { + consolidated.set(item.type, item); + } + }); + + return Array.from(consolidated.values()); + }; + + const transformToApiFormat = (data: FormData) => { + const subjectRaw = data.attributes?.map((attr) => { const result: AttributeTransform = { type: attr.type }; @@ -289,7 +317,7 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" return result; }) || []; - const sans = + const sansRaw = data.subjectAlternativeNames?.map((san) => { const result: SanTransform = { type: san.type }; @@ -304,7 +332,13 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" return result; }) || []; - const keyUsages: KeyUsagesTransform = {}; + const subject = consolidateByType(subjectRaw); + const sans = consolidateByType(sansRaw); + + const keyUsages: KeyUsagesTransform = { + required: [], + allowed: [] + }; if (data.keyUsages?.requiredUsages && data.keyUsages.requiredUsages.length > 0) { keyUsages.required = data.keyUsages.requiredUsages; } @@ -312,7 +346,10 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" keyUsages.allowed = data.keyUsages.optionalUsages; } - const extendedKeyUsages: ExtendedKeyUsagesTransform = {}; + const extendedKeyUsages: ExtendedKeyUsagesTransform = { + required: [], + allowed: [] + }; if ( data.extendedKeyUsages?.requiredUsages && data.extendedKeyUsages.requiredUsages.length > 0 @@ -364,10 +401,10 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" description: data.description, subject, sans, - keyUsages: Object.keys(keyUsages).length > 0 ? keyUsages : undefined, - extendedKeyUsages: Object.keys(extendedKeyUsages).length > 0 ? extendedKeyUsages : undefined, - algorithms: Object.keys(algorithms).length > 0 ? algorithms : undefined, - validity: Object.keys(validity).length > 0 ? validity : undefined + keyUsages, + extendedKeyUsages, + algorithms, + validity }; }; @@ -391,7 +428,7 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" return; } - const transformedData = transformToNewApiFormat(data); + const transformedData = transformToApiFormat(data); if (isEdit) { const updateData = { diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/TemplateList.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/TemplateList.tsx index 8cb011706..108fe966c 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/TemplateList.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/TemplateList.tsx @@ -23,11 +23,11 @@ import { ProjectPermissionSub } from "@app/context/ProjectPermissionContext/types"; import { useListCertificateTemplatesV2 } from "@app/hooks/api/certificateTemplates/queries"; -import { TCertificateTemplateV2New } from "@app/hooks/api/certificateTemplates/types"; +import { TCertificateTemplateV2WithPolicies } from "@app/hooks/api/certificateTemplates/types"; interface Props { - onEditTemplate: (template: TCertificateTemplateV2New) => void; - onDeleteTemplate: (template: TCertificateTemplateV2New) => void; + onEditTemplate: (template: TCertificateTemplateV2WithPolicies) => void; + onDeleteTemplate: (template: TCertificateTemplateV2WithPolicies) => void; } export const TemplateList = ({ onEditTemplate, onDeleteTemplate }: Props) => { diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/schemas.ts b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/schemas.ts index 861f4e5d9..0590c4160 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/schemas.ts +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/schemas.ts @@ -40,13 +40,15 @@ export const uiValiditySchema = z.object({ }); export const uiSignatureAlgorithmSchema = z.object({ - allowedAlgorithms: z.array(z.string()).optional(), - defaultAlgorithm: z.string().optional() + allowedAlgorithms: z + .array(z.string().min(1, "Algorithm cannot be empty")) + .min(1, "At least one algorithm must be selected") }); export const uiKeyAlgorithmSchema = z.object({ - allowedKeyTypes: z.array(z.string()).optional(), - defaultKeyType: z.string().optional() + allowedKeyTypes: z + .array(z.string().min(1, "Key type cannot be empty")) + .min(1, "At least one key type must be selected") }); export const templateSchema = z.object({ @@ -69,8 +71,8 @@ export const templateSchema = z.object({ keyUsages: uiKeyUsagesSchema.optional(), extendedKeyUsages: uiExtendedKeyUsagesSchema.optional(), validity: uiValiditySchema.optional(), - signatureAlgorithm: uiSignatureAlgorithmSchema.optional(), - keyAlgorithm: uiKeyAlgorithmSchema.optional() + signatureAlgorithm: uiSignatureAlgorithmSchema, + keyAlgorithm: uiKeyAlgorithmSchema }); export type TemplateFormData = z.infer;