mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-15 16:52:38 +00:00
misc: migrated est to ee and added license checks
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
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();
|
||||
|
||||
// definitive connection timeout to clean-up open connections and prevent memory leak
|
||||
res.raw.setTimeout(10 * 1000, () => {
|
||||
res.raw.end();
|
||||
});
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,268 @@
|
||||
import * as x509 from "@peculiar/x509";
|
||||
|
||||
import { BadRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors";
|
||||
import { isCertChainValid } from "@app/services/certificate/certificate-fns";
|
||||
import { TCertificateAuthorityCertDALFactory } from "@app/services/certificate-authority/certificate-authority-cert-dal";
|
||||
import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal";
|
||||
import { getCaCertChain, getCaCertChains } from "@app/services/certificate-authority/certificate-authority-fns";
|
||||
import { TCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/certificate-authority-service";
|
||||
import { TCertificateTemplateDALFactory } from "@app/services/certificate-template/certificate-template-dal";
|
||||
import { TCertificateTemplateServiceFactory } from "@app/services/certificate-template/certificate-template-service";
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||
|
||||
import { TLicenseServiceFactory } from "../license/license-service";
|
||||
import { convertRawCertsToPkcs7 } from "./certificate-est-fns";
|
||||
|
||||
type TCertificateEstServiceFactoryDep = {
|
||||
certificateAuthorityService: Pick<TCertificateAuthorityServiceFactory, "signCertFromCa">;
|
||||
certificateTemplateService: Pick<TCertificateTemplateServiceFactory, "getEstConfiguration">;
|
||||
certificateTemplateDAL: Pick<TCertificateTemplateDALFactory, "findById">;
|
||||
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findById">;
|
||||
certificateAuthorityCertDAL: Pick<TCertificateAuthorityCertDALFactory, "find" | "findById">;
|
||||
projectDAL: Pick<TProjectDALFactory, "findOne" | "updateById" | "transaction">;
|
||||
kmsService: Pick<TKmsServiceFactory, "decryptWithKmsKey" | "generateKmsKey">;
|
||||
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
||||
};
|
||||
|
||||
export type TCertificateEstServiceFactory = ReturnType<typeof certificateEstServiceFactory>;
|
||||
|
||||
export const certificateEstServiceFactory = ({
|
||||
certificateAuthorityService,
|
||||
certificateTemplateService,
|
||||
certificateTemplateDAL,
|
||||
certificateAuthorityCertDAL,
|
||||
certificateAuthorityDAL,
|
||||
projectDAL,
|
||||
kmsService,
|
||||
licenseService
|
||||
}: TCertificateEstServiceFactoryDep) => {
|
||||
const simpleReenroll = async ({
|
||||
csr,
|
||||
certificateTemplateId,
|
||||
sslClientCert
|
||||
}: {
|
||||
csr: string;
|
||||
certificateTemplateId: string;
|
||||
sslClientCert: string;
|
||||
}) => {
|
||||
const estConfig = await certificateTemplateService.getEstConfiguration({
|
||||
isInternal: true,
|
||||
certificateTemplateId
|
||||
});
|
||||
|
||||
const plan = await licenseService.getPlan(estConfig.orgId);
|
||||
if (!plan.pkiEst) {
|
||||
throw new BadRequestError({
|
||||
message:
|
||||
"Failed to perform EST operation - simpleReenroll due to plan restriction. Upgrade to the Enterprise plan."
|
||||
});
|
||||
}
|
||||
|
||||
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<string> = 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<string> = 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
|
||||
});
|
||||
|
||||
const plan = await licenseService.getPlan(estConfig.orgId);
|
||||
if (!plan.pkiEst) {
|
||||
throw new BadRequestError({
|
||||
message:
|
||||
"Failed to perform EST operation - simpleEnroll due to plan restriction. Upgrade to the Enterprise plan."
|
||||
});
|
||||
}
|
||||
|
||||
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 estConfig = await certificateTemplateService.getEstConfiguration({
|
||||
isInternal: true,
|
||||
certificateTemplateId
|
||||
});
|
||||
|
||||
const plan = await licenseService.getPlan(estConfig.orgId);
|
||||
if (!plan.pkiEst) {
|
||||
throw new BadRequestError({
|
||||
message: "Failed to perform EST operation - caCerts due to plan restriction. Upgrade to the Enterprise plan."
|
||||
});
|
||||
}
|
||||
|
||||
if (!estConfig.isEnabled) {
|
||||
throw new BadRequestError({
|
||||
message: "EST is disabled"
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
};
|
||||
@@ -45,7 +45,8 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
|
||||
readLimit: 60,
|
||||
writeLimit: 200,
|
||||
secretsLimit: 40
|
||||
}
|
||||
},
|
||||
pkiEst: false
|
||||
});
|
||||
|
||||
export const setupLicenceRequestWithStore = (baseURL: string, refreshUrl: string, licenseKey: string) => {
|
||||
|
||||
@@ -63,6 +63,7 @@ export type TFeatureSet = {
|
||||
writeLimit: number;
|
||||
secretsLimit: number;
|
||||
};
|
||||
pkiEst: boolean;
|
||||
};
|
||||
|
||||
export type TOrgPlansTableDTO = {
|
||||
|
||||
Reference in New Issue
Block a user