diff --git a/.claude/rules/commits-and-prs.md b/.claude/rules/commits-and-prs.md index abb2396..268a66d 100644 --- a/.claude/rules/commits-and-prs.md +++ b/.claude/rules/commits-and-prs.md @@ -55,28 +55,41 @@ When a pre-commit hook modifies files (e.g., goimports reformats struct alignmen This is especially common with `goimports` reformatting Go files. -## Resolving PR review threads +## Addressing PR feedback -When addressing review feedback, both reply AND resolve: +When working on a PR, address **both** PR review comments (on diffs) and issue-style comments (on the PR conversation). For each piece of feedback: -1. **Reply** via REST: `gh api repos/OWNER/REPO/pulls/N/comments -X POST -F body="..." -F in_reply_to=COMMENT_ID` -2. **Resolve** via GraphQL: `gh api graphql -f query='mutation { resolveReviewThread(input: {threadId: "THREAD_ID"}) { thread { isResolved } } }'` +1. **Fix the code** — make the requested change or explain why not +2. **Reply** explaining what was done: `gh api repos/OWNER/REPO/pulls/N/comments -X POST -F body="..." -F in_reply_to=COMMENT_ID` +3. **Resolve** the thread: `gh api graphql -f query='mutation { resolveReviewThread(input: {threadId: "THREAD_ID"}) { thread { isResolved } } }'` +4. **Minimize** addressed comments to reduce noise: `gh api graphql -f query='mutation { minimizeComment(input: {subjectId: "COMMENT_NODE_ID", classifier: RESOLVED}) { minimizedComment { isMinimized } } }'` -To get thread IDs, query: +To get thread IDs and comment node IDs, query: ```sh gh api graphql -f query='{ repository(owner: "sensiblebit", name: "certkit") { pullRequest(number: N) { - reviewThreads(first: 20) { - nodes { id isResolved comments(first: 1) { nodes { body } } } + reviewThreads(first: 50) { + nodes { + id + isResolved + comments(first: 5) { + nodes { id databaseId body } + } + } + } + comments(first: 50) { + nodes { id databaseId body } } } } }' ``` -Just replying does NOT mark the thread as resolved in the GitHub UI. +For issue-style comments (PR conversation), use `minimizeComment` with the comment's node `id`. For review comments, reply + resolve + minimize. + +Just replying does NOT mark the thread as resolved or minimized in the GitHub UI — all three steps are required. ## Merging PRs diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f75cd9f..db6ed30 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,6 +20,25 @@ repos: - id: go-build - id: go-test + # ── Dependency Updates ── + - repo: local + hooks: + - id: go-mod-update + name: go mod update + entry: bash -c 'go get -u ./... && go mod tidy' + language: system + files: \.go$ + pass_filenames: false + stages: [manual] + + - id: npm-update + name: npm update + entry: bash -c 'cd web && npm update' + language: system + files: ^web/ + pass_filenames: false + stages: [manual] + # ── Docs ── - repo: local hooks: diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d957b2..4a7d1b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add raw TLS 1.0–1.2 legacy prober for DHE/DHE-DSS cipher suites that Go's `crypto/tls` doesn't implement — probes individual suites via byte-level ClientHello construction ([`715cb81`]) +- Add legacy fallback to `connect` — when Go's TLS handshake fails, attempts a raw handshake to extract server certificates from DHE-only or static-RSA-only servers ([`715cb81`]) +- Add DHE cipher suite probing to `connect --ciphers` — detects 13 DHE/DHE-DSS cipher suites using raw ClientHello packets, all rated "weak" ([`715cb81`]) +- Add `dhe-kex` diagnostic to `connect --ciphers` — warns when server accepts DHE key exchange cipher suites (deprecated, vulnerable to small DH parameters) ([`715cb81`]) +- Add negotiated cipher diagnostics to `connect` — warns about CBC mode, 3DES, static RSA, DHE, and deprecated TLS versions even without `--ciphers` ([`715cb81`]) +- Add hostname-mismatch diagnostic to `connect` — detects `x509.HostnameError` and surfaces it as `[ERR] hostname-mismatch` in the diagnostics section ([`715cb81`]) +- Add error-level diagnostics (`verify-failed`, `ocsp-revoked`, `crl-revoked`) to `connect` output — validation failures now appear in the Diagnostics section instead of a redundant `Error:` line on stderr ([`715cb81`]) +- Add specific cipher diagnostics to `connect --ciphers` — replaces the single "weak cipher" message with actionable checks: `deprecated-tls10`, `deprecated-tls11`, `cbc-cipher`, `static-rsa-kex`, `3des-cipher` ([`715cb81`]) +- Add `--ciphers` flag to `connect` command — enumerates all supported cipher suites with good/weak ratings, key exchange subgrouping, and forward secrecy labels ([#82]) +- Add raw TLS 1.3 cipher prober — probes all 5 RFC 8446 cipher suites using byte-level ClientHello construction, no shared state or data races ([#82]) +- Add key exchange group probing to `--ciphers` — detects all 7 named groups including post-quantum hybrids (X25519MLKEM768, SecP256r1MLKEM768, SecP384r1MLKEM1024) with HelloRetryRequest detection ([#82]) +- Add QUIC/UDP cipher probing to `--ciphers` — automatically probes UDP 443 alongside TCP, shows "QUIC: not supported" when server rejects ([#82]) - Auto-generate CLI flag tables in README from Cobra command definitions via `go generate` ([#80]) - Add `gendocs` pre-commit hook and CI check to verify flag tables stay in sync ([#80]) - Add global `--json` persistent flag — all commands now support JSON output; overrides `--format` when both are set ([#80]) @@ -53,6 +65,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- `connect` diagnostics now distinguish `[ERR]` (verification failures) from `[WARN]` (configuration issues) ([`910b977`]) +- Harden QUIC response parser — add bounds checks for DCID/SCID lengths, varint decode guards to prevent infinite loops on malformed ACK frames, and increase UDP read buffer to 65535 bytes ([#82]) +- Harden TLS ServerHello parser — add explicit bounds check for oversized session ID length before advancing position ([#82]) +- Refactor probe functions to use input structs per CS-5 — `probeTLS13Cipher`, `probeKeyExchangeGroup`, `probeQUICCipher`, `probeCipher`, `probeKeyExchangeGroupLegacy` now take `cipherProbeInput` ([#82]) +- Convert `populateConnectResult` to a method `(*ConnectResult).populate` per CS-5 — reduces argument count from 3 to 2 (ctx + input) ([#82]) +- Convert `appendKeyShareExtension` to accept `appendKeyShareExtensionInput` struct per CS-5 — function had 3 arguments ([#82]) - **Breaking:** Rename `csr --cert` flag to `--from-cert` for clarity — avoids confusion with certificate file arguments in other commands ([#80]) - **Breaking:** `connect` JSON `sha256_fingerprint` format changed from lowercase hex to colon-separated uppercase hex for CLI-4 consistency with `inspect` and `sha1_fingerprint` ([#80]) - **Breaking:** Rename `CRLCheckResult.DistributionPoint` to `CRLCheckResult.URL` (JSON: `url`) and `OCSPResult.ResponderURL` to `OCSPResult.URL` (JSON: `url`) — consistent field name for the checked endpoint across both revocation types (CLI-4) ([#78]) @@ -69,6 +87,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Fix `connect` legacy probe showing `Verify: N/A` despite performing full x509 chain verification — now shows the real verify result (`OK`/`FAILED`); Note line updated to clarify only server key possession is unverified ([`772742c`]) +- Fix `connect --ciphers` showing "none detected" on QUIC-only servers — empty check now covers both TCP and QUIC cipher lists ([`6492fa5`]) +- Fix `probeLegacyCipher` hardcoding `"TLS 1.2"` for negotiated version — now returns the actual negotiated version from the ServerHello ([`6492fa5`]) +- Fix error strings violating ERR-4 (must be lowercase): `"tls alert received"`, `"tls record too large"`, `"quic packet too short"`, `"tls handshake with ..."` ([`6492fa5`]) +- Fix bare `return err` at CLI connect boundary — now wraps with context per ERR-1 ([`6492fa5`]) +- Fix missing `slog.Debug` before `continue` in QUIC ACK frame handler per ERR-5 ([`6492fa5`]) +- Rename `emptyClientCert` → `emptyClientCertificate` per naming convention ([`6492fa5`]) +- Fix bare `return err` in `connect` CLI dropping host context from error messages — ConnectTLS and ScanCipherSuites errors now wrap with host and operation (ERR-1) ([#82]) +- Fix potential out-of-bounds write in QUIC response parser when packet number length exceeds remaining packet bytes ([#82]) +- Add panic guard to `appendQUICVarint2` for values >= 16384 that would silently produce corrupt 2-byte encoding ([#82]) +- Skip QUIC cipher probes on non-443 ports — avoids wasted 10s of timeout when QUIC is not conventionally served ([#82]) +- Use `slices.Concat` instead of `append` for cipher suite slice concatenation — prevents potential mutation of stdlib return value ([#82]) +- Show "Cipher suites: none detected" when cipher scan finds no supported suites instead of silent empty output ([#82]) +- Fix `OverallRating`, `FormatCipherRatingLine`, and `DiagnoseCipherScan` ignoring QUIC ciphers — weak QUIC ciphers were excluded from the overall rating and diagnostic count ([#82]) +- Fix TOCTOU race in `spinner.Stop()` — remove started guard and use `stopOnce` unconditionally so Stop() is safe regardless of concurrency with Start() ([#82]) +- Fix `connect` legacy probe running OCSP and CRL checks — revocation checks are now skipped for legacy probes since there is no cryptographic chain to verify revocation against; eliminates misleading `OCSP: skipped (no issuer certificate in chain)` output ([#82]) +- Fix `connect` legacy fallback triggering on all TLS handshake failures — now only attempted on `tls.AlertError` (cipher negotiation failure), not network errors or certificate errors that would add a spurious 5-second timeout ([#82]) +- Fix `connect` error message swallowing `legacyErr` when legacy fallback also fails — both the original TLS alert and the legacy fallback error are now included ([#82]) +- Fix `spinner.Stop()` deadlock when called before `Start()` — Stop() now closes `done` via `startOnce` so `<-s.done` never blocks ([#82]) +- Fix uppercase `QUIC` and `TLS` in error strings in `quicprobe.go`, `legacyprobe.go`, and `tls13probe.go` violating ERR-4 ([#82]) +- Fix `connect --ciphers` diagnostics filter using in-place slice aliasing — now allocates a new slice to avoid confusing aliasing semantics ([#82]) +- Fix bare error returns in `deriveTrafficKeys` — wrap with `%w` context per ERR-1 ([#82]) +- Fix `SupportedVersions` missing QUIC-only TLS versions — QUIC cipher versions now added to version set alongside TCP ciphers ([`bed32df`]) +- Fix `appendQUICVarint2` panic on values ≥ 16384 — falls back to `appendQUICVarint` instead of panicking on unexpected input ([`bed32df`]) +- Fix duplicate error context in `connect` CLI — `ConnectTLS` error returned directly; `ScanCipherSuites` error uses non-repeating prefix ([`bed32df`]) +- Fix remaining uppercase protocol names in `legacyprobe.go` error strings (ERR-4) ([`bed32df`]) +- Fix `readServerCertificates` totalRead check — enforce `maxCertificatePayload` limit before allocating record payload buffer, preventing over-allocation by a malicious server ([`900d526`]) +- Fix QUIC ACK range count cap — use `len(plaintext)/2` instead of `len(plaintext)` since each range item requires at minimum 2 varint bytes ([`900d526`]) +- Fix uppercase `CRYPTO` in `quicprobe.go` error strings — lowercase per ERR-4 ([`900d526`]) +- Fix `connect` output showing misleading `Verify: OK` when result was obtained via raw legacy probe — now shows `Verify: N/A` and a `Note:` header line ([`900d526`]) +- Fix QUIC varint `uint64`→`int` overflow in `parseQUICInitialResponse` — bounds checks now compare in `uint64` space to prevent truncation on malicious packets ([#82]) +- Fix ACK range loop inner `break` not propagating to outer frame parser in QUIC decoder — malformed ACK frames could corrupt subsequent frame parsing ([#82]) +- Cap ACK `rangeCount` to plaintext length to prevent CPU exhaustion on malicious QUIC packets ([#82]) +- Fix double-wrapped error messages in `connect` CLI — "connecting to: connecting to:" and "scanning cipher suites: scanning cipher suites:" ([#82]) +- Fix `CipherScanResult` JSON encoding `supported_versions` and `ciphers` as `null` instead of `[]` when no ciphers detected ([#82]) - Fix backtick-quoted values in flag usage strings being consumed by pflag as type placeholders — all `--format`, `--trust-store`, `--log-level`, `--algorithm`, and `--curve` flags now display correctly in `--help` output ([#80]) - Fix `convert --json` without `-o` missing `format` field in JSON output ([#80]) - Fix data race in `TestCheckLeafCRL` — CRL bytes are now generated before starting the test HTTP server (CC-3) ([#78]) @@ -142,7 +195,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Tests +- Remove `TestBuildLegacyClientHelloMsg` — behavioral coverage exists through `TestLegacyFallbackConnect` per T-11 ([`6492fa5`]) +- Remove `TestParseCertificateMessage` — behavioral coverage exists through `TestReadServerCertificates` per T-11 ([#82]) +- Fix `_, _` error discards in `TestLegacyFallbackConnect` mock server goroutine — replaced with `slog.Debug` per ERR-5 ([#82]) +- Remove `TestCipherSuiteNameLegacyIDs` — behavioral coverage exists through `TestScanCipherSuites` per T-11 ([#82]) +- Strengthen `TestBuildQUICInitialPacket` — verify QUIC v1 version, DCID/SCID in header, and round-trip decrypt CRYPTO frame against original ClientHello ([#82]) +- Consolidate `TestRateCipherSuite` from 13 entries to 6 — one per distinct code path (T-12) ([#82]) +- Merge `TestScanCipherSuites_KeyExchanges` into `TestScanCipherSuites` — eliminates redundant server setup (T-14) ([#82]) +- Fix brittle `tls13Count != 3` assertion — use `>= 1` to tolerate future Go TLS 1.3 cipher additions ([#82]) +- Consolidate `FormatCipherScanResult` tests — merge QUIC and key exchange standalone tests into table-driven test ([#82]) +- Consolidate `BuildClientHello` tests — merge ALPN/QUIC test into subtests with session ID assertion ([#82]) +- Add nil and empty-ciphers test cases to `TestFormatCipherScanResult` — previously the empty case asserted nothing ([#82]) +- Consolidate `startTLSServer` to delegate to `startTLSServerWithConfig` — eliminates duplicated accept-loop code ([#82]) +- Remove tests that validate upstream behavior rather than certkit logic: `TestDeriveQUICInitialKeys`, `TestGenerateKeyShare`, `TestIsPQKeyExchange` ([#82]) +- Add `parseServerHello` edge case tests — oversized session ID length, truncation at compression method ([#82]) +- Add `FormatConnectResult` tests for "Verify: FAILED" and "Client Auth: any CA" paths ([#82]) +- Add QUIC weak cipher test case to `TestDiagnoseCipherScan` — validates QUIC ciphers are included in diagnostic count ([#82]) +- Add QUIC-only test case to `TestFormatCipherRatingLine` — validates QUIC ciphers counted in rating summary ([#82]) +- Replace RC4 test case with unknown cipher ID (0xFFFF) in `TestRateCipherSuite` — tests conservative rating for unrecognized ciphers ([#82]) +- Remove redundant `TestFormatCipherScanResult/single_cipher` and `TestFormatCipherRatingLine/TCP_good_QUIC_weak` — subsumed by stronger cases (T-14) ([#82]) - Add `TestConnectTLS_CRL_AIAFetchedIssuer` — verifies CRL checking works when issuer is obtained via AIA walking ([#78]) +- Add `TestReadServerCertificates` cases for oversized record, unexpected content type, and ServerHelloDone-without-Certificate paths (T-8) ([`900d526`]) +- Add `TestReadServerCertificates_AlertAfterServerHello` — verifies ServerHello result is preserved when alert arrives after it ([`900d526`]) +- Add `TestReadServerCertificates_PayloadLimit` — verifies `maxCertificatePayload` is enforced before allocation ([`900d526`]) +- Add `TestFormatConnectResult/LegacyProbe` case — verifies Note and `Verify: N/A` appear for raw-probe results ([`900d526`]) +- Remove T-9 violation from `TestCipherSuiteNameLegacyIDs` — `0x1301` (TLS_AES_128_GCM_SHA256) test was exercising stdlib routing, not certkit logic ([`900d526`]) - Add `TestFetchCRL_AllowPrivateNetworks` — verifies loopback IPs succeed with `AllowPrivateNetworks` ([#78]) - Add `TestFetchCRL` unit tests for HTTP handling, redirect limits, SSRF blocking, and error paths ([#78]) - Add `TestCheckLeafCRL` table-driven tests covering revoked, good, expired CRL, wrong issuer, no CDPs, and non-HTTP CDPs ([#78]) @@ -773,6 +850,10 @@ Initial release. [0.1.2]: https://github.com/sensiblebit/certkit/compare/v0.1.1...v0.1.2 [0.1.1]: https://github.com/sensiblebit/certkit/compare/v0.1.0...v0.1.1 [0.1.0]: https://github.com/sensiblebit/certkit/releases/tag/v0.1.0 +[`900d526`]: https://github.com/sensiblebit/certkit/commit/900d526 +[`bed32df`]: https://github.com/sensiblebit/certkit/commit/bed32df +[`910b977`]: https://github.com/sensiblebit/certkit/commit/910b977 +[`715cb81`]: https://github.com/sensiblebit/certkit/commit/715cb81 [`2693116`]: https://github.com/sensiblebit/certkit/commit/2693116 [`84c4edf`]: https://github.com/sensiblebit/certkit/commit/84c4edf [`2b8cb8c`]: https://github.com/sensiblebit/certkit/commit/2b8cb8c @@ -837,6 +918,7 @@ Initial release. [#76]: https://github.com/sensiblebit/certkit/pull/76 [#78]: https://github.com/sensiblebit/certkit/pull/78 [#80]: https://github.com/sensiblebit/certkit/pull/80 +[#82]: https://github.com/sensiblebit/certkit/pull/82 [#73]: https://github.com/sensiblebit/certkit/pull/73 [#64]: https://github.com/sensiblebit/certkit/pull/64 [#63]: https://github.com/sensiblebit/certkit/pull/63 @@ -853,3 +935,5 @@ Initial release. [#25]: https://github.com/sensiblebit/certkit/pull/25 [#26]: https://github.com/sensiblebit/certkit/pull/26 [#27]: https://github.com/sensiblebit/certkit/pull/27 +[`6492fa5`]: https://github.com/sensiblebit/certkit/commit/6492fa5 +[`772742c`]: https://github.com/sensiblebit/certkit/commit/772742c diff --git a/EXAMPLES.md b/EXAMPLES.md index ed3939c..efea11b 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -178,6 +178,14 @@ certkit connect example.com --crl certkit exits with code 2 if the certificate is revoked (via OCSP or CRL). +To enumerate all cipher suites the server supports with security ratings: + +```sh +certkit connect example.com --ciphers +``` + +Each cipher suite is rated `good` (ECDHE + AEAD, all TLS 1.3 suites) or `weak` (CBC, static RSA, RC4, 3DES). Weak ciphers are listed with a warning recommending they be disabled. + For machine-readable output: ```sh diff --git a/README.md b/README.md index 6eb788b..d5aed96 100644 --- a/README.md +++ b/README.md @@ -155,12 +155,13 @@ Chain verification is always performed. When the input contains an embedded priv ### Connect Flags -| Flag | Default | Description | -| -------------- | ------- | -------------------------------------------- | -| `--crl` | `false` | Check CRL distribution points for revocation | -| `--format` | `text` | Output format: text, json | -| `--no-ocsp` | `false` | Disable automatic OCSP revocation check | -| `--servername` | | Override SNI hostname (defaults to host) | +| Flag | Default | Description | +| -------------- | ------- | ----------------------------------------------------------- | +| `--ciphers` | `false` | Enumerate all supported cipher suites with security ratings | +| `--crl` | `false` | Check CRL distribution points for revocation | +| `--format` | `text` | Output format: text, json | +| `--no-ocsp` | `false` | Disable automatic OCSP revocation check | +| `--servername` | | Override SNI hostname (defaults to host) | 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). diff --git a/cmd/certkit/connect.go b/cmd/certkit/connect.go index f8d6ed0..2385d13 100644 --- a/cmd/certkit/connect.go +++ b/cmd/certkit/connect.go @@ -22,6 +22,7 @@ var ( connectFormat string connectCRL bool connectNoOCSP bool + connectCiphers bool ) var connectCmd = &cobra.Command{ @@ -32,12 +33,14 @@ and the full certificate chain. Port defaults to 443 if not specified. OCSP revocation status is checked automatically (best-effort). Use --no-ocsp to disable. Use --crl to also -check CRL distribution points. +check CRL distribution points. Use --ciphers to enumerate all cipher suites +the server supports with security ratings. Exits with code 2 if chain verification fails or the certificate is revoked.`, Example: ` certkit connect example.com certkit connect example.com:8443 certkit connect example.com --crl + certkit connect example.com --ciphers certkit connect example.com --servername alt.example.com certkit connect example.com --format json`, Args: cobra.ExactArgs(1), @@ -49,6 +52,7 @@ func init() { connectCmd.Flags().StringVar(&connectFormat, "format", "text", "Output format: text, json") connectCmd.Flags().BoolVar(&connectCRL, "crl", false, "Check CRL distribution points for revocation") connectCmd.Flags().BoolVar(&connectNoOCSP, "no-ocsp", false, "Disable automatic OCSP revocation check") + connectCmd.Flags().BoolVar(&connectCiphers, "ciphers", false, "Enumerate all supported cipher suites with security ratings") registerCompletion(connectCmd, completionInput{"format", fixedCompletion("text", "json")}) } @@ -67,6 +71,8 @@ type connectResultJSON struct { AIAFetched bool `json:"aia_fetched,omitempty"` OCSP *certkit.OCSPResult `json:"ocsp,omitempty"` CRL *certkit.CRLCheckResult `json:"crl,omitempty"` + CipherScan *certkit.CipherScanResult `json:"cipher_scan,omitempty"` + LegacyProbe bool `json:"legacy_probe,omitempty"` Chain []connectCertJSON `json:"chain"` } @@ -100,6 +106,9 @@ func runConnect(cmd *cobra.Command, args []string) error { return fmt.Errorf("parsing address %q: %w", args[0], err) } + spin := newSpinner("Connecting…") + spin.Start(cmd.Context()) + ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Second) defer cancel() @@ -111,11 +120,88 @@ func runConnect(cmd *cobra.Command, args []string) error { CheckCRL: connectCRL, }) if err != nil { + spin.Stop() return fmt.Errorf("connecting to %s: %w", args[0], err) } + // Optional cipher suite enumeration. + if connectCiphers { + spin.SetMessage("Scanning cipher suites…") + + cipherCtx, cipherCancel := context.WithTimeout(cmd.Context(), 60*time.Second) + defer cipherCancel() + cipherScan, scanErr := certkit.ScanCipherSuites(cipherCtx, certkit.ScanCipherSuitesInput{ + Host: host, + Port: port, + ServerName: connectServerName, + ProbeQUIC: true, + }) + if scanErr != nil { + spin.Stop() + return fmt.Errorf("cipher suite scan for %s: %w", args[0], scanErr) + } + result.CipherScan = cipherScan + scanDiags := certkit.DiagnoseCipherScan(cipherScan) + // Scan diagnostics supersede negotiated-cipher diagnostics + // (same check names but more comprehensive — aggregate vs. single). + // Remove negotiated-cipher diagnostics that the scan covers. + scanChecks := make(map[string]bool, len(scanDiags)) + for _, d := range scanDiags { + scanChecks[d.Check] = true + } + var filtered []certkit.ChainDiagnostic + for _, d := range result.Diagnostics { + if !scanChecks[d.Check] { + filtered = append(filtered, d) + } + } + result.Diagnostics = append(filtered, scanDiags...) + } + + spin.Stop() + now := time.Now() + // Promote validation failures to error-level diagnostics so they appear + // in the Diagnostics section rather than as a separate Error: line. + var hasValidationError bool + if result.VerifyError != "" { + // Skip if hostname-mismatch diagnostic was already added by the library. + hasHostnameDiag := false + for _, d := range result.Diagnostics { + if d.Check == "hostname-mismatch" { + hasHostnameDiag = true + break + } + } + if !hasHostnameDiag { + result.Diagnostics = append(result.Diagnostics, certkit.ChainDiagnostic{ + Check: "verify-failed", + Status: "error", + Detail: result.VerifyError, + }) + } + hasValidationError = true + } + if result.OCSP != nil && result.OCSP.Status == "revoked" { + result.Diagnostics = append(result.Diagnostics, certkit.ChainDiagnostic{ + Check: "ocsp-revoked", + Status: "error", + Detail: "certificate is revoked (OCSP)", + }) + hasValidationError = true + } + if result.CRL != nil && result.CRL.Status == "revoked" { + result.Diagnostics = append(result.Diagnostics, certkit.ChainDiagnostic{ + Check: "crl-revoked", + Status: "error", + Detail: "certificate is revoked (CRL)", + }) + hasValidationError = true + } + + certkit.SortDiagnostics(result.Diagnostics) + format := connectFormat if jsonOutput { format = "json" @@ -136,6 +222,8 @@ func runConnect(cmd *cobra.Command, args []string) error { AIAFetched: result.AIAFetched, OCSP: result.OCSP, CRL: result.CRL, + CipherScan: result.CipherScan, + LegacyProbe: result.LegacyProbe, } for _, cert := range result.PeerChain { cj := connectCertJSON{ @@ -175,18 +263,15 @@ func runConnect(cmd *cobra.Command, args []string) error { } else { fmt.Print(certkit.FormatConnectResult(result)) } + if result.CipherScan != nil { + fmt.Print(certkit.FormatCipherScanResult(result.CipherScan)) + } default: return fmt.Errorf("unsupported output format %q (use text or json)", format) } - if result.VerifyError != "" { - return &ValidationError{Message: fmt.Sprintf("certificate verification failed: %s", result.VerifyError)} - } - if result.OCSP != nil && result.OCSP.Status == "revoked" { - return &ValidationError{Message: "certificate is revoked (OCSP)"} - } - if result.CRL != nil && result.CRL.Status == "revoked" { - return &ValidationError{Message: "certificate is revoked (CRL)"} + if hasValidationError { + return &ValidationError{Message: "validation failed", Quiet: true} } return nil @@ -200,6 +285,10 @@ func formatConnectVerbose(r *certkit.ConnectResult, now time.Time) string { fmt.Fprintf(&out, "Cipher Suite: %s\n", r.CipherSuite) fmt.Fprintf(&out, "Server Name: %s\n", r.ServerName) + if r.LegacyProbe { + out.WriteString("Note: certificate obtained via raw probe — server key possession not verified\n") + } + if r.ALPN != "" { fmt.Fprintf(&out, "ALPN: %s\n", r.ALPN) } @@ -220,6 +309,8 @@ func formatConnectVerbose(r *certkit.ConnectResult, now time.Time) string { out.WriteString(certkit.FormatCRLLine(r.CRL)) } + out.WriteString(certkit.FormatCipherRatingLine(r.CipherScan)) + if r.ClientAuth != nil && r.ClientAuth.Requested { out.WriteString("Client Auth: requested\n") if len(r.ClientAuth.AcceptableCAs) > 0 { @@ -236,7 +327,11 @@ func formatConnectVerbose(r *certkit.ConnectResult, now time.Time) string { if len(r.Diagnostics) > 0 { out.WriteString("\nDiagnostics:\n") for _, d := range r.Diagnostics { - fmt.Fprintf(&out, " [WARN] %s: %s\n", d.Check, d.Detail) + tag := "WARN" + if d.Status == "error" { + tag = "ERR" + } + fmt.Fprintf(&out, " [%s] %s: %s\n", tag, d.Check, d.Detail) } } diff --git a/cmd/certkit/errors.go b/cmd/certkit/errors.go index 252764b..594abad 100644 --- a/cmd/certkit/errors.go +++ b/cmd/certkit/errors.go @@ -4,6 +4,10 @@ package main // key mismatch, expired). Commands return this to signal exit code 2. type ValidationError struct { Message string + // Quiet suppresses the "Error: ..." line on stderr. When true, the command + // has already displayed the failure details in its own output (e.g. as a + // diagnostic). The exit code is still 2. + Quiet bool } func (e *ValidationError) Error() string { return e.Message } diff --git a/cmd/certkit/main.go b/cmd/certkit/main.go index 86eb054..28f7778 100644 --- a/cmd/certkit/main.go +++ b/cmd/certkit/main.go @@ -20,11 +20,13 @@ func init() { func main() { rootCmd.Version = version if err := rootCmd.Execute(); err != nil { - fmt.Fprintf(os.Stderr, "Error: %s\n", err) - var ve *ValidationError - if errors.As(err, &ve) { + if ve, ok := errors.AsType[*ValidationError](err); ok { + if !ve.Quiet { + fmt.Fprintf(os.Stderr, "Error: %s\n", err) + } os.Exit(2) } + fmt.Fprintf(os.Stderr, "Error: %s\n", err) os.Exit(1) } } diff --git a/cmd/certkit/spinner.go b/cmd/certkit/spinner.go new file mode 100644 index 0000000..e981af8 --- /dev/null +++ b/cmd/certkit/spinner.go @@ -0,0 +1,94 @@ +package main + +import ( + "context" + "fmt" + "os" + "sync" + "time" + + "github.com/mattn/go-isatty" +) + +// spinner displays an animated progress indicator on stderr when the output +// is a terminal. It silently does nothing when piped or redirected. +type spinner struct { + mu sync.Mutex + msg string + stop chan struct{} + done chan struct{} + startOnce sync.Once + stopOnce sync.Once +} + +// newSpinner creates a spinner with the given message. Call Start() to begin +// animating and Stop() when finished. The spinner only renders when stderr +// is a terminal — safe to use unconditionally. +func newSpinner(msg string) *spinner { + return &spinner{ + msg: msg, + stop: make(chan struct{}), + done: make(chan struct{}), + } +} + +// Start begins the spinner animation in a background goroutine. The goroutine +// is tied to ctx and will stop if the context is cancelled. Safe to call +// multiple times — only the first call has any effect. +func (s *spinner) Start(ctx context.Context) { + s.startOnce.Do(func() { + if !isatty.IsTerminal(os.Stderr.Fd()) && !isatty.IsCygwinTerminal(os.Stderr.Fd()) { + close(s.done) + return + } + + go s.run(ctx) + }) +} + +// SetMessage updates the spinner text while it's running. +func (s *spinner) SetMessage(msg string) { + s.mu.Lock() + s.msg = msg + s.mu.Unlock() +} + +// Stop halts the spinner and clears the line. Safe to call before or after +// Start(), and safe to call multiple times. +func (s *spinner) Stop() { + // If Start() was never called, close done so <-s.done does not block. + s.startOnce.Do(func() { close(s.done) }) + s.stopOnce.Do(func() { close(s.stop) }) + <-s.done +} + +var spinnerFrames = [...]string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + +func (s *spinner) run(ctx context.Context) { + defer close(s.done) + + ticker := time.NewTicker(80 * time.Millisecond) + defer ticker.Stop() + + frame := 0 + for { + s.mu.Lock() + msg := s.msg + s.mu.Unlock() + + fmt.Fprintf(os.Stderr, "\r%s %s", spinnerFrames[frame%len(spinnerFrames)], msg) + frame++ + + select { + case <-s.stop: + // Clear the spinner line. + fmt.Fprintf(os.Stderr, "\r\033[K") + return + case <-ctx.Done(): + // Context cancelled — clear and exit. + fmt.Fprintf(os.Stderr, "\r\033[K") + return + case <-ticker.C: + } + } +} diff --git a/connect.go b/connect.go index de4e2d3..2fb22f6 100644 --- a/connect.go +++ b/connect.go @@ -1,15 +1,19 @@ package certkit import ( + "cmp" "context" "crypto/tls" "crypto/x509" "crypto/x509/pkix" "encoding/asn1" + "errors" "fmt" "log/slog" "net" + "slices" "strings" + "sync" "time" ) @@ -17,7 +21,7 @@ import ( type ChainDiagnostic struct { // Check is the diagnostic identifier (e.g. "root-in-chain", "duplicate-cert", "missing-intermediate"). Check string `json:"check"` - // Status is the severity level (currently always "warn"). + // Status is the severity level: "warn" for configuration issues, "error" for verification failures. Status string `json:"status"` // Detail is a human-readable description of the issue. Detail string `json:"detail"` @@ -64,6 +68,99 @@ func DiagnoseConnectChain(input DiagnoseConnectChainInput) []ChainDiagnostic { return diags } +// SortDiagnostics sorts diagnostics: errors before warnings, then alphabetically +// by check name within each group for stable output order. +func SortDiagnostics(diags []ChainDiagnostic) { + slices.SortStableFunc(diags, func(a, b ChainDiagnostic) int { + // Errors first. + if a.Status != b.Status { + if a.Status == "error" { + return -1 + } + if b.Status == "error" { + return 1 + } + } + return cmp.Compare(a.Check, b.Check) + }) +} + +// DiagnoseVerifyError returns diagnostics derived from a chain verification error. +// Currently detects hostname mismatches (x509.HostnameError). +func DiagnoseVerifyError(verifyErr error) []ChainDiagnostic { + if verifyErr == nil { + return nil + } + if hostErr, ok := errors.AsType[x509.HostnameError](verifyErr); ok { + return []ChainDiagnostic{{ + Check: "hostname-mismatch", + Status: "error", + Detail: hostErr.Error(), + }} + } + return nil +} + +// DiagnoseNegotiatedCipher returns diagnostics for the cipher suite and protocol +// version that were actually negotiated during the TLS handshake. This catches +// issues like CBC mode or deprecated TLS versions even without a full --ciphers scan. +func DiagnoseNegotiatedCipher(protocol, cipherSuite string) []ChainDiagnostic { + var diags []ChainDiagnostic + + // Deprecated TLS versions (RFC 8996). + switch protocol { + case "TLS 1.0": + diags = append(diags, ChainDiagnostic{ + Check: "deprecated-tls10", + Status: "warn", + Detail: "negotiated TLS 1.0 — deprecated since RFC 8996", + }) + case "TLS 1.1": + diags = append(diags, ChainDiagnostic{ + Check: "deprecated-tls11", + Status: "warn", + Detail: "negotiated TLS 1.1 — deprecated since RFC 8996", + }) + } + + // CBC mode — vulnerable to padding oracle attacks (BEAST, Lucky13). + if strings.Contains(cipherSuite, "CBC") { + diags = append(diags, ChainDiagnostic{ + Check: "cbc-cipher", + Status: "warn", + Detail: fmt.Sprintf("negotiated CBC mode cipher suite %s — vulnerable to padding oracle attacks", cipherSuite), + }) + } + + // 3DES — 64-bit block size, vulnerable to Sweet32. + if strings.Contains(cipherSuite, "3DES") { + diags = append(diags, ChainDiagnostic{ + Check: "3des-cipher", + Status: "warn", + Detail: fmt.Sprintf("negotiated 3DES cipher suite %s — 64-bit block size, vulnerable to Sweet32", cipherSuite), + }) + } + + // Key exchange issues. + kex := cipherKeyExchange(cipherSuite, protocol) + switch kex { + case "RSA": + diags = append(diags, ChainDiagnostic{ + Check: "static-rsa-kex", + Status: "warn", + Detail: fmt.Sprintf("negotiated static RSA key exchange (%s) — no forward secrecy", cipherSuite), + }) + case "DHE", "DHE-DSS": + diags = append(diags, ChainDiagnostic{ + Check: "dhe-kex", + Status: "warn", + Detail: fmt.Sprintf("negotiated DHE key exchange (%s) — deprecated, no guaranteed forward secrecy with small DH parameters", cipherSuite), + }) + } + + return diags +} + // ConnectTLSInput contains parameters for a TLS connection probe. type ConnectTLSInput struct { // Host is the hostname or IP to connect to. @@ -147,6 +244,14 @@ type ConnectResult struct { // CRL contains the leaf certificate's CRL revocation status. // Nil when CRL checking is not requested (CheckCRL is false). CRL *CRLCheckResult `json:"crl,omitempty"` + // CipherScan contains the cipher suite enumeration results. + // Nil when cipher scanning is not requested. + CipherScan *CipherScanResult `json:"cipher_scan,omitempty"` + // LegacyProbe is true when the certificate chain was obtained via a raw + // TLS handshake (legacy fallback) because Go's crypto/tls could not + // negotiate any cipher suite. The chain is still valid for inspection + // but no full TLS connection was established. + LegacyProbe bool `json:"legacy_probe,omitempty"` } // ConnectTLS connects to a TLS server and returns connection details including @@ -210,8 +315,47 @@ func ConnectTLS(ctx context.Context, input ConnectTLSInput) (*ConnectResult, err } handshakeErr := tlsConn.HandshakeContext(ctx) - if handshakeErr != nil && clientAuth == nil { - return nil, fmt.Errorf("TLS handshake with %s: %w", addr, handshakeErr) + var tlsAlert tls.AlertError + if handshakeErr != nil && clientAuth == nil && errors.As(handshakeErr, &tlsAlert) { + // Close the failed TLS connection before opening a new one. + // The deferred tlsConn.Close() will be a no-op after this. + _ = tlsConn.Close() + + // Try raw legacy handshake to detect DHE/static-RSA-only servers. + // Only attempt this when the server sent a TLS alert (cipher + // negotiation failure), not for network errors or certificate errors. + // Use a dedicated timeout so a stalling server can't hold the + // fallback connection open indefinitely. + fallbackCtx, fallbackCancel := context.WithTimeout(ctx, 5*time.Second) + defer fallbackCancel() + legacyResult, legacyErr := legacyFallbackConnect(fallbackCtx, legacyFallbackInput{ + addr: addr, + serverName: serverName, + }) + if legacyErr != nil { + return nil, fmt.Errorf("tls handshake with %s: %w; legacy fallback: %v", addr, handshakeErr, legacyErr) + } + result := &ConnectResult{ + Host: input.Host, + Port: port, + Protocol: tlsVersionString(legacyResult.version), + CipherSuite: cipherSuiteName(legacyResult.cipherSuite), + ServerName: serverName, + PeerChain: legacyResult.certificates, + LegacyProbe: true, + } + result.populate(ctx, input) + result.Diagnostics = append(result.Diagnostics, ChainDiagnostic{ + Check: "legacy-only", + Status: "warn", + Detail: "server only supports cipher suites not available in standard TLS libraries; certificate chain verified but server key possession not proven", + }) + return result, nil + } else if handshakeErr != nil && clientAuth == nil { + // Non-alert failure (network error, certificate error, etc.) — return + // immediately. The mTLS fallback path below is only for client auth + // rejection, which only occurs when clientAuth is non-nil. + return nil, fmt.Errorf("tls handshake with %s: %w", addr, handshakeErr) } // When the server requested a client cert and rejected our empty @@ -231,22 +375,35 @@ func ConnectTLS(ctx context.Context, input ConnectTLSInput) (*ConnectResult, err PeerChain: state.PeerCertificates, } + result.populate(ctx, input) + return result, nil +} + +// populate runs chain diagnostics, verification, OCSP, and CRL checks on the +// ConnectResult. It is shared between the normal handshake path and the legacy +// fallback path. +func (result *ConnectResult) populate(ctx context.Context, input ConnectTLSInput) { + serverName := result.ServerName + + // Diagnose the negotiated cipher suite and protocol version. + result.Diagnostics = append(result.Diagnostics, DiagnoseNegotiatedCipher(result.Protocol, result.CipherSuite)...) + // Run chain diagnostics on the raw peer chain. - if len(state.PeerCertificates) > 0 { - result.Diagnostics = DiagnoseConnectChain(DiagnoseConnectChainInput{ - PeerChain: state.PeerCertificates, - }) + if len(result.PeerChain) > 0 { + result.Diagnostics = append(result.Diagnostics, DiagnoseConnectChain(DiagnoseConnectChainInput{ + PeerChain: result.PeerChain, + })...) } // Verify the chain ourselves to capture the error message. - if len(state.PeerCertificates) > 0 { - leaf := state.PeerCertificates[0] + if len(result.PeerChain) > 0 { + leaf := result.PeerChain[0] opts := x509.VerifyOptions{ DNSName: serverName, Intermediates: x509.NewCertPool(), Roots: input.RootCAs, } - for _, cert := range state.PeerCertificates[1:] { + for _, cert := range result.PeerChain[1:] { opts.Intermediates.AddCert(cert) } chains, verifyErr := leaf.Verify(opts) @@ -277,6 +434,7 @@ func ConnectTLS(ctx context.Context, input ConnectTLSInput) (*ConnectResult, err } if verifyErr != nil { result.VerifyError = verifyErr.Error() + result.Diagnostics = append(result.Diagnostics, DiagnoseVerifyError(verifyErr)...) } else { result.VerifiedChains = chains } @@ -284,15 +442,24 @@ func ConnectTLS(ctx context.Context, input ConnectTLSInput) (*ConnectResult, err // No peer certificates means TLS completed without sending certs (unlikely // but possible on a partially-completed handshake). Return early. - if len(state.PeerCertificates) == 0 { - return result, nil + if len(result.PeerChain) == 0 { + return + } + + // For legacy probes, certificate chain verification has been run above and + // the result is included in the output. However, OCSP and CRL revocation + // checks require a verified issuer from a real TLS channel — skipping + // them prevents misleading "skipped (no issuer in chain)" OCSP output + // when the legacy handshake produced an untrusted certificate. + if result.LegacyProbe { + return } // Resolve the issuer certificate for revocation checks. // Only use VerifiedChains (cryptographically validated). Do not fall back // to PeerCertificates — those are raw, unverified certs from the server and // using them would let an attacker forge valid OCSP/CRL responses. - leaf := state.PeerCertificates[0] + leaf := result.PeerChain[0] var issuer *x509.Certificate if len(result.VerifiedChains) > 0 && len(result.VerifiedChains[0]) > 1 { issuer = result.VerifiedChains[0][1] @@ -347,8 +514,6 @@ func ConnectTLS(ctx context.Context, input ConnectTLSInput) (*ConnectResult, err Detail: "no issuer certificate available to verify CRL signature", } } - - return result, nil } // CheckLeafCRLInput holds parameters for CheckLeafCRL. @@ -569,6 +734,809 @@ func FormatCRLLine(r *CRLCheckResult) string { return FormatCRLStatusLine("CRL: ", r) } +// CipherRating indicates the security quality of a cipher suite. +type CipherRating string + +const ( + // CipherRatingGood indicates TLS 1.3 suites or TLS 1.2 ECDHE+AEAD (GCM, ChaCha20-Poly1305). + CipherRatingGood CipherRating = "good" + // CipherRatingWeak indicates cipher suites that should be disabled: + // anything in Go's InsecureCipherSuites(), CBC-mode ciphers, or non-ECDHE key exchange. + CipherRatingWeak CipherRating = "weak" +) + +// CipherProbeResult describes a single cipher suite accepted by the server. +type CipherProbeResult struct { + // Name is the IANA cipher suite name (e.g. "TLS_AES_128_GCM_SHA256"). + Name string `json:"name"` + // ID is the numeric cipher suite identifier. + ID uint16 `json:"id"` + // Version is the TLS version string (e.g. "TLS 1.3"). + Version string `json:"version"` + // KeyExchange is the key exchange mechanism (e.g. "ECDHE", "RSA"). + KeyExchange string `json:"key_exchange"` + // Rating is the security quality assessment. + Rating CipherRating `json:"rating"` +} + +// CipherScanResult contains the results of a cipher suite enumeration. +type CipherScanResult struct { + // SupportedVersions lists the TLS versions the server supports (e.g. ["TLS 1.3", "TLS 1.2"]). + SupportedVersions []string `json:"supported_versions"` + // Ciphers lists all accepted cipher suites, sorted by version (descending) then rating. + Ciphers []CipherProbeResult `json:"ciphers"` + // QUICProbed is true when QUIC/UDP cipher probing was attempted. + QUICProbed bool `json:"quic_probed"` + // QUICCiphers lists TLS 1.3 cipher suites accepted over QUIC/UDP. + QUICCiphers []CipherProbeResult `json:"quic_ciphers,omitempty"` + // KeyExchanges lists accepted key exchange groups (classical and post-quantum). + KeyExchanges []KeyExchangeProbeResult `json:"key_exchanges,omitempty"` + // OverallRating is the worst rating among all accepted ciphers. + // Empty when no ciphers were detected (omitted from JSON). + OverallRating CipherRating `json:"overall_rating,omitempty"` +} + +// ScanCipherSuitesInput contains parameters for ScanCipherSuites. +type ScanCipherSuitesInput struct { + // Host is the hostname or IP to connect to. + Host string + // Port is the TCP port (default: "443"). + Port string + // ServerName overrides the SNI hostname (defaults to Host). + ServerName string + // Concurrency is the maximum number of parallel probe connections (default: 10). + Concurrency int + // ProbeQUIC enables QUIC/UDP cipher probing alongside TCP. + ProbeQUIC bool +} + +// cipherSuiteName returns a human-readable name for any TLS cipher suite. +// It extends tls.CipherSuiteName with the two CCM suites from RFC 8446 that +// Go doesn't implement (0x1304, 0x1305), legacy DHE/DHE-DSS suites, and would +// otherwise show as hex for unknown suites. +func cipherSuiteName(id uint16) string { + switch id { + case 0x1304: + return "TLS_AES_128_CCM_SHA256" + case 0x1305: + return "TLS_AES_128_CCM_8_SHA256" + default: + // Check legacy cipher registry before falling back to Go's function, + // which returns hex for unknown suites. + for _, def := range legacyCipherSuites { + if def.ID == id { + return def.Name + } + } + return tls.CipherSuiteName(id) + } +} + +// keyExchangeName returns a human-readable name for a TLS named group. +// Go's CurveID.String() returns "CurveP256" etc.; we prefer "P-256". +func keyExchangeName(id tls.CurveID) string { + switch id { + case tls.CurveP256: + return "P-256" + case tls.CurveP384: + return "P-384" + case tls.CurveP521: + return "P-521" + default: + return id.String() + } +} + +// cipherKeyExchange returns the key exchange mechanism for a cipher suite. +// TLS 1.3 always uses ECDHE. For TLS 1.0–1.2, it's derived from the cipher name. +func cipherKeyExchange(name, version string) string { + if version == "TLS 1.3" { + return "ECDHE" + } + if strings.HasPrefix(name, "TLS_ECDHE_") { + return "ECDHE" + } + if strings.HasPrefix(name, "TLS_RSA_") { + return "RSA" + } + if strings.HasPrefix(name, "TLS_DHE_DSS_") { + return "DHE-DSS" + } + if strings.HasPrefix(name, "TLS_DHE_") { + return "DHE" + } + return "unknown" +} + +// kexRank returns a sort key for key exchange types (lower = better). +func kexRank(kex string) int { + switch kex { + case "ECDHE": + return 0 + case "DHE": + return 1 + case "DHE-DSS": + return 2 + case "RSA": + return 3 + default: + return 4 + } +} + +// RateCipherSuite returns the security rating for a cipher suite at a given TLS version. +func RateCipherSuite(cipherID uint16, tlsVersion uint16) CipherRating { + // TLS 1.3 suites are all AEAD — generally good, except TLS_AES_128_CCM_8_SHA256 + // (0x1305) which uses a truncated 8-byte authentication tag and is IANA "Not Recommended". + if tlsVersion == tls.VersionTLS13 { + if cipherID == 0x1305 { + return CipherRatingWeak + } + return CipherRatingGood + } + + // Check if it's in Go's insecure list (RC4, 3DES, null ciphers). + for _, cs := range tls.InsecureCipherSuites() { + if cs.ID == cipherID { + return CipherRatingWeak + } + } + + // For TLS 1.0–1.2: look up the cipher suite name to classify. + name := tls.CipherSuiteName(cipherID) + + // Non-ECDHE key exchange (static RSA, DHE/DSS) is weak — no modern forward secrecy guarantees. + if !strings.Contains(name, "ECDHE") { + return CipherRatingWeak + } + + // ECDHE + AEAD (GCM or ChaCha20-Poly1305) is good. + if strings.Contains(name, "GCM") || strings.Contains(name, "CHACHA20_POLY1305") { + return CipherRatingGood + } + + // ECDHE + CBC is weak (padding oracle attacks like BEAST, Lucky13). + return CipherRatingWeak +} + +// ScanCipherSuites probes a TLS server to enumerate all supported cipher suites +// and key exchange groups. TLS 1.3 ciphers are probed using raw ClientHello +// packets (all 5 RFC 8446 suites). TLS 1.0–1.2 ciphers are probed using Go's +// crypto/tls with a single-cipher config. Key exchange groups are probed via +// raw ClientHello with individual named groups. All probes run concurrently. +func ScanCipherSuites(ctx context.Context, input ScanCipherSuitesInput) (*CipherScanResult, error) { + if input.Host == "" { + return nil, fmt.Errorf("scanning cipher suites: host is required") + } + port := input.Port + if port == "" { + port = "443" + } + serverName := input.ServerName + if serverName == "" { + serverName = input.Host + } + concurrency := input.Concurrency + if concurrency <= 0 { + concurrency = 10 + } + + addr := net.JoinHostPort(input.Host, port) + + sem := make(chan struct{}, concurrency) + var mu sync.Mutex + var wg sync.WaitGroup + + // acquireSem tries to acquire a semaphore slot, returning false if the + // context is cancelled while waiting for a slot. + acquireSem := func() bool { + select { + case sem <- struct{}{}: + return true + case <-ctx.Done(): + return false + } + } + + // probeTimeout is the maximum time for a single probe attempt. Each + // probe gets a child context derived from ctx, so it also inherits the + // parent's cancellation. The short timeout prevents slow/stalling + // servers from blocking the entire scan. + const probeTimeout = 2 * time.Second + + // Probe TLS 1.3 ciphers using raw ClientHello packets. Each probe is + // fully isolated — no shared state, safe for concurrent use. + var results []CipherProbeResult + for _, id := range tls13CipherSuites { + if ctx.Err() != nil { + break + } + if !acquireSem() { + break + } + wg.Add(1) + go func(cipherID uint16) { + defer wg.Done() + defer func() { <-sem }() + + probeCtx, probeCancel := context.WithTimeout(ctx, probeTimeout) + defer probeCancel() + + if probeTLS13Cipher(probeCtx, cipherProbeInput{addr: addr, serverName: serverName, cipherID: cipherID}) { + r := CipherProbeResult{ + Name: cipherSuiteName(cipherID), + ID: cipherID, + Version: "TLS 1.3", + KeyExchange: "ECDHE", + Rating: RateCipherSuite(cipherID, tls.VersionTLS13), + } + mu.Lock() + results = append(results, r) + mu.Unlock() + } + }(id) + } + + // Collect all TLS 1.0–1.2 cipher suites to probe. + type probeTask struct { + id uint16 + version uint16 + } + var tasks []probeTask + allSuites := slices.Concat(tls.CipherSuites(), tls.InsecureCipherSuites()) + for _, cs := range allSuites { + for _, v := range cs.SupportedVersions { + if v >= tls.VersionTLS10 && v <= tls.VersionTLS12 { + tasks = append(tasks, probeTask{id: cs.ID, version: v}) + } + } + } + + // Probe TLS 1.0–1.2 ciphers concurrently using Go's crypto/tls. + for _, task := range tasks { + if ctx.Err() != nil { + break + } + if !acquireSem() { + break + } + + wg.Add(1) + go func(t probeTask) { + defer wg.Done() + defer func() { <-sem }() + + probeCtx, probeCancel := context.WithTimeout(ctx, probeTimeout) + defer probeCancel() + + if probeCipher(probeCtx, cipherProbeInput{addr: addr, serverName: serverName, cipherID: t.id, version: t.version}) { + name := cipherSuiteName(t.id) + r := CipherProbeResult{ + Name: name, + ID: t.id, + Version: tlsVersionString(t.version), + KeyExchange: cipherKeyExchange(name, tlsVersionString(t.version)), + Rating: RateCipherSuite(t.id, t.version), + } + mu.Lock() + results = append(results, r) + mu.Unlock() + } + }(task) + } + + // Probe legacy cipher suites (DHE, DHE-DSS) using raw ClientHello packets. + // These suites are not implemented in Go's crypto/tls. + for _, def := range legacyCipherSuites { + if ctx.Err() != nil { + break + } + if !acquireSem() { + break + } + wg.Add(1) + go func(d legacyCipherDef) { + defer wg.Done() + defer func() { <-sem }() + + probeCtx, probeCancel := context.WithTimeout(ctx, probeTimeout) + defer probeCancel() + + if negotiatedVer, ok := probeLegacyCipher(probeCtx, cipherProbeInput{ + addr: addr, + serverName: serverName, + cipherID: d.ID, + version: tls.VersionTLS12, + }); ok { + r := CipherProbeResult{ + Name: d.Name, + ID: d.ID, + Version: tlsVersionString(negotiatedVer), + KeyExchange: d.KeyExchange, + Rating: CipherRatingWeak, + } + mu.Lock() + results = append(results, r) + mu.Unlock() + } + }(def) + } + + // Probe key exchange groups. TLS 1.3 groups use raw ClientHello packets; + // classical groups also try TLS 1.2 (via crypto/tls CurvePreferences) + // to cover servers that don't support TLS 1.3. + var keyExchanges []KeyExchangeProbeResult + kxSeen := make(map[tls.CurveID]bool) + var kxMu sync.Mutex + for _, gid := range keyExchangeGroups { + if ctx.Err() != nil { + break + } + if !acquireSem() { + break + } + wg.Add(1) + go func(groupID tls.CurveID) { + defer wg.Done() + defer func() { <-sem }() + + probeCtx, probeCancel := context.WithTimeout(ctx, probeTimeout) + defer probeCancel() + + probeInput := cipherProbeInput{addr: addr, serverName: serverName, groupID: groupID} + accepted := probeKeyExchangeGroup(probeCtx, probeInput) + + // For classical (non-PQ) groups, also probe TLS 1.0–1.2 if TLS 1.3 didn't work. + if !accepted && !isPQKeyExchange(groupID) { + probeCtx2, probeCancel2 := context.WithTimeout(ctx, probeTimeout) + defer probeCancel2() + accepted = probeKeyExchangeGroupLegacy(probeCtx2, probeInput) + } + + if accepted { + kxMu.Lock() + if !kxSeen[groupID] { + kxSeen[groupID] = true + keyExchanges = append(keyExchanges, KeyExchangeProbeResult{ + Name: keyExchangeName(groupID), + ID: uint16(groupID), + PostQuantum: isPQKeyExchange(groupID), + }) + } + kxMu.Unlock() + } + }(gid) + } + + // Probe QUIC/UDP cipher suites if requested and the target port is 443. + // QUIC is only meaningful on UDP 443; probing arbitrary ports produces + // spurious timeouts on servers that don't run QUIC. + var quicCiphers []CipherProbeResult + if input.ProbeQUIC && port == "443" { + quicAddr := net.JoinHostPort(input.Host, port) + for _, id := range tls13CipherSuites { + if ctx.Err() != nil { + break + } + if !acquireSem() { + break + } + wg.Add(1) + go func(cipherID uint16) { + defer wg.Done() + defer func() { <-sem }() + + probeCtx, probeCancel := context.WithTimeout(ctx, probeTimeout) + defer probeCancel() + + if probeQUICCipher(probeCtx, cipherProbeInput{addr: quicAddr, serverName: serverName, cipherID: cipherID}) { + r := CipherProbeResult{ + Name: cipherSuiteName(cipherID), + ID: cipherID, + Version: "TLS 1.3", + KeyExchange: "ECDHE", + Rating: RateCipherSuite(cipherID, tls.VersionTLS13), + } + mu.Lock() + quicCiphers = append(quicCiphers, r) + mu.Unlock() + } + }(id) + } + } + + wg.Wait() + + if ctx.Err() != nil { + return nil, fmt.Errorf("scanning cipher suites: %w", ctx.Err()) + } + + // Sort ciphers: version descending, then kex type (ECDHE before RSA), + // then rating (good before weak), then name. + slices.SortFunc(results, func(a, b CipherProbeResult) int { + if c := cmp.Compare(tlsVersionRank(b.Version), tlsVersionRank(a.Version)); c != 0 { + return c + } + if c := cmp.Compare(kexRank(a.KeyExchange), kexRank(b.KeyExchange)); c != 0 { + return c + } + if c := cmp.Compare(ratingRank(a.Rating), ratingRank(b.Rating)); c != 0 { + return c + } + return cmp.Compare(a.Name, b.Name) + }) + + // Sort key exchanges: PQ first, then by ID descending for consistent output. + slices.SortFunc(keyExchanges, func(a, b KeyExchangeProbeResult) int { + if a.PostQuantum != b.PostQuantum { + if a.PostQuantum { + return -1 + } + return 1 + } + return cmp.Compare(b.ID, a.ID) + }) + + // Compute supported versions and overall rating across both TCP and QUIC ciphers. + versionSet := make(map[string]bool) + var overall CipherRating + if len(results) > 0 || len(quicCiphers) > 0 { + overall = CipherRatingGood + for _, r := range results { + versionSet[r.Version] = true + if ratingRank(r.Rating) > ratingRank(overall) { + overall = r.Rating + } + } + for _, r := range quicCiphers { + versionSet[r.Version] = true + if ratingRank(r.Rating) > ratingRank(overall) { + overall = r.Rating + } + } + } + + var versions []string + for v := range versionSet { + versions = append(versions, v) + } + slices.SortFunc(versions, func(a, b string) int { + return cmp.Compare(tlsVersionRank(b), tlsVersionRank(a)) + }) + + // Sort QUIC ciphers by name for consistent output. + slices.SortFunc(quicCiphers, func(a, b CipherProbeResult) int { + return cmp.Compare(a.Name, b.Name) + }) + + // Ensure non-omitempty slices are never nil so JSON encodes [] not null. + if versions == nil { + versions = []string{} + } + if results == nil { + results = []CipherProbeResult{} + } + + return &CipherScanResult{ + SupportedVersions: versions, + Ciphers: results, + QUICProbed: input.ProbeQUIC && port == "443", + QUICCiphers: quicCiphers, + KeyExchanges: keyExchanges, + OverallRating: overall, + }, nil +} + +// emptyClientCertificate is a GetClientCertificate callback that returns an empty +// certificate. This is needed so the handshake progresses far enough to +// negotiate a cipher suite even when the server requests client auth (mTLS). +func emptyClientCertificate(_ *tls.CertificateRequestInfo) (*tls.Certificate, error) { + return &tls.Certificate{}, nil +} + +// probeCipher attempts a TLS handshake offering only the specified cipher suite at the given version. +// Returns true if the server accepted the cipher, even if the handshake +// ultimately fails (e.g. mTLS rejection after cipher negotiation). +func probeCipher(ctx context.Context, input cipherProbeInput) bool { + dialer := &net.Dialer{} + conn, err := dialer.DialContext(ctx, "tcp", input.addr) + if err != nil { + return false + } + tlsConn := tls.Client(conn, &tls.Config{ + ServerName: input.serverName, + InsecureSkipVerify: true, //nolint:gosec // Cipher probing doesn't need cert verification. + MinVersion: input.version, + MaxVersion: input.version, + CipherSuites: []uint16{input.cipherID}, + GetClientCertificate: emptyClientCertificate, + }) + defer func() { _ = tlsConn.Close() }() + + if deadline, ok := ctx.Deadline(); ok { + _ = tlsConn.SetDeadline(deadline) + } + if tlsConn.HandshakeContext(ctx) == nil { + return true + } + // Handshake failed, but check if the server negotiated our cipher before aborting. + state := tlsConn.ConnectionState() + return state.Version == input.version && state.CipherSuite == input.cipherID +} + +// ecdheOnlyCipherSuites contains only ECDHE-based TLS 1.0–1.2 cipher suites. +// Used by probeKeyExchangeGroupLegacy to ensure the server must use ECDHE key +// exchange — without this, servers that pick RSA key exchange would incorrectly +// appear to support any offered curve. +var ecdheOnlyCipherSuites = func() []uint16 { + var ids []uint16 + for _, cs := range tls.CipherSuites() { + if strings.Contains(cs.Name, "ECDHE") { + ids = append(ids, cs.ID) + } + } + return ids +}() + +// probeKeyExchangeGroupLegacy attempts a TLS 1.0–1.2 handshake using a single +// CurvePreferences entry and returns true if the server accepts the group. Only +// ECDHE cipher suites are offered so the handshake fails if the server doesn't +// support the offered curve (RSA key exchange would bypass curve negotiation). +func probeKeyExchangeGroupLegacy(ctx context.Context, input cipherProbeInput) bool { + dialer := &net.Dialer{} + conn, err := dialer.DialContext(ctx, "tcp", input.addr) + if err != nil { + return false + } + tlsConn := tls.Client(conn, &tls.Config{ + ServerName: input.serverName, + InsecureSkipVerify: true, //nolint:gosec // Probing doesn't need cert verification. + MinVersion: tls.VersionTLS10, + MaxVersion: tls.VersionTLS12, + CipherSuites: ecdheOnlyCipherSuites, + CurvePreferences: []tls.CurveID{input.groupID}, + GetClientCertificate: emptyClientCertificate, + }) + defer func() { _ = tlsConn.Close() }() + + if deadline, ok := ctx.Deadline(); ok { + _ = tlsConn.SetDeadline(deadline) + } + if tlsConn.HandshakeContext(ctx) == nil { + return true + } + // Handshake may fail due to mTLS rejection, but the key exchange + // succeeded if a TLS version was negotiated (happens before client auth). + state := tlsConn.ConnectionState() + return state.Version != 0 +} + +// tlsVersionRank returns a sort key for TLS versions (higher = newer). +func tlsVersionRank(version string) int { + switch version { + case "TLS 1.3": + return 4 + case "TLS 1.2": + return 3 + case "TLS 1.1": + return 2 + case "TLS 1.0": + return 1 + default: + return 0 + } +} + +// ratingRank returns a sort key for cipher ratings (lower = better). +func ratingRank(r CipherRating) int { + switch r { + case CipherRatingGood: + return 0 + case CipherRatingWeak: + return 1 + default: + return 2 + } +} + +// DiagnoseCipherScan inspects cipher scan results and returns specific, +// actionable diagnostics for deprecated protocols, weak cipher modes, +// and insecure key exchange. +func DiagnoseCipherScan(r *CipherScanResult) []ChainDiagnostic { + if r == nil { + return nil + } + + allCiphers := slices.Concat(r.Ciphers, r.QUICCiphers) + if len(allCiphers) == 0 { + return nil + } + + var diags []ChainDiagnostic + + // Deprecated TLS versions (RFC 8996). + var tls10, tls11 int + for _, c := range allCiphers { + switch c.Version { + case "TLS 1.0": + tls10++ + case "TLS 1.1": + tls11++ + } + } + if tls10 > 0 { + diags = append(diags, ChainDiagnostic{ + Check: "deprecated-tls10", + Status: "warn", + Detail: fmt.Sprintf("server supports TLS 1.0 (%d cipher suite(s)) — deprecated since RFC 8996", tls10), + }) + } + if tls11 > 0 { + diags = append(diags, ChainDiagnostic{ + Check: "deprecated-tls11", + Status: "warn", + Detail: fmt.Sprintf("server supports TLS 1.1 (%d cipher suite(s)) — deprecated since RFC 8996", tls11), + }) + } + + // CBC mode cipher suites — vulnerable to padding oracle attacks (BEAST, Lucky13). + var cbc int + for _, c := range allCiphers { + if strings.Contains(c.Name, "CBC") { + cbc++ + } + } + if cbc > 0 { + diags = append(diags, ChainDiagnostic{ + Check: "cbc-cipher", + Status: "warn", + Detail: fmt.Sprintf("server accepts %d CBC mode cipher suite(s) — vulnerable to padding oracle attacks", cbc), + }) + } + + // Static RSA key exchange — no forward secrecy. + var staticRSA int + for _, c := range allCiphers { + if c.KeyExchange == "RSA" { + staticRSA++ + } + } + if staticRSA > 0 { + diags = append(diags, ChainDiagnostic{ + Check: "static-rsa-kex", + Status: "warn", + Detail: fmt.Sprintf("server accepts %d static RSA key exchange cipher suite(s) — no forward secrecy", staticRSA), + }) + } + + // 3DES cipher suites — 64-bit block size, vulnerable to Sweet32. + var tripleDES int + for _, c := range allCiphers { + if strings.Contains(c.Name, "3DES") { + tripleDES++ + } + } + if tripleDES > 0 { + diags = append(diags, ChainDiagnostic{ + Check: "3des-cipher", + Status: "warn", + Detail: fmt.Sprintf("server accepts %d 3DES cipher suite(s) — 64-bit block size, vulnerable to Sweet32", tripleDES), + }) + } + + // DHE key exchange — deprecated, vulnerable to small DH parameters. + var dhe int + for _, c := range allCiphers { + if c.KeyExchange == "DHE" || c.KeyExchange == "DHE-DSS" { + dhe++ + } + } + if dhe > 0 { + diags = append(diags, ChainDiagnostic{ + Check: "dhe-kex", + Status: "warn", + Detail: fmt.Sprintf("server accepts %d DHE key exchange cipher suite(s) — deprecated, no guaranteed forward secrecy with small DH parameters", dhe), + }) + } + + return diags +} + +// FormatCipherRatingLine formats a one-line summary for the connect header block, +// positioned alongside Host/Protocol/OCSP etc. +func FormatCipherRatingLine(r *CipherScanResult) string { + if r == nil || (len(r.Ciphers) == 0 && len(r.QUICCiphers) == 0) { + return "" + } + + var strong, weak int + for _, c := range r.Ciphers { + if c.Rating == CipherRatingGood { + strong++ + } else { + weak++ + } + } + for _, c := range r.QUICCiphers { + if c.Rating == CipherRatingGood { + strong++ + } else { + weak++ + } + } + + return fmt.Sprintf("Ciphers: %s (%d good, %d weak)\n", r.OverallRating, strong, weak) +} + +// kexLabel returns the display label for a key exchange type in the cipher +// suite subgroup header, e.g. "ECDHE" or "RSA, no forward secrecy". +func kexLabel(kex string) string { + switch kex { + case "RSA": + return "RSA, no forward secrecy" + case "DHE": + return "DHE, deprecated" + case "DHE-DSS": + return "DHE-DSS, deprecated" + default: + return kex + } +} + +// FormatCipherScanResult formats the cipher suite list as human-readable text. +func FormatCipherScanResult(r *CipherScanResult) string { + if r == nil { + return "" + } + if len(r.Ciphers) == 0 && len(r.QUICCiphers) == 0 { + return "\nCipher suites: none detected\n" + } + + var out strings.Builder + fmt.Fprintf(&out, "\nCipher suites (%d supported):\n", len(r.Ciphers)) + + // Group by version, then subgroup by key exchange type. + currentVersion := "" + currentKex := "" + for _, c := range r.Ciphers { + if c.Version != currentVersion || c.KeyExchange != currentKex { + if currentVersion != "" { + out.WriteByte('\n') + } + currentVersion = c.Version + currentKex = c.KeyExchange + fmt.Fprintf(&out, " %s (%s):\n", currentVersion, kexLabel(currentKex)) + } + fmt.Fprintf(&out, " [%s] %s\n", c.Rating, c.Name) + } + + // QUIC cipher suites (if probed). + if r.QUICProbed { + if len(r.QUICCiphers) > 0 { + fmt.Fprintf(&out, "\nQUIC cipher suites (%d supported):\n", len(r.QUICCiphers)) + for _, c := range r.QUICCiphers { + fmt.Fprintf(&out, " [%s] %s\n", c.Rating, c.Name) + } + } else { + out.WriteString("\nQUIC: not supported\n") + } + } + + // Key exchange groups (forward secrecy). + if len(r.KeyExchanges) > 0 { + fmt.Fprintf(&out, "\nKey exchange groups (%d supported, forward secrecy):\n", len(r.KeyExchanges)) + for _, g := range r.KeyExchanges { + if g.PostQuantum { + fmt.Fprintf(&out, " %s (post-quantum)\n", g.Name) + } else { + fmt.Fprintf(&out, " %s\n", g.Name) + } + } + } + + return out.String() +} + // FormatConnectResult formats a ConnectResult as human-readable text. func FormatConnectResult(r *ConnectResult) string { var out strings.Builder @@ -577,6 +1545,10 @@ func FormatConnectResult(r *ConnectResult) string { fmt.Fprintf(&out, "Cipher Suite: %s\n", r.CipherSuite) fmt.Fprintf(&out, "Server Name: %s\n", r.ServerName) + if r.LegacyProbe { + out.WriteString("Note: certificate obtained via raw probe — server key possession not verified\n") + } + if r.ALPN != "" { fmt.Fprintf(&out, "ALPN: %s\n", r.ALPN) } @@ -597,6 +1569,8 @@ func FormatConnectResult(r *ConnectResult) string { out.WriteString(FormatCRLLine(r.CRL)) } + out.WriteString(FormatCipherRatingLine(r.CipherScan)) + if r.ClientAuth != nil && r.ClientAuth.Requested { if len(r.ClientAuth.AcceptableCAs) > 0 { fmt.Fprintf(&out, "Client Auth: requested (%d acceptable CA(s))\n", len(r.ClientAuth.AcceptableCAs)) @@ -608,7 +1582,11 @@ func FormatConnectResult(r *ConnectResult) string { if len(r.Diagnostics) > 0 { out.WriteString("\nDiagnostics:\n") for _, d := range r.Diagnostics { - fmt.Fprintf(&out, " [WARN] %s: %s\n", d.Check, d.Detail) + tag := "WARN" + if d.Status == "error" { + tag = "ERR" + } + fmt.Fprintf(&out, " [%s] %s: %s\n", tag, d.Check, d.Detail) } } diff --git a/connect_test.go b/connect_test.go index 833b5e0..5cd11cc 100644 --- a/connect_test.go +++ b/connect_test.go @@ -8,6 +8,7 @@ import ( "crypto/tls" "crypto/x509" "crypto/x509/pkix" + "errors" "math/big" "net" "net/http" @@ -295,6 +296,8 @@ func TestFormatConnectResult(t *testing.T) { name string diagnostics []ChainDiagnostic aiaFetched bool + verifyError string + clientAuth *ClientAuthInfo ocsp *OCSPResult crl *CRLCheckResult wantStrings []string @@ -314,6 +317,18 @@ func TestFormatConnectResult(t *testing.T) { }, wantStrings: []string{"Diagnostics:", "[WARN] root-in-chain:", "Verify: OK\n"}, }, + { + name: "error-level diagnostic rendered with ERR tag", + diagnostics: []ChainDiagnostic{ + {Check: "hostname-mismatch", Status: "error", Detail: `x509: certificate is valid for *.badssl.com, badssl.com, not wrong.host.badssl.com`}, + }, + verifyError: "x509: certificate is valid for *.badssl.com, badssl.com, not wrong.host.badssl.com", + wantStrings: []string{ + "[ERR] hostname-mismatch:", + "Verify: FAILED", + }, + notWantStrings: []string{"[WARN] hostname-mismatch:"}, + }, { name: "AIA-aware verify line", aiaFetched: true, @@ -374,6 +389,21 @@ func TestFormatConnectResult(t *testing.T) { "OCSP: skipped (certificate has no OCSP responder URL)", }, }, + { + name: "verify failed", + verifyError: "x509: certificate signed by unknown authority", + wantStrings: []string{ + "Verify: FAILED (x509: certificate signed by unknown authority)", + }, + notWantStrings: []string{"Verify: OK"}, + }, + { + name: "client auth requested (any CA)", + clientAuth: &ClientAuthInfo{Requested: true}, + wantStrings: []string{ + "Client Auth: requested (any CA)", + }, + }, { name: "CRL good", crl: &CRLCheckResult{Status: "good", URL: "http://crl.example.com/ca.crl"}, @@ -390,6 +420,35 @@ func TestFormatConnectResult(t *testing.T) { }, } + // LegacyProbe: Note shows key-possession caveat; Verify shows real chain result. + t.Run("LegacyProbe shows Note and real Verify result", func(t *testing.T) { + t.Parallel() + result := &ConnectResult{ + Host: "test.example.com", + Port: "443", + Protocol: "TLS 1.2", + CipherSuite: "TLS_DHE_RSA_WITH_AES_128_CBC_SHA", + ServerName: "test.example.com", + PeerChain: []*x509.Certificate{cert}, + LegacyProbe: true, + } + output := FormatConnectResult(result) + for _, want := range []string{ + "Note:", + "raw probe", + "server key possession not verified", + "Verify: OK", + } { + if !strings.Contains(output, want) { + t.Errorf("output missing %q\ngot:\n%s", want, output) + } + } + // Must NOT show the old "N/A" placeholder. + if strings.Contains(output, "N/A") { + t.Errorf("output contains stale N/A placeholder\ngot:\n%s", output) + } + }) + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() @@ -402,6 +461,8 @@ func TestFormatConnectResult(t *testing.T) { PeerChain: []*x509.Certificate{cert}, Diagnostics: tt.diagnostics, AIAFetched: tt.aiaFetched, + VerifyError: tt.verifyError, + ClientAuth: tt.clientAuth, OCSP: tt.ocsp, CRL: tt.crl, } @@ -493,6 +554,198 @@ func TestDiagnoseConnectChain(t *testing.T) { } } +func TestSortDiagnostics(t *testing.T) { + t.Parallel() + + diags := []ChainDiagnostic{ + {Check: "deprecated-tls10", Status: "warn", Detail: "..."}, + {Check: "cbc-cipher", Status: "warn", Detail: "..."}, + {Check: "verify-failed", Status: "error", Detail: "..."}, + {Check: "3des-cipher", Status: "warn", Detail: "..."}, + {Check: "hostname-mismatch", Status: "error", Detail: "..."}, + {Check: "static-rsa-kex", Status: "warn", Detail: "..."}, + } + + SortDiagnostics(diags) + + wantOrder := []string{ + "hostname-mismatch", // error, alpha first + "verify-failed", // error, alpha second + "3des-cipher", // warn, alpha + "cbc-cipher", + "deprecated-tls10", + "static-rsa-kex", + } + + if len(diags) != len(wantOrder) { + t.Fatalf("got %d diagnostics, want %d", len(diags), len(wantOrder)) + } + for i, want := range wantOrder { + if diags[i].Check != want { + t.Errorf("diags[%d].Check = %q, want %q", i, diags[i].Check, want) + } + } +} + +func TestDiagnoseVerifyError(t *testing.T) { + t.Parallel() + + // Generate a self-signed CA cert for "localhost" and trust it, so that + // verifying with a wrong DNSName triggers HostnameError (not UnknownAuthorityError). + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "localhost"}, + DNSNames: []string{"localhost"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + IsCA: true, + BasicConstraintsValid: true, + } + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + cert, err := x509.ParseCertificate(certDER) + if err != nil { + t.Fatal(err) + } + + roots := x509.NewCertPool() + roots.AddCert(cert) + + // Trigger a real HostnameError by verifying with wrong DNSName. + _, hostnameErr := cert.Verify(x509.VerifyOptions{DNSName: "wrong.example.com", Roots: roots}) + if hostnameErr == nil { + t.Fatal("expected verification error for wrong hostname") + } + + tests := []struct { + name string + err error + wantChecks []string + }{ + { + name: "hostname mismatch", + err: hostnameErr, + // The error chain may include both HostnameError and UnknownAuthorityError. + // We only care that hostname-mismatch is present. + wantChecks: []string{"hostname-mismatch"}, + }, + { + name: "non-hostname error", + err: errors.New("x509: certificate signed by unknown authority"), + wantChecks: nil, + }, + { + name: "nil error", + err: nil, + wantChecks: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + diags := DiagnoseVerifyError(tt.err) + if len(diags) != len(tt.wantChecks) { + t.Fatalf("got %d diagnostics, want %d: %+v", len(diags), len(tt.wantChecks), diags) + } + for i, wantCheck := range tt.wantChecks { + if diags[i].Check != wantCheck { + t.Errorf("diag[%d].Check = %q, want %q", i, diags[i].Check, wantCheck) + } + if diags[i].Status != "error" { + t.Errorf("diag[%d].Status = %q, want %q", i, diags[i].Status, "error") + } + } + }) + } +} + +func TestDiagnoseNegotiatedCipher(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + protocol string + cipherSuite string + wantChecks []string + wantSubs [][]string // per-diagnostic substrings to match in Detail + }{ + { + name: "TLS 1.3 with AEAD — no diagnostics", + protocol: "TLS 1.3", + cipherSuite: "TLS_AES_128_GCM_SHA256", + }, + { + name: "TLS 1.2 ECDHE GCM — no diagnostics", + protocol: "TLS 1.2", + cipherSuite: "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", + }, + { + name: "TLS 1.2 CBC cipher", + protocol: "TLS 1.2", + cipherSuite: "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA", + wantChecks: []string{"cbc-cipher"}, + wantSubs: [][]string{{"CBC", "padding oracle"}}, + }, + { + name: "TLS 1.0 with CBC and static RSA", + protocol: "TLS 1.0", + cipherSuite: "TLS_RSA_WITH_AES_128_CBC_SHA", + wantChecks: []string{"deprecated-tls10", "cbc-cipher", "static-rsa-kex"}, + }, + { + name: "TLS 1.1 with 3DES", + protocol: "TLS 1.1", + cipherSuite: "TLS_RSA_WITH_3DES_EDE_CBC_SHA", + wantChecks: []string{"deprecated-tls11", "cbc-cipher", "3des-cipher", "static-rsa-kex"}, + }, + { + name: "DHE key exchange", + protocol: "TLS 1.2", + cipherSuite: "TLS_DHE_RSA_WITH_AES_128_GCM_SHA256", + wantChecks: []string{"dhe-kex"}, + wantSubs: [][]string{{"DHE", "deprecated"}}, + }, + { + name: "DHE-DSS key exchange with CBC", + protocol: "TLS 1.2", + cipherSuite: "TLS_DHE_DSS_WITH_AES_128_CBC_SHA", + wantChecks: []string{"cbc-cipher", "dhe-kex"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + diags := DiagnoseNegotiatedCipher(tt.protocol, tt.cipherSuite) + if len(diags) != len(tt.wantChecks) { + t.Fatalf("got %d diagnostics, want %d: %+v", len(diags), len(tt.wantChecks), diags) + } + for i, wantCheck := range tt.wantChecks { + if diags[i].Check != wantCheck { + t.Errorf("diag[%d].Check = %q, want %q", i, diags[i].Check, wantCheck) + } + if diags[i].Status != "warn" { + t.Errorf("diag[%d].Status = %q, want %q", i, diags[i].Status, "warn") + } + if i < len(tt.wantSubs) { + for _, sub := range tt.wantSubs[i] { + if !strings.Contains(diags[i].Detail, sub) { + t.Errorf("diag[%d].Detail missing %q, got: %s", i, sub, diags[i].Detail) + } + } + } + } + }) + } +} + func TestConnectTLS_AIAFetch(t *testing.T) { t.Parallel() @@ -1169,6 +1422,502 @@ func TestConnectTLS_CRL_AIAFetchedIssuer(t *testing.T) { } } +func TestRateCipherSuite(t *testing.T) { + t.Parallel() + + // One entry per distinct code path in RateCipherSuite (T-12). + tests := []struct { + name string + cipherID uint16 + tlsVersion uint16 + want CipherRating + }{ + // TLS 1.3 — generally good (all suites are AEAD). + { + name: "TLS 1.3 good", + cipherID: tls.TLS_AES_128_GCM_SHA256, + tlsVersion: tls.VersionTLS13, + want: CipherRatingGood, + }, + // TLS 1.3 CCM_8 — weak (truncated 8-byte auth tag, IANA "Not Recommended"). + { + name: "TLS 1.3 CCM_8 weak", + cipherID: 0x1305, + tlsVersion: tls.VersionTLS13, + want: CipherRatingWeak, + }, + // TLS 1.2 ECDHE + GCM — good (forward secrecy + AEAD). + { + name: "TLS 1.2 ECDHE+GCM good", + cipherID: tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + tlsVersion: tls.VersionTLS12, + want: CipherRatingGood, + }, + // TLS 1.2 ECDHE + ChaCha20 — good (forward secrecy + AEAD). + { + name: "TLS 1.2 ECDHE+CHACHA20 good", + cipherID: tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256, + tlsVersion: tls.VersionTLS12, + want: CipherRatingGood, + }, + // TLS 1.2 ECDHE + CBC — weak (padding oracle attacks). + { + name: "TLS 1.2 ECDHE+CBC weak", + cipherID: tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256, + tlsVersion: tls.VersionTLS12, + want: CipherRatingWeak, + }, + // TLS 1.2 static RSA — weak (no forward secrecy). + { + name: "TLS 1.2 static RSA weak", + cipherID: tls.TLS_RSA_WITH_AES_128_GCM_SHA256, + tlsVersion: tls.VersionTLS12, + want: CipherRatingWeak, + }, + // InsecureCipherSuites list — ECDHE+RC4 is still weak despite forward secrecy. + { + name: "TLS 1.2 ECDHE+RC4 insecure", + cipherID: tls.TLS_ECDHE_ECDSA_WITH_RC4_128_SHA, + tlsVersion: tls.VersionTLS12, + want: CipherRatingWeak, + }, + // Unknown cipher IDs should be rated conservatively (non-ECDHE fallthrough). + { + name: "unknown cipher ID weak", + cipherID: 0xFFFF, + tlsVersion: tls.VersionTLS12, + want: CipherRatingWeak, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := RateCipherSuite(tt.cipherID, tt.tlsVersion) + if got != tt.want { + t.Errorf("RateCipherSuite(0x%04x, 0x%04x) = %q, want %q", + tt.cipherID, tt.tlsVersion, got, tt.want) + } + }) + } +} + +func TestScanCipherSuites(t *testing.T) { + t.Parallel() + + // Create a TLS server that only accepts specific cipher suites. + ca := generateTestCA(t, "Cipher Scan CA") + leaf := generateTestLeafCert(t, ca) + + tlsCert := tls.Certificate{ + Certificate: [][]byte{leaf.DER, ca.CertDER}, + PrivateKey: leaf.Key, + } + + port := startTLSServerWithConfig(t, &tls.Config{ + Certificates: []tls.Certificate{tlsCert}, + MinVersion: tls.VersionTLS12, + MaxVersion: tls.VersionTLS13, + CipherSuites: []uint16{ + tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, + }, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + result, err := ScanCipherSuites(ctx, ScanCipherSuitesInput{ + Host: "127.0.0.1", + Port: port, + Concurrency: 5, + }) + if err != nil { + t.Fatalf("ScanCipherSuites failed: %v", err) + } + + if len(result.Ciphers) == 0 { + t.Fatal("no ciphers detected") + } + + // Should detect at least one TLS 1.3 suite (Go supports 3 standard suites; + // exact count may change if Go adds CCM support). + tls13Count := 0 + for _, c := range result.Ciphers { + if c.Version == "TLS 1.3" { + tls13Count++ + } + } + if tls13Count == 0 { + t.Error("expected at least one TLS 1.3 cipher, got 0") + } + + // Should detect the two TLS 1.2 ECDHE-ECDSA-GCM ciphers we configured. + tls12Names := make(map[string]bool) + for _, c := range result.Ciphers { + if c.Version == "TLS 1.2" { + tls12Names[c.Name] = true + } + } + for _, want := range []string{ + "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", + } { + if !tls12Names[want] { + t.Errorf("expected TLS 1.2 cipher %q in results", want) + } + } + + // Overall rating should be good (all configured ciphers are ECDHE+GCM). + // Per-cipher rating correctness is covered by TestRateCipherSuite. + if result.OverallRating != CipherRatingGood { + t.Errorf("OverallRating = %q, want %q", result.OverallRating, CipherRatingGood) + } + + // SupportedVersions should include both TLS 1.3 and TLS 1.2. + if len(result.SupportedVersions) < 2 { + t.Errorf("SupportedVersions = %v, want at least TLS 1.3 and TLS 1.2", result.SupportedVersions) + } + + // Key exchange groups: Go's TLS server should accept at least X25519. + if len(result.KeyExchanges) == 0 { + t.Fatal("no key exchange groups detected") + } + kxNames := make(map[string]bool) + for _, kx := range result.KeyExchanges { + kxNames[kx.Name] = true + if kx.Name == "X25519MLKEM768" || kx.Name == "SecP256r1MLKEM768" || kx.Name == "SecP384r1MLKEM1024" { + if !kx.PostQuantum { + t.Errorf("key exchange %s should be PostQuantum", kx.Name) + } + } else if kx.PostQuantum { + t.Errorf("key exchange %s should not be PostQuantum", kx.Name) + } + } + if !kxNames["X25519"] { + t.Error("expected X25519 in key exchange results") + } +} + +func TestScanCipherSuites_EmptyHost(t *testing.T) { + t.Parallel() + _, err := ScanCipherSuites(context.Background(), ScanCipherSuitesInput{}) + if err == nil { + t.Fatal("expected error for empty host") + } +} + +func TestScanCipherSuites_CancelledContext(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := ScanCipherSuites(ctx, ScanCipherSuitesInput{Host: "127.0.0.1", Port: "443"}) + if err == nil { + t.Fatal("expected error for cancelled context") + } +} + +func TestFormatCipherScanResult(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + result *CipherScanResult + wantExact string // if non-empty, assert exact match instead of substring checks + wantStrings []string + }{ + { + name: "nil result — no output", + result: nil, + wantExact: "", + }, + { + name: "empty ciphers — none detected", + result: &CipherScanResult{ + Ciphers: nil, + }, + wantStrings: []string{"none detected"}, + }, + { + name: "mixed ratings with kex subgroups", + result: &CipherScanResult{ + SupportedVersions: []string{"TLS 1.3", "TLS 1.2"}, + Ciphers: []CipherProbeResult{ + {Name: "TLS_AES_128_GCM_SHA256", ID: tls.TLS_AES_128_GCM_SHA256, Version: "TLS 1.3", KeyExchange: "ECDHE", Rating: CipherRatingGood}, + {Name: "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", ID: tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, Version: "TLS 1.2", KeyExchange: "ECDHE", Rating: CipherRatingGood}, + {Name: "TLS_RSA_WITH_AES_128_CBC_SHA", ID: tls.TLS_RSA_WITH_AES_128_CBC_SHA, Version: "TLS 1.2", KeyExchange: "RSA", Rating: CipherRatingWeak}, + }, + OverallRating: CipherRatingWeak, + }, + wantStrings: []string{ + "Cipher suites (3 supported)", + "TLS 1.3 (ECDHE):", + "TLS 1.2 (ECDHE):", + "TLS 1.2 (RSA, no forward secrecy):", + "[good]", + "[weak]", + "TLS_AES_128_GCM_SHA256", + "TLS_RSA_WITH_AES_128_CBC_SHA", + }, + }, + { + name: "QUIC and key exchanges", + result: &CipherScanResult{ + SupportedVersions: []string{"TLS 1.3"}, + Ciphers: []CipherProbeResult{ + {Name: "TLS_AES_128_GCM_SHA256", Version: "TLS 1.3", KeyExchange: "ECDHE", Rating: CipherRatingGood}, + }, + QUICProbed: true, + QUICCiphers: []CipherProbeResult{ + {Name: "TLS_AES_128_GCM_SHA256", Version: "TLS 1.3", KeyExchange: "ECDHE", Rating: CipherRatingGood}, + {Name: "TLS_AES_256_GCM_SHA384", Version: "TLS 1.3", KeyExchange: "ECDHE", Rating: CipherRatingGood}, + }, + KeyExchanges: []KeyExchangeProbeResult{ + {Name: "X25519MLKEM768", ID: 4588, PostQuantum: true}, + {Name: "X25519", ID: 29}, + {Name: "P-256", ID: 23}, + }, + OverallRating: CipherRatingGood, + }, + wantStrings: []string{ + "Cipher suites (1 supported)", + "QUIC cipher suites (2 supported)", + "Key exchange groups (3 supported, forward secrecy)", + "X25519MLKEM768 (post-quantum)", + "X25519\n", + "P-256\n", + }, + }, + { + name: "QUIC probed but not supported", + result: &CipherScanResult{ + SupportedVersions: []string{"TLS 1.3"}, + Ciphers: []CipherProbeResult{ + {Name: "TLS_AES_128_GCM_SHA256", Version: "TLS 1.3", KeyExchange: "ECDHE", Rating: CipherRatingGood}, + }, + QUICProbed: true, + QUICCiphers: nil, + OverallRating: CipherRatingGood, + }, + wantStrings: []string{ + "QUIC: not supported", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + output := FormatCipherScanResult(tt.result) + if tt.wantStrings == nil { + if output != tt.wantExact { + t.Errorf("want exact %q, got %q", tt.wantExact, output) + } + return + } + for _, want := range tt.wantStrings { + if !strings.Contains(output, want) { + t.Errorf("output missing %q\ngot:\n%s", want, output) + } + } + }) + } +} + +func TestFormatCipherRatingLine(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + scan *CipherScanResult + want string + }{ + { + name: "nil scan", + scan: nil, + want: "", + }, + { + name: "empty scan", + scan: &CipherScanResult{}, + want: "", + }, + { + name: "all good", + scan: &CipherScanResult{ + Ciphers: []CipherProbeResult{ + {Rating: CipherRatingGood}, + {Rating: CipherRatingGood}, + }, + OverallRating: CipherRatingGood, + }, + want: "Ciphers: good (2 good, 0 weak)\n", + }, + { + name: "mixed", + scan: &CipherScanResult{ + Ciphers: []CipherProbeResult{ + {Rating: CipherRatingGood}, + {Rating: CipherRatingWeak}, + }, + OverallRating: CipherRatingWeak, + }, + want: "Ciphers: weak (1 good, 1 weak)\n", + }, + { + name: "QUIC only", + scan: &CipherScanResult{ + QUICCiphers: []CipherProbeResult{ + {Rating: CipherRatingGood}, + {Rating: CipherRatingWeak}, + }, + OverallRating: CipherRatingWeak, + }, + want: "Ciphers: weak (1 good, 1 weak)\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := FormatCipherRatingLine(tt.scan) + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestDiagnoseCipherScan(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + result *CipherScanResult + wantChecks []string // expected diagnostic check names in order + wantSubs [][]string // per-diagnostic substrings to match in Detail + }{ + { + name: "nil result", + result: nil, + }, + { + name: "all good — no diagnostics", + result: &CipherScanResult{ + Ciphers: []CipherProbeResult{ + {Name: "TLS_AES_128_GCM_SHA256", Version: "TLS 1.3", KeyExchange: "ECDHE", Rating: CipherRatingGood}, + {Name: "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", Version: "TLS 1.2", KeyExchange: "ECDHE", Rating: CipherRatingGood}, + }, + }, + }, + { + name: "deprecated TLS 1.0 with CBC and static RSA and 3DES", + result: &CipherScanResult{ + Ciphers: []CipherProbeResult{ + {Name: "TLS_AES_128_GCM_SHA256", Version: "TLS 1.3", KeyExchange: "ECDHE", Rating: CipherRatingGood}, + {Name: "TLS_RSA_WITH_AES_128_CBC_SHA", Version: "TLS 1.0", KeyExchange: "RSA", Rating: CipherRatingWeak}, + {Name: "TLS_RSA_WITH_3DES_EDE_CBC_SHA", Version: "TLS 1.0", KeyExchange: "RSA", Rating: CipherRatingWeak}, + }, + }, + wantChecks: []string{"deprecated-tls10", "cbc-cipher", "static-rsa-kex", "3des-cipher"}, + wantSubs: [][]string{ + {"TLS 1.0", "2 cipher"}, + {"CBC", "2"}, + {"static RSA", "2"}, + {"3DES", "1"}, + }, + }, + { + name: "deprecated TLS 1.1", + result: &CipherScanResult{ + Ciphers: []CipherProbeResult{ + {Name: "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA", Version: "TLS 1.1", KeyExchange: "ECDHE", Rating: CipherRatingWeak}, + }, + }, + wantChecks: []string{"deprecated-tls11", "cbc-cipher"}, + wantSubs: [][]string{{"TLS 1.1", "1"}, {"CBC", "1"}}, + }, + { + name: "CBC only at TLS 1.2", + result: &CipherScanResult{ + Ciphers: []CipherProbeResult{ + {Name: "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256", Version: "TLS 1.2", KeyExchange: "ECDHE", Rating: CipherRatingWeak}, + }, + }, + wantChecks: []string{"cbc-cipher"}, + wantSubs: [][]string{{"CBC", "1"}}, + }, + { + name: "static RSA with GCM at TLS 1.2", + result: &CipherScanResult{ + Ciphers: []CipherProbeResult{ + {Name: "TLS_RSA_WITH_AES_128_GCM_SHA256", Version: "TLS 1.2", KeyExchange: "RSA", Rating: CipherRatingWeak}, + }, + }, + wantChecks: []string{"static-rsa-kex"}, + wantSubs: [][]string{{"static RSA", "1"}}, + }, + { + name: "QUIC ciphers included in analysis", + result: &CipherScanResult{ + QUICCiphers: []CipherProbeResult{ + {Name: "TLS_RSA_WITH_AES_128_CBC_SHA", Version: "TLS 1.0", KeyExchange: "RSA", Rating: CipherRatingWeak}, + }, + }, + wantChecks: []string{"deprecated-tls10", "cbc-cipher", "static-rsa-kex"}, + }, + { + name: "DHE key exchange", + result: &CipherScanResult{ + Ciphers: []CipherProbeResult{ + {Name: "TLS_DHE_RSA_WITH_AES_128_GCM_SHA256", Version: "TLS 1.2", KeyExchange: "DHE", Rating: CipherRatingWeak}, + {Name: "TLS_DHE_DSS_WITH_AES_128_CBC_SHA", Version: "TLS 1.2", KeyExchange: "DHE-DSS", Rating: CipherRatingWeak}, + }, + }, + wantChecks: []string{"cbc-cipher", "dhe-kex"}, + wantSubs: [][]string{ + {"CBC", "1"}, + {"DHE", "2"}, + }, + }, + { + name: "DHE-RSA only — no CBC no static RSA", + result: &CipherScanResult{ + Ciphers: []CipherProbeResult{ + {Name: "TLS_DHE_RSA_WITH_AES_128_GCM_SHA256", Version: "TLS 1.2", KeyExchange: "DHE", Rating: CipherRatingWeak}, + }, + }, + wantChecks: []string{"dhe-kex"}, + wantSubs: [][]string{{"DHE", "1", "deprecated"}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + diags := DiagnoseCipherScan(tt.result) + if len(diags) != len(tt.wantChecks) { + t.Fatalf("got %d diagnostics, want %d: %+v", len(diags), len(tt.wantChecks), diags) + } + for i, wantCheck := range tt.wantChecks { + if diags[i].Check != wantCheck { + t.Errorf("diag[%d].Check = %q, want %q", i, diags[i].Check, wantCheck) + } + if diags[i].Status != "warn" { + t.Errorf("diag[%d].Status = %q, want %q", i, diags[i].Status, "warn") + } + if i < len(tt.wantSubs) { + for _, sub := range tt.wantSubs[i] { + if !strings.Contains(diags[i].Detail, sub) { + t.Errorf("diag[%d].Detail missing %q, got: %s", i, sub, diags[i].Detail) + } + } + } + } + }) + } +} + func TestConnectTLS_CRL_DuplicateLeafInChain(t *testing.T) { t.Parallel() diff --git a/go.mod b/go.mod index 5453453..77120d5 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.26 require ( github.com/breml/rootcerts v0.3.4 github.com/jmoiron/sqlx v1.4.0 + github.com/mattn/go-isatty v0.0.20 github.com/pavlo-v-chernykh/keystore-go/v4 v4.5.0 github.com/smallstep/pkcs7 v0.2.1 github.com/spf13/cobra v1.10.2 @@ -22,7 +23,6 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kr/pretty v0.1.0 // indirect github.com/kr/text v0.2.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect diff --git a/legacyprobe.go b/legacyprobe.go new file mode 100644 index 0000000..699e92c --- /dev/null +++ b/legacyprobe.go @@ -0,0 +1,362 @@ +package certkit + +// This file implements a raw TLS 1.0–1.2 ClientHello prober for legacy cipher +// suites that Go's crypto/tls has never implemented (DHE key exchange, DHE-DSS). +// It extends the approach from tls13probe.go — byte-level packet construction, +// fully isolated probes, no shared state. +// +// The prober can: +// 1. Probe individual legacy cipher suites (probeLegacyCipher) +// 2. Perform a fallback connect that extracts server certificates +// (legacyFallbackConnect) when Go's TLS handshake fails + +import ( + "context" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "encoding/binary" + "fmt" + "io" + "net" +) + +// legacyCipherDef describes a cipher suite not implemented by Go's crypto/tls. +type legacyCipherDef struct { + ID uint16 + Name string + KeyExchange string // "DHE", "DHE-DSS" +} + +// legacyCipherSuites lists DHE and DHE-DSS cipher suites missing from Go's +// crypto/tls. These require raw ClientHello probing. +var legacyCipherSuites = []legacyCipherDef{ + // DHE-RSA + {0x0033, "TLS_DHE_RSA_WITH_AES_128_CBC_SHA", "DHE"}, + {0x0039, "TLS_DHE_RSA_WITH_AES_256_CBC_SHA", "DHE"}, + {0x0067, "TLS_DHE_RSA_WITH_AES_128_CBC_SHA256", "DHE"}, + {0x006B, "TLS_DHE_RSA_WITH_AES_256_CBC_SHA256", "DHE"}, + {0x009E, "TLS_DHE_RSA_WITH_AES_128_GCM_SHA256", "DHE"}, + {0x009F, "TLS_DHE_RSA_WITH_AES_256_GCM_SHA384", "DHE"}, + {0x0016, "TLS_DHE_RSA_WITH_3DES_EDE_CBC_SHA", "DHE"}, + // DHE-DSS + {0x0032, "TLS_DHE_DSS_WITH_AES_128_CBC_SHA", "DHE-DSS"}, + {0x0038, "TLS_DHE_DSS_WITH_AES_256_CBC_SHA", "DHE-DSS"}, + {0x0040, "TLS_DHE_DSS_WITH_AES_128_CBC_SHA256", "DHE-DSS"}, + {0x006A, "TLS_DHE_DSS_WITH_AES_256_CBC_SHA256", "DHE-DSS"}, + {0x00A2, "TLS_DHE_DSS_WITH_AES_128_GCM_SHA256", "DHE-DSS"}, + {0x00A3, "TLS_DHE_DSS_WITH_AES_256_GCM_SHA384", "DHE-DSS"}, +} + +// legacyClientHelloInput contains parameters for building a raw TLS 1.0–1.2 ClientHello. +type legacyClientHelloInput struct { + serverName string + cipherSuites []uint16 +} + +// buildLegacyClientHelloMsg constructs a TLS 1.0–1.2 ClientHello handshake +// message offering the specified cipher suites. The legacy_version field is +// set to TLS 1.2 (0x0303); the server downgrades as needed. +// +// Unlike buildClientHelloMsg (TLS 1.3), this does NOT include +// supported_versions, key_share, or psk_key_exchange_modes extensions. +func buildLegacyClientHelloMsg(input legacyClientHelloInput) ([]byte, error) { + if len(input.cipherSuites) == 0 { + return nil, fmt.Errorf("building legacy client hello: no cipher suites specified") + } + + // Build extensions. + var exts []byte + exts = appendSNIExtension(exts, input.serverName) + exts = appendSignatureAlgorithmsExtension(exts) + exts = appendECPointFormatsExtension(exts) + + // Build ClientHello body. + var body []byte + + // Legacy version: TLS 1.2. + body = append(body, 0x03, 0x03) + + // Client random (32 bytes). + random := make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, random); err != nil { + return nil, fmt.Errorf("generating client random: %w", err) + } + body = append(body, random...) + + // Session ID: empty (no middlebox compat needed for probing). + body = append(body, 0x00) + + // Cipher suites. + body = appendUint16(body, uint16(len(input.cipherSuites)*2)) + for _, cs := range input.cipherSuites { + body = appendUint16(body, cs) + } + + // Compression methods: null only. + body = append(body, 1, 0) + + // Extensions. + body = appendUint16(body, uint16(len(exts))) + body = append(body, exts...) + + // Wrap in handshake header: type(1) + length(3) + body. + msg := []byte{0x01} // ClientHello + msg = appendUint24(msg, uint32(len(body))) + msg = append(msg, body...) + + return msg, nil +} + +// appendECPointFormatsExtension appends an ec_point_formats extension (0x000b). +// Only uncompressed point format (0x00) is offered, which is required for +// interoperability and is the only format Go's crypto/tls supports. +func appendECPointFormatsExtension(b []byte) []byte { + b = appendUint16(b, 0x000b) // extension type + b = appendUint16(b, 2) // extension data length + b = append(b, 1) // formats list length + return append(b, 0x00) // uncompressed +} + +// probeLegacyCipher attempts a raw TLS 1.0–1.2 ClientHello with a single +// legacy cipher suite. It returns the negotiated TLS version and true if the +// server accepts the cipher suite; returns 0, false on any failure or rejection. +func probeLegacyCipher(ctx context.Context, input cipherProbeInput) (uint16, bool) { + dialer := &net.Dialer{} + conn, err := dialer.DialContext(ctx, "tcp", input.addr) + if err != nil { + return 0, false + } + defer func() { _ = conn.Close() }() + + if deadline, ok := ctx.Deadline(); ok { + _ = conn.SetDeadline(deadline) + } + + msg, err := buildLegacyClientHelloMsg(legacyClientHelloInput{ + serverName: input.serverName, + cipherSuites: []uint16{input.cipherID}, + }) + if err != nil { + return 0, false + } + + if _, err := conn.Write(wrapTLSRecord(msg)); err != nil { + return 0, false + } + + result, err := readServerHello(conn) + if err != nil { + return 0, false + } + + if result.version <= tls.VersionTLS12 && result.cipherSuite == input.cipherID { + return result.version, true + } + return 0, false +} + +// maxCertificatePayload is the maximum total bytes we'll read from the server +// while scanning for handshake messages (ServerHello + Certificate). This +// bounds memory usage when probing untrusted servers. +const maxCertificatePayload = 128 * 1024 + +// readServerCertificates reads TLS handshake records from r and extracts the +// ServerHello and Certificate messages. It stops after finding the Certificate +// message, encountering ServerHelloDone (0x0E), or receiving an Alert. +// +// The function handles multiple handshake messages packed into a single TLS +// record and handshake messages spanning multiple records. +func readServerCertificates(r io.Reader) (*serverHelloResult, []*x509.Certificate, error) { + var shResult *serverHelloResult + var certs []*x509.Certificate + + // Accumulate handshake data across records — a single handshake message + // may span multiple TLS records. + var handshakeBuf []byte + totalRead := 0 + + for { + // Read TLS record header (5 bytes): type(1) + version(2) + length(2). + header := make([]byte, 5) + if _, err := io.ReadFull(r, header); err != nil { + if shResult != nil { + return shResult, certs, fmt.Errorf("reading tls record: %w", err) + } + return nil, nil, fmt.Errorf("reading tls record header: %w", err) + } + totalRead += 5 + + contentType := header[0] + recordLen := int(binary.BigEndian.Uint16(header[3:5])) + + if recordLen > 16640 { + return shResult, certs, fmt.Errorf("tls record too large: %d bytes", recordLen) + } + // Check before allocating: a malicious server cannot force us to allocate + // more than maxCertificatePayload bytes even if record sizes are valid. + if totalRead+recordLen > maxCertificatePayload { + return shResult, certs, fmt.Errorf("exceeded %d byte limit reading server handshake", maxCertificatePayload) + } + + payload := make([]byte, recordLen) + if _, err := io.ReadFull(r, payload); err != nil { + return shResult, certs, fmt.Errorf("reading tls record payload: %w", err) + } + totalRead += recordLen + + // Alert record — server rejected something. + if contentType == 0x15 { + if shResult != nil { + return shResult, certs, errAlertReceived + } + return nil, nil, errAlertReceived + } + + if contentType != 0x16 { + return shResult, certs, fmt.Errorf("unexpected tls content type: 0x%02x", contentType) + } + + // Append to handshake buffer and process complete messages. + handshakeBuf = append(handshakeBuf, payload...) + + for len(handshakeBuf) >= 4 { + hsType := handshakeBuf[0] + hsLen := int(handshakeBuf[1])<<16 | int(handshakeBuf[2])<<8 | int(handshakeBuf[3]) + + if len(handshakeBuf) < 4+hsLen { + break // incomplete message, need more records + } + + hsMsg := handshakeBuf[:4+hsLen] + handshakeBuf = handshakeBuf[4+hsLen:] + + switch hsType { + case 0x02: // ServerHello + sh, err := parseServerHello(hsMsg) + if err != nil { + return nil, nil, fmt.Errorf("parsing server hello: %w", err) + } + shResult = sh + + case 0x0B: // Certificate + parsed, err := parseCertificateMessage(hsMsg[4:]) // skip handshake header + if err != nil { + return shResult, nil, fmt.Errorf("parsing certificate message: %w", err) + } + certs = parsed + return shResult, certs, nil + + case 0x0E: // ServerHelloDone + return shResult, certs, nil + } + } + } +} + +// parseCertificateMessage parses the body of a TLS Certificate handshake message. +// The format is: total_length(3) + [cert_length(3) + cert_der(...)]* +func parseCertificateMessage(data []byte) ([]*x509.Certificate, error) { + if len(data) < 3 { + return nil, fmt.Errorf("certificate message too short: %d bytes", len(data)) + } + + totalLen := int(data[0])<<16 | int(data[1])<<8 | int(data[2]) + data = data[3:] + if len(data) < totalLen { + return nil, fmt.Errorf("certificate message truncated: need %d bytes, have %d", totalLen, len(data)) + } + data = data[:totalLen] + + var certs []*x509.Certificate + for len(data) > 0 { + if len(data) < 3 { + return certs, fmt.Errorf("truncated certificate entry") + } + certLen := int(data[0])<<16 | int(data[1])<<8 | int(data[2]) + data = data[3:] + if len(data) < certLen { + return certs, fmt.Errorf("certificate entry truncated: need %d bytes, have %d", certLen, len(data)) + } + cert, err := x509.ParseCertificate(data[:certLen]) + if err != nil { + return certs, fmt.Errorf("parsing certificate: %w", err) + } + certs = append(certs, cert) + data = data[certLen:] + } + + return certs, nil +} + +// legacyFallbackInput contains parameters for a legacy TLS fallback connection. +type legacyFallbackInput struct { + addr string + serverName string +} + +// legacyFallbackResult contains the result of a legacy TLS fallback connection. +type legacyFallbackResult struct { + version uint16 + cipherSuite uint16 + certificates []*x509.Certificate +} + +// legacyFallbackConnect attempts a raw TLS handshake offering all legacy cipher +// suites plus Go's insecure cipher suites. It reads through the ServerHello and +// Certificate messages to extract the server's certificate chain. This is used +// as a fallback when Go's crypto/tls cannot handshake (e.g. DHE-only servers). +func legacyFallbackConnect(ctx context.Context, input legacyFallbackInput) (*legacyFallbackResult, error) { + // Collect all cipher suites: legacy DHE/DHE-DSS + Go's insecure suites. + var allSuites []uint16 + for _, def := range legacyCipherSuites { + allSuites = append(allSuites, def.ID) + } + for _, cs := range tls.InsecureCipherSuites() { + allSuites = append(allSuites, cs.ID) + } + // Also include Go's standard TLS 1.2 suites for maximum compatibility. + for _, cs := range tls.CipherSuites() { + allSuites = append(allSuites, cs.ID) + } + + dialer := &net.Dialer{} + conn, err := dialer.DialContext(ctx, "tcp", input.addr) + if err != nil { + return nil, fmt.Errorf("connecting to %s: %w", input.addr, err) + } + defer func() { _ = conn.Close() }() + + if deadline, ok := ctx.Deadline(); ok { + _ = conn.SetDeadline(deadline) + } + + msg, err := buildLegacyClientHelloMsg(legacyClientHelloInput{ + serverName: input.serverName, + cipherSuites: allSuites, + }) + if err != nil { + return nil, fmt.Errorf("building legacy client hello: %w", err) + } + + if _, err := conn.Write(wrapTLSRecord(msg)); err != nil { + return nil, fmt.Errorf("sending legacy client hello: %w", err) + } + + shResult, certs, err := readServerCertificates(conn) + if err != nil { + return nil, fmt.Errorf("reading server certificates: %w", err) + } + if shResult == nil { + return nil, fmt.Errorf("no server hello received") + } + if len(certs) == 0 { + return nil, fmt.Errorf("no certificates received from server") + } + + return &legacyFallbackResult{ + version: shResult.version, + cipherSuite: shResult.cipherSuite, + certificates: certs, + }, nil +} diff --git a/legacyprobe_test.go b/legacyprobe_test.go new file mode 100644 index 0000000..60a4c8d --- /dev/null +++ b/legacyprobe_test.go @@ -0,0 +1,343 @@ +package certkit + +import ( + "bytes" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "errors" + "log/slog" + "math/big" + "net" + "strings" + "testing" + "time" +) + +// buildCertificateMessageBody constructs a TLS Certificate message body +// from DER-encoded certificates. +func buildCertificateMessageBody(certs ...[]byte) []byte { + var entries []byte + for _, cert := range certs { + entries = appendUint24(entries, uint32(len(cert))) + entries = append(entries, cert...) + } + var body []byte + body = appendUint24(body, uint32(len(entries))) + body = append(body, entries...) + return body +} + +func TestReadServerCertificates(t *testing.T) { + t.Parallel() + + // Generate a test CA and leaf certificate. + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + template := &x509.Certificate{ + SerialNumber: big.NewInt(42), + Subject: pkix.Name{CommonName: "readcerts-test"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + } + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + + // Build a ServerHello handshake message (cipher 0x0033, TLS 1.2). + serverHello := buildMockServerHello(0x0303, 0x0033) + + // Build a Certificate handshake message. + certMsg := buildCertificateHandshakeMessage(certDER) + + // Build a Certificate handshake message that spans two records by splitting + // mid-message. This exercises the "incomplete message, need more records" + // branch in readServerCertificates. + certMsgSplit1 := certMsg[:len(certMsg)/2] + certMsgSplit2 := certMsg[len(certMsg)/2:] + + serverHelloDone := []byte{0x0E, 0x00, 0x00, 0x00} // ServerHelloDone handshake message + + tests := []struct { + name string + records []byte + wantCS uint16 + wantVer uint16 + wantCerts int + wantErr error // checked with errors.Is (sentinel errors) + wantErrContains string // checked with strings.Contains (non-sentinel errors) + }{ + { + name: "single record with ServerHello + Certificate", + records: wrapTLSRecord(append(serverHello, certMsg...)), + wantCS: 0x0033, + wantVer: 0x0303, + wantCerts: 1, + }, + { + name: "separate records for ServerHello and Certificate", + records: append(wrapTLSRecord(serverHello), wrapTLSRecord(certMsg)...), + wantCS: 0x0033, + wantVer: 0x0303, + wantCerts: 1, + }, + { + name: "Certificate message spanning two records", + records: append( + wrapTLSRecord(serverHello), + append(wrapTLSRecord(certMsgSplit1), wrapTLSRecord(certMsgSplit2)...)..., + ), + wantCS: 0x0033, + wantVer: 0x0303, + wantCerts: 1, + }, + { + name: "alert record", + records: buildAlertRecord(), + wantErr: errAlertReceived, + }, + { + name: "ServerHelloDone without Certificate", + records: wrapTLSRecord(append(serverHello, serverHelloDone...)), + wantCS: 0x0033, + wantVer: 0x0303, + wantCerts: 0, + }, + { + name: "oversized TLS record", + records: buildRawTLSRecord(0x16, 16641), + wantErrContains: "tls record too large", + }, + { + name: "unexpected content type", + records: buildRawTLSRecord(0x17, 1), // ApplicationData + wantErrContains: "unexpected tls content type", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r := bytes.NewReader(tt.records) + sh, certs, err := readServerCertificates(r) + if tt.wantErr != nil { + if !errors.Is(err, tt.wantErr) { + t.Fatalf("error = %v, want %v", err, tt.wantErr) + } + return + } + if tt.wantErrContains != "" { + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), tt.wantErrContains) { + t.Fatalf("error = %v, want containing %q", err, tt.wantErrContains) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if sh == nil { + t.Fatal("ServerHello result is nil") + } + if sh.cipherSuite != tt.wantCS { + t.Errorf("cipher suite = 0x%04x, want 0x%04x", sh.cipherSuite, tt.wantCS) + } + if sh.version != tt.wantVer { + t.Errorf("version = 0x%04x, want 0x%04x", sh.version, tt.wantVer) + } + if len(certs) != tt.wantCerts { + t.Errorf("got %d certificates, want %d", len(certs), tt.wantCerts) + } + if tt.wantCerts > 0 { + if certs[0].Subject.CommonName != "readcerts-test" { + t.Errorf("cert CN = %q, want %q", certs[0].Subject.CommonName, "readcerts-test") + } + } + }) + } +} + +// TestReadServerCertificates_AlertAfterServerHello verifies that when an alert +// arrives after a ServerHello, the ServerHello result is still returned alongside +// the errAlertReceived sentinel. +func TestReadServerCertificates_AlertAfterServerHello(t *testing.T) { + t.Parallel() + serverHello := buildMockServerHello(0x0303, 0x0033) + records := append(wrapTLSRecord(serverHello), buildAlertRecord()...) + + sh, certs, err := readServerCertificates(bytes.NewReader(records)) + if !errors.Is(err, errAlertReceived) { + t.Fatalf("err = %v, want errAlertReceived", err) + } + if sh == nil { + t.Error("expected non-nil ServerHello result even when alert follows") + } + if len(certs) != 0 { + t.Errorf("expected 0 certs, got %d", len(certs)) + } +} + +// TestReadServerCertificates_PayloadLimit verifies that readServerCertificates +// rejects a stream that would exceed maxCertificatePayload before allocating +// the payload buffer. +func TestReadServerCertificates_PayloadLimit(t *testing.T) { + t.Parallel() + + // Generate records with ignored handshake messages (CertificateRequest, type 0x0D) + // totalling just over maxCertificatePayload bytes. + const chunkBody = 4000 + var chunkMsg []byte + chunkMsg = append(chunkMsg, 0x0D) // CertificateRequest — ignored by readServerCertificates + chunkMsg = appendUint24(chunkMsg, chunkBody) + chunkMsg = append(chunkMsg, make([]byte, chunkBody)...) + + var records []byte + for len(records) <= maxCertificatePayload { + records = append(records, wrapTLSRecord(chunkMsg)...) + } + + _, _, err := readServerCertificates(bytes.NewReader(records)) + if err == nil { + t.Fatal("expected error for payload exceeding limit, got nil") + } + if !strings.Contains(err.Error(), "exceeded") { + t.Errorf("error = %v, want containing %q", err, "exceeded") + } +} + +// buildMockServerHello builds a minimal ServerHello handshake message. +func buildMockServerHello(version, cipherSuite uint16) []byte { + var body []byte + // Version (2 bytes). + body = appendUint16(body, version) + // Server random (32 bytes). + body = append(body, make([]byte, 32)...) + // Session ID: empty. + body = append(body, 0x00) + // Cipher suite. + body = appendUint16(body, cipherSuite) + // Compression method: null. + body = append(body, 0x00) + + // Wrap in handshake header: type 0x02 (ServerHello). + msg := []byte{0x02} + msg = appendUint24(msg, uint32(len(body))) + msg = append(msg, body...) + return msg +} + +// buildCertificateHandshakeMessage builds a TLS Certificate handshake message. +func buildCertificateHandshakeMessage(certs ...[]byte) []byte { + body := buildCertificateMessageBody(certs...) + msg := []byte{0x0B} // Certificate + msg = appendUint24(msg, uint32(len(body))) + msg = append(msg, body...) + return msg +} + +// buildRawTLSRecord builds a TLS record with the given content type and a +// payload of payloadLen zero bytes. Unlike wrapTLSRecord it does NOT cap the +// payload size, so it can be used to construct intentionally oversized records +// for negative test cases. +func buildRawTLSRecord(contentType byte, payloadLen int) []byte { + record := []byte{contentType, 0x03, 0x03} + record = appendUint16(record, uint16(payloadLen)) + record = append(record, make([]byte, payloadLen)...) + return record +} + +// buildAlertRecord builds a TLS Alert record. +func buildAlertRecord() []byte { + // Alert: handshake_failure (40), fatal (2). + payload := []byte{0x02, 0x28} + record := []byte{0x15} // ContentType: Alert + record = append(record, 0x03, 0x01) + record = appendUint16(record, uint16(len(payload))) + record = append(record, payload...) + return record +} + +func TestLegacyFallbackConnect(t *testing.T) { + t.Parallel() + + // Start a mock server that speaks raw TLS: sends ServerHello + Certificate + // at the byte level (no real TLS stack needed). + ca := generateTestCA(t, "Legacy Fallback CA") + leaf := generateTestLeafCert(t, ca) + + // Parse the leaf cert so we can check the CN. + leafCert, err := x509.ParseCertificate(leaf.DER) + if err != nil { + t.Fatal(err) + } + + // Start a TCP listener that responds with raw TLS records. + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = listener.Close() }) + + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + // Read the ClientHello (we don't parse it, just consume it). + buf := make([]byte, 4096) + if _, err := conn.Read(buf); err != nil { + slog.Debug("TestLegacyFallbackConnect: reading ClientHello", "error", err) + } + + // Send ServerHello + Certificate as raw TLS records. + serverHello := buildMockServerHello(0x0303, 0x0033) + certMsg := buildCertificateHandshakeMessage(leaf.DER) + helloDone := []byte{0x0E, 0x00, 0x00, 0x00} // ServerHelloDone + + // Pack all handshake messages into a single TLS record. + var handshake []byte + handshake = append(handshake, serverHello...) + handshake = append(handshake, certMsg...) + handshake = append(handshake, helloDone...) + + if _, err := conn.Write(wrapTLSRecord(handshake)); err != nil { + slog.Debug("TestLegacyFallbackConnect: writing server response", "error", err) + } + _ = conn.Close() + } + }() + + _, port, err := net.SplitHostPort(listener.Addr().String()) + if err != nil { + t.Fatal(err) + } + + result, err := legacyFallbackConnect(t.Context(), legacyFallbackInput{ + addr: net.JoinHostPort("127.0.0.1", port), + serverName: "localhost", + }) + if err != nil { + t.Fatalf("legacyFallbackConnect failed: %v", err) + } + + if result.version != 0x0303 { + t.Errorf("version = 0x%04x, want 0x0303", result.version) + } + if result.cipherSuite != 0x0033 { + t.Errorf("cipher suite = 0x%04x, want 0x0033", result.cipherSuite) + } + if len(result.certificates) != 1 { + t.Fatalf("got %d certificates, want 1", len(result.certificates)) + } + if result.certificates[0].Subject.CommonName != leafCert.Subject.CommonName { + t.Errorf("cert CN = %q, want %q", result.certificates[0].Subject.CommonName, leafCert.Subject.CommonName) + } +} diff --git a/quicprobe.go b/quicprobe.go new file mode 100644 index 0000000..c07c487 --- /dev/null +++ b/quicprobe.go @@ -0,0 +1,603 @@ +package certkit + +// This file implements a raw QUIC v1 Initial packet prober for detecting +// TLS 1.3 cipher suites over UDP (port 443). It wraps the same ClientHello +// from tls13probe.go in an encrypted QUIC Initial packet per RFC 9001. +// +// QUIC Initial packets are encrypted with keys derived from the client's +// chosen Destination Connection ID using HKDF + AES-128-GCM. This is not +// for security (the DCID is sent in plaintext) but for protocol correctness. + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/hkdf" + "crypto/rand" + "crypto/sha256" + "crypto/tls" + "encoding/binary" + "fmt" + "io" + "log/slog" + "net" +) + +// quicV1InitialSalt is the salt used to derive Initial keys for QUIC v1 +// connections (RFC 9001 §5.2). +var quicV1InitialSalt = []byte{ + 0x38, 0x76, 0x2c, 0xf7, 0xf5, 0x59, 0x34, 0xb3, + 0x4d, 0x17, 0x9a, 0xe6, 0xa4, 0xc8, 0x0c, 0xad, + 0xcc, 0xbb, 0x7f, 0x0a, +} + +// quicInitialKeys holds the derived encryption keys for QUIC Initial packets. +type quicInitialKeys struct { + key []byte // AES-128 key (16 bytes) + iv []byte // AES-128-GCM IV/nonce (12 bytes) + hp []byte // Header protection key (16 bytes) +} + +// deriveQUICInitialKeys derives the client and server Initial keys from +// the Destination Connection ID per RFC 9001 §5.2. +func deriveQUICInitialKeys(dcid []byte) (client, server quicInitialKeys, err error) { + initialSecret, err := hkdf.Extract(sha256.New, dcid, quicV1InitialSalt) + if err != nil { + return client, server, fmt.Errorf("extracting initial secret: %w", err) + } + + clientSecret, err := hkdfExpandLabel(hkdfExpandLabelInput{secret: initialSecret, label: "client in", length: 32}) + if err != nil { + return client, server, fmt.Errorf("deriving client secret: %w", err) + } + client, err = deriveTrafficKeys(clientSecret) + if err != nil { + return client, server, fmt.Errorf("deriving client keys: %w", err) + } + + serverSecret, err := hkdfExpandLabel(hkdfExpandLabelInput{secret: initialSecret, label: "server in", length: 32}) + if err != nil { + return client, server, fmt.Errorf("deriving server secret: %w", err) + } + server, err = deriveTrafficKeys(serverSecret) + if err != nil { + return client, server, fmt.Errorf("deriving server keys: %w", err) + } + + return client, server, nil +} + +// deriveTrafficKeys derives key, IV, and HP key from a traffic secret. +func deriveTrafficKeys(secret []byte) (quicInitialKeys, error) { + key, err := hkdfExpandLabel(hkdfExpandLabelInput{secret: secret, label: "quic key", length: 16}) + if err != nil { + return quicInitialKeys{}, fmt.Errorf("expanding quic key: %w", err) + } + iv, err := hkdfExpandLabel(hkdfExpandLabelInput{secret: secret, label: "quic iv", length: 12}) + if err != nil { + return quicInitialKeys{}, fmt.Errorf("expanding quic iv: %w", err) + } + hp, err := hkdfExpandLabel(hkdfExpandLabelInput{secret: secret, label: "quic hp", length: 16}) + if err != nil { + return quicInitialKeys{}, fmt.Errorf("expanding quic hp: %w", err) + } + return quicInitialKeys{key: key, iv: iv, hp: hp}, nil +} + +// hkdfExpandLabelInput contains parameters for HKDF-Expand-Label. +type hkdfExpandLabelInput struct { + secret []byte + label string + length int +} + +// hkdfExpandLabel implements TLS 1.3 HKDF-Expand-Label (RFC 8446 §7.1). +// The label is prefixed with "tls13 " as required by the spec. +func hkdfExpandLabel(input hkdfExpandLabelInput) ([]byte, error) { + fullLabel := "tls13 " + input.label + + // Build HkdfLabel struct: uint16 length + opaque label<7..255> + opaque context<0..255> + var info []byte + info = appendUint16(info, uint16(input.length)) + info = append(info, byte(len(fullLabel))) + info = append(info, []byte(fullLabel)...) + info = append(info, 0) // empty context + + return hkdf.Expand(sha256.New, input.secret, string(info), input.length) +} + +// quicInitialPacketInput contains parameters for building a QUIC Initial packet. +type quicInitialPacketInput struct { + clientHello []byte // raw ClientHello handshake message (no TLS record header) + dcid []byte // Destination Connection ID + scid []byte // Source Connection ID +} + +// buildQUICInitialPacket constructs an encrypted QUIC v1 Initial packet +// containing the ClientHello in a CRYPTO frame. The packet is padded to +// the 1200-byte minimum required by RFC 9000 §14.1. +func buildQUICInitialPacket(input quicInitialPacketInput) ([]byte, error) { + clientKeys, _, err := deriveQUICInitialKeys(input.dcid) + if err != nil { + return nil, fmt.Errorf("deriving quic keys: %w", err) + } + + // Build CRYPTO frame: type(1) + offset(var) + length(var) + data + var cryptoFrame []byte + cryptoFrame = append(cryptoFrame, 0x06) // CRYPTO frame type + cryptoFrame = append(cryptoFrame, 0x00) // offset = 0 (single-byte varint) + cryptoFrame = appendQUICVarint(cryptoFrame, uint64(len(input.clientHello))) + cryptoFrame = append(cryptoFrame, input.clientHello...) + + // Build Initial packet header (Long Header form). + // First byte: 1 (long) | 1 (fixed) | 00 (Initial) | 00 (reserved) | 00 (PN length - 1 = 0, meaning 1 byte) + // We use 4-byte packet number for simplicity (PN length bits = 11 = 3, meaning 4 bytes). + firstByte := byte(0xc0) // Long Header | Fixed bit | Initial type + firstByte |= 0x03 // Packet number length: 4 bytes (value = 3 means 4 bytes) + + var header []byte + header = append(header, firstByte) + header = append(header, 0x00, 0x00, 0x00, 0x01) // Version: QUIC v1 + header = append(header, byte(len(input.dcid))) + header = append(header, input.dcid...) + header = append(header, byte(len(input.scid))) + header = append(header, input.scid...) + header = append(header, 0x00) // Token length: 0 (no token for Initial) + + // Payload = CRYPTO frame + PADDING frames (0x00 bytes). + // We need to pad the total UDP datagram to at least 1200 bytes. + // Total = header + length_field(2 varint bytes) + payload + AEAD_tag(16) + packetNumberBytes := 4 + aeadOverhead := 16 + payloadWithPN := packetNumberBytes + len(cryptoFrame) + + // Calculate minimum payload size for 1200-byte datagram. + // header + 2 (length varint) + payloadWithPN + aeadOverhead + padding >= 1200 + headerWithLength := len(header) + 2 // 2 bytes for length varint (enough for < 16384) + minPayloadWithPN := 1200 - headerWithLength - aeadOverhead + if minPayloadWithPN > payloadWithPN { + padding := make([]byte, minPayloadWithPN-payloadWithPN) // PADDING frames are 0x00 + cryptoFrame = append(cryptoFrame, padding...) + payloadWithPN = minPayloadWithPN + } + + // Encode the length field (payload + packet number + AEAD tag) as a 2-byte varint. + lengthVal := uint64(payloadWithPN + aeadOverhead) + header = appendQUICVarint2(header, lengthVal) + + // Packet number (4 bytes, value = 0 for first packet). + pnOffset := len(header) + header = append(header, 0x00, 0x00, 0x00, 0x00) // PN = 0 + + // Encrypt payload with AES-128-GCM. + block, err := aes.NewCipher(clientKeys.key) + if err != nil { + return nil, fmt.Errorf("creating AES cipher: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("creating GCM: %w", err) + } + + // Nonce = IV XOR packet number (left-padded to 12 bytes). + nonce := make([]byte, 12) + copy(nonce, clientKeys.iv) + // PN = 0, so nonce = IV (XOR with 0 is identity). + + // Plaintext = CRYPTO frame (+ padding). + ciphertext := gcm.Seal(nil, nonce, cryptoFrame, header) + + // Assemble the packet before header protection. + packet := append(header, ciphertext...) + + // Apply header protection (RFC 9001 §5.4.1). + // Sample starts 4 bytes after the start of the packet number field. + sampleOffset := pnOffset + 4 + if sampleOffset+16 > len(packet) { + return nil, fmt.Errorf("packet too short for header protection sample") + } + sample := packet[sampleOffset : sampleOffset+16] + + hpBlock, err := aes.NewCipher(clientKeys.hp) + if err != nil { + return nil, fmt.Errorf("creating HP cipher: %w", err) + } + mask := make([]byte, aes.BlockSize) + hpBlock.Encrypt(mask, sample) + + // Mask the first byte (Long Header: lower 4 bits). + packet[0] ^= mask[0] & 0x0f + + // Mask the packet number bytes. + for i := range packetNumberBytes { + packet[pnOffset+i] ^= mask[1+i] + } + + return packet, nil +} + +// parseQUICInitialResponse decrypts a QUIC Initial response packet and +// extracts the ServerHello from the CRYPTO frame. +func parseQUICInitialResponse(packet []byte, serverKeys quicInitialKeys) (*serverHelloResult, error) { + if len(packet) < 5 { + return nil, fmt.Errorf("quic packet too short: %d bytes", len(packet)) + } + + // Check it's a Long Header Initial packet. + firstByte := packet[0] + if firstByte&0x80 == 0 { + return nil, fmt.Errorf("not a long header packet") + } + + // Remove header protection first. + // Parse enough of the header to find packet number offset. + pos := 1 + // Version (4 bytes). + if pos+4 > len(packet) { + return nil, fmt.Errorf("packet truncated at version") + } + pos += 4 + + // DCID. + if pos+1 > len(packet) { + return nil, fmt.Errorf("packet truncated at DCID length") + } + dcidLen := int(packet[pos]) + pos++ + if pos+dcidLen > len(packet) { + return nil, fmt.Errorf("packet truncated at DCID: need %d bytes", dcidLen) + } + pos += dcidLen + + // SCID. + if pos+1 > len(packet) { + return nil, fmt.Errorf("packet truncated at SCID length") + } + scidLen := int(packet[pos]) + pos++ + if pos+scidLen > len(packet) { + return nil, fmt.Errorf("packet truncated at SCID: need %d bytes", scidLen) + } + pos += scidLen + + // Token length (varint). + if pos >= len(packet) { + return nil, fmt.Errorf("packet truncated at token length") + } + tokenLen, tokenVarLen := decodeQUICVarint(packet[pos:]) + if tokenVarLen == 0 { + return nil, fmt.Errorf("malformed token length varint") + } + pos += tokenVarLen + if tokenLen > uint64(len(packet)-pos) { + return nil, fmt.Errorf("packet truncated at token data") + } + pos += int(tokenLen) + + // Payload length (varint) — covers packet number + encrypted data + AEAD tag. + // Must be kept to avoid decrypting coalesced packets (Initial + Handshake). + if pos >= len(packet) { + return nil, fmt.Errorf("packet truncated at payload length") + } + payloadLen, payloadVarLen := decodeQUICVarint(packet[pos:]) + if payloadVarLen == 0 { + return nil, fmt.Errorf("malformed payload length varint") + } + pos += payloadVarLen + + pnOffset := pos + if payloadLen > uint64(len(packet)-pnOffset) { + return nil, fmt.Errorf("payload length %d exceeds remaining packet", payloadLen) + } + payloadEnd := pnOffset + int(payloadLen) + + // We need the sample for header protection removal. + // The PN length is encoded in the first byte (lower 2 bits after unmasking). + // But we need to unmask it first. Sample is at pnOffset + 4. + sampleOffset := pnOffset + 4 + if sampleOffset+16 > len(packet) { + return nil, fmt.Errorf("packet too short for HP sample: need %d, have %d", sampleOffset+16, len(packet)) + } + sample := packet[sampleOffset : sampleOffset+16] + + hpBlock, err := aes.NewCipher(serverKeys.hp) + if err != nil { + return nil, fmt.Errorf("creating HP cipher: %w", err) + } + mask := make([]byte, aes.BlockSize) + hpBlock.Encrypt(mask, sample) + + // Unmask first byte to get packet number length. + packet[0] ^= mask[0] & 0x0f + pnLen := int(packet[0]&0x03) + 1 + + // Validate that the packet number bytes fit within the packet. + if pnOffset+pnLen > len(packet) { + return nil, fmt.Errorf("packet truncated at packet number bytes") + } + + // Unmask packet number. + for i := range pnLen { + packet[pnOffset+i] ^= mask[1+i] + } + + // The header is everything up to and including the packet number. + headerEnd := pnOffset + pnLen + if headerEnd >= len(packet) { + return nil, fmt.Errorf("packet truncated at packet number") + } + + // Decrypt the payload. + ciphertextStart := headerEnd + associatedData := packet[:headerEnd] + + block, err := aes.NewCipher(serverKeys.key) + if err != nil { + return nil, fmt.Errorf("creating AES cipher: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("creating GCM: %w", err) + } + + // Reconstruct nonce: IV XOR packet_number (left-padded). + nonce := make([]byte, 12) + copy(nonce, serverKeys.iv) + // XOR the packet number into the rightmost bytes. + pnBytes := packet[pnOffset:headerEnd] + for i, b := range pnBytes { + nonce[12-pnLen+i] ^= b + } + + if payloadEnd > len(packet) { + payloadEnd = len(packet) + } + plaintext, err := gcm.Open(nil, nonce, packet[ciphertextStart:payloadEnd], associatedData) + if err != nil { + return nil, fmt.Errorf("decrypting quic payload: %w", err) + } + + // Find the CRYPTO frame in the plaintext. + // Frame type 0x06 = CRYPTO, followed by offset (varint) + length (varint) + data. + fpos := 0 + for fpos < len(plaintext) { + frameType := plaintext[fpos] + if frameType == 0x00 { + slog.Debug("skipping QUIC PADDING frame") + fpos++ + continue + } + if frameType == 0x01 { + slog.Debug("skipping QUIC PING frame") + fpos++ + continue + } + if frameType == 0x02 || frameType == 0x03 { + // ACK frame (RFC 9000 §19.3): parse and skip. + fpos++ + _, varLen := decodeQUICVarint(plaintext[fpos:]) // Largest Acknowledged + if varLen == 0 { + break + } + fpos += varLen + _, varLen = decodeQUICVarint(plaintext[fpos:]) // ACK Delay + if varLen == 0 { + break + } + fpos += varLen + rangeCount, varLen := decodeQUICVarint(plaintext[fpos:]) // ACK Range Count + if varLen == 0 { + break + } + fpos += varLen + _, varLen = decodeQUICVarint(plaintext[fpos:]) // First ACK Range + if varLen == 0 { + break + } + fpos += varLen + // Cap rangeCount: each range item is at least 2 varint bytes (gap + range). + if rangeCount > uint64(len(plaintext))/2 { + break + } + malformed := false + for range rangeCount { + _, varLen = decodeQUICVarint(plaintext[fpos:]) // Gap + if varLen == 0 { + malformed = true + break + } + fpos += varLen + _, varLen = decodeQUICVarint(plaintext[fpos:]) // ACK Range Length + if varLen == 0 { + malformed = true + break + } + fpos += varLen + } + if malformed { + break + } + if frameType == 0x03 { + // ACK_ECN has 3 additional varints. + for range 3 { + _, varLen = decodeQUICVarint(plaintext[fpos:]) + if varLen == 0 { + malformed = true + break + } + fpos += varLen + } + if malformed { + break + } + } + slog.Debug("skipping QUIC ACK frame") + continue + } + if frameType != 0x06 { + // Unknown or unhandled frame type. + break + } + + // CRYPTO frame. + fpos++ // skip frame type + _, varLen := decodeQUICVarint(plaintext[fpos:]) + if varLen == 0 { + return nil, fmt.Errorf("malformed crypto frame offset") + } + fpos += varLen // skip offset + + dataLen, varLen := decodeQUICVarint(plaintext[fpos:]) + if varLen == 0 { + return nil, fmt.Errorf("malformed crypto frame length") + } + fpos += varLen + + if dataLen > uint64(len(plaintext)-fpos) { + return nil, fmt.Errorf("crypto frame data truncated") + } + cryptoData := plaintext[fpos : fpos+int(dataLen)] + + // The crypto data is a TLS handshake message (ServerHello). + return parseServerHello(cryptoData) + } + + return nil, fmt.Errorf("no crypto frame found in quic initial response") +} + +// probeQUICCipher sends a QUIC Initial packet to the provided UDP address +// with a single cipher suite and returns true if the server accepts it. +func probeQUICCipher(ctx context.Context, input cipherProbeInput) bool { + // Generate random connection IDs. + dcid := make([]byte, 8) + scid := make([]byte, 8) + if _, err := io.ReadFull(rand.Reader, dcid); err != nil { + return false + } + if _, err := io.ReadFull(rand.Reader, scid); err != nil { + return false + } + + // Build the ClientHello (without TLS record header — QUIC uses CRYPTO frames). + // QUIC requires ALPN ("h3"), quic_transport_parameters, and an empty session ID + // (RFC 9001 §8.4). + msg, err := buildClientHelloMsg(clientHelloInput{ + serverName: input.serverName, + cipherSuite: input.cipherID, + groupID: tls.X25519, + alpn: []string{"h3"}, + quic: true, + quicSCID: scid, + }) + if err != nil { + return false + } + + // Build the encrypted QUIC Initial packet. + packet, err := buildQUICInitialPacket(quicInitialPacketInput{ + clientHello: msg, + dcid: dcid, + scid: scid, + }) + if err != nil { + return false + } + + // Send via UDP. + dialer := &net.Dialer{} + conn, err := dialer.DialContext(ctx, "udp", input.addr) + if err != nil { + return false + } + defer func() { _ = conn.Close() }() + + if deadline, ok := ctx.Deadline(); ok { + _ = conn.SetDeadline(deadline) + } + + if _, err := conn.Write(packet); err != nil { + return false + } + + // Read response. Server Initial packets can include coalesced Handshake + // packets, so allocate a full UDP datagram buffer. + buf := make([]byte, 65535) + n, err := conn.Read(buf) + if err != nil { + return false + } + response := buf[:n] + + // Derive server keys for decryption (using our DCID). + _, serverKeys, err := deriveQUICInitialKeys(dcid) + if err != nil { + return false + } + + result, err := parseQUICInitialResponse(response, serverKeys) + if err != nil { + return false + } + + return result.version == tls.VersionTLS13 && result.cipherSuite == input.cipherID +} + +// ---------- QUIC varint helpers ---------- + +// appendQUICVarint appends a QUIC variable-length integer (RFC 9000 §16). +func appendQUICVarint(b []byte, v uint64) []byte { + switch { + case v < 64: + return append(b, byte(v)) + case v < 16384: + return append(b, byte(0x40|v>>8), byte(v)) + case v < 1073741824: + var buf [4]byte + binary.BigEndian.PutUint32(buf[:], uint32(v)|0x80000000) + return append(b, buf[:]...) + default: + var buf [8]byte + binary.BigEndian.PutUint64(buf[:], v|0xc000000000000000) + return append(b, buf[:]...) + } +} + +// appendQUICVarint2 appends a 2-byte QUIC varint when v < 16384. +// Falls back to appendQUICVarint for larger values to avoid panicking +// on unexpected input from untrusted servers. +func appendQUICVarint2(b []byte, v uint64) []byte { + if v < 16384 { + return append(b, byte(0x40|v>>8), byte(v)) + } + return appendQUICVarint(b, v) +} + +// decodeQUICVarint decodes a QUIC variable-length integer and returns +// the value and the number of bytes consumed. +func decodeQUICVarint(data []byte) (uint64, int) { + if len(data) == 0 { + return 0, 0 + } + prefix := data[0] >> 6 + length := 1 << prefix + + if len(data) < length { + return 0, 0 + } + + switch length { + case 1: + return uint64(data[0] & 0x3f), 1 + case 2: + v := binary.BigEndian.Uint16(data[:2]) + return uint64(v & 0x3fff), 2 + case 4: + v := binary.BigEndian.Uint32(data[:4]) + return uint64(v & 0x3fffffff), 4 + case 8: + v := binary.BigEndian.Uint64(data[:8]) + return v & 0x3fffffffffffffff, 8 + default: + return 0, 0 + } +} diff --git a/testhelpers_test.go b/testhelpers_test.go index 7209778..0767e93 100644 --- a/testhelpers_test.go +++ b/testhelpers_test.go @@ -396,13 +396,20 @@ func generateTestLeafCert(t *testing.T, ca *testCA, opts ...testLeafOption) *tes // and private key. Returns the listener port. The server is stopped via t.Cleanup. func startTLSServer(t *testing.T, certChain [][]byte, key *ecdsa.PrivateKey) string { t.Helper() - tlsCert := tls.Certificate{ - Certificate: certChain, - PrivateKey: key, - } - listener, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{ - Certificates: []tls.Certificate{tlsCert}, + return startTLSServerWithConfig(t, &tls.Config{ + Certificates: []tls.Certificate{{ + Certificate: certChain, + PrivateKey: key, + }}, }) +} + +// startTLSServerWithConfig starts a TLS server with the given tls.Config. +// The config must already have Certificates set. Returns the listener port. +// The server is stopped via t.Cleanup. +func startTLSServerWithConfig(t *testing.T, config *tls.Config) string { + t.Helper() + listener, err := tls.Listen("tcp", "127.0.0.1:0", config) if err != nil { t.Fatal(err) } @@ -416,11 +423,11 @@ func startTLSServer(t *testing.T, certChain [][]byte, key *ecdsa.PrivateKey) str } if tlsConn, ok := conn.(*tls.Conn); ok { if err := tlsConn.Handshake(); err != nil { - slog.Debug("startTLSServer: handshake error (expected during test teardown)", "error", err) + slog.Debug("startTLSServerWithConfig: handshake error", "error", err) } } if err := conn.Close(); err != nil { - slog.Debug("startTLSServer: connection close error", "error", err) + slog.Debug("startTLSServerWithConfig: connection close error", "error", err) } } }() diff --git a/tls13probe.go b/tls13probe.go new file mode 100644 index 0000000..6dcb406 --- /dev/null +++ b/tls13probe.go @@ -0,0 +1,596 @@ +package certkit + +// This file implements a raw TLS 1.3 ClientHello prober that constructs +// minimal TLS handshake packets at the byte level. It replaces the linkname +// hack (tls13hack.go) which caused data races by mutating a process-global +// variable in crypto/tls. +// +// The prober sends a ClientHello offering a single cipher suite and/or +// key exchange group, reads the ServerHello, and checks what the server +// accepted. Each probe is fully isolated — no shared state, no races. + +import ( + "context" + "crypto/ecdh" + "crypto/mlkem" + "crypto/rand" + "crypto/tls" + "encoding/binary" + "errors" + "fmt" + "io" + "net" +) + +// tls13CipherSuites lists all TLS 1.3 cipher suites from RFC 8446. +var tls13CipherSuites = []uint16{ + 0x1301, // TLS_AES_128_GCM_SHA256 + 0x1302, // TLS_AES_256_GCM_SHA384 + 0x1303, // TLS_CHACHA20_POLY1305_SHA256 + 0x1304, // TLS_AES_128_CCM_SHA256 + 0x1305, // TLS_AES_128_CCM_8_SHA256 +} + +// keyExchangeGroups lists all key exchange groups to probe, ordered by +// preference (PQ hybrids first, then classical curves). +var keyExchangeGroups = []tls.CurveID{ + tls.X25519MLKEM768, + tls.SecP256r1MLKEM768, + tls.SecP384r1MLKEM1024, + tls.X25519, + tls.CurveP256, + tls.CurveP384, + tls.CurveP521, +} + +// clientHelloInput contains parameters for building a raw TLS 1.3 ClientHello. +type clientHelloInput struct { + serverName string + cipherSuite uint16 + groupID tls.CurveID + alpn []string // optional ALPN protocols (e.g. ["h3"] for QUIC) + quic bool // include quic_transport_parameters extension + quicSCID []byte // QUIC source connection ID (for initial_source_connection_id) +} + +// serverHelloResult contains the parsed fields from a ServerHello. +type serverHelloResult struct { + cipherSuite uint16 + version uint16 // from supported_versions extension, or legacy field +} + +// errAlertReceived is returned when the server responds with a TLS Alert +// instead of a ServerHello, indicating the cipher suite or group was rejected. +var errAlertReceived = errors.New("tls alert received") + +// errHelloRetryRequest is returned when the server responds with a +// HelloRetryRequest instead of a real ServerHello. Per RFC 8446 §4.1.3, an HRR +// is a ServerHello with a specific synthetic random value. It means the server +// supports TLS 1.3 but not the offered key exchange group. +var errHelloRetryRequest = errors.New("hello retry request received, group not supported") + +// hrrSentinel is the synthetic random value that distinguishes a +// HelloRetryRequest from a real ServerHello (RFC 8446 §4.1.3). +var hrrSentinel = [32]byte{ + 0xCF, 0x21, 0xAD, 0x74, 0xE5, 0x9A, 0x61, 0x11, + 0xBE, 0x1D, 0x8C, 0x02, 0x1E, 0x65, 0xB8, 0x91, + 0xC2, 0xA2, 0x11, 0x16, 0x7A, 0xBB, 0x8C, 0x5E, + 0x07, 0x9E, 0x09, 0xE2, 0xC8, 0xA8, 0x33, 0x9C, +} + +// KeyExchangeProbeResult describes a single key exchange group accepted by the server. +type KeyExchangeProbeResult struct { + // Name is the human-readable group name (e.g. "X25519", "X25519MLKEM768"). + Name string `json:"name"` + // ID is the TLS CurveID / NamedGroup identifier. + ID uint16 `json:"id"` + // PostQuantum is true for hybrid post-quantum key exchange mechanisms. + PostQuantum bool `json:"post_quantum,omitempty"` +} + +// buildClientHelloMsg constructs a minimal TLS 1.3 ClientHello handshake +// message (type + length + body) offering a single cipher suite and a single +// key exchange group. The returned bytes do NOT include the TLS record header; +// callers wrap them in a TLS record (TCP) or QUIC CRYPTO frame (UDP). +func buildClientHelloMsg(input clientHelloInput) ([]byte, error) { + keyShareData, err := generateKeyShare(input.groupID) + if err != nil { + return nil, fmt.Errorf("generating key share for group %s: %w", input.groupID, err) + } + + // Build extensions. + var exts []byte + exts = appendSNIExtension(exts, input.serverName) + exts = appendSupportedGroupsExtension(exts, input.groupID) + exts = appendSignatureAlgorithmsExtension(exts) + exts = appendKeyShareExtension(exts, appendKeyShareExtensionInput{groupID: input.groupID, keyData: keyShareData}) + exts = appendSupportedVersionsExtension(exts) + exts = appendPSKKeyExchangeModesExtension(exts) + if len(input.alpn) > 0 { + exts = appendALPNExtension(exts, input.alpn) + } + if input.quic { + exts = appendQUICTransportParamsExtension(exts, input.quicSCID) + } + + // Build ClientHello body. + var body []byte + + // Legacy version: TLS 1.2 (required for TLS 1.3 compatibility). + body = append(body, 0x03, 0x03) + + // Client random (32 bytes). + random := make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, random); err != nil { + return nil, fmt.Errorf("generating client random: %w", err) + } + body = append(body, random...) + + // Session ID: 32 random bytes for TLS-over-TCP middlebox compatibility + // (RFC 8446 §4.1.2). QUIC MUST use an empty session ID (RFC 9001 §8.4). + if input.quic { + body = append(body, 0x00) // empty legacy_session_id + } else { + sessionID := make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, sessionID); err != nil { + return nil, fmt.Errorf("generating session ID: %w", err) + } + body = append(body, byte(len(sessionID))) + body = append(body, sessionID...) + } + + // Cipher suites: single cipher. + body = appendUint16(body, 2) + body = appendUint16(body, input.cipherSuite) + + // Compression methods: null only. + body = append(body, 1, 0) + + // Extensions. + body = appendUint16(body, uint16(len(exts))) + body = append(body, exts...) + + // Wrap in handshake header: type(1) + length(3) + body. + msg := []byte{0x01} // ClientHello + msg = appendUint24(msg, uint32(len(body))) + msg = append(msg, body...) + + return msg, nil +} + +// wrapTLSRecord wraps a handshake message in a TLS record header. +func wrapTLSRecord(handshakeMsg []byte) []byte { + record := make([]byte, 0, 5+len(handshakeMsg)) + record = append(record, 0x16) // ContentType: Handshake + record = append(record, 0x03, 0x01) // Record version: TLS 1.0 (compatibility) + record = appendUint16(record, uint16(len(handshakeMsg))) + record = append(record, handshakeMsg...) + return record +} + +// generateKeyShare produces an ephemeral key share for the given named group. +// The private key is discarded — we only need the server to accept our +// ClientHello, not to complete the full handshake. +func generateKeyShare(groupID tls.CurveID) ([]byte, error) { + switch groupID { + case tls.X25519: + key, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("generating X25519 key: %w", err) + } + return key.PublicKey().Bytes(), nil + + case tls.CurveP256: + key, err := ecdh.P256().GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("generating P-256 key: %w", err) + } + return key.PublicKey().Bytes(), nil + + case tls.CurveP384: + key, err := ecdh.P384().GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("generating P-384 key: %w", err) + } + return key.PublicKey().Bytes(), nil + + case tls.CurveP521: + key, err := ecdh.P521().GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("generating P-521 key: %w", err) + } + return key.PublicKey().Bytes(), nil + + case tls.X25519MLKEM768: + // X25519MLKEM768: ML-KEM-768 encapsulation key first, then X25519. + // See draft-ietf-tls-ecdhe-mlkem-02 §4.1 and Go crypto/tls/key_schedule.go:184. + dk, err := mlkem.GenerateKey768() + if err != nil { + return nil, fmt.Errorf("generating ML-KEM-768 key: %w", err) + } + x, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("generating X25519 key for X25519MLKEM768: %w", err) + } + return append(dk.EncapsulationKey().Bytes(), x.PublicKey().Bytes()...), nil + + case tls.SecP256r1MLKEM768: + // SecP256r1MLKEM768: ECDH (P-256) first, then ML-KEM-768. + ec, err := ecdh.P256().GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("generating P-256 key for SecP256r1MLKEM768: %w", err) + } + dk, err := mlkem.GenerateKey768() + if err != nil { + return nil, fmt.Errorf("generating ML-KEM-768 key for SecP256r1MLKEM768: %w", err) + } + return append(ec.PublicKey().Bytes(), dk.EncapsulationKey().Bytes()...), nil + + case tls.SecP384r1MLKEM1024: + // SecP384r1MLKEM1024: ECDH (P-384) first, then ML-KEM-1024. + ec, err := ecdh.P384().GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("generating P-384 key for SecP384r1MLKEM1024: %w", err) + } + dk, err := mlkem.GenerateKey1024() + if err != nil { + return nil, fmt.Errorf("generating ML-KEM-1024 key for SecP384r1MLKEM1024: %w", err) + } + return append(ec.PublicKey().Bytes(), dk.EncapsulationKey().Bytes()...), nil + + default: + return nil, fmt.Errorf("unsupported group: 0x%04x", uint16(groupID)) + } +} + +// readServerHello reads a TLS record from the connection and parses the +// ServerHello message. Returns errAlertReceived if the server sent a TLS Alert. +func readServerHello(r io.Reader) (*serverHelloResult, error) { + // Read TLS record header (5 bytes): type(1) + version(2) + length(2). + header := make([]byte, 5) + if _, err := io.ReadFull(r, header); err != nil { + return nil, fmt.Errorf("reading tls record header: %w", err) + } + + contentType := header[0] + recordLen := binary.BigEndian.Uint16(header[3:5]) + + // TLS records are limited to 16384 bytes plus some overhead. + if recordLen > 16640 { + return nil, fmt.Errorf("tls record too large: %d bytes", recordLen) + } + + payload := make([]byte, recordLen) + if _, err := io.ReadFull(r, payload); err != nil { + return nil, fmt.Errorf("reading tls record payload: %w", err) + } + + // Alert record: the server rejected the cipher suite or group. + if contentType == 0x15 { + return nil, errAlertReceived + } + + if contentType != 0x16 { + return nil, fmt.Errorf("unexpected tls content type: 0x%02x", contentType) + } + + return parseServerHello(payload) +} + +// parseServerHello extracts the cipher suite and negotiated TLS version +// from a ServerHello handshake message. +func parseServerHello(data []byte) (*serverHelloResult, error) { + if len(data) < 4 { + return nil, fmt.Errorf("handshake message too short: %d bytes", len(data)) + } + + handshakeType := data[0] + handshakeLen := int(data[1])<<16 | int(data[2])<<8 | int(data[3]) + + if handshakeType != 0x02 { + return nil, fmt.Errorf("unexpected handshake type: 0x%02x, expected server hello 0x02", handshakeType) + } + + if len(data) < 4+handshakeLen { + return nil, fmt.Errorf("server hello truncated: need %d bytes, have %d", 4+handshakeLen, len(data)) + } + + body := data[4 : 4+handshakeLen] + + // ServerHello body: version(2) + random(32) + session_id_len(1) + ... + if len(body) < 35 { + return nil, fmt.Errorf("server hello body too short: %d bytes", len(body)) + } + + // Check for HelloRetryRequest (RFC 8446 §4.1.3): a ServerHello with the + // special synthetic random value is actually an HRR, meaning the server + // doesn't support the offered key exchange group. + var serverRandom [32]byte + copy(serverRandom[:], body[2:34]) + if serverRandom == hrrSentinel { + return nil, errHelloRetryRequest + } + + pos := 34 // skip version(2) + random(32) + + // Session ID. + sessionIDLen := int(body[pos]) + pos++ + if pos+sessionIDLen > len(body) { + return nil, fmt.Errorf("server hello truncated at session ID") + } + pos += sessionIDLen + + // Cipher suite (2 bytes). + if pos+2 > len(body) { + return nil, fmt.Errorf("server hello truncated at cipher suite") + } + cipherSuite := binary.BigEndian.Uint16(body[pos : pos+2]) + pos += 2 + + // Compression method (1 byte). + if pos+1 > len(body) { + return nil, fmt.Errorf("server hello truncated at compression method") + } + pos++ + + // Default version from legacy field. + version := binary.BigEndian.Uint16(body[0:2]) + + // Parse extensions to find supported_versions (0x002b). + if pos+2 <= len(body) { + extLen := int(binary.BigEndian.Uint16(body[pos : pos+2])) + pos += 2 + + extEnd := min(pos+extLen, len(body)) + + for pos+4 <= extEnd { + extType := binary.BigEndian.Uint16(body[pos : pos+2]) + extDataLen := int(binary.BigEndian.Uint16(body[pos+2 : pos+4])) + pos += 4 + + if pos+extDataLen > extEnd { + break + } + + // supported_versions in ServerHello contains a single 2-byte version. + if extType == 0x002b && extDataLen >= 2 { + version = binary.BigEndian.Uint16(body[pos : pos+2]) + } + + pos += extDataLen + } + } + + return &serverHelloResult{ + cipherSuite: cipherSuite, + version: version, + }, nil +} + +// cipherProbeInput contains parameters for probing a single cipher suite +// or key exchange group on a TLS server. +type cipherProbeInput struct { + addr string + serverName string + cipherID uint16 + groupID tls.CurveID + version uint16 // for legacy TLS 1.0–1.2 probing +} + +// probeTLS13Cipher attempts a raw TLS 1.3 ClientHello with a single cipher +// suite and returns true if the server accepts it. Each call is fully isolated +// with no shared state — safe for concurrent use from multiple goroutines. +// +// The key share uses X25519 only. Servers that support TLS 1.3 but reject +// X25519 will trigger a HelloRetryRequest, causing this probe to return false. +// In practice this is extremely rare — X25519 is mandatory in modern browsers +// and required by RFC 8446 implementations. +func probeTLS13Cipher(ctx context.Context, input cipherProbeInput) bool { + dialer := &net.Dialer{} + conn, err := dialer.DialContext(ctx, "tcp", input.addr) + if err != nil { + return false + } + defer func() { _ = conn.Close() }() + + if deadline, ok := ctx.Deadline(); ok { + _ = conn.SetDeadline(deadline) + } + + msg, err := buildClientHelloMsg(clientHelloInput{ + serverName: input.serverName, + cipherSuite: input.cipherID, + groupID: tls.X25519, + }) + if err != nil { + return false + } + + if _, err := conn.Write(wrapTLSRecord(msg)); err != nil { + return false + } + + result, err := readServerHello(conn) + if err != nil { + return false + } + + return result.version == tls.VersionTLS13 && result.cipherSuite == input.cipherID +} + +// probeKeyExchangeGroup attempts a raw TLS 1.3 ClientHello offering a single +// named group and returns true if the server selects it. Uses +// TLS_AES_128_GCM_SHA256 as the cipher since all TLS 1.3 servers must support it. +func probeKeyExchangeGroup(ctx context.Context, input cipherProbeInput) bool { + dialer := &net.Dialer{} + conn, err := dialer.DialContext(ctx, "tcp", input.addr) + if err != nil { + return false + } + defer func() { _ = conn.Close() }() + + if deadline, ok := ctx.Deadline(); ok { + _ = conn.SetDeadline(deadline) + } + + msg, err := buildClientHelloMsg(clientHelloInput{ + serverName: input.serverName, + cipherSuite: 0x1301, // TLS_AES_128_GCM_SHA256 + groupID: input.groupID, + }) + if err != nil { + return false + } + + if _, err := conn.Write(wrapTLSRecord(msg)); err != nil { + return false + } + + result, err := readServerHello(conn) + if err != nil { + return false + } + + return result.version == tls.VersionTLS13 +} + +// isPQKeyExchange reports whether the given CurveID is a post-quantum hybrid mechanism. +func isPQKeyExchange(id tls.CurveID) bool { + switch id { + case tls.X25519MLKEM768, tls.SecP256r1MLKEM768, tls.SecP384r1MLKEM1024: + return true + default: + return false + } +} + +// ---------- byte-level helpers ---------- + +func appendUint16(b []byte, v uint16) []byte { + return append(b, byte(v>>8), byte(v)) +} + +func appendUint24(b []byte, v uint32) []byte { + return append(b, byte(v>>16), byte(v>>8), byte(v)) +} + +// appendSNIExtension appends a server_name extension (0x0000). +func appendSNIExtension(b []byte, serverName string) []byte { + if serverName == "" { + return b + } + name := []byte(serverName) + listLen := 1 + 2 + len(name) // type(1) + name_len(2) + name + + b = appendUint16(b, 0x0000) // extension type + b = appendUint16(b, uint16(2+listLen)) // extension data length + b = appendUint16(b, uint16(listLen)) // server name list length + b = append(b, 0x00) // name type: host_name + b = appendUint16(b, uint16(len(name))) + return append(b, name...) +} + +// appendSupportedGroupsExtension appends a supported_groups extension (0x000a). +func appendSupportedGroupsExtension(b []byte, groupID tls.CurveID) []byte { + b = appendUint16(b, 0x000a) // extension type + b = appendUint16(b, 4) // extension data length + b = appendUint16(b, 2) // group list length + return appendUint16(b, uint16(groupID)) +} + +// appendSignatureAlgorithmsExtension appends a signature_algorithms extension (0x000d). +func appendSignatureAlgorithmsExtension(b []byte) []byte { + b = appendUint16(b, 0x000d) // extension type + b = appendUint16(b, 8) // extension data length + b = appendUint16(b, 6) // algorithm list length (3 algorithms × 2 bytes) + b = appendUint16(b, 0x0403) // ecdsa_secp256r1_sha256 + b = appendUint16(b, 0x0804) // rsa_pss_rsae_sha256 + return appendUint16(b, 0x0401) // rsa_pkcs1_sha256 +} + +// appendKeyShareExtensionInput contains parameters for appendKeyShareExtension. +type appendKeyShareExtensionInput struct { + groupID tls.CurveID + keyData []byte +} + +// appendKeyShareExtension appends a key_share extension (0x0033). +func appendKeyShareExtension(b []byte, input appendKeyShareExtensionInput) []byte { + entryLen := 2 + 2 + len(input.keyData) // group(2) + key_len(2) + key_data + + b = appendUint16(b, 0x0033) // extension type + b = appendUint16(b, uint16(2+entryLen)) // extension data length + b = appendUint16(b, uint16(entryLen)) // client key shares length + b = appendUint16(b, uint16(input.groupID)) // named group + b = appendUint16(b, uint16(len(input.keyData))) + return append(b, input.keyData...) +} + +// appendSupportedVersionsExtension appends a supported_versions extension (0x002b) +// offering TLS 1.3 only. +func appendSupportedVersionsExtension(b []byte) []byte { + b = appendUint16(b, 0x002b) // extension type + b = appendUint16(b, 3) // extension data length + b = append(b, 2) // version list length (1 version × 2 bytes) + return appendUint16(b, 0x0304) // TLS 1.3 +} + +// appendPSKKeyExchangeModesExtension appends a psk_key_exchange_modes extension (0x002d). +func appendPSKKeyExchangeModesExtension(b []byte) []byte { + b = appendUint16(b, 0x002d) // extension type + b = appendUint16(b, 2) // extension data length + b = append(b, 1) // modes list length + return append(b, 1) // psk_dhe_ke +} + +// appendALPNExtension appends an application_layer_protocol_negotiation extension (0x0010). +func appendALPNExtension(b []byte, protocols []string) []byte { + // ALPN protocol list: each entry is length(1) + name. + var list []byte + for _, p := range protocols { + list = append(list, byte(len(p))) + list = append(list, []byte(p)...) + } + b = appendUint16(b, 0x0010) // extension type + b = appendUint16(b, uint16(2+len(list))) // extension data length + b = appendUint16(b, uint16(len(list))) // protocol list length + return append(b, list...) +} + +// appendQUICTransportParamsExtension appends a quic_transport_parameters +// extension (0x0039) as required by RFC 9001 §8.2. Includes +// initial_source_connection_id (MUST per RFC 9000 §18.2) and flow control +// parameters that real QUIC clients send. +func appendQUICTransportParamsExtension(b []byte, scid []byte) []byte { + // Format: param_id(varint) + param_len(varint) + value + var params []byte + + // initial_source_connection_id (0x0f) — MUST (RFC 9000 §18.2). + params = append(params, 0x0f) // param ID + params = appendQUICVarint(params, uint64(len(scid))) + params = append(params, scid...) + + // initial_max_data (0x04) = 1 MiB + params = append(params, 0x04) // param ID + params = append(params, 0x04) // length: 4 bytes + params = append(params, 0x80, 0x10, 0x00, 0x00) // 1048576 + + // initial_max_stream_data_bidi_local (0x05) = 256 KiB + params = append(params, 0x05, 0x04, 0x80, 0x04, 0x00, 0x00) + + // initial_max_stream_data_bidi_remote (0x06) = 256 KiB + params = append(params, 0x06, 0x04, 0x80, 0x04, 0x00, 0x00) + + // initial_max_stream_data_uni (0x07) = 256 KiB + params = append(params, 0x07, 0x04, 0x80, 0x04, 0x00, 0x00) + + // initial_max_streams_bidi (0x08) = 100 + params = append(params, 0x08, 0x02, 0x40, 0x64) + + // initial_max_streams_uni (0x09) = 100 + params = append(params, 0x09, 0x02, 0x40, 0x64) + + b = appendUint16(b, 0x0039) // extension type + return append(appendUint16(b, uint16(len(params))), params...) +} diff --git a/web/package-lock.json b/web/package-lock.json index 0f76d4b..30dfd1f 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -25,9 +25,9 @@ } }, "node_modules/@cloudflare/workers-types": { - "version": "4.20260304.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260304.0.tgz", - "integrity": "sha512-oQ0QJpWnCWK9tx5q/ZHQeSsf5EcQWa4KqdDMY/R5Ln0ojFzv6UYO0RWsfDPsoXUAwK671VwaXqAW0Mx0uWz7yw==", + "version": "4.20260305.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260305.0.tgz", + "integrity": "sha512-sCgPFnQ03SVpC2OVW8wysONLZW/A8hlp9Mq2ckG/h1oId4kr9NawA6vUiOmOjCWRn2hIohejBYVQ+Vu20rCdKA==", "dev": true, "license": "MIT OR Apache-2.0" }, @@ -596,9 +596,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "cpu": [ "arm" ], @@ -610,9 +610,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "cpu": [ "arm64" ], @@ -624,9 +624,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "cpu": [ "arm64" ], @@ -638,9 +638,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "cpu": [ "x64" ], @@ -652,9 +652,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", "cpu": [ "arm64" ], @@ -666,9 +666,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "cpu": [ "x64" ], @@ -680,9 +680,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "cpu": [ "arm" ], @@ -694,9 +694,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "cpu": [ "arm" ], @@ -708,9 +708,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "cpu": [ "arm64" ], @@ -722,9 +722,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "cpu": [ "arm64" ], @@ -736,9 +736,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", "cpu": [ "loong64" ], @@ -750,9 +750,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", "cpu": [ "loong64" ], @@ -764,9 +764,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", "cpu": [ "ppc64" ], @@ -778,9 +778,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", - "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", "cpu": [ "ppc64" ], @@ -792,9 +792,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", - "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", "cpu": [ "riscv64" ], @@ -806,9 +806,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", - "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", "cpu": [ "riscv64" ], @@ -820,9 +820,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", - "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", "cpu": [ "s390x" ], @@ -834,9 +834,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", "cpu": [ "x64" ], @@ -848,9 +848,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "cpu": [ "x64" ], @@ -862,9 +862,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", - "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", "cpu": [ "x64" ], @@ -876,9 +876,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", - "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", "cpu": [ "arm64" ], @@ -890,9 +890,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", - "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "cpu": [ "arm64" ], @@ -904,9 +904,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", - "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "cpu": [ "ia32" ], @@ -918,9 +918,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", - "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", "cpu": [ "x64" ], @@ -932,9 +932,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", - "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "cpu": [ "x64" ], @@ -1575,9 +1575,9 @@ } }, "node_modules/rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", "dependencies": { @@ -1591,31 +1591,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" } },