Repository navigation
fix(dnssec): propagate Indeterminate (not Bogus) for unreachable chain of trust (#2120) - #2139
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adjusts Blocky’s DNSSEC validation so transient chain-of-trust failures (e.g., DS/DNSKEY sub-query timeouts / unreachable upstream) propagate as Indeterminate (“could not validate”) instead of being misclassified as Bogus (SERVFAIL / EDE DNSSEC Bogus), addressing the intermittent failures reported in #2120 while preserving the GHSA-x845-2f78-7v36 fail-closed behavior for genuine cryptographic failures.
Changes:
- Introduces explicit error markers for indeterminate/unreachable validation paths and updates signature-verification flow to propagate Indeterminate correctly.
- Updates RRset aggregation logic so Bogus takes precedence over Indeterminate across RRsets (map iteration order), while still allowing Indeterminate to prevent asserting a fully-validated verdict.
- Adds regression tests covering unreachable-chain Indeterminate behavior and ensuring forged signatures with reachable chains remain Bogus.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| resolver/dnssec/validator.go | Adds indeterminate/unreachable error markers and updates validation result selection/precedence. |
| resolver/dnssec/validator_test.go | Adds regression tests for Indeterminate propagation and for preserving Bogus on forged signatures. |
| resolver/dnssec/rrset.go | Wraps DNSKEY query failures so callers can distinguish transient unreachability from genuine missing-key failures. |
| resolver/dnssec/query.go | Tags DNSKEY sub-query failures as “unreachable” to support Indeterminate classification upstream. |
Comments suppressed due to low confidence (1)
resolver/dnssec/validator.go:645
- When
chainResult == ValidationResultBogus,tryVerifyWithRRSIGreturns(false, nil). With the new Indeterminate propagation, this can incorrectly classify a RRset as Indeterminate if another signature attempt hit an unreachable chain (setshasIndeterminateChain) even though a Bogus chain was observed. Return a non-nil error here sovalidateSingleRRsetrecords it as a genuine failure and Bogus wins.
if chainResult == ValidationResultBogus {
v.logger.Debugf("Skipping RRSIG (algorithm=%d, keytag=%d): chain of trust validation failed: %s",
matchingSig.Algorithm, matchingSig.KeyTag, chainResult.String())
return false, nil
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #2139 +/- ##
==========================================
+ Coverage 87.77% 88.34% +0.56%
==========================================
Files 123 123
Lines 9589 9607 +18
==========================================
+ Hits 8417 8487 +70
+ Misses 928 873 -55
- Partials 244 247 +3 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…g (PR review) Address Copilot review on #2139: the Bogus-chain branch in tryVerifyWithRRSIG returned (false, nil), so within an RRset a genuine Bogus chain (e.g. DS digest mismatch) contributed no failure flag. Return errBogusChain instead, classified as a genuine failure, so Bogus always wins over a transient Indeterminate sibling - mirroring the cross-RRset precedence in validateRRsets - and the final Bogus log carries a real reason instead of <nil>. (The two-different-signer masking case is not reachable today - valid signers of one RRset are always in an ancestor relationship and share a chain prefix - but this makes the within-RRset precedence explicit and improves diagnostics.) Adds an end-to-end regression test: a DS digest mismatch (provably bogus chain, all sub-queries reachable) must be Bogus, never Indeterminate.
|
Addressed the Copilot review:
480/480 dnssec specs pass (incl. all GHSA-x845 regression specs); resolver package green; build/gofmt/vet clean. |
…g (PR review) Address Copilot review on #2139: the Bogus-chain branch in tryVerifyWithRRSIG returned (false, nil), so within an RRset a genuine Bogus chain (e.g. DS digest mismatch) contributed no failure flag. Return errBogusChain instead, classified as a genuine failure, so Bogus always wins over a transient Indeterminate sibling - mirroring the cross-RRset precedence in validateRRsets - and the final Bogus log carries a real reason instead of <nil>. (The two-different-signer masking case is not reachable today - valid signers of one RRset are always in an ancestor relationship and share a chain prefix - but this makes the within-RRset precedence explicit and improves diagnostics.) Adds an end-to-end regression test: a DS digest mismatch (provably bogus chain, all sub-queries reachable) must be Bogus, never Indeterminate.
b842283 to
3e50c29
Compare
…n of trust (#2120) A signed answer whose chain of trust could not be completed - because a DS/DNSKEY sub-query timed out or the upstream was momentarily unreachable - was classified Bogus, producing SERVFAIL "bogus signatures". This is the within-request half of #2120 that #2127 left open: #2127 only stopped the transient Indeterminate verdict from being *cached*, but within a single request walkChainOfTrust -> Indeterminate (and a failed signer-DNSKEY fetch) were still funnelled to Bogus by determineFinalValidationResult's default. That is why outlook.office365.com and ecs.office.com still SERVFAILed while the structurally identical outlook.office.com worked: whichever zone's DS/DNSKEY happened to be cold and flaked got mislabelled Bogus. Distinguish "could not gather validation data" (transient/unreachable) from a genuine cryptographic failure: - queryDNSKEY tags a sub-query failure with errDNSKEYUnavailable; a response that arrives with no DNSKEY records stays a genuine failure (Bogus). - tryVerifyWithRRSIG returns errIndeterminateChain for an Indeterminate chain, and errBogusChain for a provably-bogus chain (DS digest mismatch) so a real chain failure dominates any Indeterminate sibling within an RRset and is logged with a reason instead of <nil>. - determineFinalValidationResult returns Indeterminate only when the sole obstacle was an unreachable chain and no signature failed cryptographically. - validateRRsets lets a genuinely Bogus RRset take precedence over an Indeterminate sibling (the RRset map is unordered), so the relaxation cannot mask a real failure. Set the CD (Checking Disabled) bit on the DS/DNSKEY validation sub-queries so a validating upstream (e.g. 8.8.8.8) returns the RAW records instead of pre-filtering a bogus chain to SERVFAIL - which is indistinguishable from an unreachable upstream and would otherwise be served as Indeterminate. This makes validation truly independent (#1287): dnssec-failed.org's DS digest mismatch is now fetched and rejected as Bogus, while a genuinely unreachable upstream still yields Indeterminate. Indeterminate clears AD and serves the answer, re-validating once the upstream recovers - the RFC-correct outcome for "validation could not be completed". Security (GHSA-x845-2f78-7v36) is unchanged and re-verified: - Unsigned answer under a trust anchor with no authenticated insecure-delegation proof still fails closed (handleMissingRRSIG path, untouched). - A stripped DNSKEY, a forged signature with a reachable chain, and a bogus chain (DS digest mismatch) all still return Bogus (regression tests added). - The transient relaxation only fires when no signature failed cryptographically. Fixes #2120
3e50c29 to
b747a2c
Compare
This PR contains the following updates: | Package | Update | Change | |---|---|---| | [ghcr.io/0xerr0r/blocky](https://github.com/0xERR0R/blocky) | minor | `v0.32.1` → `v0.33.0` | --- ### Release Notes <details> <summary>0xERR0R/blocky (ghcr.io/0xerr0r/blocky)</summary> ### [`v0.33.0`](https://github.com/0xERR0R/blocky/releases/tag/v0.33.0) [Compare Source](0xERR0R/blocky@v0.32.1...v0.33.0) #### Changelog ##### Features - [`552eda5`](0xERR0R/blocky@552eda5): feat(cli): add 'blocky stats' command ([#​2138](0xERR0R/blocky#2138)) ([@​0xERR0R](https://github.com/0xERR0R)) - [`e9deb53`](0xERR0R/blocky@e9deb53): feat(queryLog): add dnstap as a query log destination ([#​2144](0xERR0R/blocky#2144)) ([@​rtmongold](https://github.com/rtmongold)) ##### Bug fixes - [`a191ad2`](0xERR0R/blocky@a191ad2): fix(dnssec): propagate Indeterminate (not Bogus) for unreachable chain of trust ([#​2120](0xERR0R/blocky#2120)) ([#​2139](0xERR0R/blocky#2139)) ([@​0xERR0R](https://github.com/0xERR0R)) - [`4b524e8`](0xERR0R/blocky@4b524e8): fix(ecs): apply useAsClient above cache and client-name lookup ([#​2140](0xERR0R/blocky#2140)) ([#​2141](0xERR0R/blocky#2141)) ([@​0xERR0R](https://github.com/0xERR0R)) ##### Build and dependencies - [`afea452`](0xERR0R/blocky@afea452): build(deps): bump github.com/breml/rootcerts from 0.3.5 to 0.3.6 ([#​2133](0xERR0R/blocky#2133)) ([@​dependabot](https://github.com/dependabot)\[bot]) - [`247a3c1`](0xERR0R/blocky@247a3c1): build(deps): bump github.com/jedib0t/go-pretty/v6 from 6.6.7 to 6.8.1 ([#​2145](0xERR0R/blocky#2145)) ([@​dependabot](https://github.com/dependabot)\[bot]) - [`2aa728d`](0xERR0R/blocky@2aa728d): build(deps): bump github.com/oapi-codegen/runtime from 1.4.1 to 1.4.2 ([#​2134](0xERR0R/blocky#2134)) ([@​dependabot](https://github.com/dependabot)\[bot]) - [`3f3f7d9`](0xERR0R/blocky@3f3f7d9): build(deps): bump github.com/onsi/ginkgo/v2 from 2.31.0 to 2.32.0 ([#​2135](0xERR0R/blocky#2135)) ([@​dependabot](https://github.com/dependabot)\[bot]) - [`2d0e6f2`](0xERR0R/blocky@2d0e6f2): build(deps): bump github.com/onsi/gomega from 1.42.0 to 1.42.1 ([#​2137](0xERR0R/blocky#2137)) ([@​dependabot](https://github.com/dependabot)\[bot]) - [`61d9a98`](0xERR0R/blocky@61d9a98): build(deps): bump github.com/testcontainers/testcontainers-go/modules/mariadb from 0.42.0 to 0.43.0 ([#​2131](0xERR0R/blocky#2131)) ([@​dependabot](https://github.com/dependabot)\[bot]) - [`cf865a6`](0xERR0R/blocky@cf865a6): build(deps): bump github.com/testcontainers/testcontainers-go/modules/postgres from 0.42.0 to 0.43.0 ([#​2130](0xERR0R/blocky#2130)) ([@​dependabot](https://github.com/dependabot)\[bot]) - [`e3c30dc`](0xERR0R/blocky@e3c30dc): build(deps): bump github.com/testcontainers/testcontainers-go/modules/redis from 0.42.0 to 0.43.0 ([#​2129](0xERR0R/blocky#2129)) ([@​dependabot](https://github.com/dependabot)\[bot]) - [`a58f1c8`](0xERR0R/blocky@a58f1c8): build(deps): bump google.golang.org/protobuf from 1.36.10 to 1.36.11 ([#​2148](0xERR0R/blocky#2148)) ([@​dependabot](https://github.com/dependabot)\[bot]) - [`6df83cd`](0xERR0R/blocky@6df83cd): build(deps): bump gorm.io/gorm from 1.31.1 to 1.31.2 ([#​2147](0xERR0R/blocky#2147)) ([@​dependabot](https://github.com/dependabot)\[bot]) ##### Misc - [`6da7ce1`](0xERR0R/blocky@6da7ce1): perf(blocking): Tier 2 — lock-free grouped cache, cheapest-first lookup, client-name indexing ([#​2146](0xERR0R/blocky#2146)) ([@​0xERR0R](https://github.com/0xERR0R)) - [`302ca65`](0xERR0R/blocky@302ca65): perf(log): cut per-request logger allocations on the resolver hot path ([#​2142](0xERR0R/blocky#2142)) ([@​0xERR0R](https://github.com/0xERR0R)) - [`223df0c`](0xERR0R/blocky@223df0c): perf(queryLog): skip per-request LogEntry build when off or ignored ([#​2149](0xERR0R/blocky#2149)) ([@​0xERR0R](https://github.com/0xERR0R)) - [`b73422e`](0xERR0R/blocky@b73422e): perf(resolver): allocation-free resolver selection in ParallelBestResolver ([#​2150](0xERR0R/blocky#2150)) ([@​0xERR0R](https://github.com/0xERR0R)) *** #### ❤️ Support Blocky Blocky is free and open source, developed in my spare time — no telemetry, no ads, no hidden filtering. If it's useful to you, please consider supporting its continued development: [GitHub Sponsors](https://github.com/sponsors/0xERR0R) · [thanks.dev](https://thanks.dev/u/gh/0xERR0R) · [Liberapay](https://liberapay.com/spx01) · [Ko-fi](https://ko-fi.com/0xerr0r) · [PayPal](https://paypal.me/spx01) Thank you to everyone supporting Blocky! 🙏 </details> --- ### Configuration 📅 **Schedule**: (UTC) - Branch creation - At any time (no schedule defined) - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box --- This PR has been generated by [Mend Renovate](https://github.com/renovatebot/renovate). <!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4yNDYuMSIsInVwZGF0ZWRJblZlciI6IjQzLjI0Ni4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->
Problem
With
dnssec.validate: true, signed answers on long Microsoft CNAME chains (e.g.outlook.office365.com,ecs.office.com) intermittently returnSERVFAILwithEDE 6 (DNSSEC Bogus): bogus signatures, while structurally identical names (outlook.office.com) resolve fine. Reported in #2120; the reporter confirmed it still happens on 0.32.1 after #2127.Root cause
The SERVFAIL is not a real signature failure — it's a transient one. When a
DS/DNSKEYsub-query needed to build the chain of trust times out (or the upstream is briefly unreachable), the validator could not complete validation, but the result was funnelled to Bogus instead of Indeterminate:walkChainOfTrustreturnsIndeterminateon a sub-query timeout, buttryVerifyWithRRSIGskipped the RRSIG with(false, nil), so no failure flag was set anddetermineFinalValidationResultfell through to its Bogus default.DNSKEYfetch returned a plain error that was counted as a genuine crypto failure → also Bogus.#2127only stopped the transientIndeterminateverdict from being cached; within a single request the same gap still producedBogus → SERVFAIL "bogus signatures". That is why the failing name "moves around" (whichever zone'sDS/DNSKEYhappens to be cold and flaky at that moment) and why it depends on network timing.This is environment/latency-gated: on a healthy connection it does not reproduce (consistent with not being reproducible from the config alone), but it is deterministic once a sub-query actually fails.
Fix
Distinguish "could not gather validation data" (transient / unreachable) from a genuine cryptographic failure:
queryDNSKEYtags a sub-query failure witherrDNSKEYUnavailable; a response that arrives with noDNSKEYrecords stays a genuine failure (Bogus).tryVerifyWithRRSIGreturnserrIndeterminateChainfor anIndeterminatechain (aBoguschain still funnels to Bogus).determineFinalValidationResultreturnsIndeterminatewhen the only obstacle was an unreachable chain and no signature failed cryptographically.validateRRsetslets a genuinelyBogusRRset take precedence over anIndeterminatesibling (the RRset map is unordered), so the relaxation can never mask a real failure.IndeterminateclearsADand serves the answer, re-validating once the upstream recovers — the RFC-correct outcome for "validation could not be completed". (This matches what public resolvers return for these chains: NOERROR withoutAD, since the terminal record is in an unsigned zone.)Security — GHSA-x845-2f78-7v36 preserved
The relaxation only applies when a real
RRSIGis present but its chain is unreachable. The advisory's attack paths are untouched and re-verified:handleMissingRRSIG/classifyUndetermined, unchanged).still returns Bogus for a forged signature when the chain is fully reachable).Verification
go test ./resolver/dnssec/ ./resolver/— green (479/479 dnssec specs incl. all GHSA-x845 regression specs). 3 new tests added; the 2Indeterminateones were confirmed RED before the fix.DNSKEYforoffice365.com(simulating the reporter's flaky-network / tight-timeout condition):outlook.office365.com→SERVFAIL,EDE 6 (DNSSEC Bogus): bogus signaturesNOERROR, full chain served,flags: qr rd ra(AD cleared); log:Could not complete chain of trust ... treating as Indeterminate.Notes
Indeterminatehandling) rather than SERVFAIL. AD is never asserted; moot over DoT/DoH.ADon signed-CNAME + unsigned-tail chains (e.g.outlook.office.com) where the terminal record is unsigned and public resolvers return noAD. Will file separately.Fixes #2120