From 61370cc6b2b6ba32858802933ab342b323d753e2 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Tue, 20 Aug 2024 21:44:41 -0700 Subject: [PATCH 1/6] Finish allow installing intermediate CA with external parent CA --- .../certificate-authority-service.ts | 23 +- .../src/components/v2/TextArea/TextArea.tsx | 6 +- frontend/src/views/Project/CaPage/CaPage.tsx | 1 - .../CaPage/components/CaDetailsSection.tsx | 57 ++-- .../CaInstallCertModal/CaInstallCertModal.tsx | 293 ++---------------- .../ExternalCaInstallForm.tsx | 172 ++++++++++ .../InternalCaInstallForm.tsx | 236 ++++++++++++++ 7 files changed, 483 insertions(+), 305 deletions(-) create mode 100644 frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaInstallCertModal/ExternalCaInstallForm.tsx create mode 100644 frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaInstallCertModal/InternalCaInstallForm.tsx diff --git a/backend/src/services/certificate-authority/certificate-authority-service.ts b/backend/src/services/certificate-authority/certificate-authority-service.ts index 32dbc8fd0..b2ee04c4c 100644 --- a/backend/src/services/certificate-authority/certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/certificate-authority-service.ts @@ -368,7 +368,6 @@ export const certificateAuthorityServiceFactory = ({ ); if (ca.type === CaType.ROOT) throw new BadRequestError({ message: "Root CA cannot generate CSR" }); - if (ca.activeCaCertId) throw new BadRequestError({ message: "CA already has a certificate installed" }); const { caPrivateKey, caPublicKey } = await getCaCredentials({ caId, @@ -407,7 +406,8 @@ export const certificateAuthorityServiceFactory = ({ /** * Renew certificate for CA with id [caId] - * Note: Currently implements CA renewal with same key-pair only + * Note 1: This CA renewal method is only applicable to CAs with internal parent CAs + * Note 2: Currently implements CA renewal with same key-pair only */ const renewCaCert = async ({ caId, notAfter, actorId, actorAuthMethod, actor, actorOrgId }: TRenewCaCertDTO) => { const ca = await certificateAuthorityDAL.findById(caId); @@ -888,9 +888,9 @@ export const certificateAuthorityServiceFactory = ({ }; /** - * Import certificate for (un-installed) CA with id [caId]. + * Import certificate for CA with id [caId]. * Note: Can be used to import an external certificate and certificate chain - * to be installed into the CA. + * to be into an installed or uninstalled CA. */ const importCertToCa = async ({ caId, @@ -917,7 +917,18 @@ export const certificateAuthorityServiceFactory = ({ ProjectPermissionSub.CertificateAuthorities ); - if (ca.activeCaCertId) throw new BadRequestError({ message: "CA has already imported a certificate" }); + if (ca.parentCaId) { + /** + * re-evaluate in the future if we should allow users to import a new CA certificate for an intermediate + * CA chained to an internal parent CA. Doing so would allow users to re-chain the CA to a different + * internal CA. + */ + throw new BadRequestError({ + message: "Cannot import certificate to intermediate CA chained to internal parent CA" + }); + } + + const caCert = ca.activeCaCertId ? await certificateAuthorityCertDAL.findById(ca.activeCaCertId) : undefined; const certObj = new x509.X509Certificate(certificate); const maxPathLength = certObj.getExtension(x509.BasicConstraintsExtension)?.pathLength; @@ -988,7 +999,7 @@ export const certificateAuthorityServiceFactory = ({ caId: ca.id, encryptedCertificate, encryptedCertificateChain, - version: 1, + version: caCert ? caCert.version + 1 : 1, caSecretId: caSecret.id }, tx diff --git a/frontend/src/components/v2/TextArea/TextArea.tsx b/frontend/src/components/v2/TextArea/TextArea.tsx index 3f75f5a43..a1e71edd2 100644 --- a/frontend/src/components/v2/TextArea/TextArea.tsx +++ b/frontend/src/components/v2/TextArea/TextArea.tsx @@ -11,7 +11,7 @@ type Props = { }; const textAreaVariants = cva( - "textarea w-full p-2 focus:ring-2 ring-primary-800 outline-none border border-solid text-gray-400 font-inter placeholder-gray-500 placeholder-opacity-50", + "textarea w-full p-2 focus:ring-2 ring-primary-800 outline-none border text-gray-400 font-inter placeholder-gray-500 placeholder-opacity-50", { variants: { size: { @@ -25,13 +25,13 @@ const textAreaVariants = cva( false: "" }, variant: { - filled: ["bg-bunker-800", "text-gray-400"], + filled: ["bg-mineshaft-900", "text-gray-400"], outline: ["bg-transparent"], plain: "bg-transparent outline-none" }, isError: { true: "focus:ring-red/50 placeholder-red-300 border-red", - false: "focus:ring-primary/50 border-mineshaft-400" + false: "focus:ring-primary-400/50 focus:ring-1 border-mineshaft-500" } }, compoundVariants: [ diff --git a/frontend/src/views/Project/CaPage/CaPage.tsx b/frontend/src/views/Project/CaPage/CaPage.tsx index 7edc99af9..e0bdaeb64 100644 --- a/frontend/src/views/Project/CaPage/CaPage.tsx +++ b/frontend/src/views/Project/CaPage/CaPage.tsx @@ -22,7 +22,6 @@ import { usePopUp } from "@app/hooks/usePopUp"; import { CaModal } from "@app/views/Project/CertificatesPage/components/CaTab/components/CaModal"; import { CaInstallCertModal } from "../CertificatesPage/components/CaTab/components/CaInstallCertModal"; -import { TabSections } from "../Types"; import { CaCertificatesSection, CaDetailsSection, CaRenewalModal } from "./components"; export const CaPage = withProjectPermission( diff --git a/frontend/src/views/Project/CaPage/components/CaDetailsSection.tsx b/frontend/src/views/Project/CaPage/components/CaDetailsSection.tsx index eb8ccf86f..94eae3e6b 100644 --- a/frontend/src/views/Project/CaPage/components/CaDetailsSection.tsx +++ b/frontend/src/views/Project/CaPage/components/CaDetailsSection.tsx @@ -6,7 +6,7 @@ import { ProjectPermissionCan } from "@app/components/permissions"; import { Button, IconButton, Tooltip } from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { useTimedReset } from "@app/hooks"; -import { CaStatus, useGetCaById } from "@app/hooks/api"; +import { CaStatus, CaType, useGetCaById } from "@app/hooks/api"; import { caStatusToNameMap, caTypeToNameMap } from "@app/hooks/api/ca/constants"; import { certKeyAlgorithmToNameMap } from "@app/hooks/api/certificates/constants"; import { UsePopUpState } from "@app/hooks/usePopUp"; @@ -35,6 +35,10 @@ export const CaDetailsSection = ({ caId, handlePopUpOpen }: Props) => {

CA Details

+
+

CA Type

+

{caTypeToNameMap[ca.type]}

+

CA ID

@@ -56,26 +60,30 @@ export const CaDetailsSection = ({ caId, handlePopUpOpen }: Props) => {
- {ca.parentCaId && ( + {ca.type === CaType.INTERMEDIATE && ca.status !== CaStatus.PENDING_CERTIFICATE && (

Parent CA ID

-

{ca.parentCaId}

-
- - { - navigator.clipboard.writeText(ca.parentCaId as string); - setCopyTextParentId("Copied"); - }} - > - - - -
+

+ {ca.parentCaId ? ca.parentCaId : "N/A - External Parent CA"} +

+ {ca.parentCaId && ( +
+ + { + navigator.clipboard.writeText(ca.parentCaId as string); + setCopyTextParentId("Copied"); + }} + > + + + +
+ )}
)} @@ -83,10 +91,6 @@ export const CaDetailsSection = ({ caId, handlePopUpOpen }: Props) => {

Friendly Name

{ca.friendlyName}

-
-

CA Type

-

{caTypeToNameMap[ca.type]}

-

Status

{caStatusToNameMap[ca.status]}

@@ -124,6 +128,15 @@ export const CaDetailsSection = ({ caId, handlePopUpOpen }: Props) => { colorSchema="primary" type="submit" onClick={() => { + if (ca.type === CaType.INTERMEDIATE && !ca.parentCaId) { + // intermediate CA with external parent CA + handlePopUpOpen("installCaCert", { + caId, + isParentCaExternal: true + }); + return; + } + handlePopUpOpen("renewCa", { caId }); diff --git a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaInstallCertModal/CaInstallCertModal.tsx b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaInstallCertModal/CaInstallCertModal.tsx index 4d2bcb243..1095e0d3e 100644 --- a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaInstallCertModal/CaInstallCertModal.tsx +++ b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaInstallCertModal/CaInstallCertModal.tsx @@ -1,53 +1,10 @@ import { useEffect, useState } from "react"; -import { Controller, useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { format } from "date-fns"; -import { z } from "zod"; -import { createNotification } from "@app/components/notifications"; -import { - // DatePicker, - Button, - FormControl, - Input, - Modal, - ModalContent, - Select, - SelectItem -} from "@app/components/v2"; -import { useWorkspace } from "@app/context"; -import { - CaStatus, - useGetCaById, - useGetCaCsr, - useImportCaCertificate, - useListWorkspaceCas, - useSignIntermediate -} from "@app/hooks/api"; -import { caTypeToNameMap } from "@app/hooks/api/ca/constants"; +import { FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2"; import { UsePopUpState } from "@app/hooks/usePopUp"; -const isValidDate = (dateString: string) => { - const date = new Date(dateString); - return !Number.isNaN(date.getTime()); -}; - -const getMiddleDate = (date1: Date, date2: Date) => { - const timestamp1 = date1.getTime(); - const timestamp2 = date2.getTime(); - - const middleTimestamp = (timestamp1 + timestamp2) / 2; - - return new Date(middleTimestamp); -}; - -const schema = z.object({ - parentCaId: z.string(), - notAfter: z.string().trim().refine(isValidDate, { message: "Invalid date format" }), - maxPathLength: z.string() -}); - -export type FormData = z.infer; +import { ExternalCaInstallForm } from "./ExternalCaInstallForm"; +import { InternalCaInstallForm } from "./InternalCaInstallForm"; type Props = { popUp: UsePopUpState<["installCaCert"]>; @@ -60,234 +17,23 @@ enum ParentCaType { } export const CaInstallCertModal = ({ popUp, handlePopUpToggle }: Props) => { - const [parentCaType] = useState(ParentCaType.Internal); - const { currentWorkspace } = useWorkspace(); - const caId = (popUp?.installCaCert?.data as { caId: string })?.caId || ""; - - // const [isStartDatePickerOpen, setIsStartDatePickerOpen] = useState(false); - const { data: cas } = useListWorkspaceCas({ - projectSlug: currentWorkspace?.slug ?? "", - status: CaStatus.ACTIVE - }); - const { data: ca } = useGetCaById(caId); - const { data: csr } = useGetCaCsr(caId); - - const { mutateAsync: signIntermediate } = useSignIntermediate(); - const { mutateAsync: importCaCertificate } = useImportCaCertificate(); - - const { - control, - handleSubmit, - reset, - formState: { isSubmitting }, - setValue, - watch - } = useForm({ - resolver: zodResolver(schema), - defaultValues: { - maxPathLength: "0" - } - }); + const popupData = popUp?.installCaCert?.data; + const caId = popupData?.caId || ""; + const isParentCaExternal = popupData?.isParentCaExternal || false; + const [parentCaType, setParentCaType] = useState(ParentCaType.Internal); useEffect(() => { - if (cas?.length) { - setValue("parentCaId", cas[0].id); + if (popupData?.isParentCaExternal) { + setParentCaType(ParentCaType.External); } - }, [cas, setValue]); - - const parentCaId = watch("parentCaId"); - const { data: parentCa } = useGetCaById(parentCaId); - - useEffect(() => { - if (parentCa?.maxPathLength) { - setValue( - "maxPathLength", - (parentCa.maxPathLength === -1 ? 3 : parentCa.maxPathLength - 1).toString() - ); - } - - if (parentCa?.notAfter) { - const parentCaNotAfter = new Date(parentCa.notAfter); - const middleDate = getMiddleDate(new Date(), parentCaNotAfter); - setValue("notAfter", format(middleDate, "yyyy-MM-dd")); - } - }, [parentCa]); - - const onFormSubmit = async ({ notAfter, maxPathLength }: FormData) => { - try { - if (!csr || !caId || !currentWorkspace?.slug) return; - - const { certificate, certificateChain } = await signIntermediate({ - caId: parentCaId, - csr, - maxPathLength: Number(maxPathLength), - notAfter, - notBefore: new Date().toISOString() - }); - - await importCaCertificate({ - caId, - projectSlug: currentWorkspace?.slug, - certificate, - certificateChain - }); - - reset(); - - createNotification({ - text: "Successfully installed certificate for CA", - type: "success" - }); - handlePopUpToggle("installCaCert", false); - } catch (err) { - createNotification({ - text: "Failed to install certificate for CA", - type: "error" - }); - } - }; - - function generatePathLengthOpts(parentCaMaxPathLength: number): number[] { - if (parentCaMaxPathLength === -1) { - return [-1, 0, 1, 2, 3]; - } - - return Array.from({ length: parentCaMaxPathLength }, (_, index) => index); - } + }, [popupData]); const renderForm = (parentCaTypeInput: ParentCaType) => { switch (parentCaTypeInput) { case ParentCaType.Internal: - return ( -
- ( - - - - )} - /> - {/* { - return ( - - { - onChange(date); - setIsStartDatePickerOpen(false); - }} - popUpProps={{ - open: isStartDatePickerOpen, - onOpenChange: setIsStartDatePickerOpen - }} - popUpContentProps={{}} - /> - - ); - }} - /> */} - ( - - - - )} - /> - ( - - - - )} - /> -
- - -
- - ); + return ; default: - return
External TODO
; + return ; } }; @@ -296,31 +42,32 @@ export const CaInstallCertModal = ({ popUp, handlePopUpToggle }: Props) => { isOpen={popUp?.installCaCert?.isOpen} onOpenChange={(isOpen) => { handlePopUpToggle("installCaCert", isOpen); - reset(); }} > - - {/* + + - */} + {renderForm(parentCaType)} diff --git a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaInstallCertModal/ExternalCaInstallForm.tsx b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaInstallCertModal/ExternalCaInstallForm.tsx new file mode 100644 index 000000000..d84b11f59 --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaInstallCertModal/ExternalCaInstallForm.tsx @@ -0,0 +1,172 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { faCheck, faCopy, faDownload } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import FileSaver from "file-saver"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, IconButton,TextArea, Tooltip } from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { useTimedReset } from "@app/hooks"; +import { useGetCaCsr, useImportCaCertificate } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const schema = z.object({ + certificate: z.string().min(1), + certificateChain: z.string().min(1) +}); + +export type FormData = z.infer; + +type Props = { + caId: string; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["installCaCert"]>, state?: boolean) => void; +}; + +export const ExternalCaInstallForm = ({ caId, handlePopUpToggle }: Props) => { + const { currentWorkspace } = useWorkspace(); + const [copyTextCaCsr, isCopyingCaCsr, setCopyTextCaCsr] = useTimedReset({ + initialState: "Copy to clipboard" + }); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema) + }); + + const { data: csr } = useGetCaCsr(caId); + const { mutateAsync: importCaCertificate } = useImportCaCertificate(); + + useEffect(() => { + reset(); + }, []); + + const onFormSubmit = async ({ certificate, certificateChain }: FormData) => { + try { + if (!csr || !caId || !currentWorkspace?.slug) return; + + await importCaCertificate({ + caId, + projectSlug: currentWorkspace?.slug, + certificate, + certificateChain + }); + + reset(); + + createNotification({ + text: "Successfully installed certificate for CA", + type: "success" + }); + handlePopUpToggle("installCaCert", false); + } catch (err) { + createNotification({ + text: "Failed to install certificate for CA", + type: "error" + }); + } + }; + + const downloadTxtFile = (filename: string, content: string) => { + const blob = new Blob([content], { type: "text/plain;charset=utf-8" }); + FileSaver.saveAs(blob, filename); + }; + + return ( +
+ {csr && ( + <> +
+

CSR for this CA

+
+ + { + navigator.clipboard.writeText(csr); + setCopyTextCaCsr("Copied"); + }} + > + + + + + { + downloadTxtFile("csr.pem", csr); + }} + > + + + +
+
+
+

{csr}

+
+ + )} + ( + +