Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 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
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
138 changes: 138 additions & 0 deletions app/components/form/fields/TlsCertsField.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/*
* 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)
// wildcard branch must also be case-insensitive (RFC 6125 §6.4)
expect(matchesDomain('*.SYS.EXAMPLE.COM', 'foo.sys.example.com')).toBe(true)
expect(matchesDomain('*.sys.example.com', 'FOO.SYS.EXAMPLE.COM')).toBe(true)
})

it('tolerates a trailing dot in either argument', () => {
expect(matchesDomain('example.com.', 'example.com')).toBe(true)
expect(matchesDomain('example.com', 'example.com.')).toBe(true)
expect(matchesDomain('*.example.com.', 'foo.example.com')).toBe(true)
})

it('rejects pathological "*." patterns', () => {
// empty suffix after the wildcard would otherwise match any 2-label domain
expect(matchesDomain('*.', 'a.b')).toBe(false)
expect(matchesDomain('*.', 'foo.bar')).toBe(false)
})

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({
commonNames: ['test.example.com'],
subjectAltNames: [
'test.example.com',
'*.test.example.com',
'*.dev.example.com',
'localhost',
'127.0.0.1',
],
notAfter: expect.any(Date),
isValid: true,
})
expect(result.notAfter?.toISOString()).toBe('2025-11-27T14:18:18.000Z')
})

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

it('returns invalid for empty input', async () => {
expect(await parseCertificate('')).toEqual({
commonNames: [],
subjectAltNames: [],
notAfter: null,
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({
commonNames: [],
subjectAltNames: [],
notAfter: null,
isValid: false,
})
})

it('parses the leaf when given a chain (multi-PEM bundle)', async () => {
// leaf + a second cert appended; we should parse the leaf only
const chain = `${validCert}\n${validCert}`
const result = await parseCertificate(chain)
expect(result.isValid).toBe(true)
expect(result.commonNames).toEqual(['test.example.com'])
})

it('tolerates leading whitespace and BOM', async () => {
const wrapped = ` \n\t${validCert}\n\n`
const result = await parseCertificate(wrapped)
expect(result.isValid).toBe(true)
expect(result.commonNames).toEqual(['test.example.com'])
})
})
184 changes: 181 additions & 3 deletions app/components/form/fields/TlsCertsField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +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 { getDelegatedDomain } from '~/forms/idp/util'
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 { NameField } from './NameField'

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 @@ -76,6 +87,7 @@ export function TlsCertsField({ control }: { control: Control<SiloCreateFormValu
setShowAddCert(false)
}}
allNames={items.map((item) => item.name)}
siloName={siloName}
/>
)}
</>
Expand All @@ -99,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: ['validateCert', ...(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 All @@ -126,6 +146,11 @@ const AddCertModal = ({ onDismiss, onSubmit, allNames }: AddCertModalProps) => {
required
control={control}
/>
<CertDomainNotice
{...(certValidation ?? {})}
siloName={siloName}
domain={getDelegatedDomain(window.location)}
/>
<FileField id="key-input" name="key" label="Key" required control={control} />
</Modal.Section>
</form>
Expand All @@ -138,3 +163,156 @@ const AddCertModal = ({ onDismiss, onSubmit, allNames }: AddCertModalProps) => {
</Modal>
)
}

const PEM_BLOCK_RE = /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/

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 {
// Users often paste a chain (leaf + intermediates). Take the first PEM
// block — by convention that's the leaf cert, which is what should
// match the silo URL. Also tolerates leading whitespace / BOM.
const firstPem = certPem.match(PEM_BLOCK_RE)?.[0]
if (!firstPem) throw new Error('no PEM block')
const cert = new X509Certificate(firstPem)
const nameItems = cert.getExtension(SubjectAlternativeNameExtension)?.names.items || []
return {
commonNames: cert.subjectName.getField('CN'),
subjectAltNames: nameItems.map((item) => item.value),
notAfter: cert.notAfter,
isValid: true,
}
} catch {
return {
commonNames: [],
subjectAltNames: [],
notAfter: null,
isValid: false,
}
}
}

export function matchesDomain(pattern: string, domain: string): boolean {
// RFC 6125 §6.4: DNS comparisons are case-insensitive. RFC 4592 allows
// a trailing dot in FQDNs, so normalize both sides up front.
const normPattern = pattern.replace(/\.$/, '').toLowerCase()
const normDomain = domain.replace(/\.$/, '').toLowerCase()

const patternParts = normPattern.split('.')
const domainParts = normDomain.split('.')

// RFC 6125 disallows a bare '*' wildcard
if (normPattern === '*') return false

if (patternParts[0] === '*') {
// same number of labels (prevents *.domain.com matching a.b.domain.com)
if (domainParts.length !== patternParts.length) return false
const patternSuffix = patternParts.slice(1).join('.')
// reject pathological '*.' (would match any 2-label domain)
if (patternSuffix === '') return false
return normDomain.endsWith(patternSuffix)
}

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

const SiloCertsDocsLink = () => (
<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>
)

function CertDomainNotice({
commonNames = [],
subjectAltNames = [],
isValid = true,
notAfter = null,
siloName,
domain,
}: {
commonNames?: string[]
subjectAltNames?: string[]
isValid?: boolean
notAfter?: Date | null
siloName: string
domain: string
}) {
if (!isValid) {
return (
<Message
variant="info"
title="Couldn't parse certificate"
content={
<div className="flex flex-col space-y-2">
<div>Expected an X.509 certificate in PEM format.</div>
<SiloCertsDocsLink />
</div>
}
/>
)
}

const expired = notAfter != null && notAfter < new Date()

const hasNames = commonNames.length > 0 || subjectAltNames.length > 0
const expectedDomain = siloName ? `${siloName}.sys.${domain}` : null
const domains = [...commonNames, ...subjectAltNames]
const mismatched =
expectedDomain !== null &&
hasNames &&
!domains.some((d) => matchesDomain(d, expectedDomain))

if (!expired && !mismatched) return null

return (
<div className="flex flex-col space-y-2">
{expired && notAfter && (
<Message
variant="notice"
title="Certificate expired"
content={
<div className="flex flex-col space-y-2">
<div>Expired on {notAfter.toLocaleDateString()}.</div>
<SiloCertsDocsLink />
</div>
}
/>
)}
{mismatched && expectedDomain && (
<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((d, i) => (
<li key={i}>{d}</li>
))}
</ul>
</div>
<SiloCertsDocsLink />
</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