diff --git a/.env.example b/.env.example index a6f134ace..be7e0a621 100644 --- a/.env.example +++ b/.env.example @@ -70,3 +70,5 @@ NEXT_PUBLIC_CAPTCHA_SITE_KEY= PLAIN_API_KEY= PLAIN_WISH_LABEL_IDS= + +SSL_CLIENT_CERTIFICATE_HEADER_KEY= diff --git a/backend/package-lock.json b/backend/package-lock.json index c07b9dce8..27ee940d3 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -74,6 +74,7 @@ "pg-query-stream": "^4.5.3", "picomatch": "^3.0.1", "pino": "^8.16.2", + "pkijs": "^3.2.4", "posthog-node": "^3.6.2", "probot": "^13.0.0", "safe-regex": "^2.1.1", @@ -4457,6 +4458,17 @@ "dev": true, "optional": true }, + "node_modules/@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@node-saml/node-saml": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@node-saml/node-saml/-/node-saml-4.0.5.tgz", @@ -8481,6 +8493,14 @@ "node": ">= 0.8" } }, + "node_modules/bytestreamjs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz", + "integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -14120,6 +14140,22 @@ "pathe": "^1.1.0" } }, + "node_modules/pkijs": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.2.4.tgz", + "integrity": "sha512-Et9V5QpvBilPFgagJcaKBqXjKrrgF5JL2mSDELk1vvbOTt4fuBhSSsGn9Tcz0TQTfS5GCpXQ31Whrpqeqp0VRg==", + "dependencies": { + "@noble/hashes": "^1.4.0", + "asn1js": "^3.0.5", + "bytestreamjs": "^2.0.0", + "pvtsutils": "^1.3.2", + "pvutils": "^1.1.3", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/plimit-lit": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/plimit-lit/-/plimit-lit-1.6.1.tgz", @@ -16268,9 +16304,9 @@ } }, "node_modules/tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" }, "node_modules/tsup": { "version": "8.0.1", diff --git a/backend/package.json b/backend/package.json index e2b6fa685..043394769 100644 --- a/backend/package.json +++ b/backend/package.json @@ -171,6 +171,7 @@ "pg-query-stream": "^4.5.3", "picomatch": "^3.0.1", "pino": "^8.16.2", + "pkijs": "^3.2.4", "posthog-node": "^3.6.2", "probot": "^13.0.0", "safe-regex": "^2.1.1", diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index e5c2e19e4..f181d1c23 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -36,6 +36,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 { TCertificateEstServiceFactory } from "@app/services/certificate-est/certificate-est-service"; import { TCertificateTemplateServiceFactory } from "@app/services/certificate-template/certificate-template-service"; import { TGroupProjectServiceFactory } from "@app/services/group-project/group-project-service"; import { TIdentityServiceFactory } from "@app/services/identity/identity-service"; @@ -160,6 +161,7 @@ declare module "fastify" { certificateTemplate: TCertificateTemplateServiceFactory; certificateAuthority: TCertificateAuthorityServiceFactory; certificateAuthorityCrl: TCertificateAuthorityCrlServiceFactory; + certificateEst: TCertificateEstServiceFactory; pkiCollection: TPkiCollectionServiceFactory; secretScanning: TSecretScanningServiceFactory; license: TLicenseServiceFactory; diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 6a2cdfcb9..f689f3357 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -53,6 +53,9 @@ import { TCertificateSecretsUpdate, TCertificatesInsert, TCertificatesUpdate, + TCertificateTemplateEstConfigs, + TCertificateTemplateEstConfigsInsert, + TCertificateTemplateEstConfigsUpdate, TCertificateTemplates, TCertificateTemplatesInsert, TCertificateTemplatesUpdate, @@ -372,6 +375,11 @@ declare module "knex/types/tables" { TCertificateTemplatesInsert, TCertificateTemplatesUpdate >; + [TableName.CertificateTemplateEstConfig]: KnexOriginal.CompositeTableType< + TCertificateTemplateEstConfigs, + TCertificateTemplateEstConfigsInsert, + TCertificateTemplateEstConfigsUpdate + >; [TableName.CertificateBody]: KnexOriginal.CompositeTableType< TCertificateBodies, TCertificateBodiesInsert, diff --git a/backend/src/db/migrations/20240819092916_certificate-template-est-configuration.ts b/backend/src/db/migrations/20240819092916_certificate-template-est-configuration.ts new file mode 100644 index 000000000..38dae1710 --- /dev/null +++ b/backend/src/db/migrations/20240819092916_certificate-template-est-configuration.ts @@ -0,0 +1,26 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + const hasEstConfigTable = await knex.schema.hasTable(TableName.CertificateTemplateEstConfig); + if (!hasEstConfigTable) { + await knex.schema.createTable(TableName.CertificateTemplateEstConfig, (tb) => { + tb.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + tb.uuid("certificateTemplateId").notNullable().unique(); + tb.foreign("certificateTemplateId").references("id").inTable(TableName.CertificateTemplate).onDelete("CASCADE"); + tb.binary("encryptedCaChain").notNullable(); + tb.string("hashedPassphrase").notNullable(); + tb.boolean("isEnabled").notNullable(); + tb.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.CertificateTemplateEstConfig); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.CertificateTemplateEstConfig); + await dropOnUpdateTrigger(knex, TableName.CertificateTemplateEstConfig); +} diff --git a/backend/src/db/schemas/certificate-template-est-configs.ts b/backend/src/db/schemas/certificate-template-est-configs.ts new file mode 100644 index 000000000..654b5413a --- /dev/null +++ b/backend/src/db/schemas/certificate-template-est-configs.ts @@ -0,0 +1,29 @@ +// 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 { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const CertificateTemplateEstConfigsSchema = z.object({ + id: z.string().uuid(), + certificateTemplateId: z.string().uuid(), + encryptedCaChain: zodBuffer, + hashedPassphrase: z.string(), + isEnabled: z.boolean(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TCertificateTemplateEstConfigs = z.infer; +export type TCertificateTemplateEstConfigsInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TCertificateTemplateEstConfigsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index bfa3a0a0e..2a089df2b 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -14,6 +14,7 @@ export * from "./certificate-authority-crl"; export * from "./certificate-authority-secret"; export * from "./certificate-bodies"; export * from "./certificate-secrets"; +export * from "./certificate-template-est-configs"; export * from "./certificate-templates"; export * from "./certificates"; export * from "./dynamic-secret-leases"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 03f7d6a3b..ba9dfcae1 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -3,6 +3,7 @@ import { z } from "zod"; export enum TableName { Users = "users", CertificateAuthority = "certificate_authorities", + CertificateTemplateEstConfig = "certificate_template_est_configs", CertificateAuthorityCert = "certificate_authority_certs", CertificateAuthoritySecret = "certificate_authority_secret", CertificateAuthorityCrl = "certificate_authority_crl", diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 4789200b9..981b3777e 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -166,7 +166,10 @@ export enum EventType { CREATE_CERTIFICATE_TEMPLATE = "create-certificate-template", UPDATE_CERTIFICATE_TEMPLATE = "update-certificate-template", DELETE_CERTIFICATE_TEMPLATE = "delete-certificate-template", - GET_CERTIFICATE_TEMPLATE = "get-certificate-template" + GET_CERTIFICATE_TEMPLATE = "get-certificate-template", + CREATE_CERTIFICATE_TEMPLATE_EST_CONFIG = "create-certificate-template-est-config", + UPDATE_CERTIFICATE_TEMPLATE_EST_CONFIG = "update-certificate-template-est-config", + GET_CERTIFICATE_TEMPLATE_EST_CONFIG = "get-certificate-template-est-config" } interface UserActorMetadata { @@ -1420,6 +1423,29 @@ interface OrgAdminAccessProjectEvent { }; // no metadata yet } +interface CreateCertificateTemplateEstConfig { + type: EventType.CREATE_CERTIFICATE_TEMPLATE_EST_CONFIG; + metadata: { + certificateTemplateId: string; + isEnabled: boolean; + }; +} + +interface UpdateCertificateTemplateEstConfig { + type: EventType.UPDATE_CERTIFICATE_TEMPLATE_EST_CONFIG; + metadata: { + certificateTemplateId: string; + isEnabled: boolean; + }; +} + +interface GetCertificateTemplateEstConfig { + type: EventType.GET_CERTIFICATE_TEMPLATE_EST_CONFIG; + metadata: { + certificateTemplateId: string; + }; +} + export type Event = | GetSecretsEvent | GetSecretEvent @@ -1547,4 +1573,7 @@ export type Event = | CreateCertificateTemplate | UpdateCertificateTemplate | GetCertificateTemplate - | DeleteCertificateTemplate; + | DeleteCertificateTemplate + | CreateCertificateTemplateEstConfig + | UpdateCertificateTemplateEstConfig + | GetCertificateTemplateEstConfig; diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index dea905aa2..ca19fd425 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -142,7 +142,8 @@ const envSchema = z CAPTCHA_SECRET: zpStr(z.string().optional()), PLAIN_API_KEY: zpStr(z.string().optional()), PLAIN_WISH_LABEL_IDS: zpStr(z.string().optional()), - DISABLE_AUDIT_LOG_GENERATION: zodStrBool.default("false") + DISABLE_AUDIT_LOG_GENERATION: zodStrBool.default("false"), + SSL_CLIENT_CERTIFICATE_HEADER_KEY: zpStr(z.string().optional()).default("x-ssl-client-cert") }) .transform((data) => ({ ...data, diff --git a/backend/src/server/plugins/auth/inject-identity.ts b/backend/src/server/plugins/auth/inject-identity.ts index d8814dd40..41a9e65d8 100644 --- a/backend/src/server/plugins/auth/inject-identity.ts +++ b/backend/src/server/plugins/auth/inject-identity.ts @@ -57,7 +57,6 @@ const extractAuth = async (req: FastifyRequest, jwtSecret: string) => { return { authMode: AuthMode.API_KEY, token: apiKey, actor: ActorType.USER } as const; } const authHeader = req.headers?.authorization; - if (!authHeader) return { authMode: null, token: null }; const authTokenValue = authHeader.slice(7); // slice of after Bearer @@ -103,12 +102,13 @@ export const injectIdentity = fp(async (server: FastifyZodProvider) => { server.decorateRequest("auth", null); server.addHook("onRequest", async (req) => { const appCfg = getConfig(); - const { authMode, token, actor } = await extractAuth(req, appCfg.AUTH_SECRET); - if (req.url.includes("/api/v3/auth/")) { + if (req.url.includes(".well-known/est") || req.url.includes("/api/v3/auth/")) { return; } + const { authMode, token, actor } = await extractAuth(req, appCfg.AUTH_SECRET); + if (!authMode) return; switch (authMode) { diff --git a/backend/src/server/routes/est/certificate-est-router.ts b/backend/src/server/routes/est/certificate-est-router.ts new file mode 100644 index 000000000..1eec348ce --- /dev/null +++ b/backend/src/server/routes/est/certificate-est-router.ts @@ -0,0 +1,168 @@ +import bcrypt from "bcrypt"; +import { z } from "zod"; + +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; + +export const registerCertificateEstRouter = async (server: FastifyZodProvider) => { + const appCfg = getConfig(); + + // add support for CSR bodies + server.addContentTypeParser("application/pkcs10", { parseAs: "string" }, (_, body, done) => { + try { + let csrBody = body as string; + // some EST clients send CSRs in PEM format and some in base64 format + // for CSRs sent in PEM, we leave them as is + // for CSRs sent in base64, we preprocess them to remove new lines and spaces + if (!csrBody.includes("BEGIN CERTIFICATE REQUEST")) { + csrBody = csrBody.replace(/\n/g, "").replace(/ /g, ""); + } + + done(null, csrBody); + } catch (err) { + const error = err as Error; + done(error, undefined); + } + }); + + // Authenticate EST client using Passphrase + server.addHook("onRequest", async (req, res) => { + const { authorization } = req.headers; + const urlFragments = req.url.split("/"); + + // cacerts endpoint should not have any authentication + if (urlFragments[urlFragments.length - 1] === "cacerts") { + return; + } + + if (!authorization) { + const wwwAuthenticateHeader = "WWW-Authenticate"; + const errAuthRequired = "Authentication required"; + + await res.hijack(); + + res.raw.setHeader(wwwAuthenticateHeader, `Basic realm="infisical"`); + res.raw.setHeader("Content-Length", 0); + res.raw.statusCode = 401; + + // Write the error message to the response without ending the connection + res.raw.write(errAuthRequired); + + // flush headers + res.raw.flushHeaders(); + return; + } + + const certificateTemplateId = urlFragments.slice(-2)[0]; + const estConfig = await server.services.certificateTemplate.getEstConfiguration({ + isInternal: true, + certificateTemplateId + }); + + if (!estConfig.isEnabled) { + throw new BadRequestError({ + message: "EST is disabled" + }); + } + + const rawCredential = authorization?.split(" ").pop(); + if (!rawCredential) { + throw new UnauthorizedError({ message: "Missing HTTP credentials" }); + } + + // expected format is user:password + const basicCredential = atob(rawCredential); + const password = basicCredential.split(":").pop(); + if (!password) { + throw new BadRequestError({ + message: "No password provided" + }); + } + + const isPasswordValid = await bcrypt.compare(password, estConfig.hashedPassphrase); + if (!isPasswordValid) { + throw new UnauthorizedError({ + message: "Invalid credentials" + }); + } + }); + + server.route({ + method: "POST", + url: "/:certificateTemplateId/simpleenroll", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.string().min(1), + params: z.object({ + certificateTemplateId: z.string().min(1) + }), + response: { + 200: z.string() + } + }, + handler: async (req, res) => { + void res.header("Content-Type", "application/pkcs7-mime; smime-type=certs-only"); + void res.header("Content-Transfer-Encoding", "base64"); + + return server.services.certificateEst.simpleEnroll({ + csr: req.body, + certificateTemplateId: req.params.certificateTemplateId, + sslClientCert: req.headers[appCfg.SSL_CLIENT_CERTIFICATE_HEADER_KEY] as string + }); + } + }); + + server.route({ + method: "POST", + url: "/:certificateTemplateId/simplereenroll", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.string().min(1), + params: z.object({ + certificateTemplateId: z.string().min(1) + }), + response: { + 200: z.string() + } + }, + handler: async (req, res) => { + void res.header("Content-Type", "application/pkcs7-mime; smime-type=certs-only"); + void res.header("Content-Transfer-Encoding", "base64"); + + return server.services.certificateEst.simpleReenroll({ + csr: req.body, + certificateTemplateId: req.params.certificateTemplateId, + sslClientCert: req.headers[appCfg.SSL_CLIENT_CERTIFICATE_HEADER_KEY] as string + }); + } + }); + + server.route({ + method: "GET", + url: "/:certificateTemplateId/cacerts", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + certificateTemplateId: z.string().min(1) + }), + response: { + 200: z.string() + } + }, + handler: async (req, res) => { + void res.header("Content-Type", "application/pkcs7-mime; smime-type=certs-only"); + void res.header("Content-Transfer-Encoding", "base64"); + + return server.services.certificateEst.getCaCerts({ + certificateTemplateId: req.params.certificateTemplateId + }); + } + }); +}; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 7a1c405b3..5d20f1cff 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -90,7 +90,9 @@ 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 { certificateEstServiceFactory } from "@app/services/certificate-est/certificate-est-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"; import { groupProjectDALFactory } from "@app/services/group-project/group-project-dal"; import { groupProjectMembershipRoleDALFactory } from "@app/services/group-project/group-project-membership-role-dal"; @@ -195,6 +197,7 @@ import { injectIdentity } from "../plugins/auth/inject-identity"; import { injectPermission } from "../plugins/auth/inject-permission"; import { injectRateLimits } from "../plugins/inject-rate-limits"; import { registerSecretScannerGhApp } from "../plugins/secret-scanner"; +import { registerCertificateEstRouter } from "./est/certificate-est-router"; import { registerV1Routes } from "./v1"; import { registerV2Routes } from "./v2"; import { registerV3Routes } from "./v3"; @@ -600,6 +603,7 @@ export const registerRoutes = async ( const certificateAuthoritySecretDAL = certificateAuthoritySecretDALFactory(db); const certificateAuthorityCrlDAL = certificateAuthorityCrlDALFactory(db); const certificateTemplateDAL = certificateTemplateDALFactory(db); + const certificateTemplateEstConfigDAL = certificateTemplateEstConfigDALFactory(db); const certificateDAL = certificateDALFactory(db); const certificateBodyDAL = certificateBodyDALFactory(db); @@ -657,8 +661,21 @@ export const registerRoutes = async ( const certificateTemplateService = certificateTemplateServiceFactory({ certificateTemplateDAL, + certificateTemplateEstConfigDAL, certificateAuthorityDAL, - permissionService + permissionService, + kmsService, + projectDAL + }); + + const certificateEstService = certificateEstServiceFactory({ + certificateAuthorityService, + certificateTemplateService, + certificateTemplateDAL, + certificateAuthorityCertDAL, + certificateAuthorityDAL, + projectDAL, + kmsService }); const pkiAlertService = pkiAlertServiceFactory({ @@ -1196,6 +1213,7 @@ export const registerRoutes = async ( certificateAuthority: certificateAuthorityService, certificateTemplate: certificateTemplateService, certificateAuthorityCrl: certificateAuthorityCrlService, + certificateEst: certificateEstService, pkiAlert: pkiAlertService, pkiCollection: pkiCollectionService, secretScanning: secretScanningService, @@ -1263,6 +1281,9 @@ export const registerRoutes = async ( } }); + // register special routes + await server.register(registerCertificateEstRouter, { prefix: "/.well-known/est" }); + // register routes for v1 await server.register( async (v1Server) => { diff --git a/backend/src/server/routes/v1/certificate-authority-router.ts b/backend/src/server/routes/v1/certificate-authority-router.ts index 429620410..9a866f66e 100644 --- a/backend/src/server/routes/v1/certificate-authority-router.ts +++ b/backend/src/server/routes/v1/certificate-authority-router.ts @@ -669,6 +669,7 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { handler: async (req) => { const { certificate, certificateChain, issuingCaCertificate, serialNumber, ca } = await server.services.certificateAuthority.signCertFromCa({ + isInternal: false, caId: req.params.caId, actor: req.permission.type, actorId: req.permission.id, @@ -691,7 +692,7 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { }); return { - certificate, + certificate: certificate.toString("pem"), certificateChain, issuingCaCertificate, serialNumber diff --git a/backend/src/server/routes/v1/certificate-router.ts b/backend/src/server/routes/v1/certificate-router.ts index c492e0667..91ae85982 100644 --- a/backend/src/server/routes/v1/certificate-router.ts +++ b/backend/src/server/routes/v1/certificate-router.ts @@ -210,6 +210,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { handler: async (req) => { const { certificate, certificateChain, issuingCaCertificate, serialNumber, ca } = await server.services.certificateAuthority.signCertFromCa({ + isInternal: false, actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, @@ -231,7 +232,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { }); return { - certificate, + certificate: certificate.toString("pem"), certificateChain, issuingCaCertificate, serialNumber diff --git a/backend/src/server/routes/v1/certificate-template-router.ts b/backend/src/server/routes/v1/certificate-template-router.ts index 3f3ecd5b3..c9d2410fd 100644 --- a/backend/src/server/routes/v1/certificate-template-router.ts +++ b/backend/src/server/routes/v1/certificate-template-router.ts @@ -1,6 +1,7 @@ import ms from "ms"; import { z } from "zod"; +import { CertificateTemplateEstConfigsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { CERTIFICATE_TEMPLATES } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; @@ -9,6 +10,12 @@ import { AuthMode } from "@app/services/auth/auth-type"; import { sanitizedCertificateTemplate } from "@app/services/certificate-template/certificate-template-schema"; import { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators"; +const sanitizedEstConfig = CertificateTemplateEstConfigsSchema.pick({ + id: true, + certificateTemplateId: true, + isEnabled: true +}); + export const registerCertificateTemplateRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", @@ -202,4 +209,141 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid return certificateTemplate; } }); + + server.route({ + method: "POST", + url: "/:certificateTemplateId/est-config", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Create Certificate Template EST configuration", + params: z.object({ + certificateTemplateId: z.string().trim() + }), + body: z.object({ + caChain: z.string().trim().min(1), + passphrase: z.string().min(1), + isEnabled: z.boolean().default(true) + }), + response: { + 200: sanitizedEstConfig + } + }, + handler: async (req) => { + const estConfig = await server.services.certificateTemplate.createEstConfiguration({ + certificateTemplateId: req.params.certificateTemplateId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: estConfig.projectId, + event: { + type: EventType.CREATE_CERTIFICATE_TEMPLATE_EST_CONFIG, + metadata: { + certificateTemplateId: estConfig.certificateTemplateId, + isEnabled: estConfig.isEnabled as boolean + } + } + }); + + return estConfig; + } + }); + + server.route({ + method: "PATCH", + url: "/:certificateTemplateId/est-config", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Update Certificate Template EST configuration", + params: z.object({ + certificateTemplateId: z.string().trim() + }), + body: z.object({ + caChain: z.string().trim().min(1).optional(), + passphrase: z.string().min(1).optional(), + isEnabled: z.boolean().optional() + }), + response: { + 200: sanitizedEstConfig + } + }, + handler: async (req) => { + const estConfig = await server.services.certificateTemplate.updateEstConfiguration({ + certificateTemplateId: req.params.certificateTemplateId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: estConfig.projectId, + event: { + type: EventType.UPDATE_CERTIFICATE_TEMPLATE_EST_CONFIG, + metadata: { + certificateTemplateId: estConfig.certificateTemplateId, + isEnabled: estConfig.isEnabled as boolean + } + } + }); + + return estConfig; + } + }); + + server.route({ + method: "GET", + url: "/:certificateTemplateId/est-config", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Get Certificate Template EST configuration", + params: z.object({ + certificateTemplateId: z.string().trim() + }), + response: { + 200: sanitizedEstConfig.extend({ + caChain: z.string() + }) + } + }, + handler: async (req) => { + const estConfig = await server.services.certificateTemplate.getEstConfiguration({ + isInternal: false, + certificateTemplateId: req.params.certificateTemplateId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: estConfig.projectId, + event: { + type: EventType.GET_CERTIFICATE_TEMPLATE_EST_CONFIG, + metadata: { + certificateTemplateId: estConfig.certificateTemplateId + } + } + }); + + return estConfig; + } + }); }; diff --git a/backend/src/services/certificate-authority/certificate-authority-service.ts b/backend/src/services/certificate-authority/certificate-authority-service.ts index dc39afe61..a1f062a45 100644 --- a/backend/src/services/certificate-authority/certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/certificate-authority-service.ts @@ -1295,24 +1295,23 @@ export const certificateAuthorityServiceFactory = ({ * Return new leaf certificate issued by CA with id [caId]. * Note: CSR is generated externally and submitted to Infisical. */ - const signCertFromCa = async ({ - caId, - certificateTemplateId, - csr, - pkiCollectionId, - friendlyName, - commonName, - altNames, - ttl, - notBefore, - notAfter, - actorId, - actorAuthMethod, - actor, - actorOrgId - }: TSignCertFromCaDTO) => { + const signCertFromCa = async (dto: TSignCertFromCaDTO) => { let ca: TCertificateAuthorities | undefined; let certificateTemplate: TCertificateTemplates | undefined; + + const { + caId, + certificateTemplateId, + csr, + pkiCollectionId, + friendlyName, + commonName, + altNames, + ttl, + notBefore, + notAfter + } = dto; + let collectionId = pkiCollectionId; if (caId) { @@ -1333,15 +1332,20 @@ export const certificateAuthorityServiceFactory = ({ throw new BadRequestError({ message: "CA not found" }); } - const { permission } = await permissionService.getProjectPermission( - actor, - actorId, - ca.projectId, - actorAuthMethod, - actorOrgId - ); + if (!dto.isInternal) { + const { permission } = await permissionService.getProjectPermission( + dto.actor, + dto.actorId, + ca.projectId, + dto.actorAuthMethod, + dto.actorOrgId + ); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Certificates); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.Certificates + ); + } 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" }); @@ -1382,6 +1386,8 @@ export const certificateAuthorityServiceFactory = ({ 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); @@ -1426,6 +1432,7 @@ export const certificateAuthorityServiceFactory = ({ await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey) ]; + let altNamesFromCsr: string = ""; let altNamesArray: { type: "email" | "dns"; value: string; @@ -1454,7 +1461,24 @@ export const certificateAuthorityServiceFactory = ({ // 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); } @@ -1500,7 +1524,7 @@ export const certificateAuthorityServiceFactory = ({ status: CertStatus.ACTIVE, friendlyName: friendlyName || csrObj.subject, commonName: cn, - altNames, + altNames: altNamesFromCsr || altNames, serialNumber, notBefore: notBeforeDate, notAfter: notAfterDate @@ -1538,7 +1562,7 @@ export const certificateAuthorityServiceFactory = ({ }); return { - certificate: leafCert.toString("pem"), + certificate: leafCert, certificateChain: `${issuingCaCertificate}\n${caCertChain}`.trim(), issuingCaCertificate, serialNumber, diff --git a/backend/src/services/certificate-authority/certificate-authority-types.ts b/backend/src/services/certificate-authority/certificate-authority-types.ts index c6bdeae30..764a5dca9 100644 --- a/backend/src/services/certificate-authority/certificate-authority-types.ts +++ b/backend/src/services/certificate-authority/certificate-authority-types.ts @@ -97,18 +97,33 @@ export type TIssueCertFromCaDTO = { notAfter?: string; } & Omit; -export type TSignCertFromCaDTO = { - caId?: string; - csr: string; - certificateTemplateId?: string; - pkiCollectionId?: string; - friendlyName?: string; - commonName?: string; - altNames: string; - ttl: string; - notBefore?: string; - notAfter?: string; -} & Omit; +export type TSignCertFromCaDTO = + | { + isInternal: true; + caId?: string; + csr: string; + certificateTemplateId?: string; + pkiCollectionId?: string; + friendlyName?: string; + commonName?: string; + altNames?: string; + ttl?: string; + notBefore?: string; + notAfter?: string; + } + | ({ + isInternal: false; + caId?: string; + csr: string; + certificateTemplateId?: string; + pkiCollectionId?: string; + friendlyName?: string; + commonName?: string; + altNames: string; + ttl: string; + notBefore?: string; + notAfter?: string; + } & Omit); export type TDNParts = { commonName?: string; diff --git a/backend/src/services/certificate-est/certificate-est-fns.ts b/backend/src/services/certificate-est/certificate-est-fns.ts new file mode 100644 index 000000000..a3973ae89 --- /dev/null +++ b/backend/src/services/certificate-est/certificate-est-fns.ts @@ -0,0 +1,24 @@ +import { Certificate, ContentInfo, EncapsulatedContentInfo, SignedData } from "pkijs"; + +export const convertRawCertsToPkcs7 = (rawCertificate: ArrayBuffer[]) => { + const certs = rawCertificate.map((rawCert) => Certificate.fromBER(rawCert)); + const cmsSigned = new SignedData({ + encapContentInfo: new EncapsulatedContentInfo({ + eContentType: "1.2.840.113549.1.7.1" // not encrypted and not compressed data + }), + certificates: certs + }); + + const cmsContent = new ContentInfo({ + contentType: "1.2.840.113549.1.7.2", // SignedData + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + content: cmsSigned.toSchema() + }); + + const derBuffer = cmsContent.toSchema().toBER(false); + const base64Pkcs7 = Buffer.from(derBuffer) + .toString("base64") + .replace(/(.{64})/g, "$1\n"); // we add a linebreak for CURL clients + + return base64Pkcs7; +}; diff --git a/backend/src/services/certificate-est/certificate-est-service.ts b/backend/src/services/certificate-est/certificate-est-service.ts new file mode 100644 index 000000000..e85d4658b --- /dev/null +++ b/backend/src/services/certificate-est/certificate-est-service.ts @@ -0,0 +1,231 @@ +import * as x509 from "@peculiar/x509"; + +import { BadRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; + +import { isCertChainValid } from "../certificate/certificate-fns"; +import { TCertificateAuthorityCertDALFactory } from "../certificate-authority/certificate-authority-cert-dal"; +import { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal"; +import { getCaCertChain, getCaCertChains } from "../certificate-authority/certificate-authority-fns"; +import { TCertificateAuthorityServiceFactory } from "../certificate-authority/certificate-authority-service"; +import { TCertificateTemplateDALFactory } from "../certificate-template/certificate-template-dal"; +import { TCertificateTemplateServiceFactory } from "../certificate-template/certificate-template-service"; +import { TKmsServiceFactory } from "../kms/kms-service"; +import { TProjectDALFactory } from "../project/project-dal"; +import { convertRawCertsToPkcs7 } from "./certificate-est-fns"; + +type TCertificateEstServiceFactoryDep = { + certificateAuthorityService: Pick; + certificateTemplateService: Pick; + certificateTemplateDAL: Pick; + certificateAuthorityDAL: Pick; + certificateAuthorityCertDAL: Pick; + projectDAL: Pick; + kmsService: Pick; +}; + +export type TCertificateEstServiceFactory = ReturnType; + +export const certificateEstServiceFactory = ({ + certificateAuthorityService, + certificateTemplateService, + certificateTemplateDAL, + certificateAuthorityCertDAL, + certificateAuthorityDAL, + projectDAL, + kmsService +}: TCertificateEstServiceFactoryDep) => { + const simpleReenroll = async ({ + csr, + certificateTemplateId, + sslClientCert + }: { + csr: string; + certificateTemplateId: string; + sslClientCert: string; + }) => { + const estConfig = await certificateTemplateService.getEstConfiguration({ + isInternal: true, + certificateTemplateId + }); + + if (!estConfig.isEnabled) { + throw new BadRequestError({ + message: "EST is disabled" + }); + } + + const certTemplate = await certificateTemplateDAL.findById(certificateTemplateId); + + const leafCertificate = decodeURIComponent(sslClientCert).match( + /-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g + )?.[0]; + + if (!leafCertificate) { + throw new UnauthorizedError({ message: "Missing client certificate" }); + } + + const cert = new x509.X509Certificate(leafCertificate); + // We have to assert that the client certificate provided can be traced back to the Root CA + const caCertChains = await getCaCertChains({ + caId: certTemplate.caId, + certificateAuthorityCertDAL, + certificateAuthorityDAL, + projectDAL, + kmsService + }); + + const verifiedChains = await Promise.all( + caCertChains.map((chain) => { + const caCert = new x509.X509Certificate(chain.certificate); + const caChain = + chain.certificateChain + .match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) + ?.map((c) => new x509.X509Certificate(c)) || []; + + return isCertChainValid([cert, caCert, ...caChain]); + }) + ); + + if (!verifiedChains.some(Boolean)) { + throw new BadRequestError({ + message: "Invalid client certificate: unable to build a valid certificate chain" + }); + } + + // We ensure that the Subject and SubjectAltNames of the CSR and the existing certificate are exactly the same + const csrObj = new x509.Pkcs10CertificateRequest(csr); + if (csrObj.subject !== cert.subject) { + throw new BadRequestError({ + message: "Subject mismatch" + }); + } + + let csrSanSet: Set = new Set(); + const csrSanExtension = csrObj.extensions.find((ext) => ext.type === "2.5.29.17"); + if (csrSanExtension) { + const sanNames = new x509.GeneralNames(csrSanExtension.value); + csrSanSet = new Set([...sanNames.items.map((name) => `${name.type}-${name.value}`)]); + } + + let certSanSet: Set = new Set(); + const certSanExtension = cert.extensions.find((ext) => ext.type === "2.5.29.17"); + if (certSanExtension) { + const sanNames = new x509.GeneralNames(certSanExtension.value); + certSanSet = new Set([...sanNames.items.map((name) => `${name.type}-${name.value}`)]); + } + + if (csrSanSet.size !== certSanSet.size || ![...csrSanSet].every((element) => certSanSet.has(element))) { + throw new BadRequestError({ + message: "Subject alternative names mismatch" + }); + } + + const { certificate } = await certificateAuthorityService.signCertFromCa({ + isInternal: true, + certificateTemplateId, + csr + }); + + return convertRawCertsToPkcs7([certificate.rawData]); + }; + + const simpleEnroll = async ({ + csr, + certificateTemplateId, + sslClientCert + }: { + csr: string; + certificateTemplateId: string; + sslClientCert: string; + }) => { + /* We first have to assert that the client certificate provided can be traced back to the attached + CA chain in the EST configuration + */ + const estConfig = await certificateTemplateService.getEstConfiguration({ + isInternal: true, + certificateTemplateId + }); + + if (!estConfig.isEnabled) { + throw new BadRequestError({ + message: "EST is disabled" + }); + } + + const caCerts = estConfig.caChain + .match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) + ?.map((cert) => { + return new x509.X509Certificate(cert); + }); + + if (!caCerts) { + throw new BadRequestError({ message: "Failed to parse certificate chain" }); + } + + const leafCertificate = decodeURIComponent(sslClientCert).match( + /-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g + )?.[0]; + + if (!leafCertificate) { + throw new BadRequestError({ message: "Missing client certificate" }); + } + + const certObj = new x509.X509Certificate(leafCertificate); + if (!(await isCertChainValid([certObj, ...caCerts]))) { + throw new BadRequestError({ message: "Invalid certificate chain" }); + } + + const { certificate } = await certificateAuthorityService.signCertFromCa({ + isInternal: true, + certificateTemplateId, + csr + }); + + return convertRawCertsToPkcs7([certificate.rawData]); + }; + + /** + * Return the CA certificate and CA certificate chain for the CA bound to + * the certificate template with id [certificateTemplateId] as part of EST protocol + */ + const getCaCerts = async ({ certificateTemplateId }: { certificateTemplateId: string }) => { + const certTemplate = await certificateTemplateDAL.findById(certificateTemplateId); + if (!certTemplate) { + throw new NotFoundError({ + message: "Certificate template not found" + }); + } + + const ca = await certificateAuthorityDAL.findById(certTemplate.caId); + if (!ca) { + throw new NotFoundError({ + message: "Certificate Authority not found" + }); + } + + const { caCert, caCertChain } = await getCaCertChain({ + caCertId: ca.activeCaCertId as string, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + + const certificates = caCertChain + .match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) + ?.map((cert) => new x509.X509Certificate(cert)); + + if (!certificates) { + throw new BadRequestError({ message: "Failed to parse certificate chain" }); + } + + const caCertificate = new x509.X509Certificate(caCert); + return convertRawCertsToPkcs7([caCertificate.rawData, ...certificates.map((cert) => cert.rawData)]); + }; + + return { + simpleEnroll, + simpleReenroll, + getCaCerts + }; +}; diff --git a/backend/src/services/certificate-template/certificate-template-est-config-dal.ts b/backend/src/services/certificate-template/certificate-template-est-config-dal.ts new file mode 100644 index 000000000..99d137c5a --- /dev/null +++ b/backend/src/services/certificate-template/certificate-template-est-config-dal.ts @@ -0,0 +1,11 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TCertificateTemplateEstConfigDALFactory = ReturnType; + +export const certificateTemplateEstConfigDALFactory = (db: TDbClient) => { + const certificateTemplateEstConfigOrm = ormify(db, TableName.CertificateTemplateEstConfig); + + return certificateTemplateEstConfigOrm; +}; diff --git a/backend/src/services/certificate-template/certificate-template-service.ts b/backend/src/services/certificate-template/certificate-template-service.ts index c49791175..31745e752 100644 --- a/backend/src/services/certificate-template/certificate-template-service.ts +++ b/backend/src/services/certificate-template/certificate-template-service.ts @@ -1,20 +1,35 @@ import { ForbiddenError } from "@casl/ability"; +import * as x509 from "@peculiar/x509"; +import bcrypt from "bcrypt"; +import { TCertificateTemplateEstConfigsUpdate } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { getConfig } from "@app/lib/config/env"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { isCertChainValid } from "../certificate/certificate-fns"; import { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal"; +import { TKmsServiceFactory } from "../kms/kms-service"; +import { TProjectDALFactory } from "../project/project-dal"; +import { getProjectKmsCertificateKeyId } from "../project/project-fns"; import { TCertificateTemplateDALFactory } from "./certificate-template-dal"; +import { TCertificateTemplateEstConfigDALFactory } from "./certificate-template-est-config-dal"; import { TCreateCertTemplateDTO, + TCreateEstConfigurationDTO, TDeleteCertTemplateDTO, TGetCertTemplateDTO, - TUpdateCertTemplateDTO + TGetEstConfigurationDTO, + TUpdateCertTemplateDTO, + TUpdateEstConfigurationDTO } from "./certificate-template-types"; type TCertificateTemplateServiceFactoryDep = { certificateTemplateDAL: TCertificateTemplateDALFactory; + certificateTemplateEstConfigDAL: TCertificateTemplateEstConfigDALFactory; + projectDAL: Pick; + kmsService: Pick; certificateAuthorityDAL: Pick; permissionService: Pick; }; @@ -23,8 +38,11 @@ export type TCertificateTemplateServiceFactory = ReturnType { const createCertTemplate = async ({ caId, @@ -187,10 +205,228 @@ export const certificateTemplateServiceFactory = ({ return certTemplate; }; + const createEstConfiguration = async ({ + certificateTemplateId, + caChain, + passphrase, + isEnabled, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TCreateEstConfigurationDTO) => { + const certTemplate = await certificateTemplateDAL.getById(certificateTemplateId); + if (!certTemplate) { + throw new NotFoundError({ + message: "Certificate template not found." + }); + } + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + certTemplate.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + ProjectPermissionSub.CertificateTemplates + ); + + const appCfg = getConfig(); + + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId: certTemplate.projectId, + projectDAL, + kmsService + }); + + // validate CA chain + const certificates = caChain + .match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) + ?.map((cert) => new x509.X509Certificate(cert)); + + if (!certificates) { + throw new BadRequestError({ message: "Failed to parse certificate chain" }); + } + + if (!(await isCertChainValid(certificates))) { + throw new BadRequestError({ message: "Invalid certificate chain" }); + } + + const kmsEncryptor = await kmsService.encryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + const { cipherTextBlob: encryptedCaChain } = await kmsEncryptor({ + plainText: Buffer.from(caChain) + }); + + const hashedPassphrase = await bcrypt.hash(passphrase, appCfg.SALT_ROUNDS); + const estConfig = await certificateTemplateEstConfigDAL.create({ + certificateTemplateId, + hashedPassphrase, + encryptedCaChain, + isEnabled + }); + + return { ...estConfig, projectId: certTemplate.projectId }; + }; + + const updateEstConfiguration = async ({ + certificateTemplateId, + caChain, + passphrase, + isEnabled, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TUpdateEstConfigurationDTO) => { + const certTemplate = await certificateTemplateDAL.getById(certificateTemplateId); + if (!certTemplate) { + throw new NotFoundError({ + message: "Certificate template not found." + }); + } + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + certTemplate.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + ProjectPermissionSub.CertificateTemplates + ); + + const originalCaEstConfig = await certificateTemplateEstConfigDAL.findOne({ + certificateTemplateId + }); + + if (!originalCaEstConfig) { + throw new NotFoundError({ + message: "EST configuration not found" + }); + } + + const appCfg = getConfig(); + + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId: certTemplate.projectId, + projectDAL, + kmsService + }); + + const updatedData: TCertificateTemplateEstConfigsUpdate = { + isEnabled + }; + + if (caChain) { + const certificates = caChain + .match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) + ?.map((cert) => new x509.X509Certificate(cert)); + + if (!certificates) { + throw new BadRequestError({ message: "Failed to parse certificate chain" }); + } + + if (!(await isCertChainValid(certificates))) { + throw new BadRequestError({ message: "Invalid certificate chain" }); + } + + const kmsEncryptor = await kmsService.encryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + const { cipherTextBlob: encryptedCaChain } = await kmsEncryptor({ + plainText: Buffer.from(caChain) + }); + + updatedData.encryptedCaChain = encryptedCaChain; + } + + if (passphrase) { + const hashedPassphrase = await bcrypt.hash(passphrase, appCfg.SALT_ROUNDS); + updatedData.hashedPassphrase = hashedPassphrase; + } + + const estConfig = await certificateTemplateEstConfigDAL.updateById(originalCaEstConfig.id, updatedData); + + return { ...estConfig, projectId: certTemplate.projectId }; + }; + + const getEstConfiguration = async (dto: TGetEstConfigurationDTO) => { + const { certificateTemplateId } = dto; + + const certTemplate = await certificateTemplateDAL.getById(certificateTemplateId); + if (!certTemplate) { + throw new NotFoundError({ + message: "Certificate template not found." + }); + } + + if (!dto.isInternal) { + const { permission } = await permissionService.getProjectPermission( + dto.actor, + dto.actorId, + certTemplate.projectId, + dto.actorAuthMethod, + dto.actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + ProjectPermissionSub.CertificateTemplates + ); + } + + const estConfig = await certificateTemplateEstConfigDAL.findOne({ + certificateTemplateId + }); + + if (!estConfig) { + throw new NotFoundError({ + message: "EST configuration not found" + }); + } + + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId: certTemplate.projectId, + projectDAL, + kmsService + }); + + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + const decryptedCaChain = await kmsDecryptor({ + cipherTextBlob: estConfig.encryptedCaChain + }); + + return { + certificateTemplateId, + id: estConfig.id, + isEnabled: estConfig.isEnabled, + caChain: decryptedCaChain.toString(), + hashedPassphrase: estConfig.hashedPassphrase, + projectId: certTemplate.projectId + }; + }; + return { createCertTemplate, getCertTemplate, deleteCertTemplate, - updateCertTemplate + updateCertTemplate, + createEstConfiguration, + updateEstConfiguration, + getEstConfiguration }; }; diff --git a/backend/src/services/certificate-template/certificate-template-types.ts b/backend/src/services/certificate-template/certificate-template-types.ts index c203b386c..74281e7b8 100644 --- a/backend/src/services/certificate-template/certificate-template-types.ts +++ b/backend/src/services/certificate-template/certificate-template-types.ts @@ -26,3 +26,27 @@ export type TGetCertTemplateDTO = { export type TDeleteCertTemplateDTO = { id: string; } & Omit; + +export type TCreateEstConfigurationDTO = { + certificateTemplateId: string; + caChain: string; + passphrase: string; + isEnabled: boolean; +} & Omit; + +export type TUpdateEstConfigurationDTO = { + certificateTemplateId: string; + caChain?: string; + passphrase?: string; + isEnabled?: boolean; +} & Omit; + +export type TGetEstConfigurationDTO = + | { + isInternal: true; + certificateTemplateId: string; + } + | ({ + isInternal: false; + certificateTemplateId: string; + } & Omit); diff --git a/backend/src/services/certificate/certificate-fns.ts b/backend/src/services/certificate/certificate-fns.ts index dfbd50551..1768a5011 100644 --- a/backend/src/services/certificate/certificate-fns.ts +++ b/backend/src/services/certificate/certificate-fns.ts @@ -24,3 +24,19 @@ export const revocationReasonToCrlCode = (crlReason: CrlReason) => { return x509.X509CrlReason.unspecified; } }; + +export const isCertChainValid = async (certificates: x509.X509Certificate[]) => { + if (certificates.length === 1) { + return true; + } + + const leafCert = certificates[0]; + const chain = new x509.X509ChainBuilder({ + certificates: certificates.slice(1) + }); + + const chainItems = await chain.build(leafCert); + + // chain.build() implicitly verifies the chain + return chainItems.length === certificates.length; +}; diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 422fe43f3..9e56ae589 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -7,6 +7,7 @@ services: restart: always ports: - 8080:80 + - 8443:443 volumes: - ./nginx/default.dev.conf:/etc/nginx/conf.d/default.conf:ro depends_on: diff --git a/docs/documentation/platform/pki/est.mdx b/docs/documentation/platform/pki/est.mdx new file mode 100644 index 000000000..aab822dd5 --- /dev/null +++ b/docs/documentation/platform/pki/est.mdx @@ -0,0 +1,57 @@ +--- +title: "Enrollment over Secure Transport (EST)" +sidebarTitle: "Enrollment over Secure Transport (EST)" +description: "Learn how to manage certificate enrollment of clients using EST" +--- + +## Concept + +Enrollment over Secure Transport (EST) is a protocol used to automate the secure provisioning of digital certificates for devices and applications over a secure HTTPS connection. It is primarily used when a client device needs to obtain or renew a certificate from a Certificate Authority (CA) on Infisical in a secure and standardized manner. EST is commonly employed in environments requiring strong authentication and encrypted communication, such as in IoT, enterprise networks, and secure web services. + +Infisical's EST service is based on [RFC 7030](https://datatracker.ietf.org/doc/html/rfc7030) and implements the following endpoints: + +- **cacerts** - provides the necessary CA chain for the client to validate certificates issued by the CA. +- **simpleenroll** - allows an EST client to request a new certificate from Infisical's EST server +- **simplereenroll** - similar to the /simpleenroll endpoint but is used for renewing an existing certificate. + +These endpoints are exposed on port 8443 under the .well-known/est path e.g. +`https://app.infisical.com:8443/.well-known/est/estLabel/cacerts` + +## Prerequisites + +- You need to have an existing [CA hierarchy](/documentation/platform/pki/private-ca). +- The client devices need to have a bootstrap/pre-installed certificate. +- The client devices must trust the server certificates used by Infisical's EST server. If the devices are new or lack existing trust configurations, you need to manually establish trust for the appropriate certificates. When using Infisical Cloud, this means establishing trust for certificates issued by AWS. + +## Guide to configuring EST + +1. Set up a certificate template with your selected issuing CA. This template will define the policies and parameters for certificates issued through EST. For detailed instructions on configuring a certificate template, refer to the certificate templates [documentation](/documentation/platform/pki/certificate-templates). + +2. Proceed to the certificate template's enrollment settings + ![est enrollment dashboard](/images/platform/pki/est/template-enroll-hover.png) + +3. Select **EST** as the client enrollment method and fill up the remaining fields. + + ![est enrollment modal create](/images/platform/pki/est/template-enrollment-modal.png) + + - **Certificate Authority Chain** - This is the certificate chain used to validate your devices' manufacturing/pre-installed certificates. This will be used to authenticate your devices with Infisical's EST server. + - **Passphrase** - This is also used to authenticate your devices with Infisical's EST server. When configuring the clients, use the value defined here as the EST password. + + For security reasons, Infisical authenticates EST clients using both client certificate and passphrase. + +4. Once the configuration of enrollment options is completed, a new **EST Label** field appears in the enrollment settings. This is the value to use as label in the URL when configuring the connection of EST clients to Infisical. + ![est enrollment modal create](/images/platform/pki/est/template-enrollment-est-label.png) + + For demonstration, the complete URL of the supported EST endpoints will look like the following: + + - https://app.infisical.com:8443/.well-known/est/f110f308-9888-40ab-b228-237b12de8b96/cacerts + - https://app.infisical.com:8443/.well-known/est/f110f308-9888-40ab-b228-237b12de8b96/simpleenroll + - https://app.infisical.com:8443/.well-known/est/f110f308-9888-40ab-b228-237b12de8b96/simplereenroll + +## Setting up EST clients + +- To use the EST passphrase in your clients, configure it as the EST password. The EST username can be set to any arbitrary value. +- Use the appropriate client certificates for invoking the EST endpoints. + - For `simpleenroll`, use the bootstrapped/manufacturer client certificate. + - For `simplereenroll`, use a valid EST-issued client certificate. +- When configuring the PKCS#12 objects for the client certificates, only include the leaf certificate and the private key. diff --git a/docs/images/platform/pki/est/template-enroll-hover.png b/docs/images/platform/pki/est/template-enroll-hover.png new file mode 100644 index 000000000..8b13cdd60 Binary files /dev/null and b/docs/images/platform/pki/est/template-enroll-hover.png differ diff --git a/docs/images/platform/pki/est/template-enrollment-est-label.png b/docs/images/platform/pki/est/template-enrollment-est-label.png new file mode 100644 index 000000000..8a13beec9 Binary files /dev/null and b/docs/images/platform/pki/est/template-enrollment-est-label.png differ diff --git a/docs/images/platform/pki/est/template-enrollment-modal.png b/docs/images/platform/pki/est/template-enrollment-modal.png new file mode 100644 index 000000000..c7fed648e Binary files /dev/null and b/docs/images/platform/pki/est/template-enrollment-modal.png differ diff --git a/docs/mint.json b/docs/mint.json index 44940a795..75368e3a8 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -109,6 +109,7 @@ "documentation/platform/pki/private-ca", "documentation/platform/pki/certificates", "documentation/platform/pki/certificate-templates", + "documentation/platform/pki/est", "documentation/platform/pki/alerting" ] }, diff --git a/frontend/src/hooks/api/auditLogs/constants.tsx b/frontend/src/hooks/api/auditLogs/constants.tsx index 210061f7c..b9ba07264 100644 --- a/frontend/src/hooks/api/auditLogs/constants.tsx +++ b/frontend/src/hooks/api/auditLogs/constants.tsx @@ -72,7 +72,12 @@ export const eventToNameMap: { [K in EventType]: string } = { [EventType.CREATE_CERTIFICATE_TEMPLATE]: "Create certificate template", [EventType.UPDATE_CERTIFICATE_TEMPLATE]: "Update certificate template", [EventType.DELETE_CERTIFICATE_TEMPLATE]: "Delete certificate template", - [EventType.GET_CERTIFICATE_TEMPLATE]: "Get certificate template" + [EventType.GET_CERTIFICATE_TEMPLATE]: "Get certificate template", + [EventType.GET_CERTIFICATE_TEMPLATE_EST_CONFIG]: "Get certificate template EST configuration", + [EventType.CREATE_CERTIFICATE_TEMPLATE_EST_CONFIG]: + "Create certificate template EST configuration", + [EventType.UPDATE_CERTIFICATE_TEMPLATE_EST_CONFIG]: + "Update certificate template EST configuration" }; export const userAgentTTypeoNameMap: { [K in UserAgentType]: string } = { diff --git a/frontend/src/hooks/api/auditLogs/enums.tsx b/frontend/src/hooks/api/auditLogs/enums.tsx index 80c0ce431..6df32aa0b 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -86,5 +86,8 @@ export enum EventType { CREATE_CERTIFICATE_TEMPLATE = "create-certificate-template", UPDATE_CERTIFICATE_TEMPLATE = "update-certificate-template", DELETE_CERTIFICATE_TEMPLATE = "delete-certificate-template", - GET_CERTIFICATE_TEMPLATE = "get-certificate-template" + GET_CERTIFICATE_TEMPLATE = "get-certificate-template", + CREATE_CERTIFICATE_TEMPLATE_EST_CONFIG = "create-certificate-template-est-config", + UPDATE_CERTIFICATE_TEMPLATE_EST_CONFIG = "update-certificate-template-est-config", + GET_CERTIFICATE_TEMPLATE_EST_CONFIG = "get-certificate-template-est-config" } diff --git a/frontend/src/hooks/api/auditLogs/types.tsx b/frontend/src/hooks/api/auditLogs/types.tsx index d7825637d..f9b53d037 100644 --- a/frontend/src/hooks/api/auditLogs/types.tsx +++ b/frontend/src/hooks/api/auditLogs/types.tsx @@ -719,6 +719,29 @@ interface DeleteCertificateTemplate { }; } +interface CreateCertificateTemplateEstConfig { + type: EventType.CREATE_CERTIFICATE_TEMPLATE_EST_CONFIG; + metadata: { + certificateTemplateId: string; + isEnabled: boolean; + }; +} + +interface UpdateCertificateTemplateEstConfig { + type: EventType.UPDATE_CERTIFICATE_TEMPLATE_EST_CONFIG; + metadata: { + certificateTemplateId: string; + isEnabled: boolean; + }; +} + +interface GetCertificateTemplateEstConfig { + type: EventType.GET_CERTIFICATE_TEMPLATE_EST_CONFIG; + metadata: { + certificateTemplateId: string; + }; +} + export type Event = | GetSecretsEvent | GetSecretEvent @@ -791,7 +814,10 @@ export type Event = | CreateCertificateTemplate | UpdateCertificateTemplate | GetCertificateTemplate - | DeleteCertificateTemplate; + | DeleteCertificateTemplate + | UpdateCertificateTemplateEstConfig + | CreateCertificateTemplateEstConfig + | GetCertificateTemplateEstConfig; export type AuditLog = { id: string; diff --git a/frontend/src/hooks/api/ca/index.tsx b/frontend/src/hooks/api/ca/index.tsx index ef9bf09f2..5c0ff4caa 100644 --- a/frontend/src/hooks/api/ca/index.tsx +++ b/frontend/src/hooks/api/ca/index.tsx @@ -8,4 +8,4 @@ export { useSignIntermediate, useUpdateCa } from "./mutations"; -export { useGetCaById, useGetCaCert, useGetCaCerts, useGetCaCrls,useGetCaCsr } from "./queries"; +export { useGetCaById, useGetCaCert, useGetCaCerts, useGetCaCrls, useGetCaCsr } from "./queries"; diff --git a/frontend/src/hooks/api/ca/queries.tsx b/frontend/src/hooks/api/ca/queries.tsx index 5da16462b..996043f19 100644 --- a/frontend/src/hooks/api/ca/queries.tsx +++ b/frontend/src/hooks/api/ca/queries.tsx @@ -10,7 +10,8 @@ export const caKeys = { getCaCrls: (caId: string) => [{ caId }, "ca-crls"], getCaCert: (caId: string) => [{ caId }, "ca-cert"], getCaCsr: (caId: string) => [{ caId }, "ca-csr"], - getCaCrl: (caId: string) => [{ caId }, "ca-crl"] + getCaCrl: (caId: string) => [{ caId }, "ca-crl"], + getCaEstConfig: (caId: string) => [{ caId }, "ca-est-config"] }; export const useGetCaById = (caId: string) => { diff --git a/frontend/src/hooks/api/certificateTemplates/index.tsx b/frontend/src/hooks/api/certificateTemplates/index.tsx index b8145fbcc..61dfb35a2 100644 --- a/frontend/src/hooks/api/certificateTemplates/index.tsx +++ b/frontend/src/hooks/api/certificateTemplates/index.tsx @@ -1,2 +1,8 @@ -export { useCreateCertTemplate, useDeleteCertTemplate, useUpdateCertTemplate } from "./mutations"; -export { useGetCertTemplate } from "./queries"; +export { + useCreateCertTemplate, + useCreateEstConfig, + useDeleteCertTemplate, + useUpdateCertTemplate, + useUpdateEstConfig +} from "./mutations"; +export { useGetCertTemplate, useGetEstConfig } from "./queries"; diff --git a/frontend/src/hooks/api/certificateTemplates/mutations.tsx b/frontend/src/hooks/api/certificateTemplates/mutations.tsx index 269d885a6..101507af0 100644 --- a/frontend/src/hooks/api/certificateTemplates/mutations.tsx +++ b/frontend/src/hooks/api/certificateTemplates/mutations.tsx @@ -7,8 +7,10 @@ import { certTemplateKeys } from "./queries"; import { TCertificateTemplate, TCreateCertificateTemplateDTO, + TCreateEstConfigDTO, TDeleteCertificateTemplateDTO, - TUpdateCertificateTemplateDTO + TUpdateCertificateTemplateDTO, + TUpdateEstConfigDTO } from "./types"; export const useCreateCertTemplate = () => { @@ -57,3 +59,35 @@ export const useDeleteCertTemplate = () => { } }); }; + +export const useCreateEstConfig = () => { + const queryClient = useQueryClient(); + return useMutation<{}, {}, TCreateEstConfigDTO>({ + mutationFn: async (body) => { + const { data } = await apiRequest.post( + `/api/v1/pki/certificate-templates/${body.certificateTemplateId}/est-config`, + body + ); + return data; + }, + onSuccess: (_, { certificateTemplateId }) => { + queryClient.invalidateQueries(certTemplateKeys.getEstConfig(certificateTemplateId)); + } + }); +}; + +export const useUpdateEstConfig = () => { + const queryClient = useQueryClient(); + return useMutation<{}, {}, TUpdateEstConfigDTO>({ + mutationFn: async (body) => { + const { data } = await apiRequest.patch( + `/api/v1/pki/certificate-templates/${body.certificateTemplateId}/est-config`, + body + ); + return data; + }, + onSuccess: (_, { certificateTemplateId }) => { + queryClient.invalidateQueries(certTemplateKeys.getEstConfig(certificateTemplateId)); + } + }); +}; diff --git a/frontend/src/hooks/api/certificateTemplates/queries.tsx b/frontend/src/hooks/api/certificateTemplates/queries.tsx index ca5b088e8..7ee5bbd30 100644 --- a/frontend/src/hooks/api/certificateTemplates/queries.tsx +++ b/frontend/src/hooks/api/certificateTemplates/queries.tsx @@ -2,10 +2,11 @@ import { useQuery } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; -import { TCertificateTemplate } from "./types"; +import { TCertificateTemplate, TEstConfig } from "./types"; export const certTemplateKeys = { - getCertTemplateById: (id: string) => [{ id }, "cert-template"] + getCertTemplateById: (id: string) => [{ id }, "cert-template"], + getEstConfig: (id: string) => [{ id }, "cert-template-est-config"] }; export const useGetCertTemplate = (id: string) => { @@ -20,3 +21,17 @@ export const useGetCertTemplate = (id: string) => { enabled: Boolean(id) }); }; + +export const useGetEstConfig = (certificateTemplateId: string) => { + return useQuery({ + queryKey: certTemplateKeys.getEstConfig(certificateTemplateId), + queryFn: async () => { + const { data: estConfig } = await apiRequest.get( + `/api/v1/pki/certificate-templates/${certificateTemplateId}/est-config` + ); + + return estConfig; + }, + enabled: Boolean(certificateTemplateId) + }); +}; diff --git a/frontend/src/hooks/api/certificateTemplates/types.ts b/frontend/src/hooks/api/certificateTemplates/types.ts index 237a86a5e..c7c69c3a2 100644 --- a/frontend/src/hooks/api/certificateTemplates/types.ts +++ b/frontend/src/hooks/api/certificateTemplates/types.ts @@ -35,3 +35,24 @@ export type TDeleteCertificateTemplateDTO = { id: string; projectId: string; }; + +export type TCreateEstConfigDTO = { + certificateTemplateId: string; + caChain: string; + passphrase: string; + isEnabled: boolean; +}; + +export type TUpdateEstConfigDTO = { + certificateTemplateId: string; + caChain?: string; + passphrase?: string; + isEnabled?: boolean; +}; + +export type TEstConfig = { + id: string; + certificateTemplateId: string; + caChain: string; + isEnabled: false; +}; diff --git a/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx b/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx index ea0e687bb..c1652aaba 100644 --- a/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx +++ b/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx @@ -428,6 +428,20 @@ export const LogsTableRow = ({ auditLog }: Props) => {

{`Certificate Template ID: ${event.metadata.certificateTemplateId}`}

); + case EventType.CREATE_CERTIFICATE_TEMPLATE_EST_CONFIG: + case EventType.UPDATE_CERTIFICATE_TEMPLATE_EST_CONFIG: + return ( + +

{`Certificate Template ID: ${event.metadata.certificateTemplateId}`}

+

{`Enabled: ${event.metadata.isEnabled}`}

+ + ); + case EventType.GET_CERTIFICATE_TEMPLATE_EST_CONFIG: + return ( + +

{`Certificate Template ID: ${event.metadata.certificateTemplateId}`}

+ + ); default: return ; } diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateTemplateEnrollmentModal.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateTemplateEnrollmentModal.tsx new file mode 100644 index 000000000..9384cfeec --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateTemplateEnrollmentModal.tsx @@ -0,0 +1,225 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import z from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + FormControl, + Input, + Modal, + ModalContent, + Select, + SelectItem, + Switch, + TextArea +} from "@app/components/v2"; +import { useToggle } from "@app/hooks"; +import { useCreateEstConfig, useGetEstConfig, useUpdateEstConfig } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +enum EnrollmentMethod { + EST = "est" +} + +type Props = { + popUp: UsePopUpState<["enrollmentOptions"]>; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["enrollmentOptions"]>, + state?: boolean + ) => void; +}; + +const schema = z.object({ + method: z.nativeEnum(EnrollmentMethod), + caChain: z.string(), + passphrase: z.string().optional(), + isEnabled: z.boolean() +}); + +export type FormData = z.infer; + +export const CertificateTemplateEnrollmentModal = ({ popUp, handlePopUpToggle }: Props) => { + const popUpData = popUp?.enrollmentOptions?.data as { + id: string; + }; + const certificateTemplateId = popUpData?.id; + + const { data } = useGetEstConfig(certificateTemplateId); + + const { + control, + handleSubmit, + reset, + setError, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema) + }); + + const { mutateAsync: createEstConfig } = useCreateEstConfig(); + const { mutateAsync: updateEstConfig } = useUpdateEstConfig(); + const [isPassphraseFocused, setIsPassphraseFocused] = useToggle(false); + + useEffect(() => { + if (data) { + reset({ + caChain: data.caChain, + isEnabled: data.isEnabled + }); + } else { + reset({ + caChain: "", + isEnabled: false + }); + } + }, [data]); + + const onFormSubmit = async ({ caChain, passphrase, isEnabled }: FormData) => { + try { + if (data) { + await updateEstConfig({ + certificateTemplateId, + caChain, + passphrase, + isEnabled + }); + } else { + if (!passphrase) { + setError("passphrase", { message: "Passphrase is required to setup EST" }); + return; + } + + await createEstConfig({ + certificateTemplateId, + caChain, + passphrase, + isEnabled + }); + } + + handlePopUpToggle("enrollmentOptions", false); + + createNotification({ + text: "Successfully saved changes", + type: "success" + }); + + reset(); + } catch (err) { + console.error(err); + } + }; + + return ( + { + handlePopUpToggle("enrollmentOptions", isOpen); + reset(); + }} + > + +
+ ( + + + + )} + /> + {data && ( + + + + )} + ( + +