Skip to content

fix(dnssec): propagate Indeterminate (not Bogus) for unreachable chain of trust (#2120) - #2139

Merged
0xERR0R merged 1 commit into
mainfrom
fix/dnssec-indeterminate-chain
Jun 24, 2026
Merged

0xERR0R merged 1 commit into
mainfrom
fix/dnssec-indeterminate-chain

Conversation

@0xERR0R

@0xERR0R 0xERR0R commented Jun 24, 2026

Copy link
Copy Markdown
Owner

Problem

With dnssec.validate: true, signed answers on long Microsoft CNAME chains (e.g. outlook.office365.com, ecs.office.com) intermittently return SERVFAIL with EDE 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/DNSKEY sub-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:

  • walkChainOfTrust returns Indeterminate on a sub-query timeout, but tryVerifyWithRRSIG skipped the RRSIG with (false, nil), so no failure flag was set and determineFinalValidationResult fell through to its Bogus default.
  • A failed signer-DNSKEY fetch returned a plain error that was counted as a genuine crypto failure → also Bogus.

#2127 only stopped the transient Indeterminate verdict from being cached; within a single request the same gap still produced Bogus → SERVFAIL "bogus signatures". That is why the failing name "moves around" (whichever zone's DS/DNSKEY happens 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:

  • 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 (a Bogus chain still funnels to Bogus).
  • determineFinalValidationResult returns Indeterminate when the only 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 can never mask a real failure.

Indeterminate clears AD and 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 without AD, since the terminal record is in an unsigned zone.)

Security — GHSA-x845-2f78-7v36 preserved

The relaxation only applies when a real RRSIG is present but its chain is unreachable. The advisory's attack paths are untouched and re-verified:

  • Unsigned answer (no RRSIG) under a trust anchor with no authenticated insecure-delegation proof → still Bogus (handleMissingRRSIG/classifyUndetermined, unchanged).
  • Stripped DNSKEY (response present, no key) and a forged/invalid signature with a reachable chain → still Bogus — covered by a new regression test (still returns Bogus for a forged signature when the chain is fully reachable).
  • Client-scoped validation cache and NSEC/NSEC3 DS-absence handling unchanged.

Verification

  • go test ./resolver/dnssec/ ./resolver/ — green (479/479 dnssec specs incl. all GHSA-x845 regression specs). 3 new tests added; the 2 Indeterminate ones were confirmed RED before the fix.
  • Live repro: a throwaway forwarder that drops DNSKEY for office365.com (simulating the reporter's flaky-network / tight-timeout condition):
    • before: outlook.office365.com → SERVFAIL, EDE 6 (DNSSEC Bogus): bogus signatures
    • after: NOERROR, full chain served, flags: qr rd ra (AD cleared); log: Could not complete chain of trust ... treating as Indeterminate.

Notes

  • No retry layer added — the upstream resolver already retries sub-queries 3× (the failing request takes ~6s = 3×2s); the defect is classification, not retry count.
  • Policy choice worth a look: on persistent unreachability this serves-without-AD (consistent with blocky's existing Indeterminate handling) rather than SERVFAIL. AD is never asserted; moot over DoT/DoH.
  • A separate latent issue observed but intentionally not included here: blocky sets AD on signed-CNAME + unsigned-tail chains (e.g. outlook.office.com) where the terminal record is unsigned and public resolvers return no AD. Will file separately.

Fixes #2120

Copilot AI review requested due to automatic review settings June 24, 2026 09:02

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, tryVerifyWithRRSIG returns (false, nil). With the new Indeterminate propagation, this can incorrectly classify a RRset as Indeterminate if another signature attempt hit an unreachable chain (sets hasIndeterminateChain) even though a Bogus chain was observed. Return a non-nil error here so validateSingleRRset records 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.

Comment thread resolver/dnssec/query.go
@codecov

codecov Bot commented Jun 24, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.00000% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 88.34%. Comparing base (552eda5) to head (b747a2c).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
resolver/dnssec/validator.go 89.28% 2 Missing and 1 partial ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

0xERR0R added a commit that referenced this pull request Jun 24, 2026
…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.
@0xERR0R

0xERR0R commented Jun 24, 2026

Copy link
Copy Markdown
Owner Author

Addressed the Copilot review:

  • query.go:123 (%w verbs): false positive — multiple %w verbs are valid since Go 1.20 (module is go 1.26.2); compiles and errors.Is works. Replied in-thread, no change.
  • Suppressed/low-confidence note (Bogus-chain branch returned (false, nil)): addressed in b842283. The specific masking it describes is not reachable today — two valid RRSIGs on one RRset are always signed by ancestors of the owner, so their chains share a prefix and cannot independently be {Bogus, Indeterminate}. But I applied the suggestion anyway as defense-in-depth: a provably-bogus chain now returns a dedicated errBogusChain (genuine failure) so Bogus always dominates an Indeterminate sibling within an RRset (mirroring the cross-RRset precedence), and the final Bogus log carries a real reason instead of <nil>. Added an end-to-end regression test (DS digest mismatch, fully reachable chain → Bogus, never Indeterminate).

480/480 dnssec specs pass (incl. all GHSA-x845 regression specs); resolver package green; build/gofmt/vet clean.

0xERR0R added a commit that referenced this pull request Jun 24, 2026
…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.
@0xERR0R
0xERR0R force-pushed the fix/dnssec-indeterminate-chain branch from b842283 to 3e50c29 Compare June 24, 2026 09:57
…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
@0xERR0R
0xERR0R force-pushed the fix/dnssec-indeterminate-chain branch from 3e50c29 to b747a2c Compare June 24, 2026 10:06
@0xERR0R 0xERR0R added the 🐞 bug Something isn't working label Jun 24, 2026
@0xERR0R 0xERR0R added this to the v0.33.0 milestone Jun 24, 2026
@0xERR0R
0xERR0R merged commit a191ad2 into main Jun 24, 2026
22 checks passed
@0xERR0R
0xERR0R deleted the fix/dnssec-indeterminate-chain branch June 24, 2026 19:26
dadezzz pushed a commit to dadezzz/infra_docker-compose that referenced this pull request Jul 2, 2026
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 ([#&#8203;2138](0xERR0R/blocky#2138)) ([@&#8203;0xERR0R](https://github.com/0xERR0R))
- [`e9deb53`](0xERR0R/blocky@e9deb53): feat(queryLog): add dnstap as a query log destination ([#&#8203;2144](0xERR0R/blocky#2144)) ([@&#8203;rtmongold](https://github.com/rtmongold))

##### Bug fixes

- [`a191ad2`](0xERR0R/blocky@a191ad2): fix(dnssec): propagate Indeterminate (not Bogus) for unreachable chain of trust ([#&#8203;2120](0xERR0R/blocky#2120)) ([#&#8203;2139](0xERR0R/blocky#2139)) ([@&#8203;0xERR0R](https://github.com/0xERR0R))
- [`4b524e8`](0xERR0R/blocky@4b524e8): fix(ecs): apply useAsClient above cache and client-name lookup ([#&#8203;2140](0xERR0R/blocky#2140)) ([#&#8203;2141](0xERR0R/blocky#2141)) ([@&#8203;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 ([#&#8203;2133](0xERR0R/blocky#2133)) ([@&#8203;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 ([#&#8203;2145](0xERR0R/blocky#2145)) ([@&#8203;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 ([#&#8203;2134](0xERR0R/blocky#2134)) ([@&#8203;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 ([#&#8203;2135](0xERR0R/blocky#2135)) ([@&#8203;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 ([#&#8203;2137](0xERR0R/blocky#2137)) ([@&#8203;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 ([#&#8203;2131](0xERR0R/blocky#2131)) ([@&#8203;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 ([#&#8203;2130](0xERR0R/blocky#2130)) ([@&#8203;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 ([#&#8203;2129](0xERR0R/blocky#2129)) ([@&#8203;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 ([#&#8203;2148](0xERR0R/blocky#2148)) ([@&#8203;dependabot](https://github.com/dependabot)\[bot])
- [`6df83cd`](0xERR0R/blocky@6df83cd): build(deps): bump gorm.io/gorm from 1.31.1 to 1.31.2 ([#&#8203;2147](0xERR0R/blocky#2147)) ([@&#8203;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 ([#&#8203;2146](0xERR0R/blocky#2146)) ([@&#8203;0xERR0R](https://github.com/0xERR0R))
- [`302ca65`](0xERR0R/blocky@302ca65): perf(log): cut per-request logger allocations on the resolver hot path ([#&#8203;2142](0xERR0R/blocky#2142)) ([@&#8203;0xERR0R](https://github.com/0xERR0R))
- [`223df0c`](0xERR0R/blocky@223df0c): perf(queryLog): skip per-request LogEntry build when off or ignored ([#&#8203;2149](0xERR0R/blocky#2149)) ([@&#8203;0xERR0R](https://github.com/0xERR0R))
- [`b73422e`](0xERR0R/blocky@b73422e): perf(resolver): allocation-free resolver selection in ParallelBestResolver ([#&#8203;2150](0xERR0R/blocky#2150)) ([@&#8203;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-->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🐞 bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DNSSEC validation failed: bogus signatures for outlook.office.com

2 participants