diff --git a/app/components/form/fields/TlsCertsField.spec.tsx b/app/components/form/fields/TlsCertsField.spec.tsx new file mode 100644 index 000000000..1907eee39 --- /dev/null +++ b/app/components/form/fields/TlsCertsField.spec.tsx @@ -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']) + }) +}) diff --git a/app/components/form/fields/TlsCertsField.tsx b/app/components/form/fields/TlsCertsField.tsx index 139304230..02040abfa 100644 --- a/app/components/form/fields/TlsCertsField.tsx +++ b/app/components/form/fields/TlsCertsField.tsx @@ -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 }) { +export function TlsCertsField({ + control, + siloName, +}: { + control: Control + siloName: string +}) { const [showAddCert, setShowAddCert] = useState(false) const { @@ -76,6 +87,7 @@ export function TlsCertsField({ control }: { control: Control item.name)} + siloName={siloName} /> )} @@ -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({ defaultValues }) +const AddCertModal = ({ onDismiss, onSubmit, allNames, siloName }: AddCertModalProps) => { + const { watch, control, handleSubmit } = useForm({ 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, + }) return ( @@ -126,6 +146,11 @@ const AddCertModal = ({ onDismiss, onSubmit, allNames }: AddCertModalProps) => { required control={control} /> + @@ -138,3 +163,156 @@ const AddCertModal = ({ onDismiss, onSubmit, allNames }: AddCertModalProps) => { ) } + +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 = () => ( +
+ Learn more about{' '} + + silo certs + + +
+) + +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 ( + +
Expected an X.509 certificate in PEM format.
+ + + } + /> + ) + } + + 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 ( +
+ {expired && notAfter && ( + +
Expired on {notAfter.toLocaleDateString()}.
+ +
+ } + /> + )} + {mismatched && expectedDomain && ( + + Expected to match {expectedDomain}
+
+ Found: +
    + {domains.map((d, i) => ( +
  • {d}
  • + ))} +
+
+ + + } + /> + )} + + ) +} diff --git a/app/forms/silo-create.tsx b/app/forms/silo-create.tsx index de7e01223..2a72e6f2c 100644 --- a/app/forms/silo-create.tsx +++ b/app/forms/silo-create.tsx @@ -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') { @@ -182,7 +183,7 @@ export default function CreateSiloSideModalForm() { - + ) diff --git a/app/util/links.ts b/app/util/links.ts index 7c9fcfbf5..0179c31a4 100644 --- a/app/util/links.ts +++ b/app/util/links.ts @@ -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: diff --git a/package-lock.json b/package-lock.json index 392fc10e4..9bdaaeed1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@floating-ui/react": "^0.26.23", "@headlessui/react": "^2.2.9", "@oxide/design-system": "^6.2.1", + "@peculiar/x509": "^1.12.3", "@react-aria/live-announcer": "^3.3.4", "@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/vite": "^4.2.4", @@ -1824,6 +1825,154 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.6.1.tgz", + "integrity": "sha512-vdG4fBF6Lkirkcl53q6eOdn3XYKt+kJTG59edgRZORlg/3atWWEReRCx5rYE1ZzTTX6vLK5zDMjHh7vbrcXGtw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "@peculiar/asn1-x509-attr": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.6.1.tgz", + "integrity": "sha512-WRWnKfIocHyzFYQTka8O/tXCiBquAPSrRjXbOkHbO4qdmS6loffCEGs+rby6WxxGdJCuunnhS2duHURhjyio6w==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.6.1.tgz", + "integrity": "sha512-+Vqw8WFxrtDIN5ehUdvlN2m73exS2JVG0UAyfVB31gIfor3zWEAQPD+K9ydCxaj3MLen9k0JhKpu9LqviuCE1g==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.6.1.tgz", + "integrity": "sha512-nB5jVQy3MAAWvq0KY0R2JUZG8bO/bTLpnwyOzXyEh/e54ynGTatAR+csOnXkkVD9AFZ2uL8Z7EV918+qB1qDvw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.1", + "@peculiar/asn1-pkcs8": "^2.6.1", + "@peculiar/asn1-rsa": "^2.6.1", + "@peculiar/asn1-schema": "^2.6.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.6.1.tgz", + "integrity": "sha512-JB5iQ9Izn5yGMw3ZG4Nw3Xn/hb/G38GYF3lf7WmJb8JZUydhVGEjK/ZlFSWhnlB7K/4oqEs8HnfFIKklhR58Tw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.6.1.tgz", + "integrity": "sha512-5EV8nZoMSxeWmcxWmmcolg22ojZRgJg+Y9MX2fnE2bGRo5KQLqV5IL9kdSQDZxlHz95tHvIq9F//bvL1OeNILw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.1", + "@peculiar/asn1-pfx": "^2.6.1", + "@peculiar/asn1-pkcs8": "^2.6.1", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "@peculiar/asn1-x509-attr": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.6.1.tgz", + "integrity": "sha512-1nVMEh46SElUt5CB3RUTV4EG/z7iYc7EoaDY5ECwganibQPkZ/Y2eMsTKB/LeyrUJ+W/tKoD9WUqIy8vB+CEdA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.6.0.tgz", + "integrity": "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==", + "license": "MIT", + "dependencies": { + "asn1js": "^3.0.6", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.6.1.tgz", + "integrity": "sha512-O9jT5F1A2+t3r7C4VT7LYGXqkGLK7Kj1xFpz7U0isPrubwU5PbDoyYtx6MiGst29yq7pXN5vZbQFKRCP+lLZlA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "asn1js": "^3.0.6", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.6.1.tgz", + "integrity": "sha512-tlW6cxoHwgcQghnJwv3YS+9OO1737zgPogZ+CgWRUK4roEwIPzRH4JEiG770xe5HX2ATfCpmX60gurfWIF9dcQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/x509": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", + "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-csr": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.0", + "@peculiar/asn1-pkcs9": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "pvtsutils": "^1.3.6", + "reflect-metadata": "^0.2.2", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@playwright/test": { "version": "1.58.2", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", @@ -5978,6 +6127,20 @@ "dev": true, "license": "MIT" }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -10064,6 +10227,24 @@ "node": ">=6" } }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/qs": { "version": "6.14.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", @@ -10424,6 +10605,12 @@ "node": ">=8" } }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, "node_modules/regex": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/regex/-/regex-6.0.1.tgz", @@ -11744,6 +11931,24 @@ "@esbuild/win32-x64": "0.25.12" } }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/tunnel-rat": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz", diff --git a/package.json b/package.json index f429986f9..5a4a0618c 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@floating-ui/react": "^0.26.23", "@headlessui/react": "^2.2.9", "@oxide/design-system": "^6.2.1", + "@peculiar/x509": "^1.12.3", "@react-aria/live-announcer": "^3.3.4", "@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/vite": "^4.2.4", diff --git a/test/e2e/silos.e2e.ts b/test/e2e/silos.e2e.ts index 72192517f..69d5cc737 100644 --- a/test/e2e/silos.e2e.ts +++ b/test/e2e/silos.e2e.ts @@ -17,6 +17,52 @@ import { expectVisible, } from './utils' +// Self-signed cert whose SANs cover other-silo.sys.placeholder; notAfter +// is 2036, so neither the mismatch nor expiry notice should fire. +const validCertPem = `-----BEGIN CERTIFICATE----- +MIIDCTCCAfGgAwIBAgIJANXWazDy6XofMA0GCSqGSIb3DQEBCwUAMCUxIzAhBgNV +BAMMGm90aGVyLXNpbG8uc3lzLnBsYWNlaG9sZGVyMB4XDTI2MDUyMTExMTQyNVoX +DTM2MDUxODExMTQyNVowJTEjMCEGA1UEAwwab3RoZXItc2lsby5zeXMucGxhY2Vo +b2xkZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDI3sD73Xxa3gUN +2cdgTYtOrmLubg19yBqs24m11PbTR8mUnPcgMLGS4EUsJax3IjYHoDTWa8BnKMO+ +ECYL3FlwUlabvnendefrDSgS8RFjFNdNBULVfDkyNvRVClfl9z2/T7T+OrDqnO55 +jfXGh5hVi2hUw2oZY5IMtGiinALisl9N4hE2GCeJ+xJs9XZbCjyuGW1V6TK8domR +SQ/a92KnM7zmHb3ed+sqJ5w9DOReb0JFw7E50h0DttswoZvpeqVWIjnv1n9+Q3Ef +izyWukmWMLnWbL0sZ4IzPbgoxa0nhW/cpmH2WOVfq6PiiAJ5lz5aVKtRdXyBllNm +KxMJFaRjAgMBAAGjPDA6MDgGA1UdEQQxMC+CGm90aGVyLXNpbG8uc3lzLnBsYWNl +aG9sZGVyghEqLnN5cy5wbGFjZWhvbGRlcjANBgkqhkiG9w0BAQsFAAOCAQEAnv7Q +9Ye1CN0rzfS48rrKdKinsotnI9qLTCa8Yds+aGUy4Zc1+L0JOoaf++JxIruEAIEB +QTw/K5BTTYrMtH+Z1j8oBNobz7nmqViQc1TZzbAbpLEoIDhORcR4Bfd1nFhoys44 +NuLQj2nBT4+esIq1Stnne6yWaMRGS7b4ST2fiw3YECPxZjSwDW81uis+RdvIsrRt +5oN46xZ08uBYvjGv09FDS2eFlMxxg7v92qWvlUWDjqXgMYPTdT1lC9jT5afV3auE +v79HkG0vgIb5q/KyEnpHs3NnJxBaxLH7+i8aEXD7235RNjROzRHTvGaTBkJQVk9X +cx0yc+u9JD4kNu9aOA== +-----END CERTIFICATE-----` + +// Self-signed cert with CN=test.example.com and SANs that won't match +// other-silo.sys.*; notAfter is 2025-11-27, i.e. already expired. +const expiredCertPem = `-----BEGIN CERTIFICATE----- +MIIDbjCCAlagAwIBAgIUVF36cv2UevtKOGWP3GNV1h+TpScwDQYJKoZIhvcNAQEL +BQAwGzEZMBcGA1UEAwwQdGVzdC5leGFtcGxlLmNvbTAeFw0yNDExMjcxNDE4MTha +Fw0yNTExMjcxNDE4MThaMBsxGTAXBgNVBAMMEHRlc3QuZXhhbXBsZS5jb20wggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC0cBavU9cnrTY7CaOsHdfzr7e4 +mT7eRCGJa1jmuGeADGIs1IcMr/7jgiKS/1P69SehfqpFWXKAYn5OH+ickZfs55AB +uyfh+KogmTkX6I40CnP9GohfgAaDVr119a2kdJNvinsCjNGfulMBYiw+sJBp4l/c +zQRYMXaMk1ARKBgUuVZHZXnkWQKjp/GAQjVsUjl/dnBVeUuS4/0OVTLL8U6mGzdy +f5s03bpBLOOJ9Owg1We5urYA6glCvvMh1VhBPsCnHFj6aYLnnWpJkVuJEKA+znEU +U2n6T0bQorzVnn5ROtAn3ao4sGIVMbMeIaEvUt3zyVk+gtUvqSTPChFde6/LAgMB +AAGjgakwgaYwHQYDVR0OBBYEFFzp73YRPxxu4bTQvmJy5rqHNXh7MB8GA1UdIwQY +MBaAFFzp73YRPxxu4bTQvmJy5rqHNXh7MA8GA1UdEwEB/wQFMAMBAf8wUwYDVR0R +BEwwSoIQdGVzdC5leGFtcGxlLmNvbYISKi50ZXN0LmV4YW1wbGUuY29tghEqLmRl +di5leGFtcGxlLmNvbYIJbG9jYWxob3N0hwR/AAABMA0GCSqGSIb3DQEBCwUAA4IB +AQCstbMiTwHuSlwuUslV9SxewdxTtKAjNgUnCn1Jv7hs44wNTBqvMzDq2HB26wRR +Onbt6gReOj9GdSRmJPNcgouaAGJWCXuaZPs34LgRJir6Z0FVcK7/O3SqfTOg3tJg +gzg4xmtzXc7Im4VgvaLS5iXCOvUaKf/rXeYDa3r37EF+vyzcETt5bXwtU8BBFvVT +JfPDla5lYv0h9Z+XsYEAqtbChdy+fVuHnF+EygZCT9KVFBPWQrsaF1Qc/CvP/+LM +CrdLoB+2pkWbX075tv8LIbL2dW5Gzyw+lU6lzPL9Vikm3QXGRklKHA4SVuZ3F9tr +wPRLWb4aPmo1COkgvg3Moqdw +-----END CERTIFICATE-----` + test('Create silo', async ({ page }) => { await page.goto('/system/silos') @@ -98,6 +144,8 @@ test('Create silo', async ({ page }) => { await expectVisible(page, [certRequired, keyRequired, nameRequired]) await chooseFile(page.getByLabel('Cert', { exact: true }), 'small') + // garbage content should surface the soft "couldn't parse" notice + await expect(certDialog.getByText("Couldn't parse certificate")).toBeVisible() await chooseFile(page.getByLabel('Key'), 'small') const certName = certDialog.getByRole('textbox', { name: 'Name' }) @@ -125,7 +173,30 @@ test('Create silo', async ({ page }) => { // Change the name so it's unique await certName.fill('test-cert-2') - await chooseFile(page.getByLabel('Cert', { exact: true }), 'small') + + // First upload a valid, non-expired cert that covers other-silo.sys.* — + // no soft-validation notice should be visible + const certInput = page.getByLabel('Cert', { exact: true }) + await certInput.setInputFiles({ + name: 'cert.pem', + mimeType: 'application/x-pem-file', + buffer: Buffer.from(validCertPem), + }) + await expect(certDialog.getByText("Couldn't parse certificate")).toBeHidden() + await expect(certDialog.getByText('Certificate expired')).toBeHidden() + await expect(certDialog.getByText('Certificate domain mismatch')).toBeHidden() + + // Now swap to a real (expired) PEM whose SANs don't match + // other-silo.sys.* — exercises the mismatch + expiry notices end to end + await certInput.setInputFiles({ + name: 'cert.pem', + mimeType: 'application/x-pem-file', + buffer: Buffer.from(expiredCertPem), + }) + await expect(certDialog.getByText('Certificate expired')).toBeVisible() + await expect(certDialog.getByText('Certificate domain mismatch')).toBeVisible() + await expect(certDialog.getByText('other-silo.sys.placeholder')).toBeVisible() + await expect(certDialog.getByText("Couldn't parse certificate")).toBeHidden() await chooseFile(page.getByLabel('Key'), 'small') await certSubmit.click() await expect(page.getByRole('cell', { name: 'test-cert-2', exact: true })).toBeVisible()