Skip to content
Open
103 changes: 103 additions & 0 deletions app/components/form/fields/TlsCertsField.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright Oxide Computer Company
*/
import { describe, expect, it } from 'vitest'

import { matchesDomain, parseCertificate } from './TlsCertsField'

describe('matchesDomain', () => {
it('matches wildcard subdomains', () => {
expect(matchesDomain('*.example.com', 'sub.example.com')).toBe(true)
expect(matchesDomain('*.example.com', 'example.com')).toBe(false)
expect(matchesDomain('*', 'any.domain')).toBe(false)
})

it('matches exact matches', () => {
expect(matchesDomain('example.com', 'example.com')).toBe(true)
expect(matchesDomain('example.com', 'www.example.com')).toBe(false)
})

it('only matches one level of wildcard', () => {
expect(matchesDomain('*.example.com', 'sub.sub.example.com')).toBe(false)
expect(matchesDomain('*.example.com', 'sub.sub.sub.example.com')).toBe(false)
})

it('matches with case insensitivity', () => {
expect(matchesDomain('EXAMPLE.COM', 'example.com')).toBe(true)
expect(matchesDomain('example.com', 'EXAMPLE.COM')).toBe(true)
})

it('does not match wildcards in non-leading positions', () => {
expect(matchesDomain('test.*', 'test.com')).toBe(false)
expect(matchesDomain('test.*.com', 'test.foo.com')).toBe(false)
expect(matchesDomain('a.*.b.com', 'a.x.b.com')).toBe(false)
})

it('handles silo-style expected domains', () => {
expect(
matchesDomain('foo.sys.r2.oxide-preview.com', 'foo.sys.r2.oxide-preview.com')
).toBe(true)
expect(
matchesDomain('*.sys.r2.oxide-preview.com', 'foo.sys.r2.oxide-preview.com')
).toBe(true)
expect(
matchesDomain('*.sys.r2.oxide-preview.com', 'bar.sys.r2.oxide-preview.com')
).toBe(true)
// wildcard must not match a sibling segment
expect(
matchesDomain('*.sys.r2.oxide-preview.com', 'foo.bar.r2.oxide-preview.com')
).toBe(false)
})
})

describe('parseCertificate', () => {
const validCert = `-----BEGIN CERTIFICATE-----\nMIIDbjCCAlagAwIBAgIUVF36cv2UevtKOGWP3GNV1h+TpScwDQYJKoZIhvcNAQEL\nBQAwGzEZMBcGA1UEAwwQdGVzdC5leGFtcGxlLmNvbTAeFw0yNDExMjcxNDE4MTha\nFw0yNTExMjcxNDE4MThaMBsxGTAXBgNVBAMMEHRlc3QuZXhhbXBsZS5jb20wggEi\nMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC0cBavU9cnrTY7CaOsHdfzr7e4\nmT7eRCGJa1jmuGeADGIs1IcMr/7jgiKS/1P69SehfqpFWXKAYn5OH+ickZfs55AB\nuyfh+KogmTkX6I40CnP9GohfgAaDVr119a2kdJNvinsCjNGfulMBYiw+sJBp4l/c\nzQRYMXaMk1ARKBgUuVZHZXnkWQKjp/GAQjVsUjl/dnBVeUuS4/0OVTLL8U6mGzdy\nf5s03bpBLOOJ9Owg1We5urYA6glCvvMh1VhBPsCnHFj6aYLnnWpJkVuJEKA+znEU\nU2n6T0bQorzVnn5ROtAn3ao4sGIVMbMeIaEvUt3zyVk+gtUvqSTPChFde6/LAgMB\nAAGjgakwgaYwHQYDVR0OBBYEFFzp73YRPxxu4bTQvmJy5rqHNXh7MB8GA1UdIwQY\nMBaAFFzp73YRPxxu4bTQvmJy5rqHNXh7MA8GA1UdEwEB/wQFMAMBAf8wUwYDVR0R\nBEwwSoIQdGVzdC5leGFtcGxlLmNvbYISKi50ZXN0LmV4YW1wbGUuY29tghEqLmRl\ndi5leGFtcGxlLmNvbYIJbG9jYWxob3N0hwR/AAABMA0GCSqGSIb3DQEBCwUAA4IB\nAQCstbMiTwHuSlwuUslV9SxewdxTtKAjNgUnCn1Jv7hs44wNTBqvMzDq2HB26wRR\nOnbt6gReOj9GdSRmJPNcgouaAGJWCXuaZPs34LgRJir6Z0FVcK7/O3SqfTOg3tJg\ngzg4xmtzXc7Im4VgvaLS5iXCOvUaKf/rXeYDa3r37EF+vyzcETt5bXwtU8BBFvVT\nJfPDla5lYv0h9Z+XsYEAqtbChdy+fVuHnF+EygZCT9KVFBPWQrsaF1Qc/CvP/+LM\nCrdLoB+2pkWbX075tv8LIbL2dW5Gzyw+lU6lzPL9Vikm3QXGRklKHA4SVuZ3F9tr\nwPRLWb4aPmo1COkgvg3Moqdw\n-----END CERTIFICATE-----`

const invalidCert = 'not-a-certificate'

it('parses valid certificate', async () => {
const result = await parseCertificate(validCert)
expect(result).toEqual({
commonName: ['test.example.com'],
subjectAltNames: [
'test.example.com',
'*.test.example.com',
'*.dev.example.com',
'localhost',
'127.0.0.1',
],
isValid: true,
})
})

it('returns invalid for invalid certificate', async () => {
const result = await parseCertificate(invalidCert)
expect(result).toEqual({
commonName: [],
subjectAltNames: [],
isValid: false,
})
})

it('returns invalid for empty input', async () => {
expect(await parseCertificate('')).toEqual({
commonName: [],
subjectAltNames: [],
isValid: false,
})
})

it('returns invalid for binary garbage', async () => {
// simulates a non-PEM file (e.g. PNG) read as text
const garbage = '\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR'
expect(await parseCertificate(garbage)).toEqual({
commonName: [],
subjectAltNames: [],
isValid: false,
})
})
})
164 changes: 161 additions & 3 deletions app/components/form/fields/TlsCertsField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,35 @@
*
* Copyright Oxide Computer Company
*/
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 { MiniTable } from '~/ui/lib/MiniTable'
import { Modal } from '~/ui/lib/Modal'
import { links } from '~/util/links'

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

export function TlsCertsField({ control }: { control: Control<SiloCreateFormValues> }) {
export function TlsCertsField({
control,
siloName,
}: {
control: Control<SiloCreateFormValues>
siloName: string
}) {
const [showAddCert, setShowAddCert] = useState(false)

const {
Expand Down Expand Up @@ -77,6 +87,7 @@ export function TlsCertsField({ control }: { control: Control<SiloCreateFormValu
setShowAddCert(false)
}}
allNames={items.map((item) => item.name)}
siloName={siloName}
/>
)}
</>
Expand All @@ -100,10 +111,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 ? () => file.text().then(parseCertificate) : 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 @@ -132,6 +151,11 @@ const AddCertModal = ({ onDismiss, onSubmit, allNames }: AddCertModalProps) => {
required
control={control}
/>
<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 @@ -144,3 +168,137 @@ const AddCertModal = ({ onDismiss, onSubmit, allNames }: AddCertModalProps) => {
</Modal>
)
}

export async function parseCertificate(certPem: string) {
// dynamic import to keep 50k gzipped out of the main bundle
const { SubjectAlternativeNameExtension, X509Certificate } =
await import('@peculiar/x509')
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) || [],
isValid: true,
}
} catch {
return {
commonName: [],
subjectAltNames: [],
isValid: false,
}
}
}

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

// unsure if this would be an issue but we reject it anyway
if (pattern === '*') {
return false
}

if (patternParts[0] === '*') {
// the domain parts and pattern parts should have the same number of items
// (prevents *.domain.com from matching test.test.domain.com)
if (domainParts.length !== patternParts.length) return false
// the rest should be an exact match
const patternSuffix = patternParts.slice(1).join('.')
return domain.endsWith(patternSuffix)
}

// parts must match exactly for non-wildcard patterns
return (
patternParts.length === domainParts.length &&
patternParts.every((part, i) => part.toLowerCase() === domainParts[i].toLowerCase())
)
}

function CertDomainNotice({
commonName = [],
subjectAltNames = [],
isValid = true,
siloName,
domain,
}: {
commonName?: string[]
subjectAltNames?: string[]
isValid?: boolean
siloName: string
domain: string
}) {
if (!isValid) {
return (
<Message
variant="info"
title="Could not be parsed"
content={
<div className="flex flex-col space-y-2">
<div>Expected an X.509 certificate in PEM format.</div>
<div>
Learn more about{' '}
<a
target="_blank"
rel="noreferrer"
href={links.siloTlsCertsDocs}
className="inline-flex items-center underline"
>
silo certs
<OpenLink12Icon className="ml-1" />
</a>
</div>
</div>
}
/>
)
}

// Domain matching needs a silo name to compare against
if (!siloName) return null

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.siloTlsCertsDocs}
className="inline-flex items-center underline"
>
silo certs
<OpenLink12Icon className="ml-1" />
</a>
</div>
</div>
}
/>
)
}
3 changes: 2 additions & 1 deletion app/forms/silo-create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export default 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 @@ -182,7 +183,7 @@ export default function CreateSiloSideModalForm() {
</div>
</div>
<FormDivider />
<TlsCertsField control={form.control} />
<TlsCertsField control={form.control} siloName={siloName} />
<SideModalFormDocs docs={[docLinks.systemSiloCreate, docLinks.systemSilo]} />
</SideModalForm>
)
Expand Down
2 changes: 2 additions & 0 deletions app/util/links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export const links = {
`https://docs.oxide.computer/guides/metrics/timeseries-schemas#_${metric.replace(':', '')}`,
siloQuotasDocs:
'https://docs.oxide.computer/guides/operator/silo-management#_silo_resource_quota_management',
siloTlsCertsDocs:
'https://docs.oxide.computer/guides/system/system-setup#tls-certificate',
transitIpsDocs:
'https://docs.oxide.computer/guides/configuring-guest-networking#_example_4_software_routing_tunnels',
troubleshootingAccess:
Expand Down
Loading
Loading