diff --git a/app/components/form/fields/TlsCertsField.spec.tsx b/app/components/form/fields/TlsCertsField.spec.tsx new file mode 100644 index 000000000..d49af1309 --- /dev/null +++ b/app/components/form/fields/TlsCertsField.spec.tsx @@ -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, + }) + }) +}) diff --git a/app/components/form/fields/TlsCertsField.tsx b/app/components/form/fields/TlsCertsField.tsx index dec73aa26..4c22f812a 100644 --- a/app/components/form/fields/TlsCertsField.tsx +++ b/app/components/form/fields/TlsCertsField.tsx @@ -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 }) { +export function TlsCertsField({ + control, + siloName, +}: { + control: Control + siloName: string +}) { const [showAddCert, setShowAddCert] = useState(false) const { @@ -70,7 +80,7 @@ export function TlsCertsField({ control }: { control: Control 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(), @@ -80,6 +90,7 @@ export function TlsCertsField({ control }: { control: Control item.name)} + siloName={siloName} /> )} @@ -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({ defaultValues }) +const AddCertModal = ({ onDismiss, onSubmit, allNames, siloName }: AddCertModalProps) => { + const { watch, control, handleSubmit } = useForm({ 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 ( @@ -135,6 +154,13 @@ const AddCertModal = ({ onDismiss, onSubmit, allNames }: AddCertModalProps) => { required control={control} /> + {siloName && ( + + )} @@ -147,3 +173,137 @@ const AddCertModal = ({ onDismiss, onSubmit, allNames }: AddCertModalProps) => { ) } + +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 ( + +
+ Certificate may not be valid, a silo expects a X.509 cert in PEM format. +
+
+ Learn more about{' '} + + silo certs + + +
+ + } + /> + ) + } + + 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 ( + + Expected to match {expectedDomain}
+
+ Found: +
    + {domains.map((domain, index) => ( +
  • {domain}
  • + ))} +
+
+
+ Learn more about{' '} + + silo certs + + +
+ + } + /> + ) +} diff --git a/app/forms/silo-create.tsx b/app/forms/silo-create.tsx index ea6b82651..a61aba4f0 100644 --- a/app/forms/silo-create.tsx +++ b/app/forms/silo-create.tsx @@ -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') { @@ -170,7 +171,7 @@ export function CreateSiloSideModalForm() { - + ) } diff --git a/package-lock.json b/package-lock.json index 142ed5a2b..e9b614999 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@floating-ui/react": "^0.26.23", "@headlessui/react": "^2.1.8", "@oxide/design-system": "^1.6.1", + "@peculiar/x509": "^1.12.3", "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-focus-guards": "1.0.1", @@ -2045,6 +2046,149 @@ "react-dom": "^18.0.0" } }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.3.13.tgz", + "integrity": "sha512-joqu8A7KR2G85oLPq+vB+NFr2ro7Ls4ol13Zcse/giPSzUNN0n2k3v8kMpf6QdGUhI13e5SzQYN8AKP8sJ8v4w==", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.13", + "@peculiar/asn1-x509": "^2.3.13", + "@peculiar/asn1-x509-attr": "^2.3.13", + "asn1js": "^3.0.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.3.13.tgz", + "integrity": "sha512-+JtFsOUWCw4zDpxp1LbeTYBnZLlGVOWmHHEhoFdjM5yn4wCn+JiYQ8mghOi36M2f6TPQ17PmhNL6/JfNh7/jCA==", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.13", + "@peculiar/asn1-x509": "^2.3.13", + "asn1js": "^3.0.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.3.14", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.3.14.tgz", + "integrity": "sha512-zWPyI7QZto6rnLv6zPniTqbGaLh6zBpJyI46r1yS/bVHJXT2amdMHCRRnbV5yst2H8+ppXG6uXu/M6lKakiQ8w==", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.13", + "@peculiar/asn1-x509": "^2.3.13", + "asn1js": "^3.0.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.3.13.tgz", + "integrity": "sha512-fypYxjn16BW+5XbFoY11Rm8LhZf6euqX/C7BTYpqVvLem1GvRl7A+Ro1bO/UPwJL0z+1mbvXEnkG0YOwbwz2LA==", + "dependencies": { + "@peculiar/asn1-cms": "^2.3.13", + "@peculiar/asn1-pkcs8": "^2.3.13", + "@peculiar/asn1-rsa": "^2.3.13", + "@peculiar/asn1-schema": "^2.3.13", + "asn1js": "^3.0.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.3.13.tgz", + "integrity": "sha512-VP3PQzbeSSjPjKET5K37pxyf2qCdM0dz3DJ56ZCsol3FqAXGekb4sDcpoL9uTLGxAh975WcdvUms9UcdZTuGyQ==", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.13", + "@peculiar/asn1-x509": "^2.3.13", + "asn1js": "^3.0.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.3.13.tgz", + "integrity": "sha512-rIwQXmHpTo/dgPiWqUgby8Fnq6p1xTJbRMxCiMCk833kQCeZrC5lbSKg6NDnJTnX2kC6IbXBB9yCS2C73U2gJg==", + "dependencies": { + "@peculiar/asn1-cms": "^2.3.13", + "@peculiar/asn1-pfx": "^2.3.13", + "@peculiar/asn1-pkcs8": "^2.3.13", + "@peculiar/asn1-schema": "^2.3.13", + "@peculiar/asn1-x509": "^2.3.13", + "@peculiar/asn1-x509-attr": "^2.3.13", + "asn1js": "^3.0.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.3.13.tgz", + "integrity": "sha512-wBNQqCyRtmqvXkGkL4DR3WxZhHy8fDiYtOjTeCd7SFE5F6GBeafw3EJ94PX/V0OJJrjQ40SkRY2IZu3ZSyBqcg==", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.13", + "@peculiar/asn1-x509": "^2.3.13", + "asn1js": "^3.0.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.3.13.tgz", + "integrity": "sha512-3Xq3a01WkHRZL8X04Zsfg//mGaA21xlL4tlVn4v2xGT0JStiztATRkMwa5b+f/HXmY2smsiLXYK46Gwgzvfg3g==", + "dependencies": { + "asn1js": "^3.0.5", + "pvtsutils": "^1.3.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.3.13.tgz", + "integrity": "sha512-PfeLQl2skXmxX2/AFFCVaWU8U6FKW1Db43mgBhShCOFS1bVxqtvusq1hVjfuEcuSQGedrLdCSvTgabluwN/M9A==", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.13", + "asn1js": "^3.0.5", + "ipaddr.js": "^2.1.0", + "pvtsutils": "^1.3.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.3.13.tgz", + "integrity": "sha512-WpEos6CcnUzJ6o2Qb68Z7Dz5rSjRGv/DtXITCNBtjZIRWRV12yFVci76SVfOX8sisL61QWMhpLKQibrG8pi2Pw==", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.13", + "@peculiar/asn1-x509": "^2.3.13", + "asn1js": "^3.0.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-x509/node_modules/ipaddr.js": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz", + "integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==", + "engines": { + "node": ">= 10" + } + }, + "node_modules/@peculiar/x509": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.12.3.tgz", + "integrity": "sha512-+Mzq+W7cNEKfkNZzyLl6A6ffqc3r21HGZUezgfKxpZrkORfOqgRXnS80Zu0IV6a9Ue9QBJeKD7kN0iWfc3bhRQ==", + "dependencies": { + "@peculiar/asn1-cms": "^2.3.13", + "@peculiar/asn1-csr": "^2.3.13", + "@peculiar/asn1-ecc": "^2.3.14", + "@peculiar/asn1-pkcs9": "^2.3.13", + "@peculiar/asn1-rsa": "^2.3.13", + "@peculiar/asn1-schema": "^2.3.13", + "@peculiar/asn1-x509": "^2.3.13", + "pvtsutils": "^1.3.5", + "reflect-metadata": "^0.2.2", + "tslib": "^2.7.0", + "tsyringe": "^4.8.0" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -6074,6 +6218,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asn1js": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.5.tgz", + "integrity": "sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ==", + "dependencies": { + "pvtsutils": "^1.3.2", + "pvutils": "^1.1.3", + "tslib": "^2.4.0" + }, + "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", @@ -14974,6 +15131,27 @@ "node": ">=6" } }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvtsutils/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, + "node_modules/pvutils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.3.tgz", + "integrity": "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/qs": { "version": "6.13.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", @@ -15466,6 +15644,11 @@ "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==" + }, "node_modules/reflect.getprototypeof": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.6.tgz", @@ -17216,6 +17399,22 @@ "fsevents": "~2.3.3" } }, + "node_modules/tsyringe": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.8.0.tgz", + "integrity": "sha512-YB1FG+axdxADa3ncEtRnQCFq/M0lALGLxSZeVNbTU8NqhOVc51nnv2CISTcvc1kyv6EGPtXVr0v6lWeDxiijOA==", + "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==" + }, "node_modules/tunnel-rat": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.0.4.tgz", diff --git a/package.json b/package.json index ab2b129cd..aa381b1d8 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@floating-ui/react": "^0.26.23", "@headlessui/react": "^2.1.8", "@oxide/design-system": "^1.6.1", + "@peculiar/x509": "^1.12.3", "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-focus-guards": "1.0.1",