Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
a559fd8
Initial cert validation test
benjaminleonard Nov 27, 2024
2ee5586
Bot commit: format with prettier
github-actions[bot] Nov 27, 2024
ab5e67f
Spacing tweak
benjaminleonard Nov 27, 2024
83d130e
Merge branch 'tls-cert-soft-validation' of https://github.com/oxideco…
benjaminleonard Nov 27, 2024
4a9899d
oop
benjaminleonard Nov 27, 2024
53a6a9a
Wildcard and error improvements with tests
benjaminleonard Nov 27, 2024
21287b4
More pattern matching tweaks
benjaminleonard Nov 27, 2024
b629cee
move dynamic import to avoid suspense/lazy thing
david-crespo Nov 27, 2024
6f9bdda
Merge branch 'main' into tls-cert-soft-validation
benjaminleonard Nov 27, 2024
dc4e411
merge main and resolve conflicts
david-crespo Apr 27, 2026
2b9c79c
improve copy, don't opt out of validation if there's no silo name
david-crespo Apr 27, 2026
1a04526
Merge branch 'main' into tls-cert-soft-validation
benjaminleonard May 21, 2026
be2bf01
Use getDelegatedDomain helper instead of hardcoded domain
benjaminleonard May 21, 2026
9d56b2a
Address review feedback on cert validation
benjaminleonard May 21, 2026
6730493
Tidy comments and remove dead || [] fallbacks
benjaminleonard May 21, 2026
52cfaea
Tighten matchesDomain: case, trailing dot, '*.' edge case
benjaminleonard May 21, 2026
ec078f9
parseCertificate accepts PEM chains and surrounding whitespace
benjaminleonard May 21, 2026
75a1c8f
Warn when uploaded cert is already expired
benjaminleonard May 21, 2026
2c6e0f5
E2E coverage for cert parse/expiry/mismatch notices
benjaminleonard May 21, 2026
57af93a
E2E: upload a matching cert and assert no notice fires
benjaminleonard May 21, 2026
783afc4
merge main and resolve conflicts
david-crespo Jun 18, 2026
608ce9b
fix wildcard regex for label boundary
david-crespo Jun 18, 2026
161b7d2
prefer SANs for cert domain matching
david-crespo Jun 18, 2026
765210b
don't need the import to be dynamic
david-crespo Jun 18, 2026
d0fdb55
one last small cleanup
david-crespo Jun 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,
})

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@charliepark pointed out we might want to set the stale time directly here. I'm going to experiment with that and also maybe tweaking the query key.


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()
Comment thread
benjaminleonard marked this conversation as resolved.
Outdated
)
}

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