PKI: add support to export certs in PKCS12 format

This commit is contained in:
Carlos Monastyrski
2025-11-10 18:39:51 -03:00
parent 216f5cc147
commit 76645ccd23
22 changed files with 549 additions and 31 deletions
+11 -21
View File
@@ -58,6 +58,7 @@
"@sindresorhus/slugify": "1.1.0",
"@slack/oauth": "^3.0.2",
"@slack/web-api": "^7.8.0",
"@types/node-forge": "^1.3.14",
"@ucast/mongo2js": "^1.3.4",
"acme-client": "^5.4.0",
"ajv": "^8.12.0",
@@ -97,6 +98,7 @@
"ms": "^2.1.3",
"mysql2": "^3.9.8",
"nanoid": "^3.3.8",
"node-forge": "^1.3.1",
"nodemailer": "^6.9.9",
"oci-sdk": "^2.108.0",
"odbc": "^2.4.9",
@@ -15272,6 +15274,15 @@
"form-data": "^4.0.0"
}
},
"node_modules/@types/node-forge": {
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.14.tgz",
"integrity": "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/node/node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@@ -33526,18 +33537,6 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/vite-node/node_modules/@types/node": {
"version": "24.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz",
"integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
},
"node_modules/vite-node/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -33569,15 +33568,6 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/vite-node/node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/vite-node/node_modules/vite": {
"version": "7.1.12",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz",
+2
View File
@@ -185,6 +185,7 @@
"@sindresorhus/slugify": "1.1.0",
"@slack/oauth": "^3.0.2",
"@slack/web-api": "^7.8.0",
"@types/node-forge": "^1.3.14",
"@ucast/mongo2js": "^1.3.4",
"acme-client": "^5.4.0",
"ajv": "^8.12.0",
@@ -224,6 +225,7 @@
"ms": "^2.1.3",
"mysql2": "^3.9.8",
"nanoid": "^3.3.8",
"node-forge": "^1.3.1",
"nodemailer": "^6.9.9",
"oci-sdk": "^2.108.0",
"odbc": "^2.4.9",
@@ -323,6 +323,7 @@ export enum EventType {
GET_CERT_BODY = "get-cert-body",
GET_CERT_PRIVATE_KEY = "get-cert-private-key",
GET_CERT_BUNDLE = "get-cert-bundle",
EXPORT_CERT_PKCS12 = "export-cert-pkcs12",
CREATE_PKI_ALERT = "create-pki-alert",
GET_PKI_ALERT = "get-pki-alert",
UPDATE_PKI_ALERT = "update-pki-alert",
@@ -2314,6 +2315,14 @@ interface GetCertBundle {
serialNumber: string;
};
}
interface GetCertPkcs12 {
type: EventType.EXPORT_CERT_PKCS12;
metadata: {
certId: string;
cn: string;
serialNumber: string;
};
}
interface CreatePkiAlert {
type: EventType.CREATE_PKI_ALERT;
@@ -4240,6 +4249,7 @@ export type Event =
| GetCertBody
| GetCertPrivateKey
| GetCertBundle
| GetCertPkcs12
| CreatePkiAlert
| GetPkiAlert
| UpdatePkiAlert
@@ -616,4 +616,58 @@ export const registerCertRouter = async (server: FastifyZodProvider) => {
};
}
});
server.route({
method: "POST",
url: "/:serialNumber/pkcs12",
config: {
rateLimit: writeLimit
},
onRequest: verifyAuth([AuthMode.JWT]),
schema: {
hide: true,
tags: [ApiDocsTags.PkiCertificates],
description: "Download certificate in PKCS12 format",
params: z.object({
serialNumber: z.string().trim().describe(CERTIFICATES.GET.serialNumber)
}),
body: z.object({
password: z.string().describe("Password for the keystore"),
alias: z.string().describe("Alias for the certificate in the keystore")
}),
response: {
200: z.any().describe("PKCS12 keystore as binary data")
}
},
handler: async (req, reply) => {
const { pkcs12Data, cert } = await server.services.certificate.getCertPkcs12({
serialNumber: req.params.serialNumber,
password: req.body.password,
alias: req.body.alias,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: cert.projectId,
event: {
type: EventType.EXPORT_CERT_PKCS12,
metadata: {
certId: cert.id,
cn: cert.commonName,
serialNumber: cert.serialNumber
}
}
});
addNoCacheHeaders(reply);
reply.header("Content-Type", "application/octet-stream");
reply.header("Content-Disposition", `attachment; filename="certificate-${req.params.serialNumber}.p12"`);
return pkcs12Data;
}
});
};
@@ -1,4 +1,5 @@
import * as x509 from "@peculiar/x509";
import forge from "node-forge";
import RE2 from "re2";
import { crypto } from "@app/lib/crypto/cryptography";
@@ -104,3 +105,53 @@ export const getCertificateCredentials = async ({
throw new BadRequestError({ message: `Failed to process private key for certificate with ID '${certId}'` });
}
};
export const generatePkcs12FromCertificate = async ({
certificate,
certificateChain,
privateKey,
password,
alias
}: {
certificate: string;
certificateChain: string;
privateKey: string;
password: string;
alias: string;
}): Promise<Buffer> => {
try {
if (!password || password.trim() === "") {
throw new BadRequestError({ message: "Password is required for PKCS12 keystore generation" });
}
const cert = forge.pki.certificateFromPem(certificate);
const key = forge.pki.privateKeyFromPem(privateKey);
const chainCerts = [];
if (certificateChain) {
const chainPems = splitPemChain(certificateChain);
for (const chainPem of chainPems) {
try {
const chainCert = forge.pki.certificateFromPem(chainPem);
chainCerts.push(chainCert);
} catch (error) {
// Skip invalid certificates in chain
}
}
}
// Generate PKCS12 file
const p12Asn1 = forge.pkcs12.toPkcs12Asn1(key, [cert, ...chainCerts], password, {
algorithm: "aes256", // Modern AES-256 encryption
friendlyName: alias
});
const p12Der = forge.asn1.toDer(p12Asn1).getBytes();
return Buffer.from(p12Der, "binary");
} catch (error) {
throw new BadRequestError({
message: `Failed to generate PKCS12 keystore: ${error instanceof Error ? error.message : "Unknown error"}`
});
}
};
@@ -29,7 +29,12 @@ import { TProjectDALFactory } from "@app/services/project/project-dal";
import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns";
import { expandInternalCa, getCaCertChain, rebuildCaCrl } from "../certificate-authority/certificate-authority-fns";
import { getCertificateCredentials, revocationReasonToCrlCode, splitPemChain } from "./certificate-fns";
import {
generatePkcs12FromCertificate,
getCertificateCredentials,
revocationReasonToCrlCode,
splitPemChain
} from "./certificate-fns";
import { TCertificateSecretDALFactory } from "./certificate-secret-dal";
import {
CertExtendedKeyUsage,
@@ -40,6 +45,7 @@ import {
TGetCertBodyDTO,
TGetCertBundleDTO,
TGetCertDTO,
TGetCertPkcs12DTO,
TGetCertPrivateKeyDTO,
TImportCertDTO,
TRevokeCertDTO
@@ -656,6 +662,65 @@ export const certificateServiceFactory = ({
};
};
const getCertPkcs12 = async ({
serialNumber,
password,
alias,
actorId,
actorAuthMethod,
actor,
actorOrgId
}: TGetCertPkcs12DTO) => {
if (!password || password.trim() === "") {
throw new BadRequestError({ message: "Password is required for PKCS12 keystore generation" });
}
if (!alias || alias.trim() === "") {
throw new BadRequestError({ message: "Alias is required for PKCS12 keystore generation" });
}
const cert = await certificateDAL.findOne({ serialNumber });
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: cert.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionCertificateActions.ReadPrivateKey,
ProjectPermissionSub.Certificates
);
// Get certificate bundle (certificate, chain, private key)
const { certificate, certificateChain, privateKey } = await getCertBundle({
serialNumber,
actor,
actorId,
actorAuthMethod,
actorOrgId
});
if (!privateKey) {
throw new BadRequestError({ message: "Certificate private key is required for PKCS12 export" });
}
const pkcs12Data = await generatePkcs12FromCertificate({
certificate,
certificateChain: certificateChain || "",
privateKey,
password,
alias
});
return {
pkcs12Data,
cert
};
};
return {
getCert,
getCertPrivateKey,
@@ -663,6 +728,7 @@ export const certificateServiceFactory = ({
revokeCert,
getCertBody,
importCert,
getCertBundle
getCertBundle,
getCertPkcs12
};
};
@@ -119,6 +119,12 @@ export type TGetCertBundleDTO = {
serialNumber: string;
} & Omit<TProjectPermission, "projectId">;
export type TGetCertPkcs12DTO = {
serialNumber: string;
password: string;
alias: string;
} & Omit<TProjectPermission, "projectId">;
export type TGetCertificateCredentialsDTO = {
certId: string;
projectId: string;