diff --git a/backend/src/server/routes/est/certificate-est-router.ts b/backend/src/server/routes/est/certificate-est-router.ts index 948828cdc..50846f4e5 100644 --- a/backend/src/server/routes/est/certificate-est-router.ts +++ b/backend/src/server/routes/est/certificate-est-router.ts @@ -83,7 +83,7 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) = certificateTemplateId: z.string().min(1) }), response: { - 200: z.object({}) + 200: z.string() } }, handler: async (req, res) => { @@ -97,4 +97,31 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) = }); } }); + + 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["x-ssl-client-cert"] as string + }); + } + }); }; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index f8d004618..03d4a0097 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -660,7 +660,12 @@ export const registerRoutes = async ( const certificateEstService = certificateEstServiceFactory({ certificateAuthorityService, - certificateTemplateService + certificateTemplateService, + certificateTemplateDAL, + certificateAuthorityCertDAL, + certificateAuthorityDAL, + projectDAL, + kmsService }); const pkiAlertService = pkiAlertServiceFactory({ 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..2444e055f --- /dev/null +++ b/backend/src/services/certificate-est/certificate-est-fns.ts @@ -0,0 +1,42 @@ +import { X509Certificate } from "@peculiar/x509"; +import { Certificate, ContentInfo, EncapsulatedContentInfo, SignedData } from "pkijs"; + +export const convertRawCertToPkcs7 = (rawCertificate: ArrayBuffer) => { + 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; +}; + +export const checkCertValidityAgainstChain = async (cert: X509Certificate, chainCerts: X509Certificate[]) => { + let isSslClientCertValid = true; + let certToVerify = cert; + + for await (const issuerCert of chainCerts) { + if ( + await certToVerify.verify({ + publicKey: issuerCert.publicKey + }) + ) { + certToVerify = issuerCert; // Move to the next certificate in the chain + } else { + isSslClientCertValid = false; + } + } + + return isSslClientCertValid; +}; diff --git a/backend/src/services/certificate-est/certificate-est-service.ts b/backend/src/services/certificate-est/certificate-est-service.ts index da0131c0a..d2c3f6d3f 100644 --- a/backend/src/services/certificate-est/certificate-est-service.ts +++ b/backend/src/services/certificate-est/certificate-est-service.ts @@ -1,22 +1,163 @@ import * as x509 from "@peculiar/x509"; -import { Certificate, ContentInfo, EncapsulatedContentInfo, SignedData } from "pkijs"; import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; +import { TCertificateAuthorityCertDALFactory } from "../certificate-authority/certificate-authority-cert-dal"; +import { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal"; +import { 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 { checkCertValidityAgainstChain, convertRawCertToPkcs7 } from "./certificate-est-fns"; type TCertificateEstServiceFactoryDep = { certificateAuthorityService: Pick; - certificateTemplateService: Pick; + certificateTemplateService: Pick; + certificateTemplateDAL: Pick; + certificateAuthorityDAL: Pick; + certificateAuthorityCertDAL: Pick; + projectDAL: Pick; + kmsService: Pick; }; export type TCertificateEstServiceFactory = ReturnType; export const certificateEstServiceFactory = ({ certificateAuthorityService, - certificateTemplateService + 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 (!sslClientCert || !leafCertificate) { + throw new UnauthorizedError({ message: "Missing client certificate" }); + } + + const clientCertBody = leafCertificate + .replace("-----BEGIN CERTIFICATE-----", "") + .replace("-----END CERTIFICATE-----", "") + .replace(/\n/g, "") + .replace(/ /g, "") + .trim(); + + const cert = new x509.X509Certificate(clientCertBody); + + // 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 parsedChains = caCertChains + // we need the full chain from the CA certificate to the root + .map((chain) => chain.certificate + chain.certificateChain) + .map( + (certificateChain) => + certificateChain.match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g)?.map((certEntry) => { + const processedBody = certEntry + .replace("-----BEGIN CERTIFICATE-----", "") + .replace("-----END CERTIFICATE-----", "") + .replace(/\n/g, "") + .replace(/ /g, "") + .trim(); + + const certificateBuffer = Buffer.from(processedBody, "base64"); + return new x509.X509Certificate(certificateBuffer); + }) + ); + + if (!parsedChains || !parsedChains.length) { + throw new BadRequestError({ + message: "Error parsing CA chain" + }); + } + + const certValidityAgainstChains = await Promise.all( + parsedChains.map(async (chain) => { + if (!chain) { + return false; + } + + return checkCertValidityAgainstChain(cert, chain); + }) + ); + + if (certValidityAgainstChains.every((isCertValid) => !isCertValid)) { + throw new BadRequestError({ + message: "Invalid client certificate" + }); + } + + // 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 { rawCertificate } = await certificateAuthorityService.signCertFromCa({ + isInternal: true, + certificateTemplateId, + csr + }); + + return convertRawCertToPkcs7(rawCertificate); + }; + const simpleEnroll = async ({ csr, certificateTemplateId, @@ -55,7 +196,6 @@ export const certificateEstServiceFactory = ({ .replace(/ /g, "") .trim(); - // validate SSL client cert against configured CA const chainCerts = estConfig.caChain .match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) ?.map((cert) => { @@ -74,23 +214,9 @@ export const certificateEstServiceFactory = ({ throw new BadRequestError({ message: "Failed to parse certificate chain" }); } - let isSslClientCertValid = true; - let certToVerify = new x509.X509Certificate(clientCertBody); + const cert = 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) { + if (!(await checkCertValidityAgainstChain(cert, chainCerts))) { throw new UnauthorizedError({ message: "Invalid client certificate" }); @@ -102,26 +228,10 @@ export const certificateEstServiceFactory = ({ csr }); - 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; + return convertRawCertToPkcs7(rawCertificate); }; return { - simpleEnroll + simpleEnroll, + simpleReenroll }; };