diff --git a/backend/src/server/routes/v3/certificates-router.ts b/backend/src/server/routes/v3/certificates-router.ts index 7aa19f900..568d418da 100644 --- a/backend/src/server/routes/v3/certificates-router.ts +++ b/backend/src/server/routes/v3/certificates-router.ts @@ -12,6 +12,7 @@ import { validateAndMapAltNameType, validateCaDateField } from "@app/services/certificate-authority/certificate-authority-validators"; +import { mapEnumsForValidation } from "@app/services/certificate-common/certificate-utils"; import { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators"; export const registerCertificatesRouter = async (server: FastifyZodProvider) => { @@ -49,44 +50,48 @@ 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, + subjectAlternativeNames: req.body.altNames + ? req.body.altNames + .split(", ") + .map((name) => name.trim()) + .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 "dns_name" | "ip_address" | "email" | "uri", + 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 + }; + + const mappedCertificateRequest = mapEnumsForValidation(rawCertificateRequest); + const data = await server.services.certificateV3.issueCertificateFromProfile({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, profileId: req.body.profileId, - certificateRequest: { - commonName: req.body.commonName, - keyUsages: req.body.keyUsages, - extendedKeyUsages: req.body.extendedKeyUsages, - subjectAlternativeNames: req.body.altNames - ? req.body.altNames - .split(", ") - .map((name) => name.trim()) - .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 "dns_name" | "ip_address" | "email" | "uri", - 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 - } + certificateRequest: mappedCertificateRequest }); const profile = await server.services.certificateProfile.getProfileById({ diff --git a/backend/src/services/certificate-common/certificate-utils.ts b/backend/src/services/certificate-common/certificate-utils.ts index 0287450d0..492fc0543 100644 --- a/backend/src/services/certificate-common/certificate-utils.ts +++ b/backend/src/services/certificate-common/certificate-utils.ts @@ -1,7 +1,6 @@ interface CertificateRequestInput { keyUsages?: string[]; extendedKeyUsages?: string[]; - [key: string]: unknown; } export const mapEnumsForValidation = (request: T): T => { @@ -12,7 +11,7 @@ export const mapEnumsForValidation = (request dataEncipherment: "data_encipherment", keyAgreement: "key_agreement", keyCertSign: "key_cert_sign", - crlSign: "crl_sign", + cRLSign: "crl_sign", encipherOnly: "encipher_only", decipherOnly: "decipher_only" }; diff --git a/backend/src/services/certificate-template-v2/certificate-template-v2-service.test.ts b/backend/src/services/certificate-template-v2/certificate-template-v2-service.test.ts index cb3d7614f..c578c0ae0 100644 --- a/backend/src/services/certificate-template-v2/certificate-template-v2-service.test.ts +++ b/backend/src/services/certificate-template-v2/certificate-template-v2-service.test.ts @@ -608,6 +608,31 @@ describe("CertificateTemplateV2Service", () => { expect(result.isValid).toBe(true); }); + it("should handle camelCase key usage mapping correctly", async () => { + const templateWithOptionalUsages = { + ...sampleTemplate, + keyUsages: { + requiredUsages: { all: ["digital_signature", "non_repudiation", "key_agreement"] }, + optionalUsages: { all: ["crl_sign", "decipher_only"] } + }, + extendedKeyUsages: { + requiredUsages: { all: ["client_auth", "code_signing"] }, + optionalUsages: { all: ["server_auth", "ocsp_signing"] } + } + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithOptionalUsages); + + const requestWithCamelCaseUsages = { + ...validRequest, + keyUsages: ["digital_signature", "non_repudiation", "key_agreement", "crl_sign", "decipher_only"], + extendedKeyUsages: ["client_auth", "code_signing"] + }; + + const result = await service.validateCertificateRequest("template-123", requestWithCamelCaseUsages); + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + it("should validate wildcard patterns in optional attributes", async () => { const wildcardTemplate = { ...sampleTemplate, 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 7aaf9028e..d098e4a42 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 @@ -120,6 +120,7 @@ export const certificateTemplateV2ServiceFactory = ({ request: TCertificateRequest ): TTemplateValidationResult => { const errors: string[] = []; + const warnings: string[] = []; template.attributes?.forEach((attrPolicy) => { diff --git a/backend/src/services/certificate-v3/certificate-v3-service.test.ts b/backend/src/services/certificate-v3/certificate-v3-service.test.ts index dfd4acce3..ce07621a7 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.test.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.test.ts @@ -141,6 +141,86 @@ describe("CertificateV3Service", () => { expect(result).toHaveProperty("certificateId", "cert-123"); }); + it("should correctly map camelCase key usages to snake_case before validation", async () => { + const profileId = "profile-123"; + const mockProfile = { + id: profileId, + projectId: "project-123", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123" + }; + + const mockCA = { + id: "ca-123", + externalCa: null + }; + + const mockTemplate = { + id: "template-123", + signatureAlgorithm: { defaultAlgorithm: "RSA-SHA256" }, + keyAlgorithm: { defaultKeyType: "RSA_2048" }, + attributes: [] + }; + + const mockCertificateResult = { + certificate: Buffer.from("cert"), + certificateChain: Buffer.from("chain"), + privateKey: Buffer.from("key"), + serialNumber: "123456" + }; + + const mockCertRecord = { + id: "cert-123", + serialNumber: "123456" + }; + + const camelCaseRequest = { + commonName: "test.example.com", + keyUsages: [ + CertKeyUsage.DIGITAL_SIGNATURE, + CertKeyUsage.NON_REPUDIATION, + CertKeyUsage.KEY_AGREEMENT, + CertKeyUsage.CRL_SIGN, + CertKeyUsage.DECIPHER_ONLY + ], + extendedKeyUsages: [ + CertExtendedKeyUsage.CLIENT_AUTH, + CertExtendedKeyUsage.CODE_SIGNING, + CertExtendedKeyUsage.OCSP_SIGNING, + CertExtendedKeyUsage.SERVER_AUTH + ], + validity: { ttl: "10d" } + }; + + mockCertificateProfileDAL.findByIdWithConfigs.mockResolvedValue(mockProfile); + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + mockCertificateAuthorityDAL.findByIdWithAssociatedCa.mockResolvedValue(mockCA); + mockCertificateTemplateV2Service.getTemplateV2ById.mockResolvedValue(mockTemplate); + mockInternalCaService.issueCertFromCa.mockResolvedValue(mockCertificateResult); + mockCertificateDAL.findOne.mockResolvedValue(mockCertRecord); + mockCertificateDAL.updateById.mockResolvedValue({}); + + await service.issueCertificateFromProfile({ + profileId, + certificateRequest: camelCaseRequest, + ...mockActor + }); + + // Verify that the template validation service was called with mapped snake_case values + expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalledWith( + "template-123", + expect.objectContaining({ + keyUsages: ["digital_signature", "non_repudiation", "key_agreement", "crl_sign", "decipher_only"], + extendedKeyUsages: ["client_auth", "code_signing", "ocsp_signing", "server_auth"] + }) + ); + }); + it("should throw ForbiddenRequestError when profile is not configured for API enrollment", async () => { const profileId = "profile-123"; const mockProfile = {