Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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
68 changes: 68 additions & 0 deletions app/components/form/fields/TlsCertsField.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* 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('matches multiple subdomains', () => {
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 incorrect wildcards', () => {
expect(matchesDomain('test.*', 'test.com')).toBe(false)
expect(matchesDomain('test.*', 'test.net')).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,
})
})
})
168 changes: 164 additions & 4 deletions app/components/form/fields/TlsCertsField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,34 @@
*
* 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 * 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> }) {
export function TlsCertsField({
control,
siloName,
}: {
control: Control<SiloCreateFormValues>
siloName: string
}) {
const [showAddCert, setShowAddCert] = useState(false)

const {
Expand Down Expand Up @@ -70,7 +80,7 @@ export function TlsCertsField({ control }: { control: Control<SiloCreateFormValu
<AddCertModal
onDismiss={() => setShowAddCert(false)}
onSubmit={async (values) => {
const certCreate: (typeof items)[number] = {
const certCreate: CertificateCreate = {
...values,
// cert and key are required fields. they will always be present if we get here
cert: await values.cert!.text(),
Expand All @@ -80,6 +90,7 @@ export function TlsCertsField({ control }: { control: Control<SiloCreateFormValu
setShowAddCert(false)
}}
allNames={items.map((item) => item.name)}
siloName={siloName}
/>
)}
</>
Expand All @@ -103,10 +114,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 @@ -135,6 +154,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 +173,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>
Certificate may not be valid, a silo expects a X.509 cert in PEM format.
</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>
}
/>
)
}

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>
}
/>
)
}
3 changes: 2 additions & 1 deletion app/forms/silo-create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,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 +171,7 @@ export function CreateSiloSideModalForm() {
</div>
</div>
<FormDivider />
<TlsCertsField control={form.control} />
<TlsCertsField control={form.control} siloName={siloName} />
</SideModalForm>
)
}
Expand Down
Loading