From 1184eb13f0bbe4768bf3dbd6cd31adf10c6c8dfe Mon Sep 17 00:00:00 2001
From: Kristóf Marussy <kristof@marussy.com>
Date: Fri, 22 Apr 2022 00:53:48 +0200
Subject: feat: Certificate viewer
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Show certificates with an interface modeled after firefox's certificate
viewer so that they can be inspected before trusting.

The current implementation assumes that each certificate has a unique
fingerprint (collisions are astronomically unlikely).

Signed-off-by: Kristóf Marussy <kristof@marussy.com>
---
 .../components/errorPage/CertificateDetails.tsx    | 136 +++++++++++++++++----
 1 file changed, 115 insertions(+), 21 deletions(-)

(limited to 'packages/renderer/src/components/errorPage/CertificateDetails.tsx')

diff --git a/packages/renderer/src/components/errorPage/CertificateDetails.tsx b/packages/renderer/src/components/errorPage/CertificateDetails.tsx
index 044cb5c..51e7920 100644
--- a/packages/renderer/src/components/errorPage/CertificateDetails.tsx
+++ b/packages/renderer/src/components/errorPage/CertificateDetails.tsx
@@ -19,22 +19,41 @@
  */
 
 import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
-import WarningAmberIcon from '@mui/icons-material/WarningAmber';
 import Accordion from '@mui/material/Accordion';
 import AccordionDetails from '@mui/material/AccordionDetails';
 import AccordionSummary from '@mui/material/AccordionSummary';
 import Box from '@mui/material/Box';
-import Button from '@mui/material/Button';
+import Tab from '@mui/material/Tab';
+import Tabs from '@mui/material/Tabs';
 import Typography from '@mui/material/Typography';
+import { styled } from '@mui/material/styles';
+import type { Certificate } from '@sophie/shared';
 import { observer } from 'mobx-react-lite';
-import React from 'react';
+import React, { useState } from 'react';
 import { useTranslation } from 'react-i18next';
 
 import type Service from '../../stores/Service';
 
+import SingleCertificateDetails from './SingleCertificateDetails';
+import TrustCertificateDialog from './TrustCertificateDialog';
+
 const SUMMARY_ID = 'Sophie-CertificateDetails-header';
 const DETAILS_ID = 'Sophie-CertificateDetails-content';
 
+function getCertificateChain(endEntityCertificate: Certificate): Certificate[] {
+  const chain: Certificate[] = [];
+  let certificate: Certificate | undefined = endEntityCertificate;
+  while (certificate !== undefined) {
+    chain.push(certificate);
+    certificate = certificate.issuerCert as Certificate;
+  }
+  return chain;
+}
+
+const DetailsBox = styled(Box)(({ theme }) => ({
+  padding: `0 ${theme.spacing(2)}`,
+}));
+
 function CertificateDetails({
   service,
 }: {
@@ -43,8 +62,9 @@ function CertificateDetails({
   const { t } = useTranslation(undefined, {
     keyPrefix: 'error.certificateError.details',
   });
+  const [certificateIndex, setCertificateIndex] = useState(0);
 
-  const { state } = service;
+  const { id: serviceId, state } = service;
   const { type: errorType } = state;
 
   if (errorType !== 'certificateError') {
@@ -54,6 +74,22 @@ function CertificateDetails({
 
   const { certificate, trust } = state;
 
+  const chain = getCertificateChain(certificate);
+
+  if (chain.length === 0) {
+    // eslint-disable-next-line unicorn/no-null -- React requires `null` to skip rendering.
+    return null;
+  }
+
+  const id = `${serviceId}-certificateDetails`;
+
+  const trustCertificateDialog = (
+    <TrustCertificateDialog
+      trust={trust}
+      onClick={() => service.temporarilyTrustCurrentCertificate()}
+    />
+  );
+
   return (
     <Accordion
       disableGutters
@@ -67,23 +103,81 @@ function CertificateDetails({
       >
         <Typography>{t('title')}</Typography>
       </AccordionSummary>
-      <AccordionDetails>
-        <Typography sx={{ wordWrap: 'break-word' }}>
-          {JSON.stringify(certificate)}
-        </Typography>
-        <Typography mt={2}>
-          <WarningAmberIcon fontSize="small" /> <strong>{t('warning')}</strong>
-        </Typography>
-        <Box mt={1}>
-          <Button
-            disabled={trust !== 'pending'}
-            onClick={() => service.temporarilyTrustCurrentCertificate()}
-            color="error"
-            variant="outlined"
-          >
-            {t(`acceptButton.${trust}`)}
-          </Button>
-        </Box>
+      <AccordionDetails
+        sx={{
+          px: 0,
+          pt: 0,
+        }}
+      >
+        {chain.length >= 2 ? (
+          <>
+            <Tabs
+              aria-label={t<string>('tabsLabel')}
+              value={certificateIndex < chain.length ? certificateIndex : false}
+              onChange={(_event, value) => setCertificateIndex(value as number)}
+              variant="fullWidth"
+            >
+              {chain.map((member, i) => (
+                <Tab
+                  key={member.fingerprint}
+                  value={i}
+                  id={`${id}-tab-${i}`}
+                  aria-controls={`${id}-tabpanel-${i}`}
+                  label={
+                    member.subjectName === ''
+                      ? member.fingerprint
+                      : member.subjectName
+                  }
+                />
+              ))}
+            </Tabs>
+            <DetailsBox
+              sx={(theme) => ({
+                paddingTop: 1,
+                borderTop: `1px solid ${theme.palette.divider}`,
+              })}
+            >
+              {chain.map((member, i) => (
+                <div
+                  key={member.fingerprint}
+                  role="tabpanel"
+                  hidden={certificateIndex !== i}
+                  id={`${id}-tabpanel-${i}`}
+                  aria-labelledby={`${id}-tab-${i}`}
+                >
+                  {certificateIndex === i && (
+                    <>
+                      <SingleCertificateDetails
+                        detailsId={id}
+                        certificate={member}
+                        onIssuerClick={
+                          i < chain.length - 1
+                            ? () => setCertificateIndex(i + 1)
+                            : undefined
+                        }
+                        onDownloadClick={() =>
+                          service.downloadCertificate(member.fingerprint)
+                        }
+                      />
+                      {i === 0 && trustCertificateDialog}
+                    </>
+                  )}
+                </div>
+              ))}
+            </DetailsBox>
+          </>
+        ) : (
+          <DetailsBox>
+            <SingleCertificateDetails
+              detailsId={id}
+              certificate={chain[0]}
+              onDownloadClick={() =>
+                service.downloadCertificate(chain[0].fingerprint)
+              }
+            />
+            {trustCertificateDialog}
+          </DetailsBox>
+        )}
       </AccordionDetails>
     </Accordion>
   );
-- 
cgit v1.2.3-54-g00ecf

