diff --git a/backend/package-lock.json b/backend/package-lock.json index ffe9cfcb0..3863cc25d 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -73,6 +73,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", "smee-client": "^2.0.0", @@ -7503,6 +7504,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", @@ -11492,6 +11504,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", @@ -17131,6 +17151,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", @@ -19247,9 +19283,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 1328db4dd..6e265acdb 100644 --- a/backend/package.json +++ b/backend/package.json @@ -169,6 +169,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", "smee-client": "^2.0.0", diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 0dc781092..3de092bda 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -318,6 +318,11 @@ import { TWebhooksInsert, TWebhooksUpdate } from "@app/db/schemas"; +import { + TCertificateAuthorityEstConfigs, + TCertificateAuthorityEstConfigsInsert, + TCertificateAuthorityEstConfigsUpdate +} from "@app/db/schemas/certificate-authority-est-configs"; import { TSecretV2TagJunction, TSecretV2TagJunctionInsert, @@ -374,6 +379,11 @@ declare module "knex/types/tables" { TCertificateSecretsInsert, TCertificateSecretsUpdate >; + [TableName.CertificateAuthorityEstConfig]: KnexOriginal.CompositeTableType< + TCertificateAuthorityEstConfigs, + TCertificateAuthorityEstConfigsInsert, + TCertificateAuthorityEstConfigsUpdate + >; [TableName.PkiAlert]: KnexOriginal.CompositeTableType; [TableName.PkiCollection]: KnexOriginal.CompositeTableType< TPkiCollections, diff --git a/backend/src/db/migrations/20240813141341_certificate-authority-est-configuration.ts b/backend/src/db/migrations/20240813141341_certificate-authority-est-configuration.ts new file mode 100644 index 000000000..fe83b5e06 --- /dev/null +++ b/backend/src/db/migrations/20240813141341_certificate-authority-est-configuration.ts @@ -0,0 +1,22 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasEstConfigTable = await knex.schema.hasTable(TableName.CertificateAuthorityEstConfig); + if (!hasEstConfigTable) { + await knex.schema.createTable(TableName.CertificateAuthorityEstConfig, (tb) => { + tb.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + tb.uuid("caId").notNullable().unique(); + tb.foreign("caId").references("id").inTable(TableName.CertificateAuthority).onDelete("CASCADE"); + tb.binary("encryptedCaChain").notNullable(); + tb.string("hashedPassphrase").notNullable(); + tb.boolean("isEnabled"); + tb.timestamps(true, true, true); + }); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.CertificateAuthorityEstConfig); +} diff --git a/backend/src/db/schemas/certificate-authority-est-configs.ts b/backend/src/db/schemas/certificate-authority-est-configs.ts new file mode 100644 index 000000000..43ef5d25f --- /dev/null +++ b/backend/src/db/schemas/certificate-authority-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 CertificateAuthorityEstConfigsSchema = z.object({ + id: z.string().uuid(), + caId: z.string().uuid(), + encryptedCaChain: zodBuffer, + hashedPassphrase: z.string(), + isEnabled: z.boolean().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TCertificateAuthorityEstConfigs = z.infer; +export type TCertificateAuthorityEstConfigsInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TCertificateAuthorityEstConfigsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index ebbead70c..83e3d066d 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", + CertificateAuthorityEstConfig = "certificate_authority_est_configs", CertificateAuthorityCert = "certificate_authority_certs", CertificateAuthoritySecret = "certificate_authority_secret", CertificateAuthorityCrl = "certificate_authority_crl", 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..453a260f5 --- /dev/null +++ b/backend/src/server/routes/est/certificate-est-router.ts @@ -0,0 +1,180 @@ +import * as x509 from "@peculiar/x509"; +import { Certificate, ContentInfo, EncapsulatedContentInfo, SignedData } from "pkijs"; +import { z } from "zod"; + +import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; +import { writeLimit } from "@app/server/config/rateLimiter"; + +export const registerCertificateEstRouter = async (server: FastifyZodProvider) => { + // add support for CSR bodies + server.addContentTypeParser("application/pkcs10", { parseAs: "string" }, (_, body, done) => { + try { + done(null, (body as string).replace(/\n/g, "").replace(/ /g, "")); + } catch (err) { + const error = err as Error; + done(error, undefined); + } + }); + + // Authenticate EST client + server.addHook("onRequest", async (req, res) => { + const { authorization } = req.headers; + 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 urlFragments = req.url.split("/"); + const certificateAuthorityId = urlFragments.slice(-2)[0]; + + const hardcodedCertificateChain = ` + -----BEGIN CERTIFICATE----- + MIIEYzCCA0ugAwIBAgIUbxMrGIZnxNcX2kuYpGOFqix9P80wDQYJKoZIhvcNAQEL + BQAwaTELMAkGA1UEBhMCUEgxDTALBgNVBAgMBENlYnUxDTALBgNVBAcMBENlYnUx + EjAQBgNVBAoMCUluZmlzaWNhbDEUMBIGA1UECwwLRW5naW5lZXJpbmcxEjAQBgNV + BAMMCWxvY2FsaG9zdDAeFw0yNDA4MTIxMzM4MTNaFw0yNTA4MTIxMzM4MTNaMGkx + CzAJBgNVBAYTAlBIMQ0wCwYDVQQIDARDZWJ1MQ0wCwYDVQQHDARDZWJ1MRIwEAYD + VQQKDAlJbmZpc2ljYWwxFDASBgNVBAsMC0VuZ2luZWVyaW5nMRIwEAYDVQQDDAls + b2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDqssBBMfzr + 1DDRIxl8TcCHmQU+qhmw8ACkoNN0b+vD0USVv4SC1ABKtYQBBDvBOtQulqc4yTRw + A3Q0y3XUR+pyCFb5PcTG8ZFUZ7ewrrHrdExd0enY/R3eDPAb6H7hokDS10Sr5BRR + Oow109yzX7ipbw+kYSOOLTF1gX+ewbfpcGNylJNOvFNcu4V64Qg5NXp2Lo4o/VTj + IY9yxgVjep8utC/klughk3/EUqfyZ8/9BHyYj3KWDj7VpZNU4o506ZkYsCOPESe1 + SMl8z4s4bEkfTd6+9SetKkwmCbRpZE5iS0XV0lrySK7AGwKHPuJ5RYj0WZp5O/SK + 1zC0azN787T3AgMBAAGjggEBMIH+MB0GA1UdDgQWBBT25nGrtg4VmDaXscjwEv/B + CSFd2jCBpgYDVR0jBIGeMIGbgBT25nGrtg4VmDaXscjwEv/BCSFd2qFtpGswaTEL + MAkGA1UEBhMCUEgxDTALBgNVBAgMBENlYnUxDTALBgNVBAcMBENlYnUxEjAQBgNV + BAoMCUluZmlzaWNhbDEUMBIGA1UECwwLRW5naW5lZXJpbmcxEjAQBgNVBAMMCWxv + Y2FsaG9zdIIUbxMrGIZnxNcX2kuYpGOFqix9P80wDwYDVR0TAQH/BAUwAwEB/zAO + BgNVHQ8BAf8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwIwDQYJKoZIhvcNAQEL + BQADggEBABPV6jpVHvnvp6cAPewL6SSN20KGdNX3MCpLIxPhz8dbGnc2SWMaR0Eo + GqAYvUgG0xpEWCTZ7RDtfrU7vt6+PnFpP2z0a4YToF24/tdAOMAUQ2AedULAb8UP + gwHDeZKKYhs7kscApO0VgYJgjqFe2Kjlt0zzVcMj0qrwgdDUFTNWGOdQy1ghmStc + nBw2xVppG0QAyIWnvxqPva+czHhMd8bmLR44VCuzO5xS5B/AUk7BeNBLuEEfM3DR + quZ0PRwgsaY/WND3ux93FaSiqfn5y9uZdJkqfJcPL6SKRms6v6da4Rh/DyFcWQFW + iwIeUl1cXagVKziyr4Ch5U5dnp+y8Es= + -----END CERTIFICATE----- + `; + + const sslClientCert = req.headers["x-ssl-client-cert"] as string; + if (!sslClientCert) { + throw new UnauthorizedError({ message: "Missing client certificate" }); + } + const clientCertBody = decodeURIComponent(sslClientCert) + .replace("-----BEGIN CERTIFICATE-----", "") + .replace("-----END CERTIFICATE-----", "") + .replace(/\n/g, "") + .replace(/ /g, "") + .trim(); + + // validate SSL client cert against configured CA + const chainCerts = hardcodedCertificateChain + .match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) + ?.map((cert) => { + const processedBody = cert + .replace("-----BEGIN CERTIFICATE-----", "") + .replace("-----END CERTIFICATE-----", "") + .replace(/\n/g, "") + .replace(/ /g, "") + .trim(); + + const certificateBuffer = Buffer.from(processedBody, "base64"); + return new x509.X509Certificate(certificateBuffer); + }); + + if (!chainCerts) { + throw new BadRequestError({ message: "Failed to parse certificate chain" }); + } + + let isSslClientCertValid = true; + let certToVerify = new x509.X509Certificate(clientCertBody); + + for await (const issuerCert of chainCerts) { + if ( + await certToVerify.verify({ + publicKey: issuerCert.publicKey, + date: new Date() + }) + ) { + certToVerify = issuerCert; // Move to the next certificate in the chain + } else { + isSslClientCertValid = false; + } + } + + if (!isSslClientCertValid) { + throw new UnauthorizedError({ + message: "Invalid client certificate" + }); + } + + const rawCredential = authorization?.split(" ").pop(); + if (!rawCredential) { + throw new UnauthorizedError({ message: "Missing HTTP credentials" }); + } + + const basicCredential = atob(rawCredential); + // compare with EST configuration here + }); + + server.route({ + method: "POST", + url: "/:certificateAuthorityId/simpleenroll", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.string(), + params: z.object({ + certificateAuthorityId: z.string() + }), + response: { + 200: z.object({}) + } + }, + handler: async (req, res) => { + const { rawCertificate } = await server.services.certificateAuthority.signCertFromCa({ + isInternal: true, + caId: req.params.certificateAuthorityId, + csr: req.body, + altNames: "", + ttl: "1h" + }); + + void res.header("Content-Type", "application/pkcs7-mime; smime-type=certs-only"); + void res.header("Content-Transfer-Encoding", "base64"); + + const cert = Certificate.fromBER(rawCertificate); + const cmsSigned = new SignedData({ + encapContentInfo: new EncapsulatedContentInfo({ + eContentType: "1.2.840.113549.1.7.1" // not encrypted and not compressed data + }), + certificates: [cert] + }); + + 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"); + + return base64Pkcs7; + } + }); +}; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index dbd223d7b..7b8ac6fcc 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -190,6 +190,7 @@ import { injectAuditLogInfo } from "../plugins/audit-log"; import { injectIdentity } from "../plugins/auth/inject-identity"; import { injectPermission } from "../plugins/auth/inject-permission"; 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"; @@ -1207,6 +1208,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 bc5294f05..3148d8451 100644 --- a/backend/src/server/routes/v1/certificate-authority-router.ts +++ b/backend/src/server/routes/v1/certificate-authority-router.ts @@ -667,6 +667,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, diff --git a/backend/src/services/certificate-authority/certificate-authority-est-config-dal.ts b/backend/src/services/certificate-authority/certificate-authority-est-config-dal.ts new file mode 100644 index 000000000..7043a399d --- /dev/null +++ b/backend/src/services/certificate-authority/certificate-authority-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 TCertificateAuthorityEstConfigDALFactory = ReturnType; + +export const certificateAuthorityEstConfigDALFactory = (db: TDbClient) => { + const caEstConfigOrm = ormify(db, TableName.CertificateAuthorityEstConfig); + + return caEstConfigOrm; +}; diff --git a/backend/src/services/certificate-authority/certificate-authority-service.ts b/backend/src/services/certificate-authority/certificate-authority-service.ts index 52cfa8039..c5d5a053c 100644 --- a/backend/src/services/certificate-authority/certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/certificate-authority-service.ts @@ -1,13 +1,16 @@ /* eslint-disable no-bitwise */ import { ForbiddenError } from "@casl/ability"; import * as x509 from "@peculiar/x509"; +import bcrypt from "bcrypt"; import crypto, { KeyObject } from "crypto"; import ms from "ms"; import { z } from "zod"; +import { TCertificateAuthorityEstConfigsUpdate } from "@app/db/schemas/certificate-authority-est-configs"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; -import { BadRequestError } from "@app/lib/errors"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; 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"; @@ -18,6 +21,7 @@ import { TCertificateAuthorityCrlDALFactory } from "../../ee/services/certificat import { CertKeyAlgorithm, CertStatus } from "../certificate/certificate-types"; import { TCertificateAuthorityCertDALFactory } from "./certificate-authority-cert-dal"; import { TCertificateAuthorityDALFactory } from "./certificate-authority-dal"; +import { TCertificateAuthorityEstConfigDALFactory } from "./certificate-authority-est-config-dal"; import { createDistinguishedName, getCaCertChain, // TODO: consider rename @@ -32,6 +36,7 @@ import { CaStatus, CaType, TCreateCaDTO, + TCreateCaEstConfigurationDTO, TDeleteCaDTO, TGetCaCertDTO, TGetCaCertsDTO, @@ -42,7 +47,8 @@ import { TRenewCaCertDTO, TSignCertFromCaDTO, TSignIntermediateDTO, - TUpdateCaDTO + TUpdateCaDTO, + TUpdateCaEstConfigurationDTO } from "./certificate-authority-types"; import { hostnameRegex } from "./certificate-authority-validators"; @@ -55,6 +61,7 @@ type TCertificateAuthorityServiceFactoryDep = { certificateAuthoritySecretDAL: Pick; certificateAuthorityCrlDAL: Pick; certificateAuthorityQueue: TCertificateAuthorityQueueFactory; // TODO: Pick + certificateAuthorityEstConfigDAL: Pick; certificateDAL: Pick; certificateBodyDAL: Pick; projectDAL: Pick; @@ -68,6 +75,7 @@ export const certificateAuthorityServiceFactory = ({ certificateAuthorityDAL, certificateAuthorityCertDAL, certificateAuthoritySecretDAL, + certificateAuthorityEstConfigDAL, certificateAuthorityCrlDAL, certificateDAL, certificateBodyDAL, @@ -1205,32 +1213,26 @@ 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, - csr, - friendlyName, - commonName, - altNames, - ttl, - notBefore, - notAfter, - actorId, - actorAuthMethod, - actor, - actorOrgId - }: TSignCertFromCaDTO) => { - const ca = await certificateAuthorityDAL.findById(caId); + const signCertFromCa = async (dto: TSignCertFromCaDTO) => { + const ca = await certificateAuthorityDAL.findById(dto.caId); if (!ca) 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 + ); + } + + const { csr, friendlyName, commonName, altNames, ttl, notBefore, notAfter } = dto; if (ca.status === CaStatus.DISABLED) throw new BadRequestError({ message: "CA is disabled" }); @@ -1396,6 +1398,7 @@ export const certificateAuthorityServiceFactory = ({ return { certificate: leafCert.toString("pem"), + rawCertificate: leafCert.rawData, certificateChain: `${issuingCaCertificate}\n${caCertChain}`.trim(), issuingCaCertificate, serialNumber, @@ -1403,6 +1406,134 @@ export const certificateAuthorityServiceFactory = ({ }; }; + const createCaEstConfiguration = async ({ + caId, + caChain, + passphrase, + isEnabled, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TCreateCaEstConfigurationDTO) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) { + throw new NotFoundError({ message: "CA not found" }); + } + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + ProjectPermissionSub.CertificateAuthorities + ); + + const appCfg = getConfig(); + + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + 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 certificateAuthorityEstConfigDAL.create({ + caId, + hashedPassphrase, + encryptedCaChain, + isEnabled + }); + + return estConfig; + }; + + const updateCaEstConfiguration = async ({ + caId, + caChain, + passphrase, + isEnabled, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TUpdateCaEstConfigurationDTO) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) { + throw new NotFoundError({ message: "CA not found" }); + } + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + ProjectPermissionSub.CertificateAuthorities + ); + + const originalCaEstConfig = await certificateAuthorityEstConfigDAL.findOne({ + caId + }); + + if (!originalCaEstConfig) { + throw new NotFoundError({ + message: "CA EST Config not found" + }); + } + + const appCfg = getConfig(); + + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + const updatedData: TCertificateAuthorityEstConfigsUpdate = { + isEnabled + }; + + if (caChain) { + 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 certificateAuthorityEstConfigDAL.updateById(originalCaEstConfig.id, updatedData); + + return estConfig; + }; + return { createCa, getCaById, @@ -1415,6 +1546,8 @@ export const certificateAuthorityServiceFactory = ({ signIntermediate, importCertToCa, issueCertFromCa, - signCertFromCa + signCertFromCa, + createCaEstConfiguration, + updateCaEstConfiguration }; }; diff --git a/backend/src/services/certificate-authority/certificate-authority-types.ts b/backend/src/services/certificate-authority/certificate-authority-types.ts index f97844870..48db75557 100644 --- a/backend/src/services/certificate-authority/certificate-authority-types.ts +++ b/backend/src/services/certificate-authority/certificate-authority-types.ts @@ -95,16 +95,29 @@ export type TIssueCertFromCaDTO = { notAfter?: string; } & Omit; -export type TSignCertFromCaDTO = { - caId: string; - csr: string; - friendlyName?: string; - commonName?: string; - altNames: string; - ttl: string; - notBefore?: string; - notAfter?: string; -} & Omit; +export type TSignCertFromCaDTO = + | { + isInternal: true; + caId: string; + csr: string; + friendlyName?: string; + commonName?: string; + altNames: string; + ttl: string; + notBefore?: string; + notAfter?: string; + } + | ({ + isInternal: false; + caId: string; + csr: string; + friendlyName?: string; + commonName?: string; + altNames: string; + ttl: string; + notBefore?: string; + notAfter?: string; + } & Omit); export type TDNParts = { commonName?: string; @@ -153,3 +166,17 @@ export type TRotateCaCrlTriggerDTO = { caId: string; rotationIntervalDays: number; }; + +export type TCreateCaEstConfigurationDTO = { + caId: string; + caChain: string; + passphrase: string; + isEnabled: boolean; +} & Omit; + +export type TUpdateCaEstConfigurationDTO = { + caId: string; + caChain?: string; + passphrase?: string; + isEnabled?: boolean; +} & Omit; diff --git a/frontend/src/views/Project/CaPage/CaPage.tsx b/frontend/src/views/Project/CaPage/CaPage.tsx index 7edc99af9..fcf56a50e 100644 --- a/frontend/src/views/Project/CaPage/CaPage.tsx +++ b/frontend/src/views/Project/CaPage/CaPage.tsx @@ -22,7 +22,7 @@ import { usePopUp } from "@app/hooks/usePopUp"; import { CaModal } from "@app/views/Project/CertificatesPage/components/CaTab/components/CaModal"; import { CaInstallCertModal } from "../CertificatesPage/components/CaTab/components/CaInstallCertModal"; -import { TabSections } from "../Types"; +import { CaEnrollmentModal } from "./components/CaEnrollmentModal"; import { CaCertificatesSection, CaDetailsSection, CaRenewalModal } from "./components"; export const CaPage = withProjectPermission( @@ -40,7 +40,8 @@ export const CaPage = withProjectPermission( "ca", "deleteCa", "installCaCert", - "renewCa" + "renewCa", + "enrollmentOptions" ] as const); const onRemoveCaSubmit = async (caIdToDelete: string) => { @@ -125,6 +126,7 @@ export const CaPage = withProjectPermission( )} + , + popUpName: keyof UsePopUpState<["ca", "renewCa", "installCaCert", "enrollmentOptions"]>, data?: {} ) => void; }; @@ -118,19 +118,32 @@ export const CaDetailsSection = ({ caId, handlePopUpOpen }: Props) => { > {(isAllowed) => { return ( - + <> + + + ); }} diff --git a/frontend/src/views/Project/CaPage/components/CaEnrollmentModal.tsx b/frontend/src/views/Project/CaPage/components/CaEnrollmentModal.tsx new file mode 100644 index 000000000..468a05f90 --- /dev/null +++ b/frontend/src/views/Project/CaPage/components/CaEnrollmentModal.tsx @@ -0,0 +1,173 @@ +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 { 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(), + isEnabled: z.boolean() + }) + .required(); + +export type FormData = z.infer; + +export const CaEnrollmentModal = ({ popUp, handlePopUpToggle }: Props) => { + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + isEnabled: false + } + }); + + const onFormSubmit = async ({ caChain, passphrase, isEnabled }: FormData) => { + try { + handlePopUpToggle("enrollmentOptions", false); + + createNotification({ + text: "Successfully saved changes", + type: "success" + }); + + reset(); + } catch (err) { + console.error(err); + } + }; + + return ( + { + handlePopUpToggle("enrollmentOptions", isOpen); + reset(); + }} + > + +
+ ( + + + + )} + /> + ( + +