diff --git a/.erb/scripts/protoc b/.erb/scripts/protoc index d04d4da..0f13f6f 100755 --- a/.erb/scripts/protoc +++ b/.erb/scripts/protoc @@ -42,7 +42,7 @@ if [ ! -f "$_protoc_path/bin/protoc" ]; then rm protoc.zip fi -if [ ! -x "$GOPATH/bin/protoc-gen-validate" ]; then +if [ ! -x "$(go env GOPATH)/bin/protoc-gen-validate" ]; then echo "installing protoc-gen-validate" pushd $(pwd) cd "$_protoc_3pp_path/protoc-gen-validate" && make build diff --git a/api.proto b/api.proto index 191228a..886ddac 100644 --- a/api.proto +++ b/api.proto @@ -202,6 +202,15 @@ message Certificate { optional CertificateInfo info = 3; } +// ClientCertFromStore contains additional filters to apply when searching for +// a client certificate in the system trust store. (This search will always +// take into account any CA names from the TLS CertificateRequest message.) +message ClientCertFromStore { + // filters based on a single name attribute (e.g. "CN=my cert" or "O=my org") + optional string issuer_filter = 1; + optional string subject_filter = 2; +} + // Connection message Connection { // name is a user friendly connection name that a user may define @@ -217,4 +226,7 @@ message Connection { bytes ca_cert = 6; } optional Certificate client_cert = 7; + reserved 8; // unreleased client_cert_issuer_cn search criterion + // indicates to search the system trust store for a client certificate + optional ClientCertFromStore client_cert_from_store = 9; } diff --git a/src/renderer/components/CertFilter.tsx b/src/renderer/components/CertFilter.tsx new file mode 100644 index 0000000..2913bf9 --- /dev/null +++ b/src/renderer/components/CertFilter.tsx @@ -0,0 +1,76 @@ +import FormControl from '@mui/material/FormControl'; +import FormHelperText from '@mui/material/FormHelperText'; +import Grid from '@mui/material/Grid'; +import InputLabel from '@mui/material/InputLabel'; +import MenuItem from '@mui/material/ListItem'; +import Select from '@mui/material/Select'; +import Typography from '@mui/material/Typography'; +import React, { useId, useState } from 'react'; +import TextField from '../components/TextField'; + +interface Props { + label: string; + data: string | undefined; + onChange: (a: string | undefined) => void; + disabled: boolean; +} + +const CertFilter: React.FC = ({ label, data, onChange, disabled }) => { + const [dataAttribute, dataValue] = (data || '').split('=', 2); + const attribute = dataAttribute || ''; + const value = dataValue || ''; + + const selectLabelId = useId(); + const selectId = useId(); + const setAttribute = (newAttribute: string) => { + onChange(!!newAttribute ? (newAttribute + '=' + value) : undefined); + } + const setValue = (newValue: string) => { + onChange(attribute + '=' + newValue); + } + + return ( + <> + {label} + + + + Attribute + + + + + { attribute === '' + ? + No filter + + : setValue(evt.target.value)} + /> } + + + + Limits the search to certificates where the {label} has a particular + attribute value (exact match). + + + ); +}; + +export default CertFilter; diff --git a/src/renderer/pages/ConnectForm.tsx b/src/renderer/pages/ConnectForm.tsx index a97b2db..e71ca41 100644 --- a/src/renderer/pages/ConnectForm.tsx +++ b/src/renderer/pages/ConnectForm.tsx @@ -8,17 +8,20 @@ import { CardContent, Chip, Container, + FormControl, FormControlLabel, FormHelperText, Grid, + MenuItem, IconButton, styled, + Select, Switch, Typography, } from '@mui/material'; import React, { FC, useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; -import { CheckCircle, ChevronDown, Trash } from 'react-feather'; +import { CheckCircle, ChevronDown, ChevronRight, Trash } from 'react-feather'; import { ipcRenderer } from 'electron'; import { useSnackbar } from 'notistack'; import { set } from 'lodash'; @@ -33,9 +36,10 @@ import { import TextField from '../components/TextField'; import StyledCard from '../components/StyledCard'; import { formatTag } from '../../shared/validators'; -import { Connection, Record, Selector } from '../../shared/pb/api'; +import { ClientCertFromStore, Connection, Record, Selector } from '../../shared/pb/api'; import BeforeBackActionDialog from '../components/BeforeBackActionDialog'; import CertDetails from '../components/CertDetails'; +import CertFilter from '../components/CertFilter'; export const TextArea = styled(TextField)({ '& div.MuiFilledInput-root': { @@ -67,6 +71,44 @@ export const TextArea = styled(TextField)({ }, }); +const NestedAccordion = styled((props: AccordionProps) => ( + +))(({ theme }) => ({ + border: `1px solid ${theme.palette.divider}`, + marginLeft: theme.spacing(2), + width: '100%', + '&:not(:last-child)': { + borderBottom: 0, + }, + '&:before': { + display: 'none', + }, +})); + +const NestedAccordionSummary = styled((props: AccordionSummaryProps) => ( + } + {...props} + /> +))(({ theme }) => ({ + backgroundColor: + theme.palette.mode === 'dark' + ? 'rgba(255, 255, 255, .05)' + : 'rgba(0, 0, 0, .03)', + flexDirection: 'row-reverse', + '& .MuiAccordionSummary-expandIconWrapper.Mui-expanded': { + transform: 'rotate(90deg)', + }, + '& .MuiAccordionSummary-content': { + marginLeft: theme.spacing(1), + }, +})); + +const NestedAccordionDetails = styled(AccordionDetails)(({ theme }) => ({ + borderTop: `1px solid ${theme.palette.divider}`, + paddingTop: theme.spacing(2), +})); + interface Props { onComplete?: () => void; } @@ -79,6 +121,7 @@ const initialConnData: Connection = { disableTlsVerification: false, caCert: undefined, clientCert: undefined, + clientCertFromStore: undefined, }; const ConnectForm: FC = () => { @@ -92,6 +135,9 @@ const ConnectForm: FC = () => { }; const { connectionID } = useParams(); const { enqueueSnackbar } = useSnackbar(); + const [clientCertPanel, setClientCertPanel] = React.useState( + false + ); const certRef = React.useRef(null); const keyRef = React.useRef(null); const [certText, setCertText] = useState(''); @@ -108,9 +154,15 @@ const ConnectForm: FC = () => { }); } else if (args.res.records.length === 1) { setTags(args.res.records[0].tags || []); - setConnection(args.res.records[0].conn || initialConnData); - setOriginalConnection(args.res.records[0].conn || initialConnData); - setShowCertInput(!args.res.records[0].conn.clientCert); + const { conn } = args.res.records[0]; + setConnection(conn || initialConnData); + setOriginalConnection(conn || initialConnData); + setShowCertInput(!conn.clientCert); + if (conn.clientCertFromStore !== undefined) { + setClientCertPanel('store'); + } else if (conn.clientCert) { + setClientCertPanel('file'); + } } }); ipcRenderer.once(GET_UNIQUE_TAGS, (_, args) => { @@ -180,6 +232,30 @@ const ConnectForm: FC = () => { } }; + useEffect(() => { + }, []); + + const saveClientCertFromStore = (value: ClientCertFromStore | undefined): void => { + setConnection({ + ...connection, + ...{ clientCertFromStore: value }, + }); + }; + + const saveClientCertIssuerFilter = (value: string | undefined): void => { + saveClientCertFromStore({ + ...connection?.clientCertFromStore, + ...{ issuerFilter: value }, + }); + }; + + const saveClientCertSubjectFilter = (value: string | undefined): void => { + saveClientCertFromStore({ + ...clientCertFromStore, + ...{ subjectFilter: value }, + }); + }; + const saveCertText = (value: string): void => { setCertText(value); setConnection((oldConnection) => { @@ -253,6 +329,14 @@ const ConnectForm: FC = () => { } return !!connection?.name && !!connection?.remoteAddr.trim(); }; + + const handleClientCertPanel = + (panel: string) => (event: React.SyntheticEvent, newExpanded: boolean) => { + setClientCertPanel(newExpanded ? panel : false); + }; + + const clientCertFromStoreEnabled = connection?.clientCertFromStore !== undefined; + return ( = () => { - {showCertInput && ( - - - - )} - {showCertInput && ( - - - Client Certificate Text + + + + Client certificate from system trust store -