diff --git a/backend/src/server/routes/v1/certificate-router.ts b/backend/src/server/routes/v1/certificate-router.ts index 5a6daaf6d..e8cdbb540 100644 --- a/backend/src/server/routes/v1/certificate-router.ts +++ b/backend/src/server/routes/v1/certificate-router.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-floating-promises */ +import RE2 from "re2"; import { z } from "zod"; import { CertificatesSchema } from "@app/db/schemas"; @@ -632,8 +633,11 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { 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") + password: z + .string() + .min(6, "Password must be at least 6 characters long") + .describe("Password for the keystore (minimum 6 characters)"), + alias: z.string().min(1, "Alias is required").describe("Alias for the certificate in the keystore") }), response: { 200: z.any().describe("PKCS12 keystore as binary data") @@ -665,7 +669,10 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { addNoCacheHeaders(reply); reply.header("Content-Type", "application/octet-stream"); - reply.header("Content-Disposition", `attachment; filename="certificate-${req.params.serialNumber}.p12"`); + reply.header( + "Content-Disposition", + `attachment; filename="certificate-${req.params.serialNumber.replace(new RE2("[^\\w.-]", "g"), "_")}.p12"` + ); return pkcs12Data; } diff --git a/backend/src/services/certificate/certificate-service.ts b/backend/src/services/certificate/certificate-service.ts index e4f347c85..b632e76fb 100644 --- a/backend/src/services/certificate/certificate-service.ts +++ b/backend/src/services/certificate/certificate-service.ts @@ -675,6 +675,12 @@ export const certificateServiceFactory = ({ throw new BadRequestError({ message: "Password is required for PKCS12 keystore generation" }); } + if (password.length < 6) { + throw new BadRequestError({ + message: "Password must be at least 6 characters long for PKCS12 keystore security" + }); + } + if (!alias || alias.trim() === "") { throw new BadRequestError({ message: "Alias is required for PKCS12 keystore generation" }); } diff --git a/frontend/src/hooks/api/certificates/mutations.tsx b/frontend/src/hooks/api/certificates/mutations.tsx index 2ac0003d9..078cef7dc 100644 --- a/frontend/src/hooks/api/certificates/mutations.tsx +++ b/frontend/src/hooks/api/certificates/mutations.tsx @@ -139,28 +139,38 @@ 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" - } - ); + try { + 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); + // 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); + } catch (error: any) { + if (error.response?.data instanceof ArrayBuffer) { + const decoder = new TextDecoder(); + const errorText = decoder.decode(error.response.data); + const errorData = JSON.parse(errorText); + throw new Error(errorData.message); + } + throw error; + } } }); }; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateExportModal.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateExportModal.tsx index 9c95eff68..3916477a1 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateExportModal.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateExportModal.tsx @@ -19,13 +19,17 @@ type Props = { popUpName: keyof UsePopUpState<["certificateExport"]>, state?: boolean ) => void; - onFormatSelected: (format: "pem" | "jks", serialNumber: string, options?: ExportOptions) => void; + onFormatSelected: ( + format: "pem" | "pkcs12", + serialNumber: string, + options?: ExportOptions + ) => void; }; -export type CertificateExportFormat = "pem" | "jks"; +export type CertificateExportFormat = "pem" | "pkcs12"; export type ExportOptions = { - jks?: { + pkcs12?: { password: string; alias: string; }; @@ -33,7 +37,7 @@ export type ExportOptions = { export const CertificateExportModal = ({ popUp, handlePopUpToggle, onFormatSelected }: Props) => { const [selectedFormat, setSelectedFormat] = useState("pem"); - const [jksOptions, setJksOptions] = useState({ + const [pkcs12Options, setPkcs12Options] = useState({ password: "", alias: "" }); @@ -45,7 +49,7 @@ export const CertificateExportModal = ({ popUp, handlePopUpToggle, onFormatSelec useEffect(() => { if (popUp?.certificateExport?.isOpen) { setSelectedFormat("pem"); - setJksOptions({ + setPkcs12Options({ password: "", alias: "" }); @@ -53,8 +57,8 @@ export const CertificateExportModal = ({ popUp, handlePopUpToggle, onFormatSelec }, [popUp?.certificateExport?.isOpen]); const isFormValid = () => { - if (selectedFormat === "jks") { - return jksOptions.password.trim() !== "" && jksOptions.alias.trim() !== ""; + if (selectedFormat === "pkcs12") { + return pkcs12Options.password.length >= 6 && pkcs12Options.alias.trim() !== ""; } return true; }; @@ -63,8 +67,8 @@ export const CertificateExportModal = ({ popUp, handlePopUpToggle, onFormatSelec if (serialNumber && isFormValid()) { const options: ExportOptions = {}; - if (selectedFormat === "jks") { - options.jks = jksOptions; + if (selectedFormat === "pkcs12") { + options.pkcs12 = pkcs12Options; } onFormatSelected(selectedFormat, serialNumber, options); @@ -97,20 +101,28 @@ export const CertificateExportModal = ({ popUp, handlePopUpToggle, onFormatSelec onValueChange={(value) => setSelectedFormat(value as CertificateExportFormat)} > PEM Format - PKCS12 Format + PKCS12 Format - {selectedFormat === "jks" && ( + {selectedFormat === "pkcs12" && ( <> 0 && pkcs12Options.password.length < 6 + ? undefined + : "Password to protect the PKCS12 keystore (minimum 6 characters)" + } + isError={pkcs12Options.password.length > 0 && pkcs12Options.password.length < 6} + errorText="Password must be at least 6 characters long" > setJksOptions((prev) => ({ ...prev, password: e.target.value }))} + value={pkcs12Options.password} + onChange={(e) => + setPkcs12Options((prev) => ({ ...prev, password: e.target.value })) + } type="password" /> @@ -121,8 +133,8 @@ export const CertificateExportModal = ({ popUp, handlePopUpToggle, onFormatSelec > setJksOptions((prev) => ({ ...prev, alias: e.target.value }))} + value={pkcs12Options.alias} + onChange={(e) => setPkcs12Options((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 09ee03b38..7ca9b81d0 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx @@ -53,16 +53,16 @@ export const CertificatesSection = () => { }; const handleCertificateExport = async ( - format: "pem" | "jks", + format: "pem" | "pkcs12", serialNumber: string, options?: ExportOptions ) => { if (format === "pem") { handlePopUpOpen("certificateCert", { serialNumber }); - } else if (format === "jks") { + } else if (format === "pkcs12") { if (!currentProject?.slug) return; - if (!options?.jks?.password || !options?.jks?.alias) { + if (!options?.pkcs12?.password || !options?.pkcs12?.alias) { createNotification({ text: "Password and alias are required for PKCS12 export", type: "error" @@ -74,17 +74,17 @@ export const CertificatesSection = () => { await downloadCertPkcs12({ serialNumber, projectSlug: currentProject.slug, - password: options.jks.password, - alias: options.jks.alias + password: options.pkcs12.password, + alias: options.pkcs12.alias }); createNotification({ text: "PKCS12 certificate downloaded successfully", type: "success" }); - } catch { + } catch (error: any) { createNotification({ - text: "Failed to download PKCS12 certificate", + text: error?.message || "Failed to download PKCS12 certificate", type: "error" }); }