Fix cert profile edit missing enrollment config fields

This commit is contained in:
Carlos Monastyrski
2025-10-19 00:16:47 -03:00
parent 0df3181308
commit 2b169e9c95
12 changed files with 406 additions and 119 deletions
+3 -1
View File
@@ -1139,7 +1139,9 @@ export const registerRoutes = async (
certificateTemplateV2DAL, certificateTemplateV2DAL,
apiEnrollmentConfigDAL, apiEnrollmentConfigDAL,
estEnrollmentConfigDAL, estEnrollmentConfigDAL,
permissionService permissionService,
kmsService,
projectDAL
}); });
const pkiAlertService = pkiAlertServiceFactory({ const pkiAlertService = pkiAlertServiceFactory({
@@ -35,8 +35,8 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
estConfig: z estConfig: z
.object({ .object({
disableBootstrapCaValidation: z.boolean().default(false), disableBootstrapCaValidation: z.boolean().default(false),
passphraseInput: z.string().min(1), passphrase: z.string().min(1),
encryptedCaChain: z.string().optional() caChain: z.string().optional()
}) })
.optional(), .optional(),
apiConfig: z apiConfig: z
@@ -137,6 +137,21 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
expiringCertificates: z.number(), expiringCertificates: z.number(),
revokedCertificates: z.number() revokedCertificates: z.number()
}) })
.optional(),
estConfig: z
.object({
id: z.string(),
disableBootstrapCaValidation: z.boolean(),
passphrase: z.string(),
caChain: z.string().optional()
})
.optional(),
apiConfig: z
.object({
id: z.string(),
autoRenew: z.boolean(),
autoRenewDays: z.number().optional()
})
.optional() .optional()
}).array(), }).array(),
totalCount: z.number() totalCount: z.number()
@@ -207,8 +222,8 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
.object({ .object({
id: z.string(), id: z.string(),
disableBootstrapCaValidation: z.boolean(), disableBootstrapCaValidation: z.boolean(),
hashedPassphrase: z.string(), passphrase: z.string(),
encryptedCaChain: z.string() caChain: z.string().optional()
}) })
.optional(), .optional(),
apiConfig: z apiConfig: z
@@ -333,8 +348,8 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
estConfig: z estConfig: z
.object({ .object({
disableBootstrapCaValidation: z.boolean().default(false), disableBootstrapCaValidation: z.boolean().default(false),
passphraseInput: z.string().min(1), passphrase: z.string().min(1).optional(),
encryptedCaChain: z.string() caChain: z.string().optional()
}) })
.optional(), .optional(),
apiConfig: z apiConfig: z
@@ -119,12 +119,12 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
if (!result) return undefined; if (!result) return undefined;
const estConfig = const estConfig =
result.estConfigEncryptedCaChain && result.estConfigId && result.estConfigHashedPassphrase result.estConfigId && result.estConfigHashedPassphrase
? ({ ? ({
id: result.estConfigId, id: result.estConfigId,
disableBootstrapCaValidation: !!result.estConfigDisableBootstrapCaValidation, disableBootstrapCaValidation: !!result.estConfigDisableBootstrapCaValidation,
hashedPassphrase: result.estConfigHashedPassphrase, passphrase: "",
encryptedCaChain: result.estConfigEncryptedCaChain.toString("base64") caChain: result.estConfigEncryptedCaChain ? result.estConfigEncryptedCaChain.toString("utf8") : ""
} as TCertificateProfileWithConfigs["estConfig"]) } as TCertificateProfileWithConfigs["estConfig"])
: undefined; : undefined;
@@ -207,7 +207,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
expiringDays?: number; expiringDays?: number;
} = {}, } = {},
tx?: Knex tx?: Knex
): Promise<TCertificateProfile[] | TCertificateProfileWithRawMetrics[]> => { ): Promise<TCertificateProfile[] | TCertificateProfileWithRawMetrics[] | TCertificateProfileWithConfigs[]> => {
try { try {
const { const {
offset = 0, offset = 0,
@@ -219,13 +219,13 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
expiringDays = 7 expiringDays = 7
} = options; } = options;
let query = (tx || db)(TableName.PkiCertificateProfile).where( let baseQuery = (tx || db)(TableName.PkiCertificateProfile).where(
`${TableName.PkiCertificateProfile}.projectId`, `${TableName.PkiCertificateProfile}.projectId`,
projectId projectId
); );
if (search) { if (search) {
query = query.where((builder) => { baseQuery = baseQuery.where((builder) => {
void builder.where((qb) => { void builder.where((qb) => {
void qb void qb
.whereILike(`${TableName.PkiCertificateProfile}.slug`, `%${search}%`) .whereILike(`${TableName.PkiCertificateProfile}.slug`, `%${search}%`)
@@ -235,26 +235,62 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
} }
if (enrollmentType) { if (enrollmentType) {
query = query.where(`${TableName.PkiCertificateProfile}.enrollmentType`, enrollmentType); baseQuery = baseQuery.where(`${TableName.PkiCertificateProfile}.enrollmentType`, enrollmentType);
} }
if (caId) { if (caId) {
query = query.where(`${TableName.PkiCertificateProfile}.caId`, caId); baseQuery = baseQuery.where(`${TableName.PkiCertificateProfile}.caId`, caId);
} }
let query = baseQuery
.leftJoin(
TableName.PkiEstEnrollmentConfig,
`${TableName.PkiCertificateProfile}.estConfigId`,
`${TableName.PkiEstEnrollmentConfig}.id`
)
.leftJoin(
TableName.PkiApiEnrollmentConfig,
`${TableName.PkiCertificateProfile}.apiConfigId`,
`${TableName.PkiApiEnrollmentConfig}.id`
)
.select(selectAllTableCols(TableName.PkiCertificateProfile))
.select(
db.ref("id").withSchema(TableName.PkiEstEnrollmentConfig).as("estId"),
db
.ref("disableBootstrapCaValidation")
.withSchema(TableName.PkiEstEnrollmentConfig)
.as("estDisableBootstrapCaValidation"),
db.ref("hashedPassphrase").withSchema(TableName.PkiEstEnrollmentConfig).as("estHashedPassphrase"),
db.ref("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estEncryptedCaChain"),
db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiId"),
db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenew"),
db.ref("autoRenewDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenewDays")
);
if (includeMetrics) { if (includeMetrics) {
query = query.leftJoin(
TableName.Certificate,
`${TableName.PkiCertificateProfile}.id`,
`${TableName.Certificate}.profileId`
);
const now = new Date(); const now = new Date();
const expiringDate = new Date(); const expiringDate = new Date();
expiringDate.setDate(now.getDate() + expiringDays); expiringDate.setDate(now.getDate() + expiringDays);
const certificateProfiles = await query query = query
.leftJoin(
TableName.Certificate,
`${TableName.PkiCertificateProfile}.id`,
`${TableName.Certificate}.profileId`
)
.select( .select(
selectAllTableCols(TableName.PkiCertificateProfile), selectAllTableCols(TableName.PkiCertificateProfile),
db.ref("id").withSchema(TableName.PkiEstEnrollmentConfig).as("estId"),
db
.ref("disableBootstrapCaValidation")
.withSchema(TableName.PkiEstEnrollmentConfig)
.as("estDisableBootstrapCaValidation"),
db.ref("hashedPassphrase").withSchema(TableName.PkiEstEnrollmentConfig).as("estHashedPassphrase"),
db.ref("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estEncryptedCaChain"),
db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiId"),
db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenew"),
db.ref("autoRenewDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenewDays"),
db.raw("COUNT(certificates.id) as total_certificates"), db.raw("COUNT(certificates.id) as total_certificates"),
db.raw( db.raw(
'COUNT(CASE WHEN certificates."revokedAt" IS NULL AND certificates."notAfter" > ? THEN 1 END) as active_certificates', 'COUNT(CASE WHEN certificates."revokedAt" IS NULL AND certificates."notAfter" > ? THEN 1 END) as active_certificates',
@@ -270,21 +306,66 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
), ),
db.raw('COUNT(CASE WHEN certificates."revokedAt" IS NOT NULL THEN 1 END) as revoked_certificates') db.raw('COUNT(CASE WHEN certificates."revokedAt" IS NOT NULL THEN 1 END) as revoked_certificates')
) )
.groupBy(`${TableName.PkiCertificateProfile}.id`) .groupBy(
.orderBy(`${TableName.PkiCertificateProfile}.createdAt`, "desc") `${TableName.PkiCertificateProfile}.id`,
.offset(offset) `${TableName.PkiEstEnrollmentConfig}.id`,
.limit(limit); `${TableName.PkiApiEnrollmentConfig}.id`
);
return certificateProfiles as TCertificateProfileWithRawMetrics[];
} }
const certificateProfiles = await query const results = (await query
.select(selectAllTableCols(TableName.PkiCertificateProfile))
.orderBy(`${TableName.PkiCertificateProfile}.createdAt`, "desc") .orderBy(`${TableName.PkiCertificateProfile}.createdAt`, "desc")
.offset(offset) .offset(offset)
.limit(limit); .limit(limit)) as Record<string, unknown>[];
return certificateProfiles as TCertificateProfile[]; return results.map((result: Record<string, unknown>) => {
const estConfig =
result.estId && result.estHashedPassphrase
? {
id: result.estId as string,
disableBootstrapCaValidation: !!result.estDisableBootstrapCaValidation,
passphrase: "",
caChain: result.estEncryptedCaChain ? (result.estEncryptedCaChain as Buffer).toString("utf8") : ""
}
: undefined;
const apiConfig = result.apiId
? {
id: result.apiId as string,
autoRenew: !!result.apiAutoRenew,
autoRenewDays: (result.apiAutoRenewDays as number) || undefined
}
: undefined;
const baseProfile = {
id: result.id,
projectId: result.projectId,
caId: result.caId,
certificateTemplateId: result.certificateTemplateId,
slug: result.slug,
description: result.description,
enrollmentType: result.enrollmentType as EnrollmentType,
estConfigId: result.estConfigId,
apiConfigId: result.apiConfigId,
createdAt: result.createdAt,
updatedAt: result.updatedAt,
estConfig,
apiConfig
};
if (includeMetrics) {
return {
...baseProfile,
total_certificates: result.total_certificates,
active_certificates: result.active_certificates,
expired_certificates: result.expired_certificates,
expiring_certificates: result.expiring_certificates,
revoked_certificates: result.revoked_certificates
} as TCertificateProfileWithRawMetrics & TCertificateProfileWithConfigs;
}
return baseProfile as TCertificateProfileWithConfigs;
});
} catch (error) { } catch (error) {
throw new DatabaseError({ error, name: "Find certificate profiles by project id" }); throw new DatabaseError({ error, name: "Find certificate profiles by project id" });
} }
@@ -12,6 +12,8 @@ import { ActorType, AuthMethod } from "../auth/auth-type";
import type { TCertificateTemplateV2DALFactory } from "../certificate-template-v2/certificate-template-v2-dal"; import type { TCertificateTemplateV2DALFactory } from "../certificate-template-v2/certificate-template-v2-dal";
import type { TApiEnrollmentConfigDALFactory } from "../enrollment-config/api-enrollment-config-dal"; import type { TApiEnrollmentConfigDALFactory } from "../enrollment-config/api-enrollment-config-dal";
import type { TEstEnrollmentConfigDALFactory } from "../enrollment-config/est-enrollment-config-dal"; import type { TEstEnrollmentConfigDALFactory } from "../enrollment-config/est-enrollment-config-dal";
import type { TKmsServiceFactory } from "../kms/kms-service";
import type { TProjectDALFactory } from "../project/project-dal";
import type { TCertificateProfileDALFactory } from "./certificate-profile-dal"; import type { TCertificateProfileDALFactory } from "./certificate-profile-dal";
import { certificateProfileServiceFactory, TCertificateProfileServiceFactory } from "./certificate-profile-service"; import { certificateProfileServiceFactory, TCertificateProfileServiceFactory } from "./certificate-profile-service";
import { EnrollmentType, TCertificateProfile, TCertificateProfileWithConfigs } from "./certificate-profile-types"; import { EnrollmentType, TCertificateProfile, TCertificateProfileWithConfigs } from "./certificate-profile-types";
@@ -149,6 +151,22 @@ describe("CertificateProfileService", () => {
}) })
} as unknown as Pick<TPermissionServiceFactory, "getProjectPermission">; } as unknown as Pick<TPermissionServiceFactory, "getProjectPermission">;
const mockKmsService = {
encryptWithKmsKey: vi
.fn()
.mockResolvedValue(() => Promise.resolve({ cipherTextBlob: Buffer.from("encrypted-data") })),
decryptWithKmsKey: vi.fn().mockResolvedValue(() => Promise.resolve(Buffer.from("decrypted-ca-chain"))),
generateKmsKey: vi.fn()
} as unknown as Pick<TKmsServiceFactory, "generateKmsKey" | "encryptWithKmsKey" | "decryptWithKmsKey">;
const mockProjectDAL = {
findById: vi.fn(),
findOne: vi.fn(),
updateById: vi.fn(),
findProjectBySlug: vi.fn(),
transaction: vi.fn()
} as unknown as Pick<TProjectDALFactory, "findProjectBySlug" | "findOne" | "updateById" | "findById" | "transaction">;
beforeEach(() => { beforeEach(() => {
vi.spyOn(ForbiddenError, "from").mockReturnValue({ vi.spyOn(ForbiddenError, "from").mockReturnValue({
throwUnlessCan: vi.fn() throwUnlessCan: vi.fn()
@@ -165,7 +183,9 @@ describe("CertificateProfileService", () => {
certificateTemplateV2DAL: mockCertificateTemplateV2DAL, certificateTemplateV2DAL: mockCertificateTemplateV2DAL,
apiEnrollmentConfigDAL: mockApiEnrollmentConfigDAL, apiEnrollmentConfigDAL: mockApiEnrollmentConfigDAL,
estEnrollmentConfigDAL: mockEstEnrollmentConfigDAL, estEnrollmentConfigDAL: mockEstEnrollmentConfigDAL,
permissionService: mockPermissionService permissionService: mockPermissionService,
kmsService: mockKmsService,
projectDAL: mockProjectDAL
}); });
}); });
@@ -698,8 +718,9 @@ describe("CertificateProfileService", () => {
certificateTemplateId: "template-123", certificateTemplateId: "template-123",
estConfig: { estConfig: {
disableBootstrapCaValidation: false, disableBootstrapCaValidation: false,
passphraseInput: "secret-passphrase", passphrase: "secret-passphrase",
encryptedCaChain: Buffer.from("test-ca-chain-data").toString("base64") caChain:
"-----BEGIN CERTIFICATE-----\nMIIC+DCCAeCgAwIBAgIUBmCvLQ7l6CmNYjGeGXqIaS9LPuUwDQYJKoZIhvcNAQEL\nBQAwFDESMBAGA1UEChMJSW5maXNpY2FsMB4XDTI1MTAxNzE1MjczMFoXDTM1MTAx\nNzAwMDAwMFowFDESMBAGA1UEChMJSW5maXNpY2FsMIIBIjANBgkqhkiG9w0BAQEF\nAAOCAQ8AMIIBCgKCAQEAqRS0ZKh44Y1GHvD4/ryduaelVtfvqkdCmhxpCp7OTjIA\n/gPuVoBA31gxqMVcpDgIAk8dfqds0WFzFe2byhbBalNm3+FSYJkEKa1mdCnqM/mL\nt6O0V/dPv2dcepDluwWbHJIuFf5elH1F8eeyqZV5w6c980lOyDO0DVNqB6pjGlPq\njEVcvEdEtGSfIX3B2tmODilwUvl/lGjhnK6ghfots7i1Xno9VAY/YTqR0T+lyPx4\n23r+22gstJ7XCLA7aqfRyFyYaVKqubHPBwz2qKiBTc3Shc3ii/OHc5KjTpADNRDv\nvH7X5kOXYtdpGbMsJ1uY+MPwfbOVkxy4tg4HFejmyQIDAQABo0IwQDAPBgNVHRMB\nAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUpshrlfvvw+zkoLKf\nxNUYD92/YxIwDQYJKoZIhvcNAQELBQADggEBAAWDMNe8HnoOPHF1sIUcCvJjBeUz\neB++l5Er9P+UPpkSr7+KpD+9DQGWmaOT57Vp7nBYXd42828h+cq7KEG2w5Uf6fYD\nBuitrzj2IzNznvKwOMh/qAePC17tH4mnkSnsJCMg6cvG99GG+vQoMQW7+D6VshIH\nm5hNThNGSPznk+eNk+NlIIVzD4autRn+U5geYzDaZIWfmx95gwCPK2VVw1IDExA+\naQiZi4g1JviUB97E92rZzX+Ai4GYk+CKQTxAiZPZ2M9gRFLrjKIGbRu7FaL+9lwU\nWnax4HJZ/cdVUtVp8VgaAOy7qvl5WGZ4eLopLhMkW3RyPiFr4+M3vNJocqU=\n-----END CERTIFICATE-----"
} }
}; };
@@ -723,7 +744,7 @@ describe("CertificateProfileService", () => {
{ {
disableBootstrapCaValidation: estProfileData.estConfig.disableBootstrapCaValidation, disableBootstrapCaValidation: estProfileData.estConfig.disableBootstrapCaValidation,
hashedPassphrase: "mocked-hash", hashedPassphrase: "mocked-hash",
encryptedCaChain: Buffer.from(estProfileData.estConfig.encryptedCaChain, "base64") encryptedCaChain: Buffer.from("encrypted-data")
}, },
undefined undefined
); );
@@ -1091,8 +1112,8 @@ describe("CertificateProfileService", () => {
estConfig: { estConfig: {
id: "est-config-123", id: "est-config-123",
disableBootstrapCaValidation: false, disableBootstrapCaValidation: false,
hashedPassphrase: "hashed-passphrase", passphrase: "",
encryptedCaChain: Buffer.from("mock-ca-chain").toString("base64") caChain: "mock-ca-chain"
} }
} as TCertificateProfileWithConfigs; } as TCertificateProfileWithConfigs;
@@ -1103,9 +1124,9 @@ describe("CertificateProfileService", () => {
expect(result).toEqual({ expect(result).toEqual({
orgId: "project-123", orgId: "project-123",
isEnabled: true, isEnabled: true,
caChain: "bW9jay1jYS1jaGFpbg==", // base64 encoded caChain: "mock-ca-chain",
disableBootstrapCertValidation: false, disableBootstrapCertValidation: false,
hashedPassphrase: "hashed-passphrase" hashedPassphrase: ""
}); });
}); });
@@ -1125,8 +1146,8 @@ describe("CertificateProfileService", () => {
estConfig: { estConfig: {
id: "est-config-123", id: "est-config-123",
disableBootstrapCaValidation: false, disableBootstrapCaValidation: false,
hashedPassphrase: "hashed-passphrase", passphrase: "",
encryptedCaChain: Buffer.from("mock-ca-chain").toString("base64") caChain: "mock-ca-chain"
} }
} as TCertificateProfileWithConfigs; } as TCertificateProfileWithConfigs;
@@ -1,4 +1,5 @@
import { ForbiddenError } from "@casl/ability"; import { ForbiddenError } from "@casl/ability";
import * as x509 from "@peculiar/x509";
import { ActionProjectType } from "@app/db/schemas"; import { ActionProjectType } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
@@ -6,15 +7,20 @@ import {
ProjectPermissionCertificateProfileActions, ProjectPermissionCertificateProfileActions,
ProjectPermissionSub ProjectPermissionSub
} from "@app/ee/services/permission/project-permission"; } from "@app/ee/services/permission/project-permission";
import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate";
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
import { crypto } from "@app/lib/crypto/cryptography"; import { crypto } from "@app/lib/crypto/cryptography";
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { ActorAuthMethod, ActorType } from "../auth/auth-type"; import { ActorAuthMethod, ActorType } from "../auth/auth-type";
import { isCertChainValid } from "../certificate/certificate-fns";
import { TCertificateTemplateV2DALFactory } from "../certificate-template-v2/certificate-template-v2-dal"; import { TCertificateTemplateV2DALFactory } from "../certificate-template-v2/certificate-template-v2-dal";
import { TApiEnrollmentConfigDALFactory } from "../enrollment-config/api-enrollment-config-dal"; import { TApiEnrollmentConfigDALFactory } from "../enrollment-config/api-enrollment-config-dal";
import { TApiConfigData, TEstConfigData } from "../enrollment-config/enrollment-config-types"; import { TApiConfigData, TEstConfigData } from "../enrollment-config/enrollment-config-types";
import { TEstEnrollmentConfigDALFactory } from "../enrollment-config/est-enrollment-config-dal"; import { TEstEnrollmentConfigDALFactory } from "../enrollment-config/est-enrollment-config-dal";
import { TKmsServiceFactory } from "../kms/kms-service";
import { TProjectDALFactory } from "../project/project-dal";
import { getProjectKmsCertificateKeyId } from "../project/project-fns";
import { TCertificateProfileDALFactory } from "./certificate-profile-dal"; import { TCertificateProfileDALFactory } from "./certificate-profile-dal";
import { import {
EnrollmentType, EnrollmentType,
@@ -27,18 +33,67 @@ import {
TCertificateProfileWithRawMetrics TCertificateProfileWithRawMetrics
} from "./certificate-profile-types"; } from "./certificate-profile-types";
const validateAndEncodeBase64CaChain = (caChain: unknown) => { const validateAndEncryptPemCaChain = async (
caChain: string,
projectId: string,
kmsService: Pick<TKmsServiceFactory, "generateKmsKey" | "encryptWithKmsKey">,
projectDAL: Pick<TProjectDALFactory, "findOne" | "updateById" | "transaction">
) => {
try { try {
if (typeof caChain !== "string") { const certificates = extractX509CertFromChain(caChain)?.map((cert) => new x509.X509Certificate(cert));
throw new BadRequestError({ message: "CA chain must be a string" });
if (!certificates || certificates.length === 0) {
throw new BadRequestError({ message: "Failed to parse certificate chain" });
} }
const buffer = Buffer.from(caChain, "base64");
if (buffer.toString("base64") !== caChain) { if (!(await isCertChainValid(certificates))) {
throw new BadRequestError({ message: "Invalid Base64 encoding in CA chain data" }); throw new BadRequestError({ message: "Invalid certificate chain" });
} }
return { encryptedCaChain: buffer };
const certificateManagerKmsId = await getProjectKmsCertificateKeyId({
projectId,
projectDAL,
kmsService
});
const kmsEncryptor = await kmsService.encryptWithKmsKey({
kmsId: certificateManagerKmsId
});
const { cipherTextBlob } = await kmsEncryptor({
plainText: Buffer.from(caChain)
});
return { encryptedCaChain: cipherTextBlob };
} catch (error) { } catch (error) {
throw new BadRequestError({ message: "Failed to decode CA chain data: Invalid Base64 format" }); throw new BadRequestError({ message: `Failed to process certificate chain: ${(error as Error).message}` });
}
};
const decryptCaChain = async (
encryptedCaChain: Buffer,
projectId: string,
kmsService: Pick<TKmsServiceFactory, "generateKmsKey" | "decryptWithKmsKey">,
projectDAL: Pick<TProjectDALFactory, "findOne" | "updateById" | "transaction">
): Promise<string> => {
try {
const certificateManagerKmsId = await getProjectKmsCertificateKeyId({
projectId,
projectDAL,
kmsService
});
const kmsDecryptor = await kmsService.decryptWithKmsKey({
kmsId: certificateManagerKmsId
});
const decryptedCaChain = await kmsDecryptor({
cipherTextBlob: encryptedCaChain
});
return decryptedCaChain.toString();
} catch (error) {
throw new BadRequestError({ message: `Failed to decrypt certificate chain: ${(error as Error).message}` });
} }
}; };
@@ -53,6 +108,8 @@ type TCertificateProfileServiceFactoryDep = {
apiEnrollmentConfigDAL: TApiEnrollmentConfigDALFactory; apiEnrollmentConfigDAL: TApiEnrollmentConfigDALFactory;
estEnrollmentConfigDAL: TEstEnrollmentConfigDALFactory; estEnrollmentConfigDAL: TEstEnrollmentConfigDALFactory;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">; permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
kmsService: Pick<TKmsServiceFactory, "generateKmsKey" | "encryptWithKmsKey" | "decryptWithKmsKey">;
projectDAL: Pick<TProjectDALFactory, "findProjectBySlug" | "findOne" | "updateById" | "findById" | "transaction">;
}; };
export type TCertificateProfileServiceFactory = ReturnType<typeof certificateProfileServiceFactory>; export type TCertificateProfileServiceFactory = ReturnType<typeof certificateProfileServiceFactory>;
@@ -69,7 +126,9 @@ export const certificateProfileServiceFactory = ({
certificateTemplateV2DAL, certificateTemplateV2DAL,
apiEnrollmentConfigDAL, apiEnrollmentConfigDAL,
estEnrollmentConfigDAL, estEnrollmentConfigDAL,
permissionService permissionService,
kmsService,
projectDAL
}: TCertificateProfileServiceFactoryDep) => { }: TCertificateProfileServiceFactoryDep) => {
const createProfile = async ({ const createProfile = async ({
actor, actor,
@@ -140,21 +199,17 @@ export const certificateProfileServiceFactory = ({
if (data.enrollmentType === EnrollmentType.EST && data.estConfig) { if (data.enrollmentType === EnrollmentType.EST && data.estConfig) {
const appCfg = getConfig(); const appCfg = getConfig();
// Hash the passphrase // Hash the passphrase
const hashedPassphrase = await crypto.hashing().createHash(data.estConfig.passphraseInput, appCfg.SALT_ROUNDS); const hashedPassphrase = await crypto.hashing().createHash(data.estConfig.passphrase, appCfg.SALT_ROUNDS);
let encryptedCaChainBuffer: Buffer | null = null; let encryptedCaChainBuffer: Buffer | null = null;
if (!data.estConfig.disableBootstrapCaValidation) { if (!data.estConfig.disableBootstrapCaValidation && data.estConfig.caChain) {
try { const { encryptedCaChain } = await validateAndEncryptPemCaChain(
if (!data.estConfig.encryptedCaChain || typeof data.estConfig.encryptedCaChain !== "string") { data.estConfig.caChain,
throw new BadRequestError({ message: "Invalid or missing CA chain data" }); projectId,
} kmsService,
encryptedCaChainBuffer = Buffer.from(data.estConfig.encryptedCaChain, "base64"); projectDAL
if (encryptedCaChainBuffer.toString("base64") !== data.estConfig.encryptedCaChain) { );
throw new BadRequestError({ message: "Invalid Base64 encoding in CA chain data" }); encryptedCaChainBuffer = encryptedCaChain;
}
} catch (error) {
throw new BadRequestError({ message: "Failed to decode CA chain data: Invalid Base64 format" });
}
} }
const estConfig = await estEnrollmentConfigDAL.create( const estConfig = await estEnrollmentConfigDAL.create(
@@ -256,17 +311,31 @@ export const certificateProfileServiceFactory = ({
const updatedProfile = await certificateProfileDAL.transaction(async (tx) => { const updatedProfile = await certificateProfileDAL.transaction(async (tx) => {
if (estConfig && existingProfile.estConfigId) { if (estConfig && existingProfile.estConfigId) {
await estEnrollmentConfigDAL.updateById( const updateData: {
existingProfile.estConfigId, disableBootstrapCaValidation: boolean;
{ hashedPassphrase?: string;
disableBootstrapCaValidation: estConfig.disableBootstrapCaValidation, encryptedCaChain?: Buffer;
...(estConfig.passphraseInput && { } = {
hashedPassphrase: await crypto.hashing().createHash(estConfig.passphraseInput, getConfig().SALT_ROUNDS) disableBootstrapCaValidation: estConfig.disableBootstrapCaValidation ?? false
}), };
...(estConfig.caChain && validateAndEncodeBase64CaChain(estConfig.caChain))
}, if (estConfig.passphrase) {
tx updateData.hashedPassphrase = await crypto
); .hashing()
.createHash(estConfig.passphrase, getConfig().SALT_ROUNDS);
}
if (estConfig.caChain) {
const { encryptedCaChain } = await validateAndEncryptPemCaChain(
estConfig.caChain,
existingProfile.projectId,
kmsService,
projectDAL
);
updateData.encryptedCaChain = encryptedCaChain;
}
await estEnrollmentConfigDAL.updateById(existingProfile.estConfigId, updateData, tx);
} }
if (apiConfig && existingProfile.apiConfigId) { if (apiConfig && existingProfile.apiConfigId) {
@@ -366,6 +435,25 @@ export const certificateProfileServiceFactory = ({
ProjectPermissionSub.CertificateProfiles ProjectPermissionSub.CertificateProfiles
); );
if (profile.estConfig && profile.estConfig.caChain) {
try {
const estConfig = await estEnrollmentConfigDAL.findById(profile.estConfigId!);
if (estConfig && estConfig.encryptedCaChain) {
const decryptedCaChain = await decryptCaChain(
estConfig.encryptedCaChain,
profile.projectId,
kmsService,
projectDAL
);
profile.estConfig.caChain = decryptedCaChain;
} else {
profile.estConfig.caChain = "";
}
} catch (error) {
profile.estConfig.caChain = "";
}
}
return { return {
...profile, ...profile,
enrollmentType: profile.enrollmentType as EnrollmentType enrollmentType: profile.enrollmentType as EnrollmentType
@@ -435,7 +523,7 @@ export const certificateProfileServiceFactory = ({
includeMetrics?: boolean; includeMetrics?: boolean;
expiringDays?: number; expiringDays?: number;
}): Promise<{ }): Promise<{
profiles: (TCertificateProfile & { metrics?: TCertificateProfileMetrics })[]; profiles: (TCertificateProfileWithConfigs & { metrics?: TCertificateProfileMetrics })[];
totalCount: number; totalCount: number;
}> => { }> => {
const { permission } = await permissionService.getProjectPermission({ const { permission } = await permissionService.getProjectPermission({
@@ -467,24 +555,66 @@ export const certificateProfileServiceFactory = ({
caId caId
}); });
const convertedProfiles = profiles.map((profile) => { const convertedProfiles = await Promise.all(
const converted = convertDalToService(profile); profiles.map(async (profile) => {
if (includeMetrics) { const profileWithConfigs = profile as TCertificateProfileWithConfigs;
const profileWithMetrics = profile as TCertificateProfileWithRawMetrics;
return { let decryptedEstConfig = profileWithConfigs.estConfig;
...converted, if (decryptedEstConfig && profileWithConfigs.estConfigId) {
metrics: { try {
profileId: converted.id, const estConfig = await estEnrollmentConfigDAL.findById(profileWithConfigs.estConfigId);
totalCertificates: parseInt(String(profileWithMetrics.total_certificates || 0), 10), if (estConfig && estConfig.encryptedCaChain) {
activeCertificates: parseInt(String(profileWithMetrics.active_certificates || 0), 10), const decryptedCaChain = await decryptCaChain(
expiredCertificates: parseInt(String(profileWithMetrics.expired_certificates || 0), 10), estConfig.encryptedCaChain,
expiringCertificates: parseInt(String(profileWithMetrics.expiring_certificates || 0), 10), projectId,
revokedCertificates: parseInt(String(profileWithMetrics.revoked_certificates || 0), 10) kmsService,
projectDAL
);
decryptedEstConfig = {
...decryptedEstConfig,
caChain: decryptedCaChain
};
} else if (decryptedEstConfig) {
decryptedEstConfig = {
...decryptedEstConfig,
caChain: ""
};
}
} catch (error) {
if (decryptedEstConfig) {
decryptedEstConfig = {
...decryptedEstConfig,
caChain: ""
};
}
} }
}
const converted = convertDalToService(profileWithConfigs);
let result: TCertificateProfileWithConfigs & { metrics?: TCertificateProfileMetrics } = {
...converted,
estConfig: decryptedEstConfig,
apiConfig: profileWithConfigs.apiConfig
}; };
}
return converted; if (includeMetrics) {
}); const profileWithMetrics = profile as TCertificateProfileWithRawMetrics;
result = {
...result,
metrics: {
profileId: converted.id,
totalCertificates: parseInt(String(profileWithMetrics.total_certificates || 0), 10),
activeCertificates: parseInt(String(profileWithMetrics.active_certificates || 0), 10),
expiredCertificates: parseInt(String(profileWithMetrics.expired_certificates || 0), 10),
expiringCertificates: parseInt(String(profileWithMetrics.expiring_certificates || 0), 10),
revokedCertificates: parseInt(String(profileWithMetrics.revoked_certificates || 0), 10)
}
};
}
return result;
})
);
return { return {
profiles: convertedProfiles, profiles: convertedProfiles,
@@ -673,9 +803,9 @@ export const certificateProfileServiceFactory = ({
return { return {
orgId: profile.projectId, orgId: profile.projectId,
isEnabled: true, isEnabled: true,
caChain: profile.estConfig.encryptedCaChain, caChain: profile.estConfig.caChain,
disableBootstrapCertValidation: profile.estConfig.disableBootstrapCaValidation, disableBootstrapCertValidation: profile.estConfig.disableBootstrapCaValidation,
hashedPassphrase: profile.estConfig.hashedPassphrase hashedPassphrase: ""
}; };
}; };
@@ -21,7 +21,7 @@ export type TCertificateProfileUpdate = Omit<TPkiCertificateProfilesUpdate, "enr
enrollmentType?: EnrollmentType; enrollmentType?: EnrollmentType;
estConfig?: { estConfig?: {
disableBootstrapCaValidation?: boolean; disableBootstrapCaValidation?: boolean;
passphraseInput?: string; passphrase?: string;
caChain?: string; caChain?: string;
}; };
apiConfig?: { apiConfig?: {
@@ -46,8 +46,8 @@ export type TCertificateProfileWithConfigs = TCertificateProfile & {
estConfig?: { estConfig?: {
id: string; id: string;
disableBootstrapCaValidation: boolean; disableBootstrapCaValidation: boolean;
hashedPassphrase: string; passphrase: string;
encryptedCaChain: string; caChain: string;
}; };
apiConfig?: { apiConfig?: {
id: string; id: string;
@@ -19,8 +19,8 @@ export type TApiEnrollmentConfigUpdate = TPkiApiEnrollmentConfigsUpdate;
export interface TEstConfigData { export interface TEstConfigData {
disableBootstrapCaValidation: boolean; disableBootstrapCaValidation: boolean;
passphraseInput: string; passphrase: string;
encryptedCaChain?: string; caChain?: string;
} }
export interface TApiConfigData { export interface TApiConfigData {
@@ -21,6 +21,7 @@ export const certificateProfileKeys = {
offset?: number; offset?: number;
search?: string; search?: string;
includeMetrics?: boolean; includeMetrics?: boolean;
includeConfigs?: boolean;
expiringDays?: number; expiringDays?: number;
}) => ["certificate-profiles", "list", params], }) => ["certificate-profiles", "list", params],
getById: (profileId: string) => ["certificate-profiles", "get-by-id", profileId], getById: (profileId: string) => ["certificate-profiles", "get-by-id", profileId],
@@ -50,6 +51,7 @@ export const useListCertificateProfiles = ({
offset = 0, offset = 0,
search, search,
includeMetrics = false, includeMetrics = false,
includeConfigs = false,
expiringDays = 7 expiringDays = 7
}: TListCertificateProfilesDTO) => { }: TListCertificateProfilesDTO) => {
return useQuery({ return useQuery({
@@ -59,6 +61,7 @@ export const useListCertificateProfiles = ({
offset, offset,
search, search,
includeMetrics, includeMetrics,
includeConfigs,
expiringDays expiringDays
}), }),
queryFn: async () => { queryFn: async () => {
@@ -72,6 +75,7 @@ export const useListCertificateProfiles = ({
offset, offset,
search, search,
includeMetrics, includeMetrics,
includeConfigs,
expiringDays expiringDays
} }
}); });
@@ -23,14 +23,14 @@ export type TCertificateProfileWithDetails = TCertificateProfile & {
certificateTemplate?: { certificateTemplate?: {
id: string; id: string;
projectId: string; projectId: string;
slug: string; name: string;
description?: string; description?: string;
}; };
estConfig?: { estConfig?: {
id: string; id: string;
disableBootstrapCaValidation: boolean; disableBootstrapCaValidation: boolean;
hashedPassphrase: string; passphrase: string;
encryptedCaChain: string; caChain: string;
}; };
apiConfig?: { apiConfig?: {
id: string; id: string;
@@ -48,7 +48,7 @@ export type TCreateCertificateProfileDTO = {
enrollmentType: "api" | "est"; enrollmentType: "api" | "est";
estConfig?: { estConfig?: {
disableBootstrapCaValidation?: boolean; disableBootstrapCaValidation?: boolean;
passphraseInput: string; passphrase: string;
caChain?: string; caChain?: string;
}; };
apiConfig?: { apiConfig?: {
@@ -82,6 +82,7 @@ export type TListCertificateProfilesDTO = {
offset?: number; offset?: number;
search?: string; search?: string;
includeMetrics?: boolean; includeMetrics?: boolean;
includeConfigs?: boolean;
expiringDays?: number; expiringDays?: number;
}; };
@@ -10,7 +10,7 @@ import {
ProjectPermissionSub ProjectPermissionSub
} from "@app/context/ProjectPermissionContext/types"; } from "@app/context/ProjectPermissionContext/types";
import { import {
TCertificateProfile, TCertificateProfileWithDetails,
useDeleteCertificateProfile useDeleteCertificateProfile
} from "@app/hooks/api/certificateProfiles"; } from "@app/hooks/api/certificateProfiles";
@@ -23,7 +23,9 @@ export const CertificateProfilesTab = () => {
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedProfile, setSelectedProfile] = useState<TCertificateProfile | null>(null); const [selectedProfile, setSelectedProfile] = useState<TCertificateProfileWithDetails | null>(
null
);
const deleteProfile = useDeleteCertificateProfile(); const deleteProfile = useDeleteCertificateProfile();
@@ -36,12 +38,12 @@ export const CertificateProfilesTab = () => {
setIsCreateModalOpen(true); setIsCreateModalOpen(true);
}; };
const handleEditProfile = (profile: TCertificateProfile) => { const handleEditProfile = (profile: TCertificateProfileWithDetails) => {
setSelectedProfile(profile); setSelectedProfile(profile);
setIsEditModalOpen(true); setIsEditModalOpen(true);
}; };
const handleDeleteProfile = (profile: TCertificateProfile) => { const handleDeleteProfile = (profile: TCertificateProfileWithDetails) => {
setSelectedProfile(profile); setSelectedProfile(profile);
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
}; };
@@ -111,7 +113,9 @@ export const CertificateProfilesTab = () => {
title={`Delete Certificate Profile ${selectedProfile.slug}?`} title={`Delete Certificate Profile ${selectedProfile.slug}?`}
onChange={(isOpen) => { onChange={(isOpen) => {
setIsDeleteModalOpen(isOpen); setIsDeleteModalOpen(isOpen);
if (!isOpen) setSelectedProfile(null); if (!isOpen) {
setSelectedProfile(null);
}
}} }}
deleteKey={selectedProfile.slug} deleteKey={selectedProfile.slug}
onDeleteApproved={handleDeleteConfirm} onDeleteApproved={handleDeleteConfirm}
@@ -1,3 +1,4 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
@@ -47,7 +48,7 @@ const createSchema = z
estConfig: z estConfig: z
.object({ .object({
disableBootstrapCaValidation: z.boolean().optional(), disableBootstrapCaValidation: z.boolean().optional(),
passphraseInput: z.string().min(1, "EST passphrase is required"), passphrase: z.string().min(1, "EST passphrase is required"),
caChain: z.string().min(1, "EST CA chain is required").optional() caChain: z.string().min(1, "EST CA chain is required").optional()
}) })
.refine( .refine(
@@ -107,7 +108,7 @@ const editSchema = z
estConfig: z estConfig: z
.object({ .object({
disableBootstrapCaValidation: z.boolean().optional(), disableBootstrapCaValidation: z.boolean().optional(),
passphraseInput: z.string().optional(), passphrase: z.string().optional(),
caChain: z.string().optional() caChain: z.string().optional()
}) })
.optional(), .optional(),
@@ -174,8 +175,8 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
? { ? {
disableBootstrapCaValidation: disableBootstrapCaValidation:
profile.estConfig?.disableBootstrapCaValidation || false, profile.estConfig?.disableBootstrapCaValidation || false,
passphraseInput: "", passphrase: profile.estConfig?.passphrase || "",
caChain: profile.estConfig?.encryptedCaChain || "" caChain: profile.estConfig?.caChain || ""
} }
: undefined, : undefined,
apiConfig: apiConfig:
@@ -203,6 +204,34 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
const watchedDisableBootstrapValidation = watch("estConfig.disableBootstrapCaValidation"); const watchedDisableBootstrapValidation = watch("estConfig.disableBootstrapCaValidation");
const watchedAutoRenew = watch("apiConfig.autoRenew"); const watchedAutoRenew = watch("apiConfig.autoRenew");
useEffect(() => {
if (isEdit && profile) {
reset({
slug: profile.slug,
description: profile.description || "",
enrollmentType: profile.enrollmentType,
certificateAuthorityId: profile.caId,
certificateTemplateId: profile.certificateTemplateId,
estConfig:
profile.enrollmentType === "est"
? {
disableBootstrapCaValidation:
profile.estConfig?.disableBootstrapCaValidation || false,
passphrase: profile.estConfig?.passphrase || "",
caChain: profile.estConfig?.caChain || ""
}
: undefined,
apiConfig:
profile.enrollmentType === "api"
? {
autoRenew: profile.apiConfig?.autoRenew || false,
autoRenewDays: profile.apiConfig?.autoRenewDays || 30
}
: undefined
});
}
}, [isEdit, profile, reset]);
const onFormSubmit = async (data: FormData) => { const onFormSubmit = async (data: FormData) => {
try { try {
if (!currentProject?.id && !isEdit) return; if (!currentProject?.id && !isEdit) return;
@@ -237,7 +266,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
if (data.enrollmentType === "est" && data.estConfig) { if (data.enrollmentType === "est" && data.estConfig) {
createData.estConfig = { createData.estConfig = {
passphraseInput: data.estConfig.passphraseInput, passphrase: data.estConfig.passphrase,
caChain: data.estConfig.caChain || undefined, caChain: data.estConfig.caChain || undefined,
disableBootstrapCaValidation: data.estConfig.disableBootstrapCaValidation disableBootstrapCaValidation: data.estConfig.disableBootstrapCaValidation
}; };
@@ -354,7 +383,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
if (watchedEnrollmentType === "est") { if (watchedEnrollmentType === "est") {
setValue("estConfig", { setValue("estConfig", {
disableBootstrapCaValidation: false, disableBootstrapCaValidation: false,
passphraseInput: "" passphrase: ""
}); });
setValue("apiConfig", undefined); setValue("apiConfig", undefined);
} else { } else {
@@ -398,7 +427,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
setValue("apiConfig", undefined); setValue("apiConfig", undefined);
setValue("estConfig", { setValue("estConfig", {
disableBootstrapCaValidation: false, disableBootstrapCaValidation: false,
passphraseInput: "" passphrase: ""
}); });
} else { } else {
setValue("estConfig", undefined); setValue("estConfig", undefined);
@@ -450,7 +479,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
<Controller <Controller
control={control} control={control}
name="estConfig.passphraseInput" name="estConfig.passphrase"
render={({ field, fieldState: { error } }) => ( render={({ field, fieldState: { error } }) => (
<FormControl <FormControl
label="EST Passphrase" label="EST Passphrase"
@@ -543,7 +572,6 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
if (!Number.isNaN(parsed) && parsed >= 1 && parsed <= 365) { if (!Number.isNaN(parsed) && parsed >= 1 && parsed <= 365) {
field.onChange(parsed); field.onChange(parsed);
} else { } else {
// Preserve the original field value instead of defaulting to 30
field.onChange(field.value || ""); field.onChange(field.value || "");
} }
} }
@@ -11,15 +11,15 @@ import {
} from "@app/components/v2"; } from "@app/components/v2";
import { useProject } from "@app/context"; import { useProject } from "@app/context";
import { import {
TCertificateProfile, TCertificateProfileWithDetails,
useListCertificateProfiles useListCertificateProfiles
} from "@app/hooks/api/certificateProfiles"; } from "@app/hooks/api/certificateProfiles";
import { ProfileRow } from "./ProfileRow"; import { ProfileRow } from "./ProfileRow";
interface Props { interface Props {
onEditProfile: (profile: TCertificateProfile) => void; onEditProfile: (profile: TCertificateProfileWithDetails) => void;
onDeleteProfile: (profile: TCertificateProfile) => void; onDeleteProfile: (profile: TCertificateProfileWithDetails) => void;
} }
export const ProfileList = ({ onEditProfile, onDeleteProfile }: Props) => { export const ProfileList = ({ onEditProfile, onDeleteProfile }: Props) => {
@@ -29,6 +29,7 @@ export const ProfileList = ({ onEditProfile, onDeleteProfile }: Props) => {
projectId: currentProject?.id || "", projectId: currentProject?.id || "",
limit: 100, limit: 100,
offset: 0, offset: 0,
includeConfigs: true,
includeMetrics: true includeMetrics: true
}); });