diff --git a/CHANGELOG.md b/CHANGELOG.md index 829c393..0a76c0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,21 +22,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - **Breaking:** Default `TrustStore` in `DefaultOptions()` changed from `"system"` to `"mozilla"` — pure-Go Mozilla root verification is used by default instead of macOS `SecTrustEvaluateWithError` syscalls, eliminating multi-minute hangs on large certificate stores -- Parallelize trust verification in scan summary, dump-certs, and AIA resolution — mozilla checks run concurrently, system checks only run for certs mozilla didn't trust +- Default `scan`, `verify`, `inspect`, and `connect` trust-store selection to Mozilla, and require an explicit `--trust-store system` when command trust reporting should come from host trust roots +- Parallelize trust verification in scan summary, dump-certs, and AIA resolution for the selected trust store - Add `TrustStore` label to `VerifyChainTrustInput` and debug-log every trust verification call with subject, store, and result - Normalize all exported private key PEM output (`.key`, K8s `tls.key`, YAML `key`) to PKCS#8 (`PRIVATE KEY`) regardless of input format ([#167]) - Bundle export warns when Kubernetes TLS secret contains an unencrypted private key alongside encrypted outputs ([#167]) - Use browser Web Crypto API for PBKDF2 key derivation in WASM builds to avoid blocking the main thread during encrypted key export ([#167]) -- `verify` now checks both Mozilla and system trust stores by default and treats a certificate as trusted when any available anchor source succeeds ([`0ee41ad`]) +- `verify` now uses the selected built-in trust store plus any `--roots` file-backed anchors, and treats a certificate as trusted when any requested anchor source succeeds - `scan` now counts `untrusted_*` certificates as trusted by neither Mozilla nor system, and exposes per-store trust counts in JSON output ([`0ee41ad`]) - Surface trust-source load warnings in `inspect`, `verify`, and `connect`, fail fast on invalid `verify` trust-store configuration, and stop reporting a synthetic `file` source when no file-backed roots were requested ([#171]) -### Removed - -- **Breaking:** Remove `verify --trust-store`; use the default Mozilla+system verification or `--roots` to add a file-backed trust source ([`0ee41ad`]) - ### Fixed +- Make `scan` summaries rely on the selected trust store while still retrying bundle exports against host trust roots after Mozilla unknown-authority failures +- Stop assigning fallback bundle names to certificates that do not match any configured bundle entry, so stray export directories like `bundles/spf-console.zimperium.com/` are no longer generated +- Preserve the certificate common name in generated bundle CSRs so exported requests include a subject CN alongside SANs +- Include `subject.common_name` in generated CSR JSON output so `.csr.json` matches the exported CSR subject +- Stop bundle export from retrying the system trust fallback for non-trust-store verification failures such as expired certificates - Make `connect` fail when the peer omits part of the trust path and validation only succeeds after local chain completion, instead of accepting the incomplete server-presented chain ([#190]) - Use bundle folder name as Kubernetes secret `metadata.name` instead of the CN-derived prefix, so the secret name matches the export directory ([#178]) - Validate `bundleName` in bundle config YAML against DNS-1123 rules at load time; invalid names now produce a fatal error with the file path and line number ([#178]) diff --git a/README.md b/README.md index beebd3c..b44d2d7 100644 --- a/README.md +++ b/README.md @@ -142,10 +142,11 @@ Common passwords (`""`, `"password"`, `"changeit"`, `"keypassword"`) are always ### Inspect Flags -| Flag | Default | Description | -| ------------------------- | ------- | ----------------------------------------------- | -| `--allow-private-network` | `false` | Allow AIA fetches to private/internal endpoints | -| `--format` | `text` | Output format: text, json | +| Flag | Default | Description | +| ------------------------- | --------- | ----------------------------------------------- | +| `--allow-private-network` | `false` | Allow AIA fetches to private/internal endpoints | +| `--format` | `text` | Output format: text, json | +| `--trust-store` | `mozilla` | Trust store: system, mozilla | JSON certificate records include `trust_anchors` and `trust_warnings`. @@ -153,36 +154,38 @@ JSON certificate records include `trust_anchors` and `trust_warnings`. ### Verify Flags -| Flag | Default | Description | -| ------------------------- | ------- | --------------------------------------------------------------------- | -| `--allow-private-network` | `false` | Allow AIA/OCSP/CRL fetches to private/internal endpoints | -| `--crl` | `false` | Check CRL distribution points for revocation | -| `--diagnose` | `false` | Show diagnostics when chain verification fails | -| `--expiry`, `-e` | | Check if cert expires within duration (e.g., 30d, 720h) | -| `--format` | `text` | Output format: text, json | -| `--key` | | Private key file to check against the certificate | -| `--ocsp` | `false` | Check OCSP revocation status | -| `--roots` | | Additional root certificates file (PEM, DER, PKCS#7, PKCS#12, or JKS) | +| Flag | Default | Description | +| ------------------------- | --------- | --------------------------------------------------------------------- | +| `--allow-private-network` | `false` | Allow AIA/OCSP/CRL fetches to private/internal endpoints | +| `--crl` | `false` | Check CRL distribution points for revocation | +| `--diagnose` | `false` | Show diagnostics when chain verification fails | +| `--expiry`, `-e` | | Check if cert expires within duration (e.g., 30d, 720h) | +| `--format` | `text` | Output format: text, json | +| `--key` | | Private key file to check against the certificate | +| `--ocsp` | `false` | Check OCSP revocation status | +| `--roots` | | Additional root certificates file (PEM, DER, PKCS#7, PKCS#12, or JKS) | +| `--trust-store` | `mozilla` | Trust store: system, mozilla | -Chain verification is always performed against both the embedded Mozilla roots and the host system trust store. Use `--roots` to add a file-backed trust source for private PKI, including pinned or legacy trust anchors loaded from PEM, DER, PKCS#7, PKCS#12, or JKS. When the input contains an embedded private key (PKCS#12, JKS), key match is checked automatically. Use `--ocsp` and/or `--crl` to check revocation status (requires network access and a valid chain). +Chain verification uses the embedded Mozilla roots by default; use `--trust-store system` to switch to the host trust store. Use `--roots` to add a file-backed trust source for private PKI, including pinned or legacy trust anchors loaded from PEM, DER, PKCS#7, PKCS#12, or JKS. When the input contains an embedded private key (PKCS#12, JKS), key match is checked automatically. Use `--ocsp` and/or `--crl` to check revocation status (requires network access and a valid chain). JSON output includes `trust_anchors` and `trust_warnings` for the leaf and displayed chain entries. ### Connect Flags -| Flag | Default | Description | -| ------------------------- | ------- | ----------------------------------------------------------------------------------- | -| `--allow-private-network` | `false` | Allow AIA/OCSP/CRL fetches to private/internal endpoints | -| `--ciphers` | `false` | Enumerate all supported cipher suites with security ratings | -| `--crl` | `false` | Check CRL distribution points for revocation | -| `--fips-140-2` | `false` | Apply conservative FIPS 140-2 heuristic checks to negotiated/offered TLS algorithms | -| `--fips-140-3` | `false` | Apply conservative FIPS 140-3 heuristic checks to negotiated/offered TLS algorithms | -| `--format` | `text` | Output format: text, json | -| `--no-ocsp` | `false` | Disable automatic OCSP revocation check | -| `--servername` | | Override SNI hostname (defaults to host) | -| `--tls-version` | | Pin TLS version: 1.0, 1.1, 1.2, or 1.3 (default: auto) | +| Flag | Default | Description | +| ------------------------- | --------- | ----------------------------------------------------------------------------------- | +| `--allow-private-network` | `false` | Allow AIA/OCSP/CRL fetches to private/internal endpoints | +| `--ciphers` | `false` | Enumerate all supported cipher suites with security ratings | +| `--crl` | `false` | Check CRL distribution points for revocation | +| `--fips-140-2` | `false` | Apply conservative FIPS 140-2 heuristic checks to negotiated/offered TLS algorithms | +| `--fips-140-3` | `false` | Apply conservative FIPS 140-3 heuristic checks to negotiated/offered TLS algorithms | +| `--format` | `text` | Output format: text, json | +| `--no-ocsp` | `false` | Disable automatic OCSP revocation check | +| `--servername` | | Override SNI hostname (defaults to host) | +| `--tls-version` | | Pin TLS version: 1.0, 1.1, 1.2, or 1.3 (default: auto) | +| `--trust-store` | `mozilla` | Trust store: system, mozilla | Port defaults to 443 if not specified. OCSP revocation status is checked automatically (best-effort); use `--no-ocsp` to disable. Use `--verbose` for extended details (serial, key info, signature algorithm, key usage, EKU, extensions) plus a PEM-formatted copy of the server-sent certificate chain with `# Subject`, `# Issuer`, and validity headers. @@ -267,6 +270,7 @@ Input format is auto-detected. | `--load-db` | | Load an existing database into memory before scanning | | `--max-file-size` | `10485760` | Skip files larger than this size in bytes (0 to disable) | | `--save-db` | | Save the in-memory database to disk after scanning | +| `--trust-store` | `mozilla` | Trust store: system, mozilla | ### Keygen Flags diff --git a/bundle.go b/bundle.go index 4172e69..d4638c0 100644 --- a/bundle.go +++ b/bundle.go @@ -347,6 +347,7 @@ type CheckTrustAnchorsInput struct { Cert *x509.Certificate Intermediates *x509.CertPool FileRoots *x509.CertPool + TrustStore string } // CheckTrustAnchorsResult reports which trust sources validated a certificate @@ -410,35 +411,55 @@ func verifyChainTrustChains(input VerifyChainTrustInput) ([][]*x509.Certificate, } // CheckTrustAnchors reports which trust sources validate the certificate. -// Results are returned in stable order: mozilla, system, file. +// Results are returned in stable order: selected trust store, then file. func CheckTrustAnchors(input CheckTrustAnchorsInput) CheckTrustAnchorsResult { if input.Cert == nil { return CheckTrustAnchorsResult{Anchors: []string{}, Warnings: []string{}} } result := CheckTrustAnchorsResult{ - Anchors: make([]string, 0, 3), + Anchors: make([]string, 0, 2), Warnings: make([]string, 0, 2), } - if mozillaPool, err := MozillaRootPool(); err != nil { - result.Warnings = append(result.Warnings, fmt.Sprintf("mozilla trust source unavailable: %v", err)) - } else if VerifyChainTrust(VerifyChainTrustInput{ - Cert: input.Cert, - Roots: mozillaPool, - Intermediates: input.Intermediates, - TrustStore: "mozilla", - }) { - result.Anchors = append(result.Anchors, "mozilla") + checkRoots := func(name string, roots *x509.CertPool) { + if VerifyChainTrust(VerifyChainTrustInput{ + Cert: input.Cert, + Roots: roots, + Intermediates: input.Intermediates, + TrustStore: name, + }) { + result.Anchors = append(result.Anchors, name) + } } - if systemPool, err := SystemCertPoolCached(); err != nil { - result.Warnings = append(result.Warnings, fmt.Sprintf("system trust source unavailable: %v", err)) - } else if VerifyChainTrust(VerifyChainTrustInput{ - Cert: input.Cert, - Roots: systemPool, - Intermediates: input.Intermediates, - TrustStore: "system", - }) { - result.Anchors = append(result.Anchors, "system") + + trustStore := input.TrustStore + if trustStore == "" { + trustStore = "mozilla" + } + switch trustStore { + case "mozilla": + mozillaPool, err := MozillaRootPool() + if err != nil { + result.Warnings = append(result.Warnings, fmt.Sprintf("mozilla trust source unavailable: %v", err)) + break + } + checkRoots("mozilla", mozillaPool) + case "system": + systemPool, err := SystemCertPoolCached() + if err != nil { + result.Warnings = append(result.Warnings, fmt.Sprintf("system trust source unavailable: %v", err)) + break + } + checkRoots("system", systemPool) + case "custom": + result.Warnings = append(result.Warnings, "custom trust store cannot be evaluated without an explicit roots pool") + case "file": + if input.FileRoots == nil { + result.Warnings = append(result.Warnings, "file trust store cannot be evaluated without file roots") + } + default: + result.Warnings = append(result.Warnings, fmt.Sprintf("unsupported trust store %q", trustStore)) + return result } if input.FileRoots != nil && VerifyChainTrust(VerifyChainTrustInput{ Cert: input.Cert, diff --git a/bundle_test.go b/bundle_test.go index cf604be..d35fcb6 100644 --- a/bundle_test.go +++ b/bundle_test.go @@ -194,6 +194,50 @@ func TestCheckTrustAnchors_FileRoots(t *testing.T) { } } +func TestCheckTrustAnchors_CustomTrustStoreWarnsWithoutRootsPool(t *testing.T) { + t.Parallel() + + _, intermediates, leaf := buildChain(t, 3) + intermediatePool := x509.NewCertPool() + for _, intermediate := range intermediates { + intermediatePool.AddCert(intermediate) + } + + result := CheckTrustAnchors(CheckTrustAnchorsInput{ + Cert: leaf, + Intermediates: intermediatePool, + TrustStore: "custom", + }) + if len(result.Anchors) != 0 { + t.Fatalf("CheckTrustAnchors() anchors = %v, want none", result.Anchors) + } + if got, want := strings.Join(result.Warnings, "; "), "custom trust store cannot be evaluated without an explicit roots pool"; got != want { + t.Fatalf("CheckTrustAnchors() warnings = %q, want %q", got, want) + } +} + +func TestCheckTrustAnchors_FileTrustStoreWarnsWithoutFileRoots(t *testing.T) { + t.Parallel() + + _, intermediates, leaf := buildChain(t, 3) + intermediatePool := x509.NewCertPool() + for _, intermediate := range intermediates { + intermediatePool.AddCert(intermediate) + } + + result := CheckTrustAnchors(CheckTrustAnchorsInput{ + Cert: leaf, + Intermediates: intermediatePool, + TrustStore: "file", + }) + if len(result.Anchors) != 0 { + t.Fatalf("CheckTrustAnchors() anchors = %v, want none", result.Anchors) + } + if got, want := strings.Join(result.Warnings, "; "), "file trust store cannot be evaluated without file roots"; got != want { + t.Fatalf("CheckTrustAnchors() warnings = %q, want %q", got, want) + } +} + func TestFormatTrustAnchors(t *testing.T) { t.Parallel() diff --git a/cmd/certkit/connect.go b/cmd/certkit/connect.go index 2fb2d05..b5a10b8 100644 --- a/cmd/certkit/connect.go +++ b/cmd/certkit/connect.go @@ -26,6 +26,7 @@ import ( var ( connectServerName string connectFormat string + connectTrustStore string connectTLSVersion string connectCRL bool connectNoOCSP bool @@ -53,6 +54,9 @@ the server supports with security ratings. Network fetches for AIA/OCSP/CRL block private/internal endpoints by default. Use --allow-private-network to opt in for internal PKI environments. +Chain verification uses Mozilla roots by default; use --trust-store system to +use the host trust store. + Use --tls-version 1.2 or --tls-version 1.3 to compare version-specific certificate presentation and validation behavior. @@ -71,6 +75,7 @@ Exits with code 2 if chain verification fails or the certificate is revoked.`, func init() { connectCmd.Flags().StringVar(&connectServerName, "servername", "", "Override SNI hostname (defaults to host)") connectCmd.Flags().StringVar(&connectFormat, "format", "text", "Output format: text, json") + connectCmd.Flags().StringVar(&connectTrustStore, "trust-store", "mozilla", "Trust store: system, mozilla") connectCmd.Flags().StringVar(&connectTLSVersion, "tls-version", "", "Pin TLS version: 1.0, 1.1, 1.2, or 1.3 (default: auto)") connectCmd.Flags().BoolVar(&connectCRL, "crl", false, "Check CRL distribution points for revocation") connectCmd.Flags().BoolVar(&connectNoOCSP, "no-ocsp", false, "Disable automatic OCSP revocation check") @@ -80,6 +85,7 @@ func init() { connectCmd.Flags().BoolVar(&connectAllowPrivateNetwork, "allow-private-network", false, "Allow AIA/OCSP/CRL fetches to private/internal endpoints") registerCompletion(connectCmd, completionInput{"format", fixedCompletion("text", "json")}) + registerCompletion(connectCmd, completionInput{"trust-store", fixedCompletion("system", "mozilla")}) registerCompletion(connectCmd, completionInput{"tls-version", fixedCompletion("1.0", "1.1", "1.2", "1.3")}) } @@ -144,6 +150,10 @@ func runConnect(cmd *cobra.Command, args []string) error { if err != nil { return err } + rootCAs, err := loadSelectedTrustPool(connectTrustStore) + if err != nil { + return fmt.Errorf("loading trust store: %w", err) + } spin := newSpinner("Connecting…") spin.Start(cmd.Context()) @@ -156,6 +166,7 @@ func runConnect(cmd *cobra.Command, args []string) error { Port: port, Version: version, ServerName: connectServerName, + RootCAs: rootCAs, DisableOCSP: connectNoOCSP, CheckCRL: connectCRL, AllowPrivateNetworks: connectAllowPrivateNetwork, @@ -248,7 +259,7 @@ func runConnect(cmd *cobra.Command, args []string) error { } certkit.SortDiagnostics(result.Diagnostics) - result.ChainTrustAnchors, result.ChainTrustWarnings = collectConnectTrustStatus(result) + result.ChainTrustAnchors, result.ChainTrustWarnings = collectConnectTrustStatus(result, connectTrustStore) format := connectFormat if jsonOutput { @@ -447,7 +458,7 @@ func formatConnectChainPEM(chain []*x509.Certificate) string { return out.String() } -func collectConnectTrustStatus(result *certkit.ConnectResult) ([][]string, [][]string) { +func collectConnectTrustStatus(result *certkit.ConnectResult, trustStore string) ([][]string, [][]string) { if result.TrustPathStatus == certkit.ConnectTrustPathStatusPresentedInvalid && len(result.VerifiedChains) == 0 { anchors := make([][]string, len(result.PeerChain)) warnings := make([][]string, len(result.PeerChain)) @@ -466,6 +477,7 @@ func collectConnectTrustStatus(result *certkit.ConnectResult) ([][]string, [][]s trustResult := certkit.CheckTrustAnchors(certkit.CheckTrustAnchorsInput{ Cert: cert, Intermediates: intermediates, + TrustStore: trustStore, }) anchors = append(anchors, trustResult.Anchors) warnings = append(warnings, trustResult.Warnings) diff --git a/cmd/certkit/connect_test.go b/cmd/certkit/connect_test.go index da7b2fd..9aa778b 100644 --- a/cmd/certkit/connect_test.go +++ b/cmd/certkit/connect_test.go @@ -242,7 +242,7 @@ func TestCollectConnectTrustStatus_PresentedTrustPathInvalid(t *testing.T) { ChainTrustAnchors: nil, } - anchors, warnings := collectConnectTrustStatus(result) + anchors, warnings := collectConnectTrustStatus(result, "mozilla") if len(anchors) != 1 || len(anchors[0]) != 0 { t.Fatalf("anchors = %#v, want one empty entry", anchors) } diff --git a/cmd/certkit/inspect.go b/cmd/certkit/inspect.go index ad4a8ab..7260f76 100644 --- a/cmd/certkit/inspect.go +++ b/cmd/certkit/inspect.go @@ -11,6 +11,7 @@ import ( ) var inspectFormat string +var inspectTrustStore string var inspectAllowPrivateNetwork bool var inspectCmd = &cobra.Command{ @@ -26,12 +27,18 @@ var inspectCmd = &cobra.Command{ func init() { inspectCmd.Flags().StringVar(&inspectFormat, "format", "text", "Output format: text, json") + inspectCmd.Flags().StringVar(&inspectTrustStore, "trust-store", "mozilla", "Trust store: system, mozilla") inspectCmd.Flags().BoolVar(&inspectAllowPrivateNetwork, "allow-private-network", false, "Allow AIA fetches to private/internal endpoints") registerCompletion(inspectCmd, completionInput{"format", fixedCompletion("text", "json")}) + registerCompletion(inspectCmd, completionInput{"trust-store", fixedCompletion("system", "mozilla")}) } func runInspect(cmd *cobra.Command, args []string) error { + if err := validateSelectedTrustStore(inspectTrustStore); err != nil { + return fmt.Errorf("loading trust store: %w", err) + } + passwords, err := internal.ProcessPasswords(passwordList, passwordFile) if err != nil { return fmt.Errorf("loading passwords: %w", err) @@ -54,7 +61,7 @@ func runInspect(cmd *cobra.Command, args []string) error { slog.Warn("AIA resolution", "warning", w) } - if err := internal.AnnotateInspectTrust(results); err != nil { + if err := internal.AnnotateInspectTrust(results, inspectTrustStore); err != nil { return fmt.Errorf("annotating trust: %w", err) } diff --git a/cmd/certkit/readonly_commands_test.go b/cmd/certkit/readonly_commands_test.go index 13dfe2a..808fb29 100644 --- a/cmd/certkit/readonly_commands_test.go +++ b/cmd/certkit/readonly_commands_test.go @@ -16,6 +16,7 @@ import ( "sync" "testing" + "github.com/sensiblebit/certkit/internal" "github.com/spf13/cobra" ) @@ -23,6 +24,8 @@ import ( // globals so each command under test sees a stable flag snapshot. var readonlyGlobalsMu sync.Mutex +var errValidationTrustLoaderCalled = errors.New("validation trust loader should not be called") + type readonlyGlobals struct { // root flags jsonOutput bool @@ -34,6 +37,7 @@ type readonlyGlobals struct { // scan flags scanBundlePath string scanConfigPath string + scanTrustStore string scanForceExport bool scanDuplicates bool scanDumpKeys string @@ -47,6 +51,7 @@ type readonlyGlobals struct { // connect flags connectServerName string connectFormat string + connectTrustStore string connectCRL bool connectNoOCSP bool connectCiphers bool @@ -62,6 +67,7 @@ type readonlyGlobals struct { // verify flags verifyKeyPath string verifyRootsPath string + verifyTrustStore string verifyExpiry string verifyFormat string verifyDiagnose bool @@ -71,6 +77,7 @@ type readonlyGlobals struct { // inspect flags inspectFormat string + inspectTrustStore string inspectAllowPrivateNetwork bool // tree flags @@ -90,6 +97,7 @@ func snapshotReadonlyGlobals() readonlyGlobals { scanBundlePath: scanBundlePath, scanConfigPath: scanConfigPath, + scanTrustStore: scanTrustStore, scanForceExport: scanForceExport, scanDuplicates: scanDuplicates, scanDumpKeys: scanDumpKeys, @@ -102,6 +110,7 @@ func snapshotReadonlyGlobals() readonlyGlobals { connectServerName: connectServerName, connectFormat: connectFormat, + connectTrustStore: connectTrustStore, connectCRL: connectCRL, connectNoOCSP: connectNoOCSP, connectCiphers: connectCiphers, @@ -115,6 +124,7 @@ func snapshotReadonlyGlobals() readonlyGlobals { verifyKeyPath: verifyKeyPath, verifyRootsPath: verifyRootsPath, + verifyTrustStore: verifyTrustStore, verifyExpiry: verifyExpiry, verifyFormat: verifyFormat, verifyDiagnose: verifyDiagnose, @@ -123,6 +133,7 @@ func snapshotReadonlyGlobals() readonlyGlobals { verifyAllowPrivateNetwork: verifyAllowPrivateNetwork, inspectFormat: inspectFormat, + inspectTrustStore: inspectTrustStore, inspectAllowPrivateNetwork: inspectAllowPrivateNetwork, treeIncludeFlags: treeIncludeFlags, @@ -139,6 +150,7 @@ func restoreReadonlyGlobals(g readonlyGlobals) { scanBundlePath = g.scanBundlePath scanConfigPath = g.scanConfigPath + scanTrustStore = g.scanTrustStore scanForceExport = g.scanForceExport scanDuplicates = g.scanDuplicates scanDumpKeys = g.scanDumpKeys @@ -151,6 +163,7 @@ func restoreReadonlyGlobals(g readonlyGlobals) { connectServerName = g.connectServerName connectFormat = g.connectFormat + connectTrustStore = g.connectTrustStore connectCRL = g.connectCRL connectNoOCSP = g.connectNoOCSP connectCiphers = g.connectCiphers @@ -164,6 +177,7 @@ func restoreReadonlyGlobals(g readonlyGlobals) { verifyKeyPath = g.verifyKeyPath verifyRootsPath = g.verifyRootsPath + verifyTrustStore = g.verifyTrustStore verifyExpiry = g.verifyExpiry verifyFormat = g.verifyFormat verifyDiagnose = g.verifyDiagnose @@ -172,6 +186,7 @@ func restoreReadonlyGlobals(g readonlyGlobals) { verifyAllowPrivateNetwork = g.verifyAllowPrivateNetwork inspectFormat = g.inspectFormat + inspectTrustStore = g.inspectTrustStore inspectAllowPrivateNetwork = g.inspectAllowPrivateNetwork treeIncludeFlags = g.treeIncludeFlags @@ -327,6 +342,199 @@ func TestRunScan_CommandSurface(t *testing.T) { } } +func TestRunScan_JSONSummaryUsesMozillaTrustOnly(t *testing.T) { + snap := snapshotReadonlyGlobals() + defer restoreReadonlyGlobals(snap) + + origSummaryLoader := scanSummaryTrustPoolLoader + t.Cleanup(func() { + scanSummaryTrustPoolLoader = origSummaryLoader + }) + + dir := t.TempDir() + caKey, caCert := generateKeyAndCert(t, "System Root", true) + _, leaf := signCert(t, "scan.example.com", false, caKey, caCert) + writeCertPEM(t, dir, "leaf.pem", leaf) + + mozillaPool := x509.NewCertPool() + systemPool := x509.NewCertPool() + systemPool.AddCert(caCert) + scanSummaryTrustPoolLoader = func(trustStore string) (scanTrustPools, error) { + switch trustStore { + case "system": + return scanTrustPools{System: systemPool}, nil + default: + return scanTrustPools{Mozilla: mozillaPool}, nil + } + } + + passwordList = nil + passwordFile = "" + verbose = false + jsonOutput = true + scanBundlePath = "" + scanTrustStore = "mozilla" + scanConfigPath = filepath.Join(dir, "missing-config.yaml") + scanForceExport = false + scanDuplicates = false + scanDumpKeys = "" + scanDumpCerts = "" + scanMaxFileSize = 10 * 1024 * 1024 + scanFormat = "json" + scanAllowPrivateNetwork = false + scanSaveDB = "" + scanLoadDB = "" + + stdout, stderr, err := captureOutput(t, func() error { return runScan(newCommandWithContext(), []string{dir}) }) + if err != nil { + t.Fatalf("runScan json failed: %v", err) + } + if stderr != "" { + t.Fatalf("scan json wrote unexpected stderr:\n%s", stderr) + } + + var payload map[string]any + if err := json.Unmarshal([]byte(stdout), &payload); err != nil { + t.Fatalf("scan json unmarshal: %v\noutput:\n%s", err, stdout) + } + + systemTrustedLeaves, ok := payload["system_trusted_leaves"].(float64) + if !ok { + t.Fatalf("scan json missing system_trusted_leaves: %v", payload) + } + if systemTrustedLeaves != 0 { + t.Fatalf("system_trusted_leaves = %v, want 0", systemTrustedLeaves) + } + + untrustedLeaves, ok := payload["untrusted_leaves"].(float64) + if !ok { + t.Fatalf("scan json missing untrusted_leaves: %v", payload) + } + if untrustedLeaves != 1 { + t.Fatalf("untrusted_leaves = %v, want 1", untrustedLeaves) + } +} + +func TestRunScan_DumpCertsForceSkipsTrustLoading(t *testing.T) { + snap := snapshotReadonlyGlobals() + defer restoreReadonlyGlobals(snap) + + origValidationLoader := scanValidationTrustPoolLoader + origSummaryLoader := scanSummaryTrustPoolLoader + t.Cleanup(func() { + scanValidationTrustPoolLoader = origValidationLoader + scanSummaryTrustPoolLoader = origSummaryLoader + }) + + dir := t.TempDir() + _, cert := generateKeyAndCert(t, "scan-force.example.com", false) + writeCertPEM(t, dir, "leaf.pem", cert) + dumpPath := filepath.Join(dir, "dump.pem") + + scanValidationTrustPoolLoader = func(string) (scanTrustPools, error) { + return scanTrustPools{}, errValidationTrustLoaderCalled + } + scanSummaryTrustPoolLoader = func(string) (scanTrustPools, error) { + return scanTrustPools{}, nil + } + + passwordList = nil + passwordFile = "" + verbose = false + jsonOutput = false + scanBundlePath = "" + scanTrustStore = "system" + scanConfigPath = filepath.Join(dir, "missing-config.yaml") + scanForceExport = true + scanDuplicates = false + scanDumpKeys = "" + scanDumpCerts = dumpPath + scanMaxFileSize = 10 * 1024 * 1024 + scanFormat = "text" + scanAllowPrivateNetwork = false + scanSaveDB = "" + scanLoadDB = "" + + stdout, stderr, err := captureOutput(t, func() error { return runScan(newCommandWithContext(), []string{dir}) }) + if err != nil { + t.Fatalf("runScan force dump-certs failed: %v", err) + } + if !strings.Contains(stdout, "Found 1 certificate(s)") { + t.Fatalf("scan text output missing summary:\n%s", stdout) + } + if stderr != "" { + t.Fatalf("scan text wrote unexpected stderr:\n%s", stderr) + } + + //nolint:gosec // Test reads from a tempdir path controlled entirely by the test. + data, err := os.ReadFile(dumpPath) + if err != nil { + t.Fatalf("reading dumped certs: %v", err) + } + if !strings.Contains(string(data), "BEGIN CERTIFICATE") { + t.Fatalf("dumped cert file missing PEM data:\n%s", string(data)) + } +} + +func TestRunScan_ExportEnablesSystemFallback(t *testing.T) { + snap := snapshotReadonlyGlobals() + defer restoreReadonlyGlobals(snap) + + origSummaryLoader := scanSummaryTrustPoolLoader + origExportBundles := scanExportBundles + t.Cleanup(func() { + scanSummaryTrustPoolLoader = origSummaryLoader + scanExportBundles = origExportBundles + }) + + dir := t.TempDir() + _, cert := generateKeyAndCert(t, "scan-export.example.com", false) + writeCertPEM(t, dir, "leaf.pem", cert) + + configPath := filepath.Join(dir, "bundles.yaml") + configData := []byte("bundles:\n - bundleName: scan-export\n commonNames:\n - scan-export.example.com\n") + if err := os.WriteFile(configPath, configData, 0o600); err != nil { + t.Fatalf("write bundle config: %v", err) + } + + var gotFallback bool + scanSummaryTrustPoolLoader = func(string) (scanTrustPools, error) { + return scanTrustPools{}, nil + } + scanExportBundles = func(_ context.Context, input internal.ExportBundlesInput) error { + gotFallback = input.AllowSystemFallback + return nil + } + + passwordList = nil + passwordFile = "" + verbose = false + jsonOutput = false + scanBundlePath = filepath.Join(dir, "out") + scanConfigPath = configPath + scanTrustStore = "mozilla" + scanForceExport = false + scanDuplicates = false + scanDumpKeys = "" + scanDumpCerts = "" + scanMaxFileSize = 10 * 1024 * 1024 + scanFormat = "text" + scanAllowPrivateNetwork = false + scanSaveDB = "" + scanLoadDB = "" + + _, stderr, err := captureOutput(t, func() error { return runScan(newCommandWithContext(), []string{dir}) }) + if err != nil { + t.Fatalf("runScan export failed: %v", err) + } + if !gotFallback { + t.Fatal("scan export did not enable system fallback") + } + if !strings.Contains(stderr, "Exported bundles to") { + t.Fatalf("scan export stderr missing export status:\n%s", stderr) + } +} + func TestRunInspect_CommandSurface(t *testing.T) { snap := snapshotReadonlyGlobals() defer restoreReadonlyGlobals(snap) @@ -370,6 +578,37 @@ func TestRunInspect_CommandSurface(t *testing.T) { } } +func TestRunInspect_InvalidTrustStoreFailsFast(t *testing.T) { + snap := snapshotReadonlyGlobals() + defer restoreReadonlyGlobals(snap) + + dir := t.TempDir() + _, cert := generateKeyAndCert(t, "inspect-invalid.example.com", false) + certPath := writeCertPEM(t, dir, "inspect.pem", cert) + + passwordList = nil + passwordFile = "" + allowExpired = true + jsonOutput = false + inspectFormat = "text" + inspectTrustStore = "garbage" + inspectAllowPrivateNetwork = false + + stdout, stderr, err := captureOutput(t, func() error { return runInspect(newCommandWithContext(), []string{certPath}) }) + if err == nil { + t.Fatal("runInspect expected trust-store error") + } + if !strings.Contains(err.Error(), "loading trust store: unsupported trust store") { + t.Fatalf("runInspect error = %v, want trust-store load failure", err) + } + if stdout != "" { + t.Fatalf("inspect invalid trust store wrote unexpected stdout:\n%s", stdout) + } + if stderr != "" { + t.Fatalf("inspect invalid trust store wrote unexpected stderr:\n%s", stderr) + } +} + func TestRunVerify_CommandSurfaceValidation(t *testing.T) { snap := snapshotReadonlyGlobals() defer restoreReadonlyGlobals(snap) @@ -427,6 +666,43 @@ func TestRunVerify_CommandSurfaceValidation(t *testing.T) { } } +func TestRunVerify_InvalidTrustStoreFailsFast(t *testing.T) { + snap := snapshotReadonlyGlobals() + defer restoreReadonlyGlobals(snap) + + dir := t.TempDir() + _, cert := generateKeyAndCert(t, "verify-invalid.example.com", false) + certPath := writeCertPEM(t, dir, "verify.pem", cert) + + passwordList = nil + passwordFile = "" + allowExpired = true + jsonOutput = false + verifyFormat = "text" + verifyKeyPath = "" + verifyRootsPath = "" + verifyTrustStore = "garbage" + verifyExpiry = "" + verifyDiagnose = false + verifyOCSP = false + verifyCRL = false + verifyAllowPrivateNetwork = false + + stdout, stderr, err := captureOutput(t, func() error { return runVerify(newCommandWithContext(), []string{certPath}) }) + if err == nil { + t.Fatal("runVerify expected trust-store error") + } + if !strings.Contains(err.Error(), "loading trust store: unsupported trust store") { + t.Fatalf("runVerify error = %v, want trust-store load failure", err) + } + if stdout != "" { + t.Fatalf("verify invalid trust store wrote unexpected stdout:\n%s", stdout) + } + if stderr != "" { + t.Fatalf("verify invalid trust store wrote unexpected stderr:\n%s", stderr) + } +} + func TestRunConnect_CommandSurfaceValidation(t *testing.T) { snap := snapshotReadonlyGlobals() defer restoreReadonlyGlobals(snap) diff --git a/cmd/certkit/scan.go b/cmd/certkit/scan.go index 14ce3f7..0e7e06a 100644 --- a/cmd/certkit/scan.go +++ b/cmd/certkit/scan.go @@ -29,6 +29,7 @@ var ( scanSaveDB string scanConfigPath string scanBundlePath string + scanTrustStore string scanForceExport bool scanDuplicates bool scanDumpKeys string @@ -41,6 +42,7 @@ var ( errScanAIARedirects = errors.New("AIA redirect limit exceeded") errScanAIAHTTPStatus = errors.New("AIA server returned non-200 status") errScanAIAResponseLarge = errors.New("AIA response exceeds size limit") + scanExportBundles = internal.ExportBundles ) var scanCmd = &cobra.Command{ @@ -58,6 +60,7 @@ var scanCmd = &cobra.Command{ func init() { scanCmd.Flags().StringVar(&scanBundlePath, "bundle-path", "", "Export bundles to this directory") scanCmd.Flags().StringVarP(&scanConfigPath, "config", "c", "./bundles.yaml", "Path to bundle config YAML") + scanCmd.Flags().StringVar(&scanTrustStore, "trust-store", "mozilla", "Trust store: system, mozilla") scanCmd.Flags().BoolVarP(&scanForceExport, "force", "f", false, "Allow export of untrusted certificate bundles") scanCmd.Flags().BoolVar(&scanDuplicates, "duplicates", false, "Export all certificates per bundle, not just the newest") scanCmd.Flags().StringVar(&scanDumpKeys, "dump-keys", "", "Dump all discovered keys to a single PEM file") @@ -70,6 +73,7 @@ func init() { scanCmd.Flags().StringVar(&scanLoadDB, "load-db", "", "Load an existing database into memory before scanning") registerCompletion(scanCmd, completionInput{"format", fixedCompletion("text", "json")}) + registerCompletion(scanCmd, completionInput{"trust-store", fixedCompletion("system", "mozilla")}) registerCompletion(scanCmd, completionInput{"bundle-path", directoryCompletion}) } @@ -275,15 +279,19 @@ func runScan(cmd *cobra.Command, args []string) error { if scanDumpCerts != "" { certs := store.AllCertsFlat() if len(certs) > 0 { - trustPools, err := loadScanTrustPools() - if err != nil { - return err + trustPools := scanTrustPools{} + var intermediatePool *x509.CertPool + if !scanForceExport { + var err error + trustPools, err = scanValidationTrustPoolLoader(scanTrustStore) + if err != nil { + return err + } + intermediatePool = store.IntermediatePool() } - intermediatePool := store.IntermediatePool() - // Pre-compute trust status concurrently. Mozilla checks are pure Go - // and fast; system checks hit macOS SecTrust which is slow. Run - // mozilla first, then only check system for untrusted remainders. + // Pre-compute trust status concurrently against the selected trust + // source so export-time filtering does not re-verify each cert. type dumpTrust struct { mozilla bool system bool @@ -393,14 +401,16 @@ func runScan(cmd *cobra.Command, args []string) error { if err := os.MkdirAll(scanBundlePath, 0o755); err != nil { return fmt.Errorf("creating output directory %s: %w", scanBundlePath, err) } - if err := internal.ExportBundles(cmd.Context(), internal.ExportBundlesInput{ - Configs: bundleConfigs, - OutDir: scanBundlePath, - Store: store, - ForceBundle: scanForceExport, - Duplicates: scanDuplicates, - P12Password: p12Password, - EncryptKey: len(passwordSets.Export) > 0, + if err := scanExportBundles(cmd.Context(), internal.ExportBundlesInput{ + Configs: bundleConfigs, + OutDir: scanBundlePath, + Store: store, + TrustStore: scanTrustStore, + ForceBundle: scanForceExport, + Duplicates: scanDuplicates, + P12Password: p12Password, + AllowSystemFallback: true, + EncryptKey: len(passwordSets.Export) > 0, }); err != nil { return fmt.Errorf("exporting bundles: %w", err) } @@ -410,7 +420,7 @@ func runScan(cmd *cobra.Command, args []string) error { store.DumpDebug() switch format { case "json": - trustPools, err := loadScanTrustPools() + trustPools, err := scanSummaryTrustPoolLoader(scanTrustStore) if err != nil { return err } @@ -428,7 +438,7 @@ func runScan(cmd *cobra.Command, args []string) error { } fmt.Println(string(data)) case "text": - trustPools, err := loadScanTrustPools() + trustPools, err := scanSummaryTrustPoolLoader(scanTrustStore) if err != nil { return err } @@ -463,7 +473,7 @@ func runScan(cmd *cobra.Command, args []string) error { } } // Print summary with trust and expiry annotations - trustPools, err := loadScanTrustPools() + trustPools, err := scanSummaryTrustPoolLoader(scanTrustStore) if err != nil { return err } @@ -528,19 +538,23 @@ type scanTrustPools struct { System *x509.CertPool } -func loadScanTrustPools() (scanTrustPools, error) { - mozillaPool, err := certkit.MozillaRootPool() +var ( + scanValidationTrustPoolLoader = loadScanTrustPools + scanSummaryTrustPoolLoader = loadScanTrustPools +) + +func loadScanTrustPools(trustStore string) (scanTrustPools, error) { + pool, err := loadSelectedTrustPool(trustStore) if err != nil { - return scanTrustPools{}, fmt.Errorf("loading Mozilla root pool: %w", err) + return scanTrustPools{}, fmt.Errorf("loading scan trust pool: %w", err) } - systemPool, err := certkit.SystemCertPoolCached() - if err != nil { - slog.Debug("system cert pool unavailable for scan trust summary", "error", err) + + switch trustStore { + case "system": + return scanTrustPools{System: pool}, nil + default: + return scanTrustPools{Mozilla: pool}, nil } - return scanTrustPools{ - Mozilla: mozillaPool, - System: systemPool, - }, nil } // scanCertEntry holds per-certificate details for verbose scan output. diff --git a/cmd/certkit/truststore.go b/cmd/certkit/truststore.go new file mode 100644 index 0000000..05dcda8 --- /dev/null +++ b/cmd/certkit/truststore.go @@ -0,0 +1,48 @@ +package main + +import ( + "crypto/x509" + "errors" + "fmt" + + "github.com/sensiblebit/certkit" +) + +var errUnsupportedTrustStore = errors.New("unsupported trust store") + +var ( + loadMozillaRootPool = certkit.MozillaRootPool + loadSystemCertPool = certkit.SystemCertPoolCached +) + +func validateSelectedTrustStore(trustStore string) error { + switch trustStore { + case "", "mozilla", "system": + return nil + default: + return fmt.Errorf("%w: %q", errUnsupportedTrustStore, trustStore) + } +} + +func loadSelectedTrustPool(trustStore string) (*x509.CertPool, error) { + if err := validateSelectedTrustStore(trustStore); err != nil { + return nil, err + } + + switch trustStore { + case "", "mozilla": + pool, err := loadMozillaRootPool() + if err != nil { + return nil, fmt.Errorf("loading mozilla root pool: %w", err) + } + return pool, nil + case "system": + pool, err := loadSystemCertPool() + if err != nil { + return nil, fmt.Errorf("loading system cert pool: %w", err) + } + return pool, nil + default: + return nil, fmt.Errorf("%w: %q", errUnsupportedTrustStore, trustStore) + } +} diff --git a/cmd/certkit/verify.go b/cmd/certkit/verify.go index 3c5c3ae..33a3c69 100644 --- a/cmd/certkit/verify.go +++ b/cmd/certkit/verify.go @@ -18,6 +18,7 @@ import ( var ( verifyKeyPath string verifyRootsPath string + verifyTrustStore string verifyExpiry string verifyFormat string verifyDiagnose bool @@ -34,11 +35,12 @@ var verifyCmd = &cobra.Command{ Long: `Verify a certificate's chain of trust, check if a key matches, or check if it expires within a given duration. -Accepts PEM, DER, PKCS#12, JKS, or PKCS#7 input. The chain is always verified -against both the embedded Mozilla roots and the host system trust store. Use ---roots to add a file-backed trust source. When the input contains an embedded -private key (PKCS#12, JKS), the key match is checked automatically. Use --key -to check against an external key file. +Accepts PEM, DER, PKCS#12, JKS, or PKCS#7 input. The chain is verified against +Mozilla roots by default; use --trust-store system to use the host trust store. +Use --roots to add a file-backed trust source alongside the selected trust +store. When the input contains an embedded private key (PKCS#12, JKS), the key +match is checked automatically. Use --key to check against an external key +file. Use --ocsp to check OCSP revocation status, and --crl to check CRL distribution points. Both require network access and a valid chain (the issuer certificate @@ -61,6 +63,7 @@ Exits with code 2 if verification finds any errors (including revocation).`, func init() { verifyCmd.Flags().StringVar(&verifyKeyPath, "key", "", "Private key file to check against the certificate") verifyCmd.Flags().StringVar(&verifyRootsPath, "roots", "", "Additional root certificates file (PEM, DER, PKCS#7, PKCS#12, or JKS)") + verifyCmd.Flags().StringVar(&verifyTrustStore, "trust-store", "mozilla", "Trust store: system, mozilla") verifyCmd.Flags().StringVarP(&verifyExpiry, "expiry", "e", "", "Check if cert expires within duration (e.g., 30d, 720h)") verifyCmd.Flags().StringVar(&verifyFormat, "format", "text", "Output format: text, json") verifyCmd.Flags().BoolVar(&verifyDiagnose, "diagnose", false, "Show diagnostics when chain verification fails") @@ -69,6 +72,7 @@ func init() { verifyCmd.Flags().BoolVar(&verifyAllowPrivateNetwork, "allow-private-network", false, "Allow AIA/OCSP/CRL fetches to private/internal endpoints") registerCompletion(verifyCmd, completionInput{"format", fixedCompletion("text", "json")}) + registerCompletion(verifyCmd, completionInput{"trust-store", fixedCompletion("system", "mozilla")}) } // parseDuration extends time.ParseDuration to support a "d" suffix for days. @@ -97,6 +101,10 @@ func runVerify(cmd *cobra.Command, args []string) error { } } + if err := validateSelectedTrustStore(verifyTrustStore); err != nil { + return fmt.Errorf("loading trust store: %w", err) + } + passwords, err := internal.ProcessPasswords(passwordList, passwordFile) if err != nil { return fmt.Errorf("loading passwords: %w", err) @@ -153,6 +161,7 @@ func runVerify(cmd *cobra.Command, args []string) error { CheckKeyMatch: key != nil, CheckChain: true, // Always verify chain ExpiryDuration: expiryDuration, + TrustStore: verifyTrustStore, Verbose: verbose, CheckOCSP: verifyOCSP, CheckCRL: verifyCRL, diff --git a/cmd/certkit/verify_test.go b/cmd/certkit/verify_test.go index e9015f7..62e5cc2 100644 --- a/cmd/certkit/verify_test.go +++ b/cmd/certkit/verify_test.go @@ -1,6 +1,7 @@ package main import ( + "crypto/x509" "errors" "os" "path/filepath" @@ -11,6 +12,8 @@ import ( "github.com/sensiblebit/certkit" ) +var errInjectedSystemTrustLoadFailure = errors.New("injected system trust load failure") + func TestParseDuration(t *testing.T) { t.Parallel() @@ -195,6 +198,60 @@ func TestRunVerify_AllowsLeafTrustAnchorInRootsFile(t *testing.T) { } } +func TestRunVerify_FileRootsStillWorkWhenTrustStorePreloadWouldFail(t *testing.T) { + // Serial: runVerify mutates package-level Cobra flag globals guarded by + // snapshotReadonlyGlobals/restoreReadonlyGlobals. + snap := snapshotReadonlyGlobals() + defer restoreReadonlyGlobals(snap) + + origSystemLoader := loadSystemCertPool + t.Cleanup(func() { + loadSystemCertPool = origSystemLoader + }) + loadSystemCertPool = func() (*x509.CertPool, error) { + return nil, errInjectedSystemTrustLoadFailure + } + + dir := t.TempDir() + rootKey, rootCert := generateKeyAndCert(t, "Verify Root", true) + _, leafCert := signCert(t, "verify-file-roots.example.com", false, rootKey, rootCert) + + certPath := filepath.Join(dir, "leaf.pem") + if err := os.WriteFile(certPath, []byte(certkit.CertToPEM(leafCert)), 0o600); err != nil { + t.Fatal(err) + } + + rootsPath := filepath.Join(dir, "roots.pem") + if err := os.WriteFile(rootsPath, []byte(certkit.CertToPEM(rootCert)), 0o600); err != nil { + t.Fatal(err) + } + + passwordList = nil + passwordFile = "" + allowExpired = true + jsonOutput = false + verifyFormat = "text" + verifyKeyPath = "" + verifyRootsPath = rootsPath + verifyTrustStore = "system" + verifyExpiry = "" + verifyDiagnose = false + verifyOCSP = false + verifyCRL = false + verifyAllowPrivateNetwork = false + + stdout, stderr, err := captureOutput(t, func() error { return runVerify(newCommandWithContext(), []string{certPath}) }) + if err != nil { + t.Fatalf("runVerify with file roots failed: %v\nstdout:\n%s\nstderr:\n%s", err, stdout, stderr) + } + if !strings.Contains(stdout, "Verification OK") { + t.Fatalf("verify output missing success summary:\n%s", stdout) + } + if stderr != "" { + t.Fatalf("verify wrote unexpected stderr:\n%s", stderr) + } +} + func TestRunVerify_UnsupportedFormat(t *testing.T) { // Serial: runVerify mutates package-level Cobra flag globals guarded by // snapshotReadonlyGlobals/restoreReadonlyGlobals. diff --git a/cmd/wasm/inspect.go b/cmd/wasm/inspect.go index cc0dfa1..d7aed0d 100644 --- a/cmd/wasm/inspect.go +++ b/cmd/wasm/inspect.go @@ -118,7 +118,7 @@ func inspectFiles(_ js.Value, args []js.Value) any { } // Annotate trust for certificates. - if err := internal.AnnotateInspectTrust(allResults); err != nil { + if err := internal.AnnotateInspectTrust(allResults, "mozilla"); err != nil { slog.Debug("trust annotation failed", "error", err) } diff --git a/go.mod b/go.mod index c5536af..d90e62b 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/sensiblebit/certkit go 1.26 require ( - github.com/breml/rootcerts v0.3.4 + github.com/breml/rootcerts v0.3.5 github.com/google/certificate-transparency-go v1.3.3 github.com/jmoiron/sqlx v1.4.0 github.com/mattn/go-isatty v0.0.21 @@ -14,7 +14,7 @@ require ( golang.org/x/crypto v0.50.0 golang.org/x/sys v0.43.0 gopkg.in/yaml.v3 v3.0.1 - modernc.org/sqlite v1.48.2 + modernc.org/sqlite v1.49.1 software.sslmate.com/src/go-pkcs12 v0.7.1 ) diff --git a/go.sum b/go.sum index e9fb942..305654b 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,8 @@ filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw= filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= -github.com/breml/rootcerts v0.3.4 h1:9i7WNl/ctd9OEAOaTfLy//Wrlfxq/tRQ7v4okYFN9Ys= -github.com/breml/rootcerts v0.3.4/go.mod h1:S/PKh+4d1HUn4HQovEB8hPJZO6pUZYrIhmXBhsegfXw= +github.com/breml/rootcerts v0.3.5 h1:oi7YiZ25HH52+mrKyjrMkcAFfnRDUf6HO8aUDr7RlJI= +github.com/breml/rootcerts v0.3.5/go.mod h1:S/PKh+4d1HUn4HQovEB8hPJZO6pUZYrIhmXBhsegfXw= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -166,8 +166,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.48.2 h1:5CnW4uP8joZtA0LedVqLbZV5GD7F/0x91AXeSyjoh5c= -modernc.org/sqlite v1.48.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= +modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U= +modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= diff --git a/internal/certstore/export.go b/internal/certstore/export.go index 65878a1..da2d14b 100644 --- a/internal/certstore/export.go +++ b/internal/certstore/export.go @@ -336,6 +336,7 @@ func GenerateYAML(bundle *certkit.BundleResult, keyPEM []byte, keyType string, b // override or the existing certificate's subject fields. func buildCSRSubject(cert *x509.Certificate, override *CSRSubjectOverride) pkix.Name { subj := pkix.Name{ + CommonName: cert.Subject.CommonName, StreetAddress: cert.Subject.StreetAddress, PostalCode: cert.Subject.PostalCode, SerialNumber: cert.Subject.SerialNumber, @@ -372,6 +373,7 @@ type BundleWriter interface { type ExportMatchedBundleInput struct { Store *MemStore SKIs []string // matched-pair SKIs to export + Certs []*CertRecord BundleOpts certkit.BundleOptions Writer BundleWriter CSRSubject *CSRSubjectOverride // optional; nil uses cert's own subject @@ -388,8 +390,9 @@ func ExportMatchedBundles(ctx context.Context, input ExportMatchedBundleInput) e opts := input.BundleOpts opts.ExtraIntermediates = slices.Concat(opts.ExtraIntermediates, intermediates) - for _, ski := range input.SKIs { - certRec := input.Store.GetCert(ski) + for _, selection := range exportCertSelections(input) { + ski := selection.ski + certRec := selection.cert keyRec := input.Store.GetKey(ski) if certRec == nil || keyRec == nil { slog.Debug("skipping export entry without cert or key", "ski", ski) @@ -464,6 +467,31 @@ func ExportMatchedBundles(ctx context.Context, input ExportMatchedBundleInput) e return nil } +type exportCertSelection struct { + ski string + cert *CertRecord +} + +func exportCertSelections(input ExportMatchedBundleInput) []exportCertSelection { + if len(input.Certs) > 0 { + selections := make([]exportCertSelection, 0, len(input.Certs)) + for _, certRec := range input.Certs { + ski := "" + if certRec != nil { + ski = certRec.SKI + } + selections = append(selections, exportCertSelection{ski: ski, cert: certRec}) + } + return selections + } + + selections := make([]exportCertSelection, 0, len(input.SKIs)) + for _, ski := range input.SKIs { + selections = append(selections, exportCertSelection{ski: ski, cert: input.Store.GetCert(ski)}) + } + return selections +} + // GenerateCSR creates a CSR using the certificate's details and private key. // The subject parameter optionally overrides the certificate's subject fields. func GenerateCSR(leaf *x509.Certificate, keyPEM []byte, subject *CSRSubjectOverride) (csrPEM []byte, csrJSON []byte, err error) { @@ -527,6 +555,7 @@ func GenerateCSR(leaf *x509.Certificate, keyPEM []byte, subject *CSRSubjectOverr csrDetails := map[string]any{ "subject": map[string]any{ + "common_name": parsedCSR.Subject.CommonName, "country": parsedCSR.Subject.Country, "province": parsedCSR.Subject.Province, "locality": parsedCSR.Subject.Locality, diff --git a/internal/certstore/export_test.go b/internal/certstore/export_test.go index 82979a3..188094d 100644 --- a/internal/certstore/export_test.go +++ b/internal/certstore/export_test.go @@ -1,12 +1,16 @@ package certstore import ( + "crypto/rand" + "crypto/rsa" "crypto/x509" + "crypto/x509/pkix" "encoding/base64" "encoding/json" "encoding/pem" "errors" "fmt" + "math/big" "net" "strings" "testing" @@ -750,6 +754,9 @@ func TestGenerateCSR_RoundTrip(t *testing.T) { } // Verify subject fields are copied from cert + if csr.Subject.CommonName != "csr.example.com" { + t.Errorf("CSR common name = %q, want %q", csr.Subject.CommonName, "csr.example.com") + } if len(csr.Subject.Organization) != 1 || csr.Subject.Organization[0] != "TestOrg" { t.Errorf("CSR organization = %v, want [TestOrg]", csr.Subject.Organization) } @@ -770,6 +777,13 @@ func TestGenerateCSR_RoundTrip(t *testing.T) { if err := json.Unmarshal(csrJSON, &jsonResult); err != nil { t.Fatalf("CSR JSON is not valid: %v", err) } + subjectRaw, ok := jsonResult["subject"].(map[string]any) + if !ok { + t.Fatal("CSR JSON subject is not an object") + } + if commonName, ok := subjectRaw["common_name"].(string); !ok || commonName != "csr.example.com" { + t.Errorf("CSR JSON subject.common_name = %v, want %q", subjectRaw["common_name"], "csr.example.com") + } // dns_names content must match the certificate SANs dnsRaw, ok := jsonResult["dns_names"].([]any) @@ -887,8 +901,9 @@ func TestGenerateCSR_SANExclusion(t *testing.T) { } func TestGenerateCSR_SubjectOverride(t *testing.T) { - // WHY: Verifies that CSRSubjectOverride replaces (not merges with) the - // certificate's own subject fields, and that OU defaults to "None" when empty. + // WHY: Verifies that CSRSubjectOverride replaces the configurable subject + // fields while preserving the certificate CN, and that OU defaults to + // "None" when empty. t.Parallel() ca := newRSACA(t) @@ -916,6 +931,9 @@ func TestGenerateCSR_SubjectOverride(t *testing.T) { if len(csr.Subject.Organization) != 1 || csr.Subject.Organization[0] != "New Org" { t.Errorf("organization = %v, want [New Org]", csr.Subject.Organization) } + if csr.Subject.CommonName != "sub.example.com" { + t.Errorf("common_name = %q, want %q", csr.Subject.CommonName, "sub.example.com") + } // OU should default to "None" since override didn't set it if len(csr.Subject.OrganizationalUnit) != 1 || csr.Subject.OrganizationalUnit[0] != "None" { t.Errorf("organizational_unit = %v, want [None]", csr.Subject.OrganizationalUnit) @@ -1476,6 +1494,98 @@ func TestExportMatchedBundles_WriterErrorFailsFast(t *testing.T) { } } +func TestExportMatchedBundles_UsesExactCertRecordWhenProvided(t *testing.T) { + t.Parallel() + + ca := newRSACA(t) + matchedLeaf := newRSALeaf(t, ca, "*.dns-stg.zimperium.com", []string{"*.dns-stg.zimperium.com", "dns-stg.zimperium.com"}) + matchedKey, ok := matchedLeaf.key.(*rsa.PrivateKey) + if !ok { + t.Fatal("matched leaf key is not RSA") + } + + newerTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(200), + Subject: pkix.Name{ + CommonName: "*.zimperium.com", + Organization: []string{"TestOrg"}, + Country: []string{"US"}, + }, + DNSNames: []string{"*.zimperium.com", "zimperium.com"}, + NotBefore: time.Now().Add(-1 * time.Hour), + NotAfter: time.Now().Add(2 * 365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + AuthorityKeyId: ca.cert.SubjectKeyId, + } + newerDER, err := x509.CreateCertificate(rand.Reader, newerTemplate, ca.cert, &matchedKey.PublicKey, ca.key) + if err != nil { + t.Fatalf("create newer cert: %v", err) + } + newerCert, err := x509.ParseCertificate(newerDER) + if err != nil { + t.Fatalf("parse newer cert: %v", err) + } + + store := NewMemStore() + for _, cert := range []*x509.Certificate{matchedLeaf.cert, newerCert, ca.cert} { + if err := store.HandleCertificate(cert, "test"); err != nil { + t.Fatal(err) + } + } + if err := store.HandleKey(matchedLeaf.key, matchedLeaf.keyPEM, "test"); err != nil { + t.Fatal(err) + } + + var matchedRec *CertRecord + for _, rec := range store.AllCertsFlat() { + switch rec.Cert.Subject.CommonName { + case "*.dns-stg.zimperium.com": + rec.BundleName = "dnsstg-zimperium-tls" + matchedRec = rec + case "*.zimperium.com": + rec.BundleName = "" + } + } + if matchedRec == nil { + t.Fatal("expected matched cert record") + } + + var written []mockWriteCall + writer := &mockBundleWriter{calls: &written} + err = ExportMatchedBundles(t.Context(), ExportMatchedBundleInput{ + Store: store, + Certs: []*CertRecord{matchedRec}, + Writer: writer, + BundleOpts: certkit.BundleOptions{ + CustomRoots: []*x509.Certificate{ca.cert}, + TrustStore: "custom", + Verify: true, + }, + P12Password: "testpass", + }) + if err != nil { + t.Fatalf("ExportMatchedBundles: %v", err) + } + if len(written) != 1 { + t.Fatalf("expected 1 write call, got %d", len(written)) + } + if written[0].folder != "dnsstg-zimperium-tls" { + t.Fatalf("folder = %q, want %q", written[0].folder, "dnsstg-zimperium-tls") + } + + nameSet := make(map[string]bool, len(written[0].files)) + for _, file := range written[0].files { + nameSet[file.Name] = true + } + if !nameSet["_.dns-stg.zimperium.com.pem"] { + t.Fatalf("expected exact bundle file prefix in %v", written[0].files) + } + if nameSet["_.zimperium.com.pem"] { + t.Fatalf("did not expect newer same-SKI cert prefix in %v", written[0].files) + } +} + type mockWriteCall struct { folder string files []BundleFile diff --git a/internal/certstore/memstore.go b/internal/certstore/memstore.go index 350c85b..f227a19 100644 --- a/internal/certstore/memstore.go +++ b/internal/certstore/memstore.go @@ -182,6 +182,20 @@ func (s *MemStore) SetBundleName(ski, name string) { } } +// UpdateBundleName sets the bundle name for a specific certificate record. +func (s *MemStore) UpdateBundleName(rec *CertRecord, name string) { + if rec == nil || rec.Cert == nil { + return + } + + id := certID(rec.Cert) + s.mu.Lock() + defer s.mu.Unlock() + if current := s.certsByID[id]; current != nil { + current.BundleName = name + } +} + // GetCert returns the certificate record with the latest NotAfter for the // given SKI, or nil if not found. This preserves backward compatibility with // callers that expect a single cert per SKI. diff --git a/internal/exporter.go b/internal/exporter.go index cb5fc76..6e2449a 100644 --- a/internal/exporter.go +++ b/internal/exporter.go @@ -174,9 +174,13 @@ type ExportBundlesInput struct { Configs []BundleConfig OutDir string Store *certstore.MemStore + TrustStore string ForceBundle bool Duplicates bool P12Password string + // AllowSystemFallback retries unknown-authority bundle verification with the + // host system roots after a Mozilla trust failure. + AllowSystemFallback bool // EncryptKey when true encrypts .key PEM output using PKCS#8 v2. EncryptKey bool } @@ -189,20 +193,24 @@ func ExportBundles(ctx context.Context, input ExportBundlesInput) error { for _, bundleName := range bundleNames { opts := certkit.DefaultOptions() + if input.TrustStore != "" { + opts.TrustStore = input.TrustStore + } if input.ForceBundle { opts.Verify = false } if err := exportBundleCerts(ctx, exportBundleCertsInput{ - Store: input.Store, - Opts: opts, - Configs: input.Configs, - OutDir: input.OutDir, - BundleName: bundleName, - Duplicates: input.Duplicates, - P12Password: input.P12Password, - EncryptKey: input.EncryptKey, - UsedFolders: usedFolders, + Store: input.Store, + Opts: opts, + Configs: input.Configs, + OutDir: input.OutDir, + BundleName: bundleName, + Duplicates: input.Duplicates, + P12Password: input.P12Password, + AllowSystemFallback: input.AllowSystemFallback, + EncryptKey: input.EncryptKey, + UsedFolders: usedFolders, }); err != nil { return fmt.Errorf("exporting bundle %q: %w", bundleName, err) } @@ -211,13 +219,14 @@ func ExportBundles(ctx context.Context, input ExportBundlesInput) error { } type exportBundleCertsInput struct { - Store *certstore.MemStore - Opts certkit.BundleOptions - Configs []BundleConfig - OutDir string - BundleName string - Duplicates bool - P12Password string + Store *certstore.MemStore + Opts certkit.BundleOptions + Configs []BundleConfig + OutDir string + BundleName string + Duplicates bool + P12Password string + AllowSystemFallback bool // EncryptKey when true encrypts .key PEM output using PKCS#8 v2. EncryptKey bool UsedFolders map[string]string @@ -285,7 +294,7 @@ func exportBundleCerts(ctx context.Context, input exportBundleCertsInput) error exportInput := certstore.ExportMatchedBundleInput{ Store: input.Store, - SKIs: []string{certRec.SKI}, + Certs: []*certstore.CertRecord{certRec}, BundleOpts: input.Opts, Writer: &folderOverrideWriter{outDir: input.OutDir, folder: folder}, CSRSubject: csrSubject, @@ -293,7 +302,13 @@ func exportBundleCerts(ctx context.Context, input exportBundleCertsInput) error P12Password: input.P12Password, EncryptKey: input.EncryptKey, } - skipped, err := exportMatchedBundleWithSystemFallback(ctx, certRec.Cert.Subject.CommonName, exportInput, input.Opts, certstore.ExportMatchedBundles) + skipped, err := exportMatchedBundleWithSystemFallback(ctx, exportMatchedBundleWithSystemFallbackInput{ + CommonName: certRec.Cert.Subject.CommonName, + ExportInput: exportInput, + Opts: input.Opts, + AllowSystemFallback: input.AllowSystemFallback, + Export: certstore.ExportMatchedBundles, + }) if err != nil { return err } @@ -305,41 +320,51 @@ func exportBundleCerts(ctx context.Context, input exportBundleCertsInput) error return nil } +type exportMatchedBundleWithSystemFallbackInput struct { + CommonName string + ExportInput certstore.ExportMatchedBundleInput + Opts certkit.BundleOptions + AllowSystemFallback bool + Export func(context.Context, certstore.ExportMatchedBundleInput) error +} + func exportMatchedBundleWithSystemFallback( ctx context.Context, - commonName string, - exportInput certstore.ExportMatchedBundleInput, - opts certkit.BundleOptions, - exportFn func(context.Context, certstore.ExportMatchedBundleInput) error, + input exportMatchedBundleWithSystemFallbackInput, ) (bool, error) { - err := exportFn(ctx, exportInput) + err := input.Export(ctx, input.ExportInput) if err == nil { return false, nil } - // If mozilla verification failed, retry with system trust store so + // If mozilla verification failed due to an unknown authority, retry with system trust store so // certificates trusted only by the host OS (e.g. corporate keychain // roots) still export without requiring --force. - if opts.Verify && isBundleVerificationError(err) && opts.TrustStore != "system" { - slog.Debug("mozilla trust failed, retrying with system trust store", "cn", commonName) - systemOpts := opts + if input.AllowSystemFallback && input.Opts.Verify && shouldRetrySystemFallback(err) && input.Opts.TrustStore != "system" { + slog.Debug("mozilla trust failed, retrying with system trust store", "cn", input.CommonName) + systemOpts := input.Opts systemOpts.TrustStore = "system" - exportInput.BundleOpts = systemOpts - retryErr := exportFn(ctx, exportInput) + input.ExportInput.BundleOpts = systemOpts + retryErr := input.Export(ctx, input.ExportInput) if retryErr == nil { return false, nil } if !isBundleVerificationError(retryErr) { - return false, fmt.Errorf("exporting bundle for %q: %w", commonName, retryErr) + return false, fmt.Errorf("exporting bundle for %q: %w", input.CommonName, retryErr) } } - if opts.Verify && isBundleVerificationError(err) { - slog.Debug("skipping untrusted bundle candidate", "cn", commonName, "error", err) + if input.Opts.Verify && isBundleVerificationError(err) { + slog.Debug("skipping untrusted bundle candidate", "cn", input.CommonName, "error", err) return true, nil } - return false, fmt.Errorf("exporting bundle for %q: %w", commonName, err) + return false, fmt.Errorf("exporting bundle for %q: %w", input.CommonName, err) +} + +func shouldRetrySystemFallback(err error) bool { + var unknownAuthorityErr x509.UnknownAuthorityError + return errors.As(err, &unknownAuthorityErr) } func isBundleVerificationError(err error) bool { @@ -389,8 +414,7 @@ func (w *folderOverrideWriter) WriteBundleFiles(_ string, files []certstore.Bund // is complete to avoid per-cert overhead during scanning. func AssignBundleNames(store *certstore.MemStore, configs []BundleConfig) { for _, rec := range store.AllCertsFlat() { - name := determineBundleName(rec.Cert.Subject.CommonName, configs) - store.SetBundleName(rec.SKI, name) + store.UpdateBundleName(rec, determineBundleName(rec.Cert.Subject.CommonName, configs)) } } @@ -408,5 +432,5 @@ func determineBundleName(cn string, configs []BundleConfig) string { } } } - return strings.ReplaceAll(cn, "*", "_") + return "" } diff --git a/internal/exporter_test.go b/internal/exporter_test.go index 2af2c18..2c4bd70 100644 --- a/internal/exporter_test.go +++ b/internal/exporter_test.go @@ -2,14 +2,19 @@ package internal import ( "context" + "crypto/rand" + "crypto/rsa" "crypto/x509" + "crypto/x509/pkix" "encoding/hex" "errors" "fmt" + "math/big" "os" "path/filepath" "strings" "testing" + "time" "github.com/sensiblebit/certkit" "github.com/sensiblebit/certkit/internal/certstore" @@ -407,15 +412,15 @@ func TestExportBundles_UntrustedBundleSkippedWithoutForce(t *testing.T) { } func TestExportMatchedBundleWithSystemFallback_RetriesWithSystemTrust(t *testing.T) { - // WHY: Bundle export now defaults to Mozilla trust but should retry with the - // host system roots before classifying a candidate as untrusted. + // WHY: When explicitly enabled, bundle export should retry unknown-authority + // failures with the host system roots before classifying a candidate as untrusted. t.Parallel() var trustStores []string exportFn := func(_ context.Context, input certstore.ExportMatchedBundleInput) error { trustStores = append(trustStores, input.BundleOpts.TrustStore) if input.BundleOpts.TrustStore == "mozilla" { - return fmt.Errorf("%w: mozilla verify failed", certkit.ErrChainVerificationFailed) + return fmt.Errorf("%w: %w", certkit.ErrChainVerificationFailed, x509.UnknownAuthorityError{}) } if input.BundleOpts.TrustStore == "system" { return nil @@ -423,13 +428,13 @@ func TestExportMatchedBundleWithSystemFallback_RetriesWithSystemTrust(t *testing return errUnexpectedTrustStore } - skipped, err := exportMatchedBundleWithSystemFallback( - context.Background(), - "fallback.example.com", - certstore.ExportMatchedBundleInput{BundleOpts: certkit.BundleOptions{TrustStore: "mozilla", Verify: true}}, - certkit.BundleOptions{TrustStore: "mozilla", Verify: true}, - exportFn, - ) + skipped, err := exportMatchedBundleWithSystemFallback(context.Background(), exportMatchedBundleWithSystemFallbackInput{ + CommonName: "fallback.example.com", + ExportInput: certstore.ExportMatchedBundleInput{BundleOpts: certkit.BundleOptions{TrustStore: "mozilla", Verify: true}}, + Opts: certkit.BundleOptions{TrustStore: "mozilla", Verify: true}, + AllowSystemFallback: true, + Export: exportFn, + }) if err != nil { t.Fatalf("exportMatchedBundleWithSystemFallback() error = %v, want nil", err) } @@ -448,7 +453,7 @@ func TestExportMatchedBundleWithSystemFallback_ReturnsRetryFailure(t *testing.T) exportFn := func(_ context.Context, input certstore.ExportMatchedBundleInput) error { if input.BundleOpts.TrustStore == "mozilla" { - return fmt.Errorf("%w: mozilla verify failed", certkit.ErrChainVerificationFailed) + return fmt.Errorf("%w: %w", certkit.ErrChainVerificationFailed, x509.UnknownAuthorityError{}) } if input.BundleOpts.TrustStore == "system" { return errInjectedWriteFailure @@ -456,13 +461,13 @@ func TestExportMatchedBundleWithSystemFallback_ReturnsRetryFailure(t *testing.T) return errUnexpectedTrustStore } - skipped, err := exportMatchedBundleWithSystemFallback( - context.Background(), - "fallback.example.com", - certstore.ExportMatchedBundleInput{BundleOpts: certkit.BundleOptions{TrustStore: "mozilla", Verify: true}}, - certkit.BundleOptions{TrustStore: "mozilla", Verify: true}, - exportFn, - ) + skipped, err := exportMatchedBundleWithSystemFallback(context.Background(), exportMatchedBundleWithSystemFallbackInput{ + CommonName: "fallback.example.com", + ExportInput: certstore.ExportMatchedBundleInput{BundleOpts: certkit.BundleOptions{TrustStore: "mozilla", Verify: true}}, + Opts: certkit.BundleOptions{TrustStore: "mozilla", Verify: true}, + AllowSystemFallback: true, + Export: exportFn, + }) if skipped { t.Fatal("exportMatchedBundleWithSystemFallback() skipped = true, want false") } @@ -474,6 +479,64 @@ func TestExportMatchedBundleWithSystemFallback_ReturnsRetryFailure(t *testing.T) } } +func TestExportMatchedBundleWithSystemFallback_DoesNotRetryExpiredCertificate(t *testing.T) { + t.Parallel() + + var trustStores []string + exportFn := func(_ context.Context, input certstore.ExportMatchedBundleInput) error { + trustStores = append(trustStores, input.BundleOpts.TrustStore) + return fmt.Errorf( + "%w: %w", + certkit.ErrChainVerificationFailed, + x509.CertificateInvalidError{Reason: x509.Expired}, + ) + } + + skipped, err := exportMatchedBundleWithSystemFallback(context.Background(), exportMatchedBundleWithSystemFallbackInput{ + CommonName: "expired.example.com", + ExportInput: certstore.ExportMatchedBundleInput{BundleOpts: certkit.BundleOptions{TrustStore: "mozilla", Verify: true}}, + Opts: certkit.BundleOptions{TrustStore: "mozilla", Verify: true}, + AllowSystemFallback: true, + Export: exportFn, + }) + if err != nil { + t.Fatalf("exportMatchedBundleWithSystemFallback() error = %v, want nil", err) + } + if !skipped { + t.Fatal("exportMatchedBundleWithSystemFallback() skipped = false, want true") + } + if got, want := strings.Join(trustStores, ","), "mozilla"; got != want { + t.Fatalf("trust store attempts = %q, want %q", got, want) + } +} + +func TestExportMatchedBundleWithSystemFallback_DisabledSkipsUnknownAuthority(t *testing.T) { + t.Parallel() + + var trustStores []string + exportFn := func(_ context.Context, input certstore.ExportMatchedBundleInput) error { + trustStores = append(trustStores, input.BundleOpts.TrustStore) + return fmt.Errorf("%w: %w", certkit.ErrChainVerificationFailed, x509.UnknownAuthorityError{}) + } + + skipped, err := exportMatchedBundleWithSystemFallback(context.Background(), exportMatchedBundleWithSystemFallbackInput{ + CommonName: "fallback.example.com", + ExportInput: certstore.ExportMatchedBundleInput{BundleOpts: certkit.BundleOptions{TrustStore: "mozilla", Verify: true}}, + Opts: certkit.BundleOptions{TrustStore: "mozilla", Verify: true}, + AllowSystemFallback: false, + Export: exportFn, + }) + if err != nil { + t.Fatalf("exportMatchedBundleWithSystemFallback() error = %v, want nil", err) + } + if !skipped { + t.Fatal("exportMatchedBundleWithSystemFallback() skipped = false, want true") + } + if got, want := strings.Join(trustStores, ","), "mozilla"; got != want { + t.Fatalf("trust store attempts = %q, want %q", got, want) + } +} + func TestExportBundles_UntrustedSkipDoesNotReserveFolder(t *testing.T) { // WHY: Skipping an untrusted bundle candidate must release any reserved // folder name so later candidates with the same sanitized folder can proceed. @@ -639,7 +702,7 @@ func TestAssignBundleNames(t *testing.T) { CommonNames: []string{"app.example.com"}, BundleName: "app-bundle", }, - // No config for *.wild.example.com — should get sanitized CN + // No config for *.wild.example.com — should not export a bundle } AssignBundleNames(store, configs) @@ -654,7 +717,84 @@ func TestAssignBundleNames(t *testing.T) { if !nameSet["app-bundle"] { t.Error("expected 'app-bundle' in BundleNames") } - if !nameSet["_.wild.example.com"] { - t.Errorf("expected '_.wild.example.com' in BundleNames, got %v", bundleNames) + if nameSet["_.wild.example.com"] { + t.Errorf("did not expect unmatched wildcard CN bundle in BundleNames, got %v", bundleNames) + } +} + +func TestAssignBundleNames_IgnoresUnmatchedRenewalWithSameSKI(t *testing.T) { + t.Parallel() + + ca := newRSACA(t) + matchedLeaf := newRSALeaf(t, ca, "app.example.com", []string{"app.example.com"}, nil) + matchedKey, ok := matchedLeaf.key.(*rsa.PrivateKey) + if !ok { + t.Fatal("matched leaf key is not RSA") + } + + renewalTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{ + CommonName: "unmatched.example.com", + Organization: []string{"TestOrg"}, + Country: []string{"US"}, + Province: []string{"California"}, + Locality: []string{"San Francisco"}, + }, + DNSNames: []string{"unmatched.example.com"}, + NotBefore: time.Now().Add(-1 * time.Hour), + NotAfter: time.Now().Add(2 * 365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + AuthorityKeyId: ca.cert.SubjectKeyId, + } + renewalDER, err := x509.CreateCertificate(rand.Reader, renewalTemplate, ca.cert, &matchedKey.PublicKey, ca.key) + if err != nil { + t.Fatalf("create renewal cert: %v", err) + } + renewalCert, err := x509.ParseCertificate(renewalDER) + if err != nil { + t.Fatalf("parse renewal cert: %v", err) + } + + store := certstore.NewMemStore() + if err := store.HandleCertificate(matchedLeaf.cert, "test"); err != nil { + t.Fatal(err) + } + if err := store.HandleCertificate(renewalCert, "test"); err != nil { + t.Fatal(err) + } + if err := store.HandleCertificate(ca.cert, "test"); err != nil { + t.Fatal(err) + } + if err := store.HandleKey(matchedLeaf.key, matchedLeaf.keyPEM, "test"); err != nil { + t.Fatal(err) + } + + configs := []BundleConfig{ + { + CommonNames: []string{"app.example.com"}, + BundleName: "app-bundle", + }, + } + + AssignBundleNames(store, configs) + + bundleNames := store.BundleNames() + if len(bundleNames) != 1 || bundleNames[0] != "app-bundle" { + t.Fatalf("BundleNames() = %v, want [app-bundle]", bundleNames) + } + + for _, rec := range store.AllCertsFlat() { + switch rec.Cert.Subject.CommonName { + case "app.example.com": + if rec.BundleName != "app-bundle" { + t.Fatalf("bundle name for %q = %q, want app-bundle", rec.Cert.Subject.CommonName, rec.BundleName) + } + case "unmatched.example.com": + if rec.BundleName != "" { + t.Fatalf("bundle name for %q = %q, want empty", rec.Cert.Subject.CommonName, rec.BundleName) + } + } } } diff --git a/internal/inspect.go b/internal/inspect.go index 4f42a89..cdd7aca 100644 --- a/internal/inspect.go +++ b/internal/inspect.go @@ -410,10 +410,13 @@ func ResolveInspectAIA(ctx context.Context, input ResolveInspectAIAInput) ([]Ins return results, aiaResult.Warnings } -// AnnotateInspectTrust sets the Expired, Trusted, and TrustAnchors fields on -// certificate results. Intermediate certificates found in the results are used -// to build chains. -func AnnotateInspectTrust(results []InspectResult) error { +// AnnotateInspectTrust sets the Expired, Trusted, TrustAnchors, and +// TrustWarnings fields on certificate results. Intermediate certificates found +// in the results are used to build chains. trustStore selects the built-in +// trust source used during trust evaluation; empty uses the default certkit +// behavior, and unsupported values surface warnings while leaving certificates +// untrusted. +func AnnotateInspectTrust(results []InspectResult, trustStore string) error { // Build intermediate pool from all certs in the results intermediatePool := x509.NewCertPool() for i := range results { @@ -434,6 +437,7 @@ func AnnotateInspectTrust(results []InspectResult) error { trustResult := certkit.CheckTrustAnchors(certkit.CheckTrustAnchorsInput{ Cert: cert, Intermediates: intermediatePool, + TrustStore: trustStore, }) results[i].TrustAnchors = trustResult.Anchors results[i].TrustWarnings = trustResult.Warnings diff --git a/internal/inspect_test.go b/internal/inspect_test.go index 6ee486e..7a74c89 100644 --- a/internal/inspect_test.go +++ b/internal/inspect_test.go @@ -611,7 +611,7 @@ func TestAnnotateInspectTrust(t *testing.T) { results := make([]InspectResult, len(tt.results)) copy(results, tt.results) - if err := AnnotateInspectTrust(results); err != nil { + if err := AnnotateInspectTrust(results, "mozilla"); err != nil { t.Fatalf("AnnotateInspectTrust: %v", err) } diff --git a/internal/verify.go b/internal/verify.go index ce22921..54a7590 100644 --- a/internal/verify.go +++ b/internal/verify.go @@ -173,7 +173,12 @@ func VerifyCert(ctx context.Context, input *VerifyInput) (*VerifyResult, error) result.Errors = append(result.Errors, "chain validation: "+bundleErr.Error()) } if bundle != nil { - result.Chain = buildChainDisplay(bundle, input.Verbose, fileRootsPool) + result.Chain = buildChainDisplay(buildChainDisplayInput{ + Bundle: bundle, + Verbose: input.Verbose, + FileRoots: fileRootsPool, + TrustStore: input.TrustStore, + }) } } @@ -351,16 +356,23 @@ func verifyTrustSources(fileRootsPool *x509.CertPool, trustStore string) ([]veri case "": sources := []verifyTrustSource{ loadVerifyTrustSource("mozilla", certkit.MozillaRootPool), - loadVerifyTrustSource("system", certkit.SystemCertPoolCached), } if fileRootsPool != nil { sources = append(sources, verifyTrustSource{name: "file", roots: fileRootsPool}) } return sources, nil case "mozilla": - return []verifyTrustSource{loadVerifyTrustSource("mozilla", certkit.MozillaRootPool)}, nil + sources := []verifyTrustSource{loadVerifyTrustSource("mozilla", certkit.MozillaRootPool)} + if fileRootsPool != nil { + sources = append(sources, verifyTrustSource{name: "file", roots: fileRootsPool}) + } + return sources, nil case "system": - return []verifyTrustSource{loadVerifyTrustSource("system", certkit.SystemCertPoolCached)}, nil + sources := []verifyTrustSource{loadVerifyTrustSource("system", certkit.SystemCertPoolCached)} + if fileRootsPool != nil { + sources = append(sources, verifyTrustSource{name: "file", roots: fileRootsPool}) + } + return sources, nil case "custom", "file": return []verifyTrustSource{{name: "file", roots: fileRootsPool}}, nil default: @@ -557,14 +569,22 @@ func checkVerifyOCSP(ctx context.Context, input certkit.CheckOCSPInput) *certkit return ocspResult } +type buildChainDisplayInput struct { + Bundle *certkit.BundleResult + Verbose bool + FileRoots *x509.CertPool + TrustStore string +} + // buildChainDisplay creates the display chain from a BundleResult. -func buildChainDisplay(bundle *certkit.BundleResult, verbose bool, fileRoots *x509.CertPool) []ChainCert { - intermediatePool := newCertPool(bundle.Intermediates) +func buildChainDisplay(input buildChainDisplayInput) []ChainCert { + intermediatePool := newCertPool(input.Bundle.Intermediates) buildEntry := func(c *x509.Certificate, isRoot bool) ChainCert { trustResult := certkit.CheckTrustAnchors(certkit.CheckTrustAnchorsInput{ Cert: c, Intermediates: intermediatePool, - FileRoots: fileRoots, + FileRoots: input.FileRoots, + TrustStore: input.TrustStore, }) cc := ChainCert{ Subject: certkit.FormatDNFromRaw(c.RawSubject, c.Subject), @@ -574,7 +594,7 @@ func buildChainDisplay(bundle *certkit.BundleResult, verbose bool, fileRoots *x5 TrustAnchors: trustResult.Anchors, TrustWarnings: trustResult.Warnings, } - if verbose { + if input.Verbose { cc.Issuer = certkit.FormatDNFromRaw(c.RawIssuer, c.Issuer) cc.Serial = certkit.FormatSerialNumber(c.SerialNumber) cc.NotBefore = c.NotBefore.UTC().Format(time.RFC3339) @@ -593,11 +613,11 @@ func buildChainDisplay(bundle *certkit.BundleResult, verbose bool, fileRoots *x5 } var chain []ChainCert - chain = append(chain, buildEntry(bundle.Leaf, false)) - for _, c := range bundle.Intermediates { + chain = append(chain, buildEntry(input.Bundle.Leaf, false)) + for _, c := range input.Bundle.Intermediates { chain = append(chain, buildEntry(c, false)) } - for _, c := range bundle.Roots { + for _, c := range input.Bundle.Roots { chain = append(chain, buildEntry(c, true)) } return chain diff --git a/internal/verify_test.go b/internal/verify_test.go index b3c69d2..be889b7 100644 --- a/internal/verify_test.go +++ b/internal/verify_test.go @@ -179,8 +179,11 @@ func TestVerifyCert_NoTrustAnchors(t *testing.T) { if result.ChainValid == nil || *result.ChainValid { t.Fatalf("expected ChainValid false, got %v", result.ChainValid) } - if !strings.Contains(result.ChainErr, "mozilla:") || !strings.Contains(result.ChainErr, "system:") { - t.Errorf("expected ChainErr to mention mozilla and system attempts, got %q", result.ChainErr) + if !strings.Contains(result.ChainErr, "mozilla:") { + t.Errorf("expected ChainErr to mention mozilla attempt, got %q", result.ChainErr) + } + if strings.Contains(result.ChainErr, "system:") { + t.Errorf("did not expect ChainErr to mention system attempt, got %q", result.ChainErr) } if strings.Contains(result.ChainErr, "file:") { t.Errorf("expected ChainErr not to mention file trust source when no file roots were requested, got %q", result.ChainErr) diff --git a/web/package-lock.json b/web/package-lock.json index 52180e3..045545a 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -14,14 +14,15 @@ } }, "node_modules/@asamuzakjp/css-color": { - "version": "5.1.9", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.9.tgz", - "integrity": "sha512-zd9c/Wdso6v1U7v6w3i/hbAr4K7NaSHImdpvmLt+Y9ea5BhilnIGNkfhOJ7FEIuPipAnE9tZeDOll05WDT0kgg==", + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", "dev": true, "license": "MIT", "dependencies": { - "@csstools/css-calc": "^3.1.1", - "@csstools/css-color-parser": "^4.0.2", + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" }, @@ -30,12 +31,13 @@ } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "7.0.9", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.9.tgz", - "integrity": "sha512-r3ElRr7y8ucyN2KdICwGsmj19RoN13CLCa/pvGydghWK6ZzeKQ+TcDjVdtEZz2ElpndM5jXw//B9CEee0mWnVg==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", "dev": true, "license": "MIT", "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.2.1", @@ -45,6 +47,16 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/@asamuzakjp/nwsapi": { "version": "2.3.9", "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", @@ -66,9 +78,9 @@ } }, "node_modules/@cloudflare/workers-types": { - "version": "4.20260410.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260410.1.tgz", - "integrity": "sha512-dPZT4aXxwhGHFhWA9iZhWVfFoO8g9exiLzeaS8y43Dw0Sard6Gb3o5LJjReav3ejHbQLHUfGEiZsRPGW8qmgMg==", + "version": "4.20260422.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260422.1.tgz", + "integrity": "sha512-kI4baXLJloST4J/PxAfhz3azy4TPC7L2wcX6nusOzSNJJD/L5CSsivSJobgkYaMG5yspzRFQUQlCdPza0nTHbQ==", "dev": true, "license": "MIT OR Apache-2.0" }, @@ -93,9 +105,9 @@ } }, "node_modules/@csstools/css-calc": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", - "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", + "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", "dev": true, "funding": [ { @@ -117,9 +129,9 @@ } }, "node_modules/@csstools/css-color-parser": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", - "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", + "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", "dev": true, "funding": [ { @@ -134,7 +146,7 @@ "license": "MIT", "dependencies": { "@csstools/color-helpers": "^6.0.2", - "@csstools/css-calc": "^3.1.1" + "@csstools/css-calc": "^3.2.0" }, "engines": { "node": ">=20.19.0" @@ -168,9 +180,9 @@ } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.2.tgz", - "integrity": "sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", + "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", "dev": true, "funding": [ { @@ -272,9 +284,9 @@ "license": "MIT" }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", - "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", "dev": true, "license": "MIT", "optional": true, @@ -291,9 +303,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.124.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", - "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", + "version": "0.126.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.126.0.tgz", + "integrity": "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ==", "dev": true, "license": "MIT", "funding": { @@ -301,9 +313,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", - "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.16.tgz", + "integrity": "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA==", "cpu": [ "arm64" ], @@ -318,9 +330,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", - "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.16.tgz", + "integrity": "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ==", "cpu": [ "arm64" ], @@ -335,9 +347,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", - "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.16.tgz", + "integrity": "sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ==", "cpu": [ "x64" ], @@ -352,9 +364,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", - "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.16.tgz", + "integrity": "sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g==", "cpu": [ "x64" ], @@ -369,9 +381,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", - "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.16.tgz", + "integrity": "sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg==", "cpu": [ "arm" ], @@ -386,9 +398,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", - "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.16.tgz", + "integrity": "sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg==", "cpu": [ "arm64" ], @@ -406,9 +418,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", - "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.16.tgz", + "integrity": "sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg==", "cpu": [ "arm64" ], @@ -426,9 +438,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", - "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.16.tgz", + "integrity": "sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ==", "cpu": [ "ppc64" ], @@ -446,9 +458,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", - "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.16.tgz", + "integrity": "sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ==", "cpu": [ "s390x" ], @@ -466,9 +478,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", - "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.16.tgz", + "integrity": "sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg==", "cpu": [ "x64" ], @@ -486,9 +498,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", - "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.16.tgz", + "integrity": "sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w==", "cpu": [ "x64" ], @@ -506,9 +518,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", - "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.16.tgz", + "integrity": "sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA==", "cpu": [ "arm64" ], @@ -523,9 +535,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", - "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.16.tgz", + "integrity": "sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ==", "cpu": [ "wasm32" ], @@ -535,16 +547,16 @@ "dependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2", - "@napi-rs/wasm-runtime": "^1.1.3" + "@napi-rs/wasm-runtime": "^1.1.4" }, "engines": { - "node": ">=14.0.0" + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", - "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.16.tgz", + "integrity": "sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q==", "cpu": [ "arm64" ], @@ -559,9 +571,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", - "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.16.tgz", + "integrity": "sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g==", "cpu": [ "x64" ], @@ -576,9 +588,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", - "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.16.tgz", + "integrity": "sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA==", "dev": true, "license": "MIT" }, @@ -626,16 +638,16 @@ "license": "MIT" }, "node_modules/@vitest/expect": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", - "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.4", - "@vitest/utils": "4.1.4", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -644,13 +656,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz", - "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.4", + "@vitest/spy": "4.1.5", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -671,9 +683,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", - "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", "dev": true, "license": "MIT", "dependencies": { @@ -684,13 +696,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz", - "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.4", + "@vitest/utils": "4.1.5", "pathe": "^2.0.3" }, "funding": { @@ -698,14 +710,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz", - "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.4", - "@vitest/utils": "4.1.4", + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -714,9 +726,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", - "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", "dev": true, "license": "MIT", "funding": { @@ -724,13 +736,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", - "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.4", + "@vitest/pretty-format": "4.1.5", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -821,13 +833,13 @@ } }, "node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", "dev": true, "license": "BSD-2-Clause", "engines": { - "node": ">=0.12" + "node": ">=20.19.0" }, "funding": { "url": "https://github.com/fb55/entities?sponsor=1" @@ -1228,9 +1240,9 @@ } }, "node_modules/lru-cache": { - "version": "11.3.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.3.tgz", - "integrity": "sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==", + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -1285,13 +1297,13 @@ "license": "MIT" }, "node_modules/parse5": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", - "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", "dev": true, "license": "MIT", "dependencies": { - "entities": "^6.0.0" + "entities": "^8.0.0" }, "funding": { "url": "https://github.com/inikulin/parse5?sponsor=1" @@ -1325,9 +1337,9 @@ } }, "node_modules/postcss": { - "version": "8.5.9", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", - "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", "dev": true, "funding": [ { @@ -1374,14 +1386,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", - "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.16.tgz", + "integrity": "sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.124.0", - "@rolldown/pluginutils": "1.0.0-rc.15" + "@oxc-project/types": "=0.126.0", + "@rolldown/pluginutils": "1.0.0-rc.16" }, "bin": { "rolldown": "bin/cli.mjs" @@ -1390,21 +1402,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.15", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", - "@rolldown/binding-darwin-x64": "1.0.0-rc.15", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" + "@rolldown/binding-android-arm64": "1.0.0-rc.16", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.16", + "@rolldown/binding-darwin-x64": "1.0.0-rc.16", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.16", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.16", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.16", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.16", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.16", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.16", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.16", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.16", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.16", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.16", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.16", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.16" } }, "node_modules/saxes": { @@ -1445,9 +1457,9 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", - "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", "dev": true, "license": "MIT" }, @@ -1557,9 +1569,9 @@ "optional": true }, "node_modules/undici": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz", - "integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", "dev": true, "license": "MIT", "engines": { @@ -1567,17 +1579,17 @@ } }, "node_modules/vite": { - "version": "8.0.8", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", - "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", + "version": "8.0.9", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.9.tgz", + "integrity": "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.15", - "tinyglobby": "^0.2.15" + "postcss": "^8.5.10", + "rolldown": "1.0.0-rc.16", + "tinyglobby": "^0.2.16" }, "bin": { "vite": "bin/vite.js" @@ -1645,19 +1657,19 @@ } }, "node_modules/vitest": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz", - "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.4", - "@vitest/mocker": "4.1.4", - "@vitest/pretty-format": "4.1.4", - "@vitest/runner": "4.1.4", - "@vitest/snapshot": "4.1.4", - "@vitest/spy": "4.1.4", - "@vitest/utils": "4.1.4", + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -1685,12 +1697,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.4", - "@vitest/browser-preview": "4.1.4", - "@vitest/browser-webdriverio": "4.1.4", - "@vitest/coverage-istanbul": "4.1.4", - "@vitest/coverage-v8": "4.1.4", - "@vitest/ui": "4.1.4", + "@vitest/browser-playwright": "4.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"