diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 2c23ec43e..79953f39b 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -53,6 +53,7 @@ import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; import { TCertificateServiceFactory } from "@app/services/certificate/certificate-service"; import { TCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/certificate-authority-service"; +import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal-certificate-authority-service"; import { TCertificateTemplateServiceFactory } from "@app/services/certificate-template/certificate-template-service"; import { TCmekServiceFactory } from "@app/services/cmek/cmek-service"; import { TExternalGroupOrgRoleMappingServiceFactory } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-service"; @@ -254,6 +255,7 @@ declare module "fastify" { microsoftTeams: TMicrosoftTeamsServiceFactory; assumePrivileges: TAssumePrivilegeServiceFactory; githubOrgSync: TGithubOrgSyncServiceFactory; + internalCertificateAuthority: TInternalCertificateAuthorityServiceFactory; }; // this is exclusive use for middlewares in which we need to inject data // everywhere else access using service layer diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 5b7c679c8..70bc1c76c 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -152,6 +152,9 @@ import { TIntegrations, TIntegrationsInsert, TIntegrationsUpdate, + TInternalCertificateAuthorities, + TInternalCertificateAuthoritiesInsert, + TInternalCertificateAuthoritiesUpdate, TInternalKms, TInternalKmsInsert, TInternalKmsUpdate, @@ -530,6 +533,11 @@ declare module "knex/types/tables" { TCertificateAuthorityCrlInsert, TCertificateAuthorityCrlUpdate >; + [TableName.InternalCertificateAuthority]: KnexOriginal.CompositeTableType< + TInternalCertificateAuthorities, + TInternalCertificateAuthoritiesInsert, + TInternalCertificateAuthoritiesUpdate + >; [TableName.Certificate]: KnexOriginal.CompositeTableType; [TableName.CertificateTemplate]: KnexOriginal.CompositeTableType< TCertificateTemplates, diff --git a/backend/src/db/migrations/20250512133213_add-external-ca-pki.ts b/backend/src/db/migrations/20250512133213_add-external-ca-pki.ts new file mode 100644 index 000000000..89b0587c2 --- /dev/null +++ b/backend/src/db/migrations/20250512133213_add-external-ca-pki.ts @@ -0,0 +1,139 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasCATable = await knex.schema.hasTable(TableName.CertificateAuthority); + const hasExternalCATable = await knex.schema.hasTable(TableName.ExternalCertificateAuthority); + const hasInternalCATable = await knex.schema.hasTable(TableName.InternalCertificateAuthority); + + if (hasCATable && !hasInternalCATable) { + await knex.schema.createTableLike(TableName.InternalCertificateAuthority, TableName.CertificateAuthority, (t) => { + t.uuid("certificateAuthorityId").nullable(); + }); + + await knex(TableName.InternalCertificateAuthority).insert(knex(TableName.CertificateAuthority).select("*")); + await knex(TableName.InternalCertificateAuthority).update("certificateAuthorityId", knex.ref("id")); + + await knex.schema.alterTable(TableName.InternalCertificateAuthority, (t) => { + t.dropColumn("projectId"); + t.dropColumn("requireTemplateForIssuance"); + t.dropColumn("createdAt"); + t.dropColumn("updatedAt"); + t.uuid("parentCaId") + .nullable() + .references("id") + .inTable(TableName.CertificateAuthority) + .onDelete("CASCADE") + .alter(); + t.uuid("activeCaCertId").nullable().references("id").inTable(TableName.CertificateAuthorityCert).alter(); + t.uuid("certificateAuthorityId") + .notNullable() + .references("id") + .inTable(TableName.CertificateAuthority) + .onDelete("CASCADE") + .alter(); + }); + + await knex.schema.alterTable(TableName.CertificateAuthority, (t) => { + t.dropColumn("parentCaId"); + t.dropColumn("type"); + t.dropColumn("status"); + t.dropColumn("friendlyName"); + t.dropColumn("organization"); + t.dropColumn("ou"); + t.dropColumn("country"); + t.dropColumn("province"); + t.dropColumn("locality"); + t.dropColumn("commonName"); + t.dropColumn("dn"); + t.dropColumn("serialNumber"); + t.dropColumn("maxPathLength"); + t.dropColumn("keyAlgorithm"); + t.dropColumn("notBefore"); + t.dropColumn("notAfter"); + t.dropColumn("activeCaCertId"); + t.renameColumn("requireTemplateForIssuance", "disableDirectIssuance"); + }); + } + + if (!hasExternalCATable) { + await knex.schema.createTable(TableName.ExternalCertificateAuthority, (t) => { + // + }); + } +} + +export async function down(knex: Knex): Promise { + const hasCATable = await knex.schema.hasTable(TableName.CertificateAuthority); + const hasExternalCATable = await knex.schema.hasTable(TableName.ExternalCertificateAuthority); + const hasInternalCATable = await knex.schema.hasTable(TableName.InternalCertificateAuthority); + + if (hasCATable && hasInternalCATable) { + // First add all columns as nullable + await knex.schema.alterTable(TableName.CertificateAuthority, (t) => { + t.uuid("parentCaId").nullable().references("id").inTable(TableName.CertificateAuthority).onDelete("CASCADE"); + t.string("type").nullable(); + t.string("status").nullable(); + t.string("friendlyName").nullable(); + t.string("organization").nullable(); + t.string("ou").nullable(); + t.string("country").nullable(); + t.string("province").nullable(); + t.string("locality").nullable(); + t.string("commonName").nullable(); + t.string("dn").nullable(); + t.string("serialNumber").nullable().unique(); + t.integer("maxPathLength").nullable(); + t.string("keyAlgorithm").nullable(); + t.timestamp("notBefore").nullable(); + t.timestamp("notAfter").nullable(); + t.uuid("activeCaCertId").nullable().references("id").inTable(TableName.CertificateAuthorityCert); + t.renameColumn("disableDirectIssuance", "requireTemplateForIssuance"); + }); + + await knex.raw(` + UPDATE ${TableName.CertificateAuthority} ca + SET + type = ica.type, + status = ica.status, + "friendlyName" = ica."friendlyName", + organization = ica.organization, + ou = ica.ou, + country = ica.country, + province = ica.province, + locality = ica.locality, + "commonName" = ica."commonName", + dn = ica.dn, + "parentCaId" = ica."parentCaId", + "serialNumber" = ica."serialNumber", + "maxPathLength" = ica."maxPathLength", + "keyAlgorithm" = ica."keyAlgorithm", + "notBefore" = ica."notBefore", + "notAfter" = ica."notAfter", + "activeCaCertId" = ica."activeCaCertId" + FROM ${TableName.InternalCertificateAuthority} ica + WHERE ca.id = ica.id + `); + + await knex.schema.alterTable(TableName.CertificateAuthority, (t) => { + t.string("type").notNullable().alter(); + t.string("status").notNullable().alter(); + t.string("friendlyName").notNullable().alter(); + t.string("organization").notNullable().alter(); + t.string("ou").notNullable().alter(); + t.string("country").notNullable().alter(); + t.string("province").notNullable().alter(); + t.string("locality").notNullable().alter(); + t.string("commonName").notNullable().alter(); + t.string("dn").notNullable().alter(); + t.string("keyAlgorithm").notNullable().alter(); + }); + + await knex.schema.dropTable(TableName.InternalCertificateAuthority); + } + + if (hasExternalCATable) { + await knex.schema.dropTable(TableName.ExternalCertificateAuthority); + } +} diff --git a/backend/src/db/schemas/certificate-authorities.ts b/backend/src/db/schemas/certificate-authorities.ts index ffe0f7c44..8752f25d0 100644 --- a/backend/src/db/schemas/certificate-authorities.ts +++ b/backend/src/db/schemas/certificate-authorities.ts @@ -11,25 +11,8 @@ export const CertificateAuthoritiesSchema = z.object({ id: z.string().uuid(), createdAt: z.date(), updatedAt: z.date(), - parentCaId: z.string().uuid().nullable().optional(), projectId: z.string(), - type: z.string(), - status: z.string(), - friendlyName: z.string(), - organization: z.string(), - ou: z.string(), - country: z.string(), - province: z.string(), - locality: z.string(), - commonName: z.string(), - dn: z.string(), - serialNumber: z.string().nullable().optional(), - maxPathLength: z.number().nullable().optional(), - keyAlgorithm: z.string(), - notBefore: z.date().nullable().optional(), - notAfter: z.date().nullable().optional(), - activeCaCertId: z.string().uuid().nullable().optional(), - requireTemplateForIssuance: z.boolean().default(false) + disableDirectIssuance: z.boolean().default(false) }); export type TCertificateAuthorities = z.infer; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index ebbe417c4..7830c6ba4 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -48,6 +48,7 @@ export * from "./identity-universal-auths"; export * from "./incident-contacts"; export * from "./integration-auths"; export * from "./integrations"; +export * from "./internal-certificate-authorities"; export * from "./internal-kms"; export * from "./kmip-client-certificates"; export * from "./kmip-clients"; diff --git a/backend/src/db/schemas/internal-certificate-authorities.ts b/backend/src/db/schemas/internal-certificate-authorities.ts new file mode 100644 index 000000000..3a27bfcdb --- /dev/null +++ b/backend/src/db/schemas/internal-certificate-authorities.ts @@ -0,0 +1,39 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const InternalCertificateAuthoritiesSchema = z.object({ + id: z.string().uuid(), + parentCaId: z.string().uuid().nullable().optional(), + type: z.string(), + status: z.string(), + friendlyName: z.string(), + organization: z.string(), + ou: z.string(), + country: z.string(), + province: z.string(), + locality: z.string(), + commonName: z.string(), + dn: z.string(), + serialNumber: z.string().nullable().optional(), + maxPathLength: z.number().nullable().optional(), + keyAlgorithm: z.string(), + notBefore: z.date().nullable().optional(), + notAfter: z.date().nullable().optional(), + activeCaCertId: z.string().uuid().nullable().optional(), + certificateAuthorityId: z.string().uuid() +}); + +export type TInternalCertificateAuthorities = z.infer; +export type TInternalCertificateAuthoritiesInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TInternalCertificateAuthoritiesUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index cc78c7327..875d3d335 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -13,6 +13,8 @@ export enum TableName { SshCertificate = "ssh_certificates", SshCertificateBody = "ssh_certificate_bodies", CertificateAuthority = "certificate_authorities", + ExternalCertificateAuthority = "external_certificate_authorities", + InternalCertificateAuthority = "internal_certificate_authorities", CertificateTemplateEstConfig = "certificate_template_est_configs", CertificateAuthorityCert = "certificate_authority_certs", CertificateAuthoritySecret = "certificate_authority_secret", diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index f9f586128..249cf4ea6 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -133,6 +133,8 @@ import { certificateAuthorityDALFactory } from "@app/services/certificate-author import { certificateAuthorityQueueFactory } from "@app/services/certificate-authority/certificate-authority-queue"; import { certificateAuthoritySecretDALFactory } from "@app/services/certificate-authority/certificate-authority-secret-dal"; import { certificateAuthorityServiceFactory } from "@app/services/certificate-authority/certificate-authority-service"; +import { internalCertificateAuthorityDALFactory } from "@app/services/certificate-authority/internal-certificate-authority-dal"; +import { internalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal-certificate-authority-service"; import { certificateTemplateDALFactory } from "@app/services/certificate-template/certificate-template-dal"; import { certificateTemplateEstConfigDALFactory } from "@app/services/certificate-template/certificate-template-est-config-dal"; import { certificateTemplateServiceFactory } from "@app/services/certificate-template/certificate-template-service"; @@ -814,6 +816,7 @@ export const registerRoutes = async ( }); const certificateAuthorityDAL = certificateAuthorityDALFactory(db); + const internalCertificateAuthorityDAL = internalCertificateAuthorityDALFactory(db); const certificateAuthorityCertDAL = certificateAuthorityCertDALFactory(db); const certificateAuthoritySecretDAL = certificateAuthoritySecretDALFactory(db); const certificateAuthorityCrlDAL = certificateAuthorityCrlDALFactory(db); @@ -912,6 +915,24 @@ export const registerRoutes = async ( permissionService }); + const internalCertificateAuthorityService = internalCertificateAuthorityServiceFactory({ + certificateAuthorityDAL, + certificateAuthorityCertDAL, + certificateAuthoritySecretDAL, + certificateAuthorityCrlDAL, + certificateTemplateDAL, + certificateAuthorityQueue, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + pkiCollectionDAL, + pkiCollectionItemDAL, + projectDAL, + internalCertificateAuthorityDAL, + kmsService, + permissionService + }); + const certificateAuthorityCrlService = certificateAuthorityCrlServiceFactory({ certificateAuthorityDAL, certificateAuthorityCrlDAL, @@ -1741,6 +1762,7 @@ export const registerRoutes = async ( sshHost: sshHostService, sshHostGroup: sshHostGroupService, certificateAuthority: certificateAuthorityService, + internalCertificateAuthority: internalCertificateAuthorityService, certificateTemplate: certificateTemplateService, certificateAuthorityCrl: certificateAuthorityCrlService, certificateEst: certificateEstService, diff --git a/backend/src/server/routes/v1/certificate-authority-router.ts b/backend/src/server/routes/v1/certificate-authority-router.ts index f6538b797..7160e1068 100644 --- a/backend/src/server/routes/v1/certificate-authority-router.ts +++ b/backend/src/server/routes/v1/certificate-authority-router.ts @@ -73,7 +73,7 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - const ca = await server.services.certificateAuthority.createCa({ + const ca = await server.services.internalCertificateAuthority.createCa({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, @@ -120,7 +120,7 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - const ca = await server.services.certificateAuthority.getCaById({ + const ca = await server.services.internalCertificateAuthority.getCaById({ caId: req.params.caId, actor: req.permission.type, actorId: req.permission.id, @@ -167,7 +167,7 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { } }, handler: async (req, res) => { - const caCert = await server.services.certificateAuthority.getCaCertById(req.params); + const caCert = await server.services.internalCertificateAuthority.getCaCertById(req.params); res.header("Content-Type", "application/pkix-cert"); @@ -203,7 +203,7 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - const ca = await server.services.certificateAuthority.updateCaById({ + const ca = await server.services.internalCertificateAuthority.updateCaById({ caId: req.params.caId, actor: req.permission.type, actorId: req.permission.id, @@ -252,7 +252,7 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - const ca = await server.services.certificateAuthority.deleteCaById({ + const ca = await server.services.internalCertificateAuthority.deleteCaById({ caId: req.params.caId, actor: req.permission.type, actorId: req.permission.id, @@ -299,7 +299,7 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - const { ca, csr } = await server.services.certificateAuthority.getCaCsr({ + const { ca, csr } = await server.services.internalCertificateAuthority.getCaCsr({ caId: req.params.caId, actor: req.permission.type, actorId: req.permission.id, @@ -353,7 +353,7 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { }, handler: async (req) => { const { certificate, certificateChain, serialNumber, ca } = - await server.services.certificateAuthority.renewCaCert({ + await server.services.internalCertificateAuthority.renewCaCert({ caId: req.params.caId, actor: req.permission.type, actorId: req.permission.id, @@ -408,7 +408,7 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - const { caCerts, ca } = await server.services.certificateAuthority.getCaCerts({ + const { caCerts, ca } = await server.services.internalCertificateAuthority.getCaCerts({ caId: req.params.caId, actor: req.permission.type, actorId: req.permission.id, @@ -455,13 +455,14 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - const { certificate, certificateChain, serialNumber, ca } = await server.services.certificateAuthority.getCaCert({ - caId: req.params.caId, - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId - }); + const { certificate, certificateChain, serialNumber, ca } = + await server.services.internalCertificateAuthority.getCaCert({ + caId: req.params.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, @@ -517,7 +518,7 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { }, handler: async (req) => { const { certificate, certificateChain, issuingCaCertificate, serialNumber, ca } = - await server.services.certificateAuthority.signIntermediate({ + await server.services.internalCertificateAuthority.signIntermediate({ caId: req.params.caId, actor: req.permission.type, actorId: req.permission.id, @@ -574,7 +575,7 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - const { ca } = await server.services.certificateAuthority.importCertToCa({ + const { ca } = await server.services.internalCertificateAuthority.importCertToCa({ caId: req.params.caId, actor: req.permission.type, actorId: req.permission.id, @@ -653,7 +654,7 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { }, handler: async (req) => { const { certificate, certificateChain, issuingCaCertificate, privateKey, serialNumber, ca } = - await server.services.certificateAuthority.issueCertFromCa({ + await server.services.internalCertificateAuthority.issueCertFromCa({ caId: req.params.caId, actor: req.permission.type, actorId: req.permission.id, @@ -746,7 +747,7 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { }, handler: async (req) => { const { certificate, certificateChain, issuingCaCertificate, serialNumber, ca, commonName } = - await server.services.certificateAuthority.signCertFromCa({ + await server.services.internalCertificateAuthority.signCertFromCa({ isInternal: false, caId: req.params.caId, actor: req.permission.type, @@ -809,13 +810,15 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - const { certificateTemplates, ca } = await server.services.certificateAuthority.getCaCertificateTemplates({ - caId: req.params.caId, - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId - }); + const { certificateTemplates, ca } = await server.services.internalCertificateAuthority.getCaCertificateTemplates( + { + caId: req.params.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + } + ); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, diff --git a/backend/src/services/certificate-authority/certificate-authority-dal.ts b/backend/src/services/certificate-authority/certificate-authority-dal.ts index 837bbcf37..35a390226 100644 --- a/backend/src/services/certificate-authority/certificate-authority-dal.ts +++ b/backend/src/services/certificate-authority/certificate-authority-dal.ts @@ -1,13 +1,80 @@ +import { Knex } from "knex"; + import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; +import { CertificateAuthoritiesSchema, TableName } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; -import { ormify } from "@app/lib/knex"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; export type TCertificateAuthorityDALFactory = ReturnType; export const certificateAuthorityDALFactory = (db: TDbClient) => { const caOrm = ormify(db, TableName.CertificateAuthority); + const findByIdWithAssociatedCa = async (caId: string, tx?: Knex) => { + const result = await (tx || db.replicaNode())(TableName.CertificateAuthority) + .leftJoin( + TableName.InternalCertificateAuthority, + `${TableName.CertificateAuthority}.id`, + `${TableName.InternalCertificateAuthority}.certificateAuthorityId` + ) + .where(`${TableName.CertificateAuthority}.id`, caId) + .select(selectAllTableCols(TableName.CertificateAuthority)) + .select( + db.ref("id").withSchema(TableName.InternalCertificateAuthority).as("internalCaId"), + db.ref("parentCaId").withSchema(TableName.InternalCertificateAuthority).as("internalParentCaId"), + db.ref("type").withSchema(TableName.InternalCertificateAuthority).as("internalType"), + db.ref("status").withSchema(TableName.InternalCertificateAuthority).as("internalStatus"), + db.ref("friendlyName").withSchema(TableName.InternalCertificateAuthority).as("internalFriendlyName"), + db.ref("organization").withSchema(TableName.InternalCertificateAuthority).as("internalOrganization"), + db.ref("ou").withSchema(TableName.InternalCertificateAuthority).as("internalOu"), + db.ref("country").withSchema(TableName.InternalCertificateAuthority).as("internalCountry"), + db.ref("province").withSchema(TableName.InternalCertificateAuthority).as("internalProvince"), + db.ref("locality").withSchema(TableName.InternalCertificateAuthority).as("internalLocality"), + db.ref("commonName").withSchema(TableName.InternalCertificateAuthority).as("internalCommonName"), + db.ref("dn").withSchema(TableName.InternalCertificateAuthority).as("internalDn"), + db.ref("serialNumber").withSchema(TableName.InternalCertificateAuthority).as("internalSerialNumber"), + db.ref("maxPathLength").withSchema(TableName.InternalCertificateAuthority).as("internalMaxPathLength"), + db.ref("keyAlgorithm").withSchema(TableName.InternalCertificateAuthority).as("internalKeyAlgorithm"), + db.ref("notBefore").withSchema(TableName.InternalCertificateAuthority).as("internalNotBefore"), + db.ref("notAfter").withSchema(TableName.InternalCertificateAuthority).as("internalNotAfter"), + db.ref("activeCaCertId").withSchema(TableName.InternalCertificateAuthority).as("internalActiveCaCertId"), + db + .ref("certificateAuthorityId") + .withSchema(TableName.InternalCertificateAuthority) + .as("internalCertificateAuthorityId") + ) + .first(); + + const data = { + ...CertificateAuthoritiesSchema.parse(result), + internalCa: result + ? { + id: result.internalCaId, + parentCaId: result.internalParentCaId, + type: result.internalType, + status: result.internalStatus, + friendlyName: result.internalFriendlyName, + organization: result.internalOrganization, + ou: result.internalOu, + country: result.internalCountry, + province: result.internalProvince, + locality: result.internalLocality, + commonName: result.internalCommonName, + dn: result.internalDn, + serialNumber: result.internalSerialNumber, + maxPathLength: result.internalMaxPathLength, + keyAlgorithm: result.internalKeyAlgorithm, + notBefore: result.internalNotBefore, + notAfter: result.internalNotAfter, + activeCaCertId: result.internalActiveCaCertId, + certificateAuthorityId: result.internalCertificateAuthorityId + } + : undefined + }; + + return data; + }; + // note: not used const buildCertificateChain = async (caId: string) => { try { @@ -44,6 +111,7 @@ export const certificateAuthorityDALFactory = (db: TDbClient) => { return { ...caOrm, - buildCertificateChain + buildCertificateChain, + findByIdWithAssociatedCa }; }; diff --git a/backend/src/services/certificate-authority/certificate-authority-fns.ts b/backend/src/services/certificate-authority/certificate-authority-fns.ts index d2c87e772..1ee13428f 100644 --- a/backend/src/services/certificate-authority/certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/certificate-authority-fns.ts @@ -5,6 +5,7 @@ import { NotFoundError } from "@app/lib/errors"; import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; import { CertKeyAlgorithm, CertStatus } from "../certificate/certificate-types"; +import { TCertificateAuthorityDALFactory } from "./certificate-authority-dal"; import { TDNParts, TGetCaCertChainDTO, @@ -318,3 +319,10 @@ export const rebuildCaCrl = async ({ } ); }; + +export const expandInternalCa = ( + ca: Awaited> +) => ({ + ...ca, + ...ca.internalCa +}); diff --git a/backend/src/services/certificate-authority/internal-certificate-authority-dal.ts b/backend/src/services/certificate-authority/internal-certificate-authority-dal.ts new file mode 100644 index 000000000..c3ea228fe --- /dev/null +++ b/backend/src/services/certificate-authority/internal-certificate-authority-dal.ts @@ -0,0 +1,13 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TInternalCertificateAuthorityDALFactory = ReturnType; + +export const internalCertificateAuthorityDALFactory = (db: TDbClient) => { + const caOrm = ormify(db, TableName.InternalCertificateAuthority); + + return { + ...caOrm + }; +}; diff --git a/backend/src/services/certificate-authority/internal-certificate-authority-service.ts b/backend/src/services/certificate-authority/internal-certificate-authority-service.ts new file mode 100644 index 000000000..bdb651878 --- /dev/null +++ b/backend/src/services/certificate-authority/internal-certificate-authority-service.ts @@ -0,0 +1,1945 @@ +/* eslint-disable no-bitwise */ +import { ForbiddenError } from "@casl/ability"; +import * as x509 from "@peculiar/x509"; +import crypto, { KeyObject } from "crypto"; +import { z } from "zod"; + +import { ActionProjectType, ProjectType, TCertificateAuthorities, TCertificateTemplates } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { + ProjectPermissionActions, + ProjectPermissionCertificateActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; +import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { ms } from "@app/lib/ms"; +import { isFQDN } from "@app/lib/validator/validate-url"; +import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; +import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TPkiCollectionDALFactory } from "@app/services/pki-collection/pki-collection-dal"; +import { TPkiCollectionItemDALFactory } from "@app/services/pki-collection/pki-collection-item-dal"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; + +import { TCertificateAuthorityCrlDALFactory } from "../../ee/services/certificate-authority-crl/certificate-authority-crl-dal"; +import { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal"; +import { + CertExtendedKeyUsage, + CertExtendedKeyUsageOIDToName, + CertKeyAlgorithm, + CertKeyUsage, + CertStatus +} from "../certificate/certificate-types"; +import { TCertificateTemplateDALFactory } from "../certificate-template/certificate-template-dal"; +import { validateCertificateDetailsAgainstTemplate } from "../certificate-template/certificate-template-fns"; +import { TCertificateAuthorityCertDALFactory } from "./certificate-authority-cert-dal"; +import { TCertificateAuthorityDALFactory } from "./certificate-authority-dal"; +import { + createDistinguishedName, + createSerialNumber, + expandInternalCa, + getCaCertChain, // TODO: consider rename + getCaCertChains, + getCaCredentials, + keyAlgorithmToAlgCfg, + parseDistinguishedName +} from "./certificate-authority-fns"; +import { TCertificateAuthorityQueueFactory } from "./certificate-authority-queue"; +import { TCertificateAuthoritySecretDALFactory } from "./certificate-authority-secret-dal"; +import { + CaStatus, + CaType, + TCreateCaDTO, + TDeleteCaDTO, + TGetCaCertDTO, + TGetCaCertificateTemplatesDTO, + TGetCaCertsDTO, + TGetCaCsrDTO, + TGetCaDTO, + TImportCertToCaDTO, + TIssueCertFromCaDTO, + TRenewCaCertDTO, + TSignCertFromCaDTO, + TSignIntermediateDTO, + TUpdateCaDTO +} from "./certificate-authority-types"; +import { TInternalCertificateAuthorityDALFactory } from "./internal-certificate-authority-dal"; + +type TInternalCertificateAuthorityServiceFactoryDep = { + certificateAuthorityDAL: Pick< + TCertificateAuthorityDALFactory, + "transaction" | "create" | "findById" | "updateById" | "deleteById" | "findOne" | "findByIdWithAssociatedCa" + >; + internalCertificateAuthorityDAL: Pick< + TInternalCertificateAuthorityDALFactory, + "transaction" | "create" | "findById" | "updateById" | "deleteById" | "findOne" | "update" + >; + certificateAuthorityCertDAL: Pick< + TCertificateAuthorityCertDALFactory, + "create" | "findOne" | "transaction" | "find" | "findById" + >; + certificateAuthoritySecretDAL: Pick; + certificateAuthorityCrlDAL: Pick; + certificateTemplateDAL: Pick; + certificateAuthorityQueue: TCertificateAuthorityQueueFactory; // TODO: Pick + certificateDAL: Pick; + certificateSecretDAL: Pick; + certificateBodyDAL: Pick; + pkiCollectionDAL: Pick; + pkiCollectionItemDAL: Pick; + projectDAL: Pick< + TProjectDALFactory, + "findProjectBySlug" | "findOne" | "updateById" | "findById" | "transaction" | "getProjectFromSplitId" + >; + kmsService: Pick; + permissionService: Pick; +}; + +export type TInternalCertificateAuthorityServiceFactory = ReturnType; + +export const internalCertificateAuthorityServiceFactory = ({ + certificateAuthorityDAL, + certificateAuthorityCertDAL, + certificateAuthoritySecretDAL, + certificateAuthorityCrlDAL, + certificateTemplateDAL, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + pkiCollectionDAL, + pkiCollectionItemDAL, + internalCertificateAuthorityDAL, + projectDAL, + kmsService, + permissionService +}: TInternalCertificateAuthorityServiceFactoryDep) => { + const createCa = async ({ + projectSlug, + type, + friendlyName, + commonName, + organization, + ou, + country, + province, + locality, + notBefore, + notAfter, + maxPathLength, + keyAlgorithm, + requireTemplateForIssuance, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TCreateCaDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); + let projectId = project.id; + + const certManagerProjectFromSplit = await projectDAL.getProjectFromSplitId( + projectId, + ProjectType.CertificateManager + ); + if (certManagerProjectFromSplit) { + projectId = certManagerProjectFromSplit.id; + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.CertificateAuthorities + ); + + const dn = createDistinguishedName({ + commonName, + organization, + ou, + country, + province, + locality + }); + + const alg = keyAlgorithmToAlgCfg(keyAlgorithm); + const keys = await crypto.subtle.generateKey(alg, true, ["sign", "verify"]); + + const newCa = await certificateAuthorityDAL.transaction(async (tx) => { + const notBeforeDate = notBefore ? new Date(notBefore) : new Date(); + + // if undefined, set [notAfterDate] to 10 years from now + const notAfterDate = notAfter + ? new Date(notAfter) + : new Date(new Date().setFullYear(new Date().getFullYear() + 10)); + + const serialNumber = createSerialNumber(); + + const ca = await certificateAuthorityDAL.create( + { + projectId, + disableDirectIssuance: requireTemplateForIssuance + }, + tx + ); + + const internalCa = await internalCertificateAuthorityDAL.create( + { + certificateAuthorityId: ca.id, + type, + organization, + ou, + country, + province, + locality, + friendlyName: friendlyName || dn, + commonName, + status: type === CaType.ROOT ? CaStatus.ACTIVE : CaStatus.PENDING_CERTIFICATE, + dn, + keyAlgorithm, + ...(type === CaType.ROOT && { + maxPathLength, + notBefore: notBeforeDate, + notAfter: notAfterDate, + serialNumber + }) + }, + tx + ); + + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId, + projectDAL, + kmsService + }); + const kmsEncryptor = await kmsService.encryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + // // https://nodejs.org/api/crypto.html#static-method-keyobjectfromkey + const skObj = KeyObject.from(keys.privateKey); + + const { cipherTextBlob: encryptedPrivateKey } = await kmsEncryptor({ + plainText: skObj.export({ + type: "pkcs8", + format: "der" + }) + }); + + const caSecret = await certificateAuthoritySecretDAL.create( + { + caId: ca.id, + encryptedPrivateKey + }, + tx + ); + + if (type === CaType.ROOT) { + // note: create self-signed cert only applicable for root CA + const cert = await x509.X509CertificateGenerator.createSelfSigned({ + name: dn, + serialNumber, + notBefore: notBeforeDate, + notAfter: notAfterDate, + signingAlgorithm: alg, + keys, + extensions: [ + new x509.BasicConstraintsExtension(true, maxPathLength === -1 ? undefined : maxPathLength, true), + // eslint-disable-next-line no-bitwise + new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, true), + await x509.SubjectKeyIdentifierExtension.create(keys.publicKey) + ] + }); + + const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({ + plainText: Buffer.from(new Uint8Array(cert.rawData)) + }); + + const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({ + plainText: Buffer.alloc(0) + }); + + const caCert = await certificateAuthorityCertDAL.create( + { + caId: ca.id, + encryptedCertificate, + encryptedCertificateChain, + version: 1, + caSecretId: caSecret.id + }, + tx + ); + + await internalCertificateAuthorityDAL.updateById( + internalCa.id, + { + activeCaCertId: caCert.id + }, + tx + ); + } + + // create empty CRL + const crl = await x509.X509CrlGenerator.create({ + issuer: internalCa.dn, + thisUpdate: new Date(), + nextUpdate: new Date("2025/12/12"), // TODO: change + entries: [], + signingAlgorithm: alg, + signingKey: keys.privateKey + }); + + const { cipherTextBlob: encryptedCrl } = await kmsEncryptor({ + plainText: Buffer.from(new Uint8Array(crl.rawData)) + }); + + await certificateAuthorityCrlDAL.create( + { + caId: ca.id, + encryptedCrl, + caSecretId: caSecret.id + }, + tx + ); + + return certificateAuthorityDAL.findByIdWithAssociatedCa(ca.id, tx); + }); + + return expandInternalCa(newCa); + }; + + /** + * Return CA with id [caId] + */ + const getCaById = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TGetCaDTO) => { + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); + if (!ca.internalCa) throw new NotFoundError({ message: `CA with ID '${caId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: ca.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.CertificateAuthorities + ); + + return expandInternalCa(ca); + }; + + /** + * Update CA with id [caId]. + * Note: Used to enable/disable CA + */ + const updateCaById = async ({ + caId, + status, + requireTemplateForIssuance, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TUpdateCaDTO) => { + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); + if (!ca.internalCa) throw new NotFoundError({ message: `CA with ID '${caId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: ca.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + ProjectPermissionSub.CertificateAuthorities + ); + + const updatedCa = await certificateAuthorityDAL.transaction(async (tx) => { + await internalCertificateAuthorityDAL.updateById(caId, { status }, tx); + await certificateAuthorityDAL.updateById(ca.id, { disableDirectIssuance: requireTemplateForIssuance }, tx); + + return certificateAuthorityDAL.findByIdWithAssociatedCa(caId, tx); + }); + + return expandInternalCa(updatedCa); + }; + + /** + * Delete CA with id [caId] + */ + const deleteCaById = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TDeleteCaDTO) => { + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); + if (!ca.internalCa) throw new NotFoundError({ message: `CA with ID '${caId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: ca.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + ProjectPermissionSub.CertificateAuthorities + ); + + return certificateAuthorityDAL.transaction(async (tx) => { + const deletedInternalCa = await internalCertificateAuthorityDAL.deleteById(caId, tx); + const deletedCa = await certificateAuthorityDAL.deleteById(ca.id, tx); + + return { + ...deletedCa, + ...deletedInternalCa + }; + }); + }; + + /** + * Return certificate signing request (CSR) made with CA with id [caId] + */ + const getCaCsr = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TGetCaCsrDTO) => { + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); + if (!ca.internalCa) throw new NotFoundError({ message: `CA with ID '${caId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: ca.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.CertificateAuthorities + ); + + if (ca.internalCa.type === CaType.ROOT) throw new BadRequestError({ message: "Root CA cannot generate CSR" }); + + const { caPrivateKey, caPublicKey } = await getCaCredentials({ + caId, + certificateAuthorityDAL, + certificateAuthoritySecretDAL, + projectDAL, + kmsService + }); + + const alg = keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm); + + const csrObj = await x509.Pkcs10CertificateRequestGenerator.create({ + name: ca.internalCa.dn, + keys: { + privateKey: caPrivateKey, + publicKey: caPublicKey + }, + signingAlgorithm: alg, + extensions: [ + // eslint-disable-next-line no-bitwise + new x509.KeyUsagesExtension( + x509.KeyUsageFlags.keyCertSign | + x509.KeyUsageFlags.cRLSign | + x509.KeyUsageFlags.digitalSignature | + x509.KeyUsageFlags.keyEncipherment + ) + ], + attributes: [new x509.ChallengePasswordAttribute("password")] + }); + + return { + csr: csrObj.toString("pem"), + ca + }; + }; + + /** + * Renew certificate for CA with id [caId] + * Note 1: This CA renewal method is only applicable to CAs with internal parent CAs + * Note 2: Currently implements CA renewal with same key-pair only + */ + const renewCaCert = async ({ caId, notAfter, actorId, actorAuthMethod, actor, actorOrgId }: TRenewCaCertDTO) => { + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); + if (!ca.internalCa) throw new NotFoundError({ message: `CA with ID '${caId}' not found` }); + + if (!ca.internalCa.activeCaCertId) + throw new BadRequestError({ message: "CA does not have a certificate installed" }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: ca.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.CertificateAuthorities + ); + + if (ca.internalCa.status === CaStatus.DISABLED) throw new BadRequestError({ message: "CA is disabled" }); + + // get latest CA certificate + const caCert = await certificateAuthorityCertDAL.findById(ca.internalCa.activeCaCertId); + + const serialNumber = createSerialNumber(); + + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + const kmsEncryptor = await kmsService.encryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + const { caPrivateKey, caPublicKey, caSecret } = await getCaCredentials({ + caId: ca.id, + certificateAuthorityDAL, + certificateAuthoritySecretDAL, + projectDAL, + kmsService + }); + + const alg = keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm); + + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + const decryptedCaCert = await kmsDecryptor({ + cipherTextBlob: caCert.encryptedCertificate + }); + + const caCertObj = new x509.X509Certificate(decryptedCaCert); + + let certificate = ""; + let certificateChain = ""; + + switch (ca.internalCa.type) { + case CaType.ROOT: { + if (new Date(notAfter) <= new Date(caCertObj.notAfter)) { + throw new BadRequestError({ + message: + "New Root CA certificate must have notAfter date that is greater than the current certificate notAfter date" + }); + } + + const notBeforeDate = new Date(); + const cert = await x509.X509CertificateGenerator.createSelfSigned({ + name: ca.internalCa.dn, + serialNumber, + notBefore: notBeforeDate, + notAfter: new Date(notAfter), + signingAlgorithm: alg, + keys: { + privateKey: caPrivateKey, + publicKey: caPublicKey + }, + extensions: [ + new x509.BasicConstraintsExtension( + true, + ca.internalCa.maxPathLength === -1 || !ca.internalCa.maxPathLength + ? undefined + : ca.internalCa.maxPathLength, + true + ), + // eslint-disable-next-line no-bitwise + new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, true), + await x509.SubjectKeyIdentifierExtension.create(caPublicKey) + ] + }); + + const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({ + plainText: Buffer.from(new Uint8Array(cert.rawData)) + }); + + const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({ + plainText: Buffer.alloc(0) + }); + + await internalCertificateAuthorityDAL.transaction(async (tx) => { + const newCaCert = await certificateAuthorityCertDAL.create( + { + caId: ca.id, + encryptedCertificate, + encryptedCertificateChain, + version: caCert.version + 1, + caSecretId: caSecret.id + }, + tx + ); + + await internalCertificateAuthorityDAL.update( + { + certificateAuthorityId: ca.id + }, + { + activeCaCertId: newCaCert.id, + notBefore: notBeforeDate, + notAfter: new Date(notAfter) + }, + tx + ); + }); + + certificate = cert.toString("pem"); + break; + } + case CaType.INTERMEDIATE: { + if (!ca.internalCa.parentCaId) { + // TODO: look into optimal way to support renewal of intermediate CA with external parent CA + throw new BadRequestError({ + message: "Failed to renew intermediate CA certificate with external parent CA" + }); + } + + const parentCa = await certificateAuthorityDAL.findByIdWithAssociatedCa(ca.internalCa.parentCaId); + const { caPrivateKey: parentCaPrivateKey } = await getCaCredentials({ + caId: parentCa.id, + certificateAuthorityDAL, + certificateAuthoritySecretDAL, + projectDAL, + kmsService + }); + + if (!parentCa.internalCa) { + throw new BadRequestError({ message: "Parent CA not found" }); + } + + // get latest parent CA certificate + if (!parentCa.internalCa.activeCaCertId) + throw new BadRequestError({ message: "Parent CA does not have a certificate installed" }); + + const parentCaCert = await certificateAuthorityCertDAL.findById(parentCa.internalCa.activeCaCertId); + + const decryptedParentCaCert = await kmsDecryptor({ + cipherTextBlob: parentCaCert.encryptedCertificate + }); + + const parentCaCertObj = new x509.X509Certificate(decryptedParentCaCert); + + if (new Date(notAfter) <= new Date(caCertObj.notAfter)) { + throw new BadRequestError({ + message: + "New Intermediate CA certificate must have notAfter date that is greater than the current certificate notAfter date" + }); + } + + if (new Date(notAfter) > new Date(parentCaCertObj.notAfter)) { + throw new BadRequestError({ + message: + "New Intermediate CA certificate must have notAfter date that is equal to or smaller than the notAfter date of the parent CA certificate current certificate notAfter date" + }); + } + + const csrObj = await x509.Pkcs10CertificateRequestGenerator.create({ + name: ca.internalCa.dn, + keys: { + privateKey: caPrivateKey, + publicKey: caPublicKey + }, + signingAlgorithm: alg, + extensions: [ + // eslint-disable-next-line no-bitwise + new x509.KeyUsagesExtension( + x509.KeyUsageFlags.keyCertSign | + x509.KeyUsageFlags.cRLSign | + x509.KeyUsageFlags.digitalSignature | + x509.KeyUsageFlags.keyEncipherment + ) + ], + attributes: [new x509.ChallengePasswordAttribute("password")] + }); + + const notBeforeDate = new Date(); + const intermediateCert = await x509.X509CertificateGenerator.create({ + serialNumber, + subject: csrObj.subject, + issuer: parentCaCertObj.subject, + notBefore: notBeforeDate, + notAfter: new Date(notAfter), + signingKey: parentCaPrivateKey, + publicKey: csrObj.publicKey, + signingAlgorithm: alg, + extensions: [ + new x509.KeyUsagesExtension( + x509.KeyUsageFlags.keyCertSign | + x509.KeyUsageFlags.cRLSign | + x509.KeyUsageFlags.digitalSignature | + x509.KeyUsageFlags.keyEncipherment, + true + ), + new x509.BasicConstraintsExtension( + true, + ca.internalCa.maxPathLength === -1 || !ca.internalCa.maxPathLength + ? undefined + : ca.internalCa.maxPathLength, + true + ), + await x509.AuthorityKeyIdentifierExtension.create(parentCaCertObj, false), + await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey) + ] + }); + + const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({ + plainText: Buffer.from(new Uint8Array(intermediateCert.rawData)) + }); + + const { caCert: parentCaCertificate, caCertChain: parentCaCertChain } = await getCaCertChain({ + caCertId: parentCa.internalCa.activeCaCertId, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + + certificateChain = `${parentCaCertificate}\n${parentCaCertChain}`.trim(); + + const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({ + plainText: Buffer.from(certificateChain) + }); + + await internalCertificateAuthorityDAL.transaction(async (tx) => { + const newCaCert = await certificateAuthorityCertDAL.create( + { + caId: ca.id, + encryptedCertificate, + encryptedCertificateChain, + version: caCert.version + 1, + caSecretId: caSecret.id + }, + tx + ); + + await internalCertificateAuthorityDAL.update( + { + certificateAuthorityId: ca.id + }, + { + activeCaCertId: newCaCert.id, + notBefore: notBeforeDate, + notAfter: new Date(notAfter) + }, + tx + ); + }); + + certificate = intermediateCert.toString("pem"); + break; + } + default: { + throw new BadRequestError({ + message: "Unrecognized CA type" + }); + } + } + + return { + certificate, + certificateChain, + serialNumber, + ca: { + ...ca, + ...ca.internalCa + } + }; + }; + + const getCaCerts = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TGetCaCertsDTO) => { + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); + if (!ca.internalCa) throw new NotFoundError({ message: `CA with ID '${caId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: ca.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.CertificateAuthorities + ); + + const caCertChains = await getCaCertChains({ + caId, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + + return { + ca: expandInternalCa(ca), + caCerts: caCertChains + }; + }; + + /** + * Return current certificate and certificate chain for CA + */ + const getCaCert = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TGetCaCertDTO) => { + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); + if (!ca.internalCa) throw new NotFoundError({ message: `CA with ID '${caId}' not found` }); + if (!ca.internalCa.activeCaCertId) + throw new BadRequestError({ message: "CA does not have a certificate installed" }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: ca.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.CertificateAuthorities + ); + + const { caCert, caCertChain, serialNumber } = await getCaCertChain({ + caCertId: ca.internalCa.activeCaCertId, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + + return { + certificate: caCert, + certificateChain: caCertChain, + serialNumber, + ca: expandInternalCa(ca) + }; + }; + + /** + * Return CA certificate object by ID + */ + const getCaCertById = async ({ caId, caCertId }: { caId: string; caCertId: string }) => { + const caCert = await certificateAuthorityCertDAL.findOne({ + caId, + id: caCertId + }); + + if (!caCert) { + throw new NotFoundError({ message: `Ca certificate with ID '${caCertId}' not found for CA with ID '${caId}'` }); + } + + const ca = await certificateAuthorityDAL.findById(caId); + const keyId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: keyId + }); + + const decryptedCaCert = await kmsDecryptor({ + cipherTextBlob: caCert.encryptedCertificate + }); + + const caCertObj = new x509.X509Certificate(decryptedCaCert); + + return caCertObj; + }; + + /** + * Issue certificate to be imported back in for intermediate CA + */ + const signIntermediate = async ({ + caId, + actorId, + actorAuthMethod, + actor, + actorOrgId, + csr, + notBefore, + notAfter, + maxPathLength + }: TSignIntermediateDTO) => { + const appCfg = getConfig(); + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new NotFoundError({ message: "CA not found" }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: ca.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.CertificateAuthorities + ); + + if (ca.status === CaStatus.DISABLED) throw new BadRequestError({ message: "CA is disabled" }); + if (!ca.activeCaCertId) throw new BadRequestError({ message: "CA does not have a certificate installed" }); + + const caCert = await certificateAuthorityCertDAL.findById(ca.activeCaCertId); + + if (ca.notAfter && new Date() > new Date(ca.notAfter)) { + throw new BadRequestError({ message: "CA is expired" }); + } + + const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm); + + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + const decryptedCaCert = await kmsDecryptor({ + cipherTextBlob: caCert.encryptedCertificate + }); + + const caCertObj = new x509.X509Certificate(decryptedCaCert); + const csrObj = new x509.Pkcs10CertificateRequest(csr); + + // check path length constraint + const caPathLength = caCertObj.getExtension(x509.BasicConstraintsExtension)?.pathLength; + if (caPathLength !== undefined) { + if (caPathLength === 0) + throw new BadRequestError({ + message: "Failed to issue intermediate certificate due to CA path length constraint" + }); + if (maxPathLength >= caPathLength || (maxPathLength === -1 && caPathLength !== -1)) + throw new BadRequestError({ + message: "The requested path length constraint exceeds the CA's allowed path length" + }); + } + + const notBeforeDate = notBefore ? new Date(notBefore) : new Date(); + const notAfterDate = new Date(notAfter); + + const caCertNotBeforeDate = new Date(caCertObj.notBefore); + const caCertNotAfterDate = new Date(caCertObj.notAfter); + + // check not before constraint + if (notBeforeDate < caCertNotBeforeDate) { + throw new BadRequestError({ message: "notBefore date is before CA certificate's notBefore date" }); + } + + if (notBeforeDate > notAfterDate) throw new BadRequestError({ message: "notBefore date is after notAfter date" }); + + // check not after constraint + if (notAfterDate > caCertNotAfterDate) { + throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" }); + } + + const { caPrivateKey, caSecret } = await getCaCredentials({ + caId: ca.id, + certificateAuthorityDAL, + certificateAuthoritySecretDAL, + projectDAL, + kmsService + }); + + const serialNumber = createSerialNumber(); + + const caCrl = await certificateAuthorityCrlDAL.findOne({ caSecretId: caSecret.id }); + const distributionPointUrl = `${appCfg.SITE_URL}/api/v1/pki/crl/${caCrl.id}/der`; + + const caIssuerUrl = `${appCfg.SITE_URL}/api/v1/pki/ca/${ca.id}/certificates/${caCert.id}/der`; + const intermediateCert = await x509.X509CertificateGenerator.create({ + serialNumber, + subject: csrObj.subject, + issuer: caCertObj.subject, + notBefore: notBeforeDate, + notAfter: notAfterDate, + signingKey: caPrivateKey, + publicKey: csrObj.publicKey, + signingAlgorithm: alg, + extensions: [ + new x509.KeyUsagesExtension( + x509.KeyUsageFlags.keyCertSign | + x509.KeyUsageFlags.cRLSign | + x509.KeyUsageFlags.digitalSignature | + x509.KeyUsageFlags.keyEncipherment, + true + ), + new x509.BasicConstraintsExtension(true, maxPathLength === -1 ? undefined : maxPathLength, true), + await x509.AuthorityKeyIdentifierExtension.create(caCertObj, false), + await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey), + new x509.CRLDistributionPointsExtension([distributionPointUrl]), + new x509.AuthorityInfoAccessExtension({ + caIssuers: new x509.GeneralName("url", caIssuerUrl) + }) + ] + }); + + const { caCert: issuingCaCertificate, caCertChain } = await getCaCertChain({ + caCertId: ca.activeCaCertId, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + + return { + certificate: intermediateCert.toString("pem"), + issuingCaCertificate, + certificateChain: `${issuingCaCertificate}\n${caCertChain}`.trim(), + serialNumber: intermediateCert.serialNumber, + ca + }; + }; + + /** + * Import certificate for CA with id [caId]. + * Note: Can be used to import an external certificate and certificate chain + * to be into an installed or uninstalled CA. + */ + const importCertToCa = async ({ + caId, + actorId, + actorAuthMethod, + actor, + actorOrgId, + certificate, + certificateChain + }: TImportCertToCaDTO) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new NotFoundError({ message: `CA with ID '${caId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: ca.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.CertificateAuthorities + ); + + if (ca.parentCaId) { + /** + * re-evaluate in the future if we should allow users to import a new CA certificate for an intermediate + * CA chained to an internal parent CA. Doing so would allow users to re-chain the CA to a different + * internal CA. + */ + throw new BadRequestError({ + message: "Cannot import certificate to intermediate CA chained to internal parent CA" + }); + } + + const caCert = ca.activeCaCertId ? await certificateAuthorityCertDAL.findById(ca.activeCaCertId) : undefined; + + const certObj = new x509.X509Certificate(certificate); + const maxPathLength = certObj.getExtension(x509.BasicConstraintsExtension)?.pathLength; + + // validate imported certificate and certificate chain + const certificates = extractX509CertFromChain(certificateChain)?.map((cert) => new x509.X509Certificate(cert)); + + if (!certificates) throw new BadRequestError({ message: "Failed to parse certificate chain" }); + + const chain = new x509.X509ChainBuilder({ + certificates + }); + + const chainItems = await chain.build(certObj); + + // chain.build() implicitly verifies the chain + if (chainItems.length !== certificates.length + 1) + throw new BadRequestError({ message: "Invalid certificate chain" }); + + const parentCertObj = chainItems[1]; + const parentCertSubject = parentCertObj.subject; + + const parentCa = await certificateAuthorityDAL.findOne({ + projectId: ca.projectId, + dn: parentCertSubject + }); + + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + const kmsEncryptor = await kmsService.encryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({ + plainText: Buffer.from(new Uint8Array(certObj.rawData)) + }); + + const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({ + plainText: Buffer.from(certificateChain) + }); + + // TODO: validate that latest key-pair of CA is used to sign the certificate + // once renewal with new key pair is supported + const { caSecret, caPublicKey } = await getCaCredentials({ + caId: ca.id, + certificateAuthorityDAL, + certificateAuthoritySecretDAL, + projectDAL, + kmsService + }); + + const isCaAndCertPublicKeySame = Buffer.from(await crypto.subtle.exportKey("spki", caPublicKey)).equals( + Buffer.from(certObj.publicKey.rawData) + ); + + if (!isCaAndCertPublicKeySame) { + throw new BadRequestError({ message: "CA and certificate public key do not match" }); + } + + await certificateAuthorityCertDAL.transaction(async (tx) => { + const newCaCert = await certificateAuthorityCertDAL.create( + { + caId: ca.id, + encryptedCertificate, + encryptedCertificateChain, + version: caCert ? caCert.version + 1 : 1, + caSecretId: caSecret.id + }, + tx + ); + + await certificateAuthorityDAL.updateById( + ca.id, + { + status: CaStatus.ACTIVE, + maxPathLength: maxPathLength === undefined ? -1 : maxPathLength, + notBefore: new Date(certObj.notBefore), + notAfter: new Date(certObj.notAfter), + serialNumber: certObj.serialNumber, + parentCaId: parentCa?.id, + activeCaCertId: newCaCert.id + }, + tx + ); + }); + + return { ca }; + }; + + /** + * Return new leaf certificate issued by CA with id [caId] and private key. + * Note: private key and CSR are generated within Infisical. + */ + const issueCertFromCa = async ({ + caId, + certificateTemplateId, + pkiCollectionId, + friendlyName, + commonName, + altNames, + ttl, + notBefore, + notAfter, + actorId, + actorAuthMethod, + actor, + actorOrgId, + keyUsages, + extendedKeyUsages + }: TIssueCertFromCaDTO) => { + let ca: TCertificateAuthorities | undefined; + let certificateTemplate: TCertificateTemplates | undefined; + let collectionId = pkiCollectionId; + + if (caId) { + ca = await certificateAuthorityDAL.findById(caId); + } else if (certificateTemplateId) { + certificateTemplate = await certificateTemplateDAL.getById(certificateTemplateId); + if (!certificateTemplate) { + throw new NotFoundError({ + message: `Certificate template with ID '${certificateTemplateId}' not found` + }); + } + + collectionId = certificateTemplate.pkiCollectionId as string; + ca = await certificateAuthorityDAL.findById(certificateTemplate.caId); + } + + if (!ca) { + throw new NotFoundError({ message: `CA with ID '${caId}' not found` }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: ca.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateActions.Create, + ProjectPermissionSub.Certificates + ); + + if (ca.status !== CaStatus.ACTIVE) throw new BadRequestError({ message: "CA is not active" }); + if (!ca.activeCaCertId) throw new BadRequestError({ message: "CA does not have a certificate installed" }); + if (ca.requireTemplateForIssuance && !certificateTemplate) { + throw new BadRequestError({ message: "Certificate template is required for issuance" }); + } + const caCert = await certificateAuthorityCertDAL.findById(ca.activeCaCertId); + + if (ca.notAfter && new Date() > new Date(ca.notAfter)) { + throw new BadRequestError({ message: "CA is expired" }); + } + + // check PKI collection + if (collectionId) { + const pkiCollection = await pkiCollectionDAL.findById(collectionId); + if (!pkiCollection) throw new NotFoundError({ message: "PKI collection not found" }); + if (pkiCollection.projectId !== ca.projectId) throw new BadRequestError({ message: "Invalid PKI collection" }); + } + + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + const decryptedCaCert = await kmsDecryptor({ + cipherTextBlob: caCert.encryptedCertificate + }); + + const caCertObj = new x509.X509Certificate(decryptedCaCert); + + const notBeforeDate = notBefore ? new Date(notBefore) : new Date(); + + let notAfterDate = new Date(new Date().setFullYear(new Date().getFullYear() + 1)); + if (notAfter) { + notAfterDate = new Date(notAfter); + } else if (ttl) { + notAfterDate = new Date(new Date().getTime() + ms(ttl)); + } + + const caCertNotBeforeDate = new Date(caCertObj.notBefore); + const caCertNotAfterDate = new Date(caCertObj.notAfter); + + // check not before constraint + if (notBeforeDate < caCertNotBeforeDate) { + throw new BadRequestError({ message: "notBefore date is before CA certificate's notBefore date" }); + } + + if (notBeforeDate > notAfterDate) throw new BadRequestError({ message: "notBefore date is after notAfter date" }); + + // check not after constraint + if (notAfterDate > caCertNotAfterDate) { + throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" }); + } + + const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm); + const leafKeys = await crypto.subtle.generateKey(alg, true, ["sign", "verify"]); + + const csrObj = await x509.Pkcs10CertificateRequestGenerator.create({ + name: `CN=${commonName}`, + keys: leafKeys, + signingAlgorithm: alg, + extensions: [ + // eslint-disable-next-line no-bitwise + new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment) + ], + attributes: [new x509.ChallengePasswordAttribute("password")] + }); + + const { caPrivateKey, caSecret } = await getCaCredentials({ + caId: ca.id, + certificateAuthorityDAL, + certificateAuthoritySecretDAL, + projectDAL, + kmsService + }); + + const caCrl = await certificateAuthorityCrlDAL.findOne({ caSecretId: caSecret.id }); + const appCfg = getConfig(); + + const distributionPointUrl = `${appCfg.SITE_URL}/api/v1/pki/crl/${caCrl.id}/der`; + const caIssuerUrl = `${appCfg.SITE_URL}/api/v1/pki/ca/${ca.id}/certificates/${caCert.id}/der`; + + const extensions: x509.Extension[] = [ + new x509.BasicConstraintsExtension(false), + new x509.CRLDistributionPointsExtension([distributionPointUrl]), + await x509.AuthorityKeyIdentifierExtension.create(caCertObj, false), + await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey), + new x509.AuthorityInfoAccessExtension({ + caIssuers: new x509.GeneralName("url", caIssuerUrl) + }), + new x509.CertificatePolicyExtension(["2.5.29.32.0"]) // anyPolicy + ]; + + // handle key usages + let selectedKeyUsages: CertKeyUsage[] = keyUsages ?? []; + if (keyUsages === undefined && !certificateTemplate) { + selectedKeyUsages = [CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT]; + } + + if (keyUsages === undefined && certificateTemplate) { + selectedKeyUsages = (certificateTemplate.keyUsages ?? []) as CertKeyUsage[]; + } + + if (keyUsages?.length && certificateTemplate) { + const validKeyUsages = certificateTemplate.keyUsages || []; + if (keyUsages.some((keyUsage) => !validKeyUsages.includes(keyUsage))) { + throw new BadRequestError({ + message: "Invalid key usage value based on template policy" + }); + } + selectedKeyUsages = keyUsages; + } + + const keyUsagesBitValue = selectedKeyUsages.reduce((accum, keyUsage) => accum | x509.KeyUsageFlags[keyUsage], 0); + if (keyUsagesBitValue) { + extensions.push(new x509.KeyUsagesExtension(keyUsagesBitValue, true)); + } + + // handle extended key usages + let selectedExtendedKeyUsages: CertExtendedKeyUsage[] = extendedKeyUsages ?? []; + if (extendedKeyUsages === undefined && certificateTemplate) { + selectedExtendedKeyUsages = (certificateTemplate.extendedKeyUsages ?? []) as CertExtendedKeyUsage[]; + } + + if (extendedKeyUsages?.length && certificateTemplate) { + const validExtendedKeyUsages = certificateTemplate.extendedKeyUsages || []; + if (extendedKeyUsages.some((eku) => !validExtendedKeyUsages.includes(eku))) { + throw new BadRequestError({ + message: "Invalid extended key usage value based on template policy" + }); + } + selectedExtendedKeyUsages = extendedKeyUsages; + } + + if (selectedExtendedKeyUsages.length) { + extensions.push( + new x509.ExtendedKeyUsageExtension( + selectedExtendedKeyUsages.map((eku) => x509.ExtendedKeyUsage[eku]), + true + ) + ); + } + + let altNamesArray: { + type: "email" | "dns"; + value: string; + }[] = []; + + if (altNames) { + altNamesArray = altNames + .split(",") + .map((name) => name.trim()) + .map((altName) => { + // check if the altName is a valid email + if (z.string().email().safeParse(altName).success) { + return { + type: "email", + value: altName + }; + } + + // check if the altName is a valid hostname + if (isFQDN(altName, { allow_wildcard: true })) { + return { + type: "dns", + value: altName + }; + } + + // If altName is neither a valid email nor a valid hostname, throw an error or handle it accordingly + throw new Error(`Invalid altName: ${altName}`); + }); + + const altNamesExtension = new x509.SubjectAlternativeNameExtension(altNamesArray, false); + extensions.push(altNamesExtension); + } + + if (certificateTemplate) { + validateCertificateDetailsAgainstTemplate( + { + commonName, + notBeforeDate, + notAfterDate, + altNames: altNamesArray.map((entry) => entry.value) + }, + certificateTemplate + ); + } + + const serialNumber = createSerialNumber(); + const leafCert = await x509.X509CertificateGenerator.create({ + serialNumber, + subject: csrObj.subject, + issuer: caCertObj.subject, + notBefore: notBeforeDate, + notAfter: notAfterDate, + signingKey: caPrivateKey, + publicKey: csrObj.publicKey, + signingAlgorithm: alg, + extensions + }); + + const skLeafObj = KeyObject.from(leafKeys.privateKey); + const skLeaf = skLeafObj.export({ format: "pem", type: "pkcs8" }) as string; + + const kmsEncryptor = await kmsService.encryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({ + plainText: Buffer.from(new Uint8Array(leafCert.rawData)) + }); + const { cipherTextBlob: encryptedPrivateKey } = await kmsEncryptor({ + plainText: Buffer.from(skLeaf) + }); + + const { caCert: issuingCaCertificate, caCertChain } = await getCaCertChain({ + caCertId: caCert.id, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + + const certificateChainPem = `${issuingCaCertificate}\n${caCertChain}`.trim(); + + const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({ + plainText: Buffer.from(certificateChainPem) + }); + + await certificateDAL.transaction(async (tx) => { + const cert = await certificateDAL.create( + { + caId: (ca as TCertificateAuthorities).id, + caCertId: caCert.id, + certificateTemplateId: certificateTemplate?.id, + status: CertStatus.ACTIVE, + friendlyName: friendlyName || commonName, + commonName, + altNames, + serialNumber, + notBefore: notBeforeDate, + notAfter: notAfterDate, + keyUsages: selectedKeyUsages, + extendedKeyUsages: selectedExtendedKeyUsages + }, + tx + ); + + await certificateBodyDAL.create( + { + certId: cert.id, + encryptedCertificate, + encryptedCertificateChain + }, + tx + ); + + await certificateSecretDAL.create( + { + certId: cert.id, + encryptedPrivateKey + }, + tx + ); + + if (collectionId) { + await pkiCollectionItemDAL.create( + { + pkiCollectionId: collectionId, + certId: cert.id + }, + tx + ); + } + + return cert; + }); + + return { + certificate: leafCert.toString("pem"), + certificateChain: certificateChainPem, + issuingCaCertificate, + privateKey: skLeaf, + serialNumber, + ca + }; + }; + + /** + * Return new leaf certificate issued by CA with id [caId]. + * Note: CSR is generated externally and submitted to Infisical. + */ + const signCertFromCa = async (dto: TSignCertFromCaDTO) => { + const appCfg = getConfig(); + let ca: TCertificateAuthorities | undefined; + let certificateTemplate: TCertificateTemplates | undefined; + + const { + caId, + certificateTemplateId, + csr, + pkiCollectionId, + friendlyName, + commonName, + altNames, + ttl, + notBefore, + notAfter, + keyUsages, + extendedKeyUsages + } = dto; + + let collectionId = pkiCollectionId; + + if (caId) { + ca = await certificateAuthorityDAL.findById(caId); + } else if (certificateTemplateId) { + certificateTemplate = await certificateTemplateDAL.getById(certificateTemplateId); + if (!certificateTemplate) { + throw new NotFoundError({ + message: `Certificate template with ID '${certificateTemplateId}' not found` + }); + } + + collectionId = certificateTemplate.pkiCollectionId as string; + ca = await certificateAuthorityDAL.findById(certificateTemplate.caId); + } + + if (!ca) { + throw new NotFoundError({ message: `CA with ID '${caId}' not found` }); + } + + if (!dto.isInternal) { + const { permission } = await permissionService.getProjectPermission({ + actor: dto.actor, + actorId: dto.actorId, + projectId: ca.projectId, + actorAuthMethod: dto.actorAuthMethod, + actorOrgId: dto.actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateActions.Create, + ProjectPermissionSub.Certificates + ); + } + + if (ca.status !== CaStatus.ACTIVE) throw new BadRequestError({ message: "CA is not active" }); + if (!ca.activeCaCertId) throw new BadRequestError({ message: "CA does not have a certificate installed" }); + if (ca.requireTemplateForIssuance && !certificateTemplate) { + throw new BadRequestError({ message: "Certificate template is required for issuance" }); + } + + const caCert = await certificateAuthorityCertDAL.findById(ca.activeCaCertId); + + if (ca.notAfter && new Date() > new Date(ca.notAfter)) { + throw new BadRequestError({ message: "CA is expired" }); + } + + // check PKI collection + if (pkiCollectionId) { + const pkiCollection = await pkiCollectionDAL.findById(pkiCollectionId); + if (!pkiCollection) throw new NotFoundError({ message: `PKI collection with ID '${pkiCollectionId}' not found` }); + if (pkiCollection.projectId !== ca.projectId) throw new BadRequestError({ message: "Invalid PKI collection" }); + } + + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + const decryptedCaCert = await kmsDecryptor({ + cipherTextBlob: caCert.encryptedCertificate + }); + + const caCertObj = new x509.X509Certificate(decryptedCaCert); + + const notBeforeDate = notBefore ? new Date(notBefore) : new Date(); + + let notAfterDate = new Date(new Date().setFullYear(new Date().getFullYear() + 1)); + if (notAfter) { + notAfterDate = new Date(notAfter); + } else if (ttl) { + notAfterDate = new Date(new Date().getTime() + ms(ttl)); + } else if (certificateTemplate?.ttl) { + notAfterDate = new Date(new Date().getTime() + ms(certificateTemplate.ttl)); + } + + const caCertNotBeforeDate = new Date(caCertObj.notBefore); + const caCertNotAfterDate = new Date(caCertObj.notAfter); + + // check not before constraint + if (notBeforeDate < caCertNotBeforeDate) { + throw new BadRequestError({ message: "notBefore date is before CA certificate's notBefore date" }); + } + + if (notBeforeDate > notAfterDate) throw new BadRequestError({ message: "notBefore date is after notAfter date" }); + + // check not after constraint + if (notAfterDate > caCertNotAfterDate) { + throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" }); + } + + const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm); + + const csrObj = new x509.Pkcs10CertificateRequest(csr); + + const dn = parseDistinguishedName(csrObj.subject); + const cn = commonName || dn.commonName; + + if (!cn) + throw new BadRequestError({ + message: "A common name (CN) is required in the CSR or as a parameter to this endpoint" + }); + + const { caPrivateKey, caSecret } = await getCaCredentials({ + caId: ca.id, + certificateAuthorityDAL, + certificateAuthoritySecretDAL, + projectDAL, + kmsService + }); + + const caCrl = await certificateAuthorityCrlDAL.findOne({ caSecretId: caSecret.id }); + const distributionPointUrl = `${appCfg.SITE_URL}/api/v1/pki/crl/${caCrl.id}/der`; + + const caIssuerUrl = `${appCfg.SITE_URL}/api/v1/pki/ca/${ca.id}/certificates/${caCert.id}/der`; + const extensions: x509.Extension[] = [ + new x509.BasicConstraintsExtension(false), + await x509.AuthorityKeyIdentifierExtension.create(caCertObj, false), + await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey), + new x509.CRLDistributionPointsExtension([distributionPointUrl]), + new x509.AuthorityInfoAccessExtension({ + caIssuers: new x509.GeneralName("url", caIssuerUrl) + }), + new x509.CertificatePolicyExtension(["2.5.29.32.0"]) // anyPolicy + ]; + + // handle key usages + const csrKeyUsageExtension = csrObj.getExtension("2.5.29.15") as x509.KeyUsagesExtension; + let csrKeyUsages: CertKeyUsage[] = []; + if (csrKeyUsageExtension) { + csrKeyUsages = Object.values(CertKeyUsage).filter( + (keyUsage) => (x509.KeyUsageFlags[keyUsage] & csrKeyUsageExtension.usages) !== 0 + ); + } + + let selectedKeyUsages: CertKeyUsage[] = keyUsages ?? []; + if (keyUsages === undefined && !certificateTemplate) { + if (csrKeyUsageExtension) { + selectedKeyUsages = csrKeyUsages; + } else { + selectedKeyUsages = [CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT]; + } + } + + if (keyUsages === undefined && certificateTemplate) { + if (csrKeyUsageExtension) { + const validKeyUsages = certificateTemplate.keyUsages || []; + if (csrKeyUsages.some((keyUsage) => !validKeyUsages.includes(keyUsage))) { + throw new BadRequestError({ + message: "Invalid key usage value based on template policy" + }); + } + selectedKeyUsages = csrKeyUsages; + } else { + selectedKeyUsages = (certificateTemplate.keyUsages ?? []) as CertKeyUsage[]; + } + } + + if (keyUsages?.length && certificateTemplate) { + const validKeyUsages = certificateTemplate.keyUsages || []; + if (keyUsages.some((keyUsage) => !validKeyUsages.includes(keyUsage))) { + throw new BadRequestError({ + message: "Invalid key usage value based on template policy" + }); + } + selectedKeyUsages = keyUsages; + } + + const keyUsagesBitValue = selectedKeyUsages.reduce((accum, keyUsage) => accum | x509.KeyUsageFlags[keyUsage], 0); + if (keyUsagesBitValue) { + extensions.push(new x509.KeyUsagesExtension(keyUsagesBitValue, true)); + } + + // handle extended key usages + const csrExtendedKeyUsageExtension = csrObj.getExtension("2.5.29.37") as x509.ExtendedKeyUsageExtension; + let csrExtendedKeyUsages: CertExtendedKeyUsage[] = []; + if (csrExtendedKeyUsageExtension) { + csrExtendedKeyUsages = csrExtendedKeyUsageExtension.usages.map( + (ekuOid) => CertExtendedKeyUsageOIDToName[ekuOid as string] + ); + } + + let selectedExtendedKeyUsages: CertExtendedKeyUsage[] = extendedKeyUsages ?? []; + if (extendedKeyUsages === undefined && !certificateTemplate && csrExtendedKeyUsageExtension) { + selectedExtendedKeyUsages = csrExtendedKeyUsages; + } + + if (extendedKeyUsages === undefined && certificateTemplate) { + if (csrExtendedKeyUsageExtension) { + const validExtendedKeyUsages = certificateTemplate.extendedKeyUsages || []; + if (csrExtendedKeyUsages.some((eku) => !validExtendedKeyUsages.includes(eku))) { + throw new BadRequestError({ + message: "Invalid extended key usage value based on template policy" + }); + } + selectedExtendedKeyUsages = csrExtendedKeyUsages; + } else { + selectedExtendedKeyUsages = (certificateTemplate.extendedKeyUsages ?? []) as CertExtendedKeyUsage[]; + } + } + + if (extendedKeyUsages?.length && certificateTemplate) { + const validExtendedKeyUsages = certificateTemplate.extendedKeyUsages || []; + if (extendedKeyUsages.some((keyUsage) => !validExtendedKeyUsages.includes(keyUsage))) { + throw new BadRequestError({ + message: "Invalid extended key usage value based on template policy" + }); + } + selectedExtendedKeyUsages = extendedKeyUsages; + } + + if (selectedExtendedKeyUsages.length) { + extensions.push( + new x509.ExtendedKeyUsageExtension( + selectedExtendedKeyUsages.map((eku) => x509.ExtendedKeyUsage[eku]), + true + ) + ); + } + + let altNamesFromCsr: string = ""; + let altNamesArray: { + type: "email" | "dns"; + value: string; + }[] = []; + if (altNames) { + altNamesArray = altNames + .split(",") + .map((name) => name.trim()) + .map((altName) => { + // check if the altName is a valid email + if (z.string().email().safeParse(altName).success) { + return { + type: "email", + value: altName + }; + } + + // check if the altName is a valid hostname + if (isFQDN(altName, { allow_wildcard: true })) { + return { + type: "dns", + value: altName + }; + } + + // If altName is neither a valid email nor a valid hostname, throw an error or handle it accordingly + throw new Error(`Invalid altName: ${altName}`); + }); + } else { + // attempt to read from CSR if altNames is not explicitly provided + const sanExtension = csrObj.extensions.find((ext) => ext.type === "2.5.29.17"); + if (sanExtension) { + const sanNames = new x509.GeneralNames(sanExtension.value); + + altNamesArray = sanNames.items + .filter((value) => value.type === "email" || value.type === "dns") + .map((name) => ({ + type: name.type as "email" | "dns", + value: name.value + })); + + altNamesFromCsr = sanNames.items.map((item) => item.value).join(","); + } + } + + if (altNamesArray.length) { + const altNamesExtension = new x509.SubjectAlternativeNameExtension(altNamesArray, false); + extensions.push(altNamesExtension); + } + + if (certificateTemplate) { + validateCertificateDetailsAgainstTemplate( + { + commonName: cn, + notBeforeDate, + notAfterDate, + altNames: altNamesArray.map((entry) => entry.value) + }, + certificateTemplate + ); + } + + const serialNumber = createSerialNumber(); + const leafCert = await x509.X509CertificateGenerator.create({ + serialNumber, + subject: csrObj.subject, + issuer: caCertObj.subject, + notBefore: notBeforeDate, + notAfter: notAfterDate, + signingKey: caPrivateKey, + publicKey: csrObj.publicKey, + signingAlgorithm: alg, + extensions + }); + + const kmsEncryptor = await kmsService.encryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({ + plainText: Buffer.from(new Uint8Array(leafCert.rawData)) + }); + + await certificateDAL.transaction(async (tx) => { + const cert = await certificateDAL.create( + { + caId: (ca as TCertificateAuthorities).id, + caCertId: caCert.id, + certificateTemplateId: certificateTemplate?.id, + status: CertStatus.ACTIVE, + friendlyName: friendlyName || csrObj.subject, + commonName: cn, + altNames: altNamesFromCsr || altNames, + serialNumber, + notBefore: notBeforeDate, + notAfter: notAfterDate, + keyUsages: selectedKeyUsages, + extendedKeyUsages: selectedExtendedKeyUsages + }, + tx + ); + + await certificateBodyDAL.create( + { + certId: cert.id, + encryptedCertificate + }, + tx + ); + + if (collectionId) { + await pkiCollectionItemDAL.create( + { + pkiCollectionId: collectionId, + certId: cert.id + }, + tx + ); + } + + return cert; + }); + + const { caCert: issuingCaCertificate, caCertChain } = await getCaCertChain({ + caCertId: ca.activeCaCertId, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + + return { + certificate: leafCert, + certificateChain: `${issuingCaCertificate}\n${caCertChain}`.trim(), + issuingCaCertificate, + serialNumber, + ca, + commonName: cn + }; + }; + + /** + * Return list of certificate templates for CA with id [caId]. + */ + const getCaCertificateTemplates = async ({ + caId, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TGetCaCertificateTemplatesDTO) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new NotFoundError({ message: `CA with ID '${caId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: ca.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.CertificateTemplates + ); + + const certificateTemplates = await certificateTemplateDAL.find({ caId }); + + return { + certificateTemplates, + ca + }; + }; + + return { + createCa, + getCaById, + updateCaById, + deleteCaById, + getCaCsr, + renewCaCert, + getCaCerts, + getCaCert, + getCaCertById, + signIntermediate, + importCertToCa, + issueCertFromCa, + signCertFromCa, + getCaCertificateTemplates + }; +};