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
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,
})

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
Loading