diff --git a/app/components/form/fields/TlsCertsField.spec.tsx b/app/components/form/fields/TlsCertsField.spec.tsx new file mode 100644 index 000000000..5a7b64930 --- /dev/null +++ b/app/components/form/fields/TlsCertsField.spec.tsx @@ -0,0 +1,103 @@ +/* + * 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) + }) + + 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({ + 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, + }) + }) + + it('returns invalid for empty input', async () => { + expect(await parseCertificate('')).toEqual({ + commonName: [], + subjectAltNames: [], + 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({ + commonName: [], + subjectAltNames: [], + isValid: false, + }) + }) +}) diff --git a/app/components/form/fields/TlsCertsField.tsx b/app/components/form/fields/TlsCertsField.tsx index 824f98612..d415bb396 100644 --- a/app/components/form/fields/TlsCertsField.tsx +++ b/app/components/form/fields/TlsCertsField.tsx @@ -5,17 +5,21 @@ * * 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 { MiniTable } from '~/ui/lib/MiniTable' import { Modal } from '~/ui/lib/Modal' +import { links } from '~/util/links' import { DescriptionField } from './DescriptionField' import { ErrorMessage } from './ErrorMessage' @@ -23,7 +27,13 @@ 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 { @@ -77,6 +87,7 @@ export function TlsCertsField({ control }: { control: Control item.name)} + siloName={siloName} /> )} @@ -100,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: ['validateImage', ...(file ? [file.name, file.size, file.lastModified] : [])], + queryFn: file ? () => file.text().then(parseCertificate) : skipToken, + }) return ( @@ -132,6 +151,11 @@ const AddCertModal = ({ onDismiss, onSubmit, allNames }: AddCertModalProps) => { required control={control} /> + @@ -144,3 +168,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 ( + +
Expected an X.509 certificate in PEM format.
+
+ Learn more about{' '} + + silo certs + + +
+ + } + /> + ) + } + + // Domain matching needs a silo name to compare against + if (!siloName) return null + + 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 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",