Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial cert validation test #2582

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
120 changes: 117 additions & 3 deletions app/components/form/fields/TlsCertsField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,37 @@
*
* Copyright Oxide Computer Company
*/
import { SubjectAlternativeNameExtension, X509Certificate } from '@peculiar/x509'
import { skipToken, useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useController, useForm, type Control } from 'react-hook-form'
import type { Merge } from 'type-fest'

import type { CertificateCreate } from '@oxide/api'
import { OpenLink12Icon } from '@oxide/design-system/icons/react'

import type { SiloCreateFormValues } from '~/forms/silo-create'
import { Button } from '~/ui/lib/Button'
import { FieldLabel } from '~/ui/lib/FieldLabel'
import { Message } from '~/ui/lib/Message'
import * as MiniTable from '~/ui/lib/MiniTable'
import { Modal } from '~/ui/lib/Modal'
import { links } from '~/util/links'

import { DescriptionField } from './DescriptionField'
import { FileField } from './FileField'
import { validateName } from './NameField'
import { TextField } from './TextField'

export function TlsCertsField({ control }: { control: Control<SiloCreateFormValues> }) {
// default export is most convenient for dynamic import
// eslint-disable-next-line import/no-default-export
export default function TlsCertsField({
control,
siloName,
}: {
control: Control<SiloCreateFormValues>
siloName: string
}) {
const [showAddCert, setShowAddCert] = useState(false)

const {
Expand Down Expand Up @@ -80,6 +93,7 @@ export function TlsCertsField({ control }: { control: Control<SiloCreateFormValu
setShowAddCert(false)
}}
allNames={items.map((item) => item.name)}
siloName={siloName}
/>
)}
</>
Expand All @@ -103,10 +117,18 @@ type AddCertModalProps = {
onDismiss: () => void
onSubmit: (values: CertFormValues) => void
allNames: string[]
siloName: string
}

const AddCertModal = ({ onDismiss, onSubmit, allNames }: AddCertModalProps) => {
const { control, handleSubmit } = useForm<CertFormValues>({ defaultValues })
const AddCertModal = ({ onDismiss, onSubmit, allNames, siloName }: AddCertModalProps) => {
const { watch, control, handleSubmit } = useForm<CertFormValues>({ defaultValues })

const file = watch('cert')

const { data: certValidation } = useQuery({
queryKey: ['validateImage', ...(file ? [file.name, file.size, file.lastModified] : [])],
queryFn: file ? () => validateCertificate(file) : skipToken,
})

return (
<Modal isOpen onDismiss={onDismiss} title="Add TLS certificate">
Expand Down Expand Up @@ -135,6 +157,13 @@ const AddCertModal = ({ onDismiss, onSubmit, allNames }: AddCertModalProps) => {
required
control={control}
/>
{siloName && (
<CertDomainNotice
{...certValidation}
siloName={siloName}
domain="r2.oxide-preview.com"
/>
)}
<FileField id="key-input" name="key" label="Key" required control={control} />
</Modal.Section>
</form>
Expand All @@ -147,3 +176,88 @@ const AddCertModal = ({ onDismiss, onSubmit, allNames }: AddCertModalProps) => {
</Modal>
)
}

const validateCertificate = async (file: File) => {
return parseCertificate(await file.text())
}

function parseCertificate(certPem: string) {
try {
const cert = new X509Certificate(certPem)
const nameItems = cert.getExtension(SubjectAlternativeNameExtension)?.names.items || []
return {
commonName: cert.subjectName.getField('CN') || [],
subjectAltNames: nameItems.map((item) => item.value) || [],
}
} catch {
return null
}
}

function matchesDomain(pattern: string, domain: string): boolean {
const patternParts = pattern.split('.')
const domainParts = domain.split('.')

if (patternParts.length !== domainParts.length) return false

return patternParts.every(
(part, i) => part === '*' || part.toLowerCase() === domainParts[i].toLowerCase()
benjaminleonard marked this conversation as resolved.
Show resolved Hide resolved
)
}

function CertDomainNotice({
commonName = [],
subjectAltNames = [],
siloName,
domain,
}: {
commonName?: string[]
subjectAltNames?: string[]
siloName: string
domain: string
}) {
if (commonName.length === 0 && subjectAltNames.length === 0) {
return null
}

const expectedDomain = `${siloName}.sys.${domain}`
const domains = [...commonName, ...subjectAltNames]

const matches = domains.some(
(d) => matchesDomain(d, expectedDomain) || matchesDomain(d, `*.sys.${domain}`)
)

if (matches) return null

return (
<Message
variant="info"
title="Certificate domain mismatch"
content={
<div className="flex flex-col space-y-2">
Expected to match {expectedDomain} <br />
<div>
Found:
<ul className="ml-4 list-disc">
{domains.map((domain, index) => (
<li key={index}>{domain}</li>
))}
</ul>
</div>
<div>
Learn more about{' '}
<a
target="_blank"
rel="noreferrer"
href={links.systemSiloDocs} // would need updating
className="inline-flex items-center underline"
>
silo certs
<OpenLink12Icon className="ml-1" />
</a>
</div>
</div>
}
/>
)
}
11 changes: 8 additions & 3 deletions app/forms/silo-create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*
* Copyright Oxide Computer Company
*/
import { useEffect } from 'react'
import { lazy, Suspense, useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { useNavigate } from 'react-router-dom'

Expand All @@ -17,7 +17,6 @@ import { NameField } from '~/components/form/fields/NameField'
import { NumberField } from '~/components/form/fields/NumberField'
import { RadioField } from '~/components/form/fields/RadioField'
import { TextField } from '~/components/form/fields/TextField'
import { TlsCertsField } from '~/components/form/fields/TlsCertsField'
import { SideModalForm } from '~/components/form/SideModalForm'
import { HL } from '~/components/HL'
import { addToast } from '~/stores/toast'
Expand All @@ -27,6 +26,9 @@ import { Message } from '~/ui/lib/Message'
import { pb } from '~/util/path-builder'
import { GiB } from '~/util/units'

// Lazy loading `TlsCertsFields` to avoid adding the cert parser to the main bundle
const TlsCertsField = lazy(() => import('~/components/form/fields/TlsCertsField'))

export type SiloCreateFormValues = Omit<SiloCreate, 'mappedFleetRoles'> & {
siloAdminGetsFleetAdmin: boolean
siloViewerGetsFleetViewer: boolean
Expand Down Expand Up @@ -65,6 +67,7 @@ export function CreateSiloSideModalForm() {

const form = useForm({ defaultValues })
const identityMode = form.watch('identityMode')
const siloName = form.watch('name')
// Clear the adminGroupName if the user selects the "local only" identity mode
useEffect(() => {
if (identityMode === 'local_only') {
Expand Down Expand Up @@ -170,7 +173,9 @@ export function CreateSiloSideModalForm() {
</div>
</div>
<FormDivider />
<TlsCertsField control={form.control} />
<Suspense fallback={null}>
<TlsCertsField control={form.control} siloName={siloName} />
</Suspense>
</SideModalForm>
)
}
Expand Down
Loading
Loading