PKI revamp EST improvements and fixes

This commit is contained in:
Carlos Monastyrski
2025-10-18 03:16:36 -03:00
parent ebac93e500
commit 0df3181308
29 changed files with 1994 additions and 839 deletions
@@ -34,7 +34,7 @@ export async function up(knex: Knex): Promise<void> {
t.boolean("disableBootstrapCaValidation").defaultTo(false);
t.text("hashedPassphrase").notNullable();
t.binary("encryptedCaChain").notNullable();
t.binary("encryptedCaChain");
t.timestamps(true, true, true);
});
@@ -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()
});
@@ -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" });
+1
View File
@@ -2126,6 +2126,7 @@ export const registerRoutes = async (
const certificateEstV3Service = certificateEstV3ServiceFactory({
internalCertificateAuthorityService,
certificateTemplateDAL,
certificateTemplateV2Service,
certificateAuthorityDAL,
certificateAuthorityCertDAL,
projectDAL,
@@ -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
@@ -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<typeof item> => 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({
@@ -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<string, string> = {
digitalSignature: "digital_signature",
keyEncipherment: "key_encipherment",
keyCertSign: "key_cert_sign"
};
return mapping[usage] || usage;
}),
mapLegacyExtendedKeyUsageToStandard: vi.fn((usage: string) => {
const mapping: Record<string, string> = {
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<string, string> = {
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()
})
);
});
});
});
@@ -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<TInternalCertificateAuthorityServiceFactory, "signCertFromCa">;
certificateTemplateDAL: Pick<TCertificateTemplateDALFactory, "findById">;
certificateTemplateV2Service: Pick<TCertificateTemplateV2ServiceFactory, "validateCertificateRequest">;
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findById" | "findByIdWithAssociatedCa">;
certificateAuthorityCertDAL: Pick<TCertificateAuthorityCertDALFactory, "find" | "findById">;
projectDAL: Pick<TProjectDALFactory, "findOne" | "updateById" | "transaction">;
@@ -35,6 +54,7 @@ export type TCertificateEstV3ServiceFactory = ReturnType<typeof certificateEstV3
export const certificateEstV3ServiceFactory = ({
internalCertificateAuthorityService,
certificateTemplateDAL,
certificateTemplateV2Service,
certificateAuthorityCertDAL,
certificateAuthorityDAL,
projectDAL,
@@ -43,6 +63,59 @@ export const certificateEstV3ServiceFactory = ({
certificateProfileDAL,
estEnrollmentConfigDAL
}: TCertificateEstV3ServiceFactoryDep) => {
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
});
@@ -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({
@@ -27,12 +27,34 @@ type TCertificateTemplateV2ServiceFactoryDep = {
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
};
export type TCertificateTemplateV2ServiceFactory = ReturnType<typeof certificateTemplateV2ServiceFactory>;
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<string, T>();
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<typeof certificateTemplateV2ServiceFactory>;
@@ -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;
@@ -20,7 +20,7 @@ export type TApiEnrollmentConfigUpdate = TPkiApiEnrollmentConfigsUpdate;
export interface TEstConfigData {
disableBootstrapCaValidation: boolean;
passphraseInput: string;
encryptedCaChain: string;
encryptedCaChain?: string;
}
export interface TApiConfigData {