diff --git a/backend/package-lock.json b/backend/package-lock.json index 9cdaa764b..cfe22d916 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index fa4ee2f5f..baa81e65b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", 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 0b45544e3..771be5ffd 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -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 diff --git a/backend/src/server/routes/v1/certificate-router.ts b/backend/src/server/routes/v1/certificate-router.ts index 443d64e22..5a6daaf6d 100644 --- a/backend/src/server/routes/v1/certificate-router.ts +++ b/backend/src/server/routes/v1/certificate-router.ts @@ -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; + } + }); }; diff --git a/backend/src/services/certificate/certificate-fns.ts b/backend/src/services/certificate/certificate-fns.ts index b2bc4df41..d2d2aa3ce 100644 --- a/backend/src/services/certificate/certificate-fns.ts +++ b/backend/src/services/certificate/certificate-fns.ts @@ -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 => { + 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"}` + }); + } +}; diff --git a/backend/src/services/certificate/certificate-service.ts b/backend/src/services/certificate/certificate-service.ts index eb8006f00..e4f347c85 100644 --- a/backend/src/services/certificate/certificate-service.ts +++ b/backend/src/services/certificate/certificate-service.ts @@ -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 }; }; diff --git a/backend/src/services/certificate/certificate-types.ts b/backend/src/services/certificate/certificate-types.ts index d654c96ba..085bb9588 100644 --- a/backend/src/services/certificate/certificate-types.ts +++ b/backend/src/services/certificate/certificate-types.ts @@ -119,6 +119,12 @@ export type TGetCertBundleDTO = { serialNumber: string; } & Omit; +export type TGetCertPkcs12DTO = { + serialNumber: string; + password: string; + alias: string; +} & Omit; + export type TGetCertificateCredentialsDTO = { certId: string; projectId: string; diff --git a/docs/documentation/platform/pki/certificates/certificates.mdx b/docs/documentation/platform/pki/certificates/certificates.mdx index bc3698e6c..6e255917a 100644 --- a/docs/documentation/platform/pki/certificates/certificates.mdx +++ b/docs/documentation/platform/pki/certificates/certificates.mdx @@ -54,6 +54,107 @@ The following examples demonstrate different approaches to certificate renewal: - Using the ACME enrollment method, you may use [cert-manager](https://cert-manager.io/) with Infisical to issue and renew certificates for Kubernetes workloads; cert-manager will pursue a client-driven approach and submit certificate requests upon certificate expiration for you, saving renewed certificates back to Kubernetes secrets. - Using the API enrollment method, you may push and auto-renew certificates to AWS and Azure using [certificate syncs](/documentation/platform/pki/certificate-syncs/overview). Certificates issued over the API enrollment method, where key pairs are generated server-side, are also eligible for server-side auto-renewal; once renewed, certificates are automatically pushed back to their sync destination. +## Guide to Exporting Certificates + +In the following steps, we explore how to export certificates from Infisical in different formats for use in your applications and infrastructure. + +### Accessing the Export Certificate Modal + +To export any certificate, first navigate to your project's certificate inventory and locate the certificate you want to export. Click on the **Export Certificate** option from the certificate's action menu. + +![pki export certificate option](/images/platform/pki/certificate/cert-export-option.png) + + + + + + In the export modal, choose **PEM** as the format and click **Export**. + + ![pki export certificate pem](/images/platform/pki/certificate/cert-export-pem.png) + + The PEM export modal will display the certificate details including: + - **Serial Number**: The unique identifier for the certificate + - **Certificate Body**: The X.509 certificate in PEM format + - **Certificate Chain**: The intermediate and root CA certificates + - **Private Key**: The private key associated with the certificate (if available) + + ![pki export certificate pem modal](/images/platform/pki/certificate/cert-export-pem-modal.png) + + You can copy each component individually or use the **Copy All** button to copy the complete certificate bundle. + + + PEM format certificates can be used directly with most web servers and applications: + + - **Apache HTTP Server**: Configure SSL certificates in your virtual host + - **Nginx**: Use the certificate and private key files in your server configuration + - **Docker containers**: Mount certificate files for TLS-enabled applications + - **Load balancers**: Upload PEM certificates to AWS ALB, Azure Application Gateway, etc. + + Example Nginx configuration: + ```nginx + server { + listen 443 ssl; + server_name example.com; + + ssl_certificate /path/to/certificate.pem; + ssl_certificate_key /path/to/private-key.pem; + } + ``` + + + + + + + In the export modal, choose **PKCS12** as the format and provide the required configuration: + + ![pki export certificate pkcs12](/images/platform/pki/certificate/cert-export-pkcs12.png) + + - **Password**: A secure password to protect the PKCS12 keystore + - **Alias**: A friendly name for the certificate within the keystore + + Click **Export** to generate and download the `.p12` file containing the certificate, certificate chain, and private key. + + + PKCS12 files (`.p12` extension) are binary keystore files that contain the certificate, certificate chain, and private key in a single encrypted file: + + - **Java applications**: Import directly into Java KeyStore (JKS) or use with SSL/TLS + - **Windows IIS**: Import the PKCS12 file for web server SSL configuration + - **Browser certificates**: Install client certificates for authentication + - **Mobile applications**: Deploy certificates to iOS and Android applications + + To verify the contents of a PKCS12 file: + ```bash + openssl pkcs12 -in certificate.p12 -nokeys -clcerts + ``` + + To extract the private key: + ```bash + openssl pkcs12 -in certificate.p12 -nocerts -out private-key.pem + ``` + + + If you need to convert the PKCS12 file to Java KeyStore (JKS) format for applications running on Java 8 or earlier, use the following keytool command: + + ```bash + keytool -importkeystore \ + -srckeystore certificate.p12 \ + -srcstoretype PKCS12 \ + -srcstorepass \ + -destkeystore certificate.jks \ + -deststoretype JKS \ + -deststorepass + ``` + + Replace `` with the password you used when exporting the PKCS12 file, and `` with your desired JKS keystore password. + + The resulting `.jks` file can then be used with Java applications that require JKS format keystores. + + + + + + ## Guide to Revoking Certificates In the following steps, we explore how to revoke a X.509 certificate and obtain a Certificate Revocation List (CRL) for a CA. diff --git a/docs/images/platform/pki/certificate/cert-export-option.png b/docs/images/platform/pki/certificate/cert-export-option.png new file mode 100644 index 000000000..ae966905c Binary files /dev/null and b/docs/images/platform/pki/certificate/cert-export-option.png differ diff --git a/docs/images/platform/pki/certificate/cert-export-pem-modal.png b/docs/images/platform/pki/certificate/cert-export-pem-modal.png new file mode 100644 index 000000000..df01db0bf Binary files /dev/null and b/docs/images/platform/pki/certificate/cert-export-pem-modal.png differ diff --git a/docs/images/platform/pki/certificate/cert-export-pem.png b/docs/images/platform/pki/certificate/cert-export-pem.png new file mode 100644 index 000000000..927227394 Binary files /dev/null and b/docs/images/platform/pki/certificate/cert-export-pem.png differ diff --git a/docs/images/platform/pki/certificate/cert-export-pkcs12.png b/docs/images/platform/pki/certificate/cert-export-pkcs12.png new file mode 100644 index 000000000..773c69c74 Binary files /dev/null and b/docs/images/platform/pki/certificate/cert-export-pkcs12.png differ diff --git a/frontend/src/hooks/api/certificates/index.tsx b/frontend/src/hooks/api/certificates/index.tsx index 7d9c5df08..bc7f80d2c 100644 --- a/frontend/src/hooks/api/certificates/index.tsx +++ b/frontend/src/hooks/api/certificates/index.tsx @@ -1,6 +1,7 @@ export { CertStatus } from "./enums"; export { useDeleteCert, + useDownloadCertPkcs12, useImportCertificate, useRenewCertificate, useRevokeCert, diff --git a/frontend/src/hooks/api/certificates/mutations.tsx b/frontend/src/hooks/api/certificates/mutations.tsx index eed1d9e5f..2ac0003d9 100644 --- a/frontend/src/hooks/api/certificates/mutations.tsx +++ b/frontend/src/hooks/api/certificates/mutations.tsx @@ -7,6 +7,7 @@ import { projectKeys } from "../projects"; import { TCertificate, TDeleteCertDTO, + TDownloadPkcs12DTO, TImportCertificateDTO, TImportCertificateResponse, TRenewCertificateDTO, @@ -134,3 +135,32 @@ export const useUpdateRenewalConfig = () => { } }); }; + +export const useDownloadCertPkcs12 = () => { + return useMutation({ + mutationFn: async ({ serialNumber, projectSlug, password, alias }) => { + const response = await apiRequest.post( + `/api/v1/pki/certificates/${serialNumber}/pkcs12`, + { + password, + alias + }, + { + params: { projectSlug }, + responseType: "arraybuffer" + } + ); + + // Create blob and trigger download + const blob = new Blob([response.data], { type: "application/octet-stream" }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `certificate-${serialNumber}.p12`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + } + }); +}; diff --git a/frontend/src/hooks/api/certificates/types.ts b/frontend/src/hooks/api/certificates/types.ts index adfb815a6..5e950b9c9 100644 --- a/frontend/src/hooks/api/certificates/types.ts +++ b/frontend/src/hooks/api/certificates/types.ts @@ -72,3 +72,10 @@ export type TUpdateRenewalConfigDTO = { enableAutoRenewal?: boolean; projectSlug: string; }; + +export type TDownloadPkcs12DTO = { + serialNumber: string; + projectSlug: string; + password: string; + alias: string; +}; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateExportModal.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateExportModal.tsx new file mode 100644 index 000000000..9c95eff68 --- /dev/null +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateExportModal.tsx @@ -0,0 +1,151 @@ +import { useEffect, useState } from "react"; +import { faDownload } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { + Button, + FormControl, + Input, + Modal, + ModalContent, + Select, + SelectItem +} from "@app/components/v2"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + popUp: UsePopUpState<["certificateExport"]>; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["certificateExport"]>, + state?: boolean + ) => void; + onFormatSelected: (format: "pem" | "jks", serialNumber: string, options?: ExportOptions) => void; +}; + +export type CertificateExportFormat = "pem" | "jks"; + +export type ExportOptions = { + jks?: { + password: string; + alias: string; + }; +}; + +export const CertificateExportModal = ({ popUp, handlePopUpToggle, onFormatSelected }: Props) => { + const [selectedFormat, setSelectedFormat] = useState("pem"); + const [jksOptions, setJksOptions] = useState({ + password: "", + alias: "" + }); + + const serialNumber = + (popUp?.certificateExport?.data as { serialNumber: string })?.serialNumber || ""; + + // Reset form whenever the modal opens + useEffect(() => { + if (popUp?.certificateExport?.isOpen) { + setSelectedFormat("pem"); + setJksOptions({ + password: "", + alias: "" + }); + } + }, [popUp?.certificateExport?.isOpen]); + + const isFormValid = () => { + if (selectedFormat === "jks") { + return jksOptions.password.trim() !== "" && jksOptions.alias.trim() !== ""; + } + return true; + }; + + const handleExport = () => { + if (serialNumber && isFormValid()) { + const options: ExportOptions = {}; + + if (selectedFormat === "jks") { + options.jks = jksOptions; + } + + onFormatSelected(selectedFormat, serialNumber, options); + handlePopUpToggle("certificateExport", false); + } + }; + + return ( + { + handlePopUpToggle("certificateExport", isOpen); + }} + > + +
+

Choose the format for exporting your certificate

+ + + + + + {selectedFormat === "jks" && ( + <> + + setJksOptions((prev) => ({ ...prev, password: e.target.value }))} + type="password" + /> + + + + setJksOptions((prev) => ({ ...prev, alias: e.target.value }))} + /> + + + )} + +
+ + +
+
+
+
+ ); +}; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx index 6a5566d6a..09ee03b38 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx @@ -9,10 +9,11 @@ import { ProjectPermissionSub, useProject } from "@app/context"; -import { useDeleteCert } from "@app/hooks/api"; +import { useDeleteCert, useDownloadCertPkcs12 } from "@app/hooks/api"; import { usePopUp } from "@app/hooks/usePopUp"; import { CertificateCertModal } from "./CertificateCertModal"; +import { CertificateExportModal, ExportOptions } from "./CertificateExportModal"; import { CertificateImportModal } from "./CertificateImportModal"; import { CertificateIssuanceModal } from "./CertificateIssuanceModal"; import { CertificateManagePkiSyncsModal } from "./CertificateManagePkiSyncsModal"; @@ -24,11 +25,13 @@ import { CertificatesTable } from "./CertificatesTable"; export const CertificatesSection = () => { const { currentProject } = useProject(); const { mutateAsync: deleteCert } = useDeleteCert(); + const { mutateAsync: downloadCertPkcs12 } = useDownloadCertPkcs12(); const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ "issueCertificate", "certificateImport", "certificateCert", + "certificateExport", "deleteCertificate", "revokeCertificate", "manageRenewal", @@ -49,6 +52,45 @@ export const CertificatesSection = () => { handlePopUpClose("deleteCertificate"); }; + const handleCertificateExport = async ( + format: "pem" | "jks", + serialNumber: string, + options?: ExportOptions + ) => { + if (format === "pem") { + handlePopUpOpen("certificateCert", { serialNumber }); + } else if (format === "jks") { + if (!currentProject?.slug) return; + + if (!options?.jks?.password || !options?.jks?.alias) { + createNotification({ + text: "Password and alias are required for PKCS12 export", + type: "error" + }); + return; + } + + try { + await downloadCertPkcs12({ + serialNumber, + projectSlug: currentProject.slug, + password: options.jks.password, + alias: options.jks.alias + }); + + createNotification({ + text: "PKCS12 certificate downloaded successfully", + type: "success" + }); + } catch { + createNotification({ + text: "Failed to download PKCS12 certificate", + type: "error" + }); + } + } + }; + return (
@@ -84,6 +126,11 @@ export const CertificatesSection = () => { + diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesTable.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesTable.tsx index 9bccff31d..a60142762 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesTable.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesTable.tsx @@ -68,6 +68,7 @@ type Props = { "deleteCertificate", "revokeCertificate", "certificateCert", + "certificateExport", "manageRenewal", "renewCertificate", "managePkiSyncs" @@ -275,7 +276,7 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => { !isAllowed && "pointer-events-none cursor-not-allowed opacity-50" )} onClick={async () => - handlePopUpOpen("certificateCert", { + handlePopUpOpen("certificateExport", { serialNumber: certificate.serialNumber }) } diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx index 78517b49f..ab34abec8 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx @@ -1,6 +1,6 @@ +import { useState } from "react"; import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { useState } from "react"; import { createNotification } from "@app/components/notifications"; import { Button, DeleteActionModal } from "@app/components/v2"; diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx index 40bc1f671..f53084a4e 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx @@ -1,8 +1,8 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useEffect } from "react"; -import { Controller, useForm } from "react-hook-form"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx index 1e6f259e1..009ebe1a4 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx @@ -1,3 +1,4 @@ +import { useCallback } from "react"; import { faCheck, faCircleInfo, @@ -9,7 +10,6 @@ import { faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { useCallback } from "react"; import { createNotification } from "@app/components/notifications"; import { diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/RevealAcmeEabSecretModal.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/RevealAcmeEabSecretModal.tsx index 628e4106b..970918cc0 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/RevealAcmeEabSecretModal.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/RevealAcmeEabSecretModal.tsx @@ -1,3 +1,6 @@ +import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + import { Alert, AlertDescription, @@ -11,8 +14,6 @@ import { import { useToggle } from "@app/hooks"; import { TCertificateProfileWithDetails } from "@app/hooks/api/certificateProfiles"; import { useRevealAcmeEabSecret } from "@app/hooks/api/certificateProfiles/queries"; -import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; const RESET_COPIED_DELAY = 1 * 1000;