From eda0548dae4b4fe0acd34d29fe07db0a7f6d489e Mon Sep 17 00:00:00 2001 From: Roy Hagland Date: Wed, 19 Aug 2026 14:01:32 +0200 Subject: [PATCH 1/2] Add DNSSEC online signing for APP and ANAME/ALIAS records Signs the per-query dynamic answer from APP records (Split Horizon, GeoDistance/Country/Continent, Failover, WeightedRoundRobin, etc.) and resolved ANAME/ALIAS records, and secures NODATA responses at the record's own owner name, so both record types can now exist and work correctly in DNSSEC signed primary zones. Covers non-wildcard and wildcard owners, and both NSEC and NSEC3. Previously PrimaryZone.SetRecords/AddRecord/SignRRSet explicitly refused APP and ANAME records in a signed zone, and the dynamic answer itself was never signed even apart from that guard. Verified with delv against a real signed test zone under both NSEC and NSEC3: positive answers, wildcard positive answers, and NODATA at the record's own name all validate cleanly for both record types. Out of scope: proving nonexistence of an arbitrary name under a wildcard owned dynamic record (true NXDOMAIN) needs per query NSEC synthesis (RFC 4470/4471 "white lies"), which this does not attempt. As a result, a resolver doing RFC 8198 aggressive NSEC/NSEC3 caching may occasionally skip querying for a dynamic name it has unrelated cached denial records for. That is a known, accepted limitation until white lies are implemented separately. --- DnsServerCore/Dns/DnsServer.cs | 195 +++++++++++++++++- .../Dns/ZoneManagers/AuthZoneManager.cs | 79 ++++++- DnsServerCore/Dns/Zones/AuthZone.cs | 11 + .../Dns/Zones/PrimarySubDomainZone.cs | 30 +-- DnsServerCore/Dns/Zones/PrimaryZone.cs | 34 +-- 5 files changed, 289 insertions(+), 60 deletions(-) diff --git a/DnsServerCore/Dns/DnsServer.cs b/DnsServerCore/Dns/DnsServer.cs index 8a6228eda..59a1303d3 100644 --- a/DnsServerCore/Dns/DnsServer.cs +++ b/DnsServerCore/Dns/DnsServer.cs @@ -3970,6 +3970,123 @@ internal async Task AuthoritativeQueryAsync(DnsDatagram request, Dn } } + //Online DNSSEC signing for dynamically resolved answers (APP and ANAME/ALIAS records): the actual answer + //content is not known until query time, so it cannot be pre-signed by the normal offline zone signing + //cycle. Only the RRset(s) that directly answer the query (owner name == qname) are signed here; any + //other owner names an app/ANAME chain may touch (e.g. an externally recursed CNAME target) are left + //unsigned since they are not this zone's data to sign. + private IReadOnlyList SignDynamicAnswer(ApexZone apexZone, string recordOwnerName, string qname, IReadOnlyList answer) + { + Dictionary> rrsetsByType = null; + + foreach (DnsResourceRecord record in answer) + { + if (!record.Name.Equals(qname, StringComparison.OrdinalIgnoreCase)) + continue; + + rrsetsByType ??= new Dictionary>(); + + if (!rrsetsByType.TryGetValue(record.Type, out List rrset)) + { + rrset = new List(); + rrsetsByType[record.Type] = rrset; + } + + rrset.Add(record); + } + + if (rrsetsByType is null) + return answer; + + List newAnswer = new List(answer); + + foreach (List rrset in rrsetsByType.Values) + newAnswer.AddRange(SignDynamicAnswerRRSet(apexZone, recordOwnerName, rrset)); + + return newAnswer; + } + + //Two-phase sign so that a wildcard-owned dynamic record (e.g. "*.example.com") gets an RRSIG with the + //correct RFC 4035 Labels field: DnssecPrivateKey.SignRRSet derives Labels from records[0].Name, so the + //RRset must be signed under its literal owner name in the zone (the wildcard form), then the resulting + //RRSIG's envelope owner name is rewritten to the actual (possibly QNAME-expanded) answer name, mirroring + //AuthZone.QueryRecordsWildcard's handling of static wildcard records. + private IReadOnlyList SignDynamicAnswerRRSet(ApexZone apexZone, string recordOwnerName, IReadOnlyList rrset) + { + string answerName = rrset[0].Name; + + if (recordOwnerName.Equals(answerName, StringComparison.OrdinalIgnoreCase)) + return apexZone.SignRRSet(rrset); + + DnsResourceRecord[] signingRRset = new DnsResourceRecord[rrset.Count]; + + for (int i = 0; i < rrset.Count; i++) + signingRRset[i] = new DnsResourceRecord(recordOwnerName, rrset[i].Type, rrset[i].Class, rrset[i].TTL, rrset[i].RDATA); + + IReadOnlyList rrsigRecords = apexZone.SignRRSet(signingRRset); + if (rrsigRecords.Count == 0) + return rrsigRecords; + + DnsResourceRecord[] rewrittenRRSigRecords = new DnsResourceRecord[rrsigRecords.Count]; + + for (int i = 0; i < rrsigRecords.Count; i++) + rewrittenRRSigRecords[i] = new DnsResourceRecord(answerName, rrsigRecords[i].Type, rrsigRecords[i].Class, rrsigRecords[i].TTL, rrsigRecords[i].RDATA); + + return rewrittenRRSigRecords; + } + + //Signs every RRset in an ANAME/ALIAS-resolved answer. Each record already carries the ANAME record's own + //owner name (set by ResolveANAMEAsync), which for a wildcard-owned ANAME has already been expanded to the + //queried name by AuthZoneManager.InternalQuery's wildcard substitution. To get the correct RFC 4035 + //Labels field in that case, re-derive the literal (possibly wildcard) owner name of the AuthZone that + //actually stores the ANAME record via AuthZoneManager.GetRecordOwnerName, and route through the same + //two-phase signing path APP already uses. + private IReadOnlyList SignAnameAnswer(ApexZone apexZone, IReadOnlyList answer, out IReadOnlyList wildcardProofAuthority) + { + wildcardProofAuthority = null; + + if (answer.Count == 0) + return answer; + + Dictionary<(string, DnsResourceRecordType), List> rrsetsByNameType = new Dictionary<(string, DnsResourceRecordType), List>(); + + foreach (DnsResourceRecord record in answer) + { + var key = (record.Name.ToLowerInvariant(), record.Type); + if (!rrsetsByNameType.TryGetValue(key, out List rrset)) + { + rrset = new List(); + rrsetsByNameType[key] = rrset; + } + + rrset.Add(record); + } + + List newAnswer = new List(answer); + List newWildcardProofAuthority = null; + + foreach (List rrset in rrsetsByNameType.Values) + { + string recordOwnerName = _authZoneManager.GetRecordOwnerName(rrset[0].Name) ?? rrset[0].Name; + newAnswer.AddRange(SignDynamicAnswerRRSet(apexZone, recordOwnerName, rrset)); + + if (recordOwnerName.StartsWith('*')) + { + //RFC 4035 5.3.4: prove the exact qname does not exist as a literal zone entry, so the + //validator can tell this wildcard expansion apart from a spoofed answer + IReadOnlyList wildcardProof = _authZoneManager.GetNSecProofOfWildcardAnswer(rrset[0].Name); + if (wildcardProof.Count > 0) + { + newWildcardProofAuthority ??= new List(); + newWildcardProofAuthority.AddRange(wildcardProof); + } + } + } + + wildcardProofAuthority = newWildcardProofAuthority; + return newAnswer; + } + private async Task ProcessAPPAsync(DnsDatagram request, DnsDatagram response, IPEndPoint remoteEP, DnsTransportProtocol protocol, bool isRecursionAllowed, bool skipDnsAppAuthoritativeRequestHandlers, int clientTimeout) { DnsResourceRecord appResourceRecord = response.Authority[0]; @@ -3980,6 +4097,7 @@ private async Task ProcessAPPAsync(DnsDatagram request, DnsDatagram if (application.DnsAppRecordRequestHandlers.TryGetValue(appRecord.ClassPath, out IDnsAppRecordRequestHandler appRecordRequestHandler)) { AuthZoneInfo zoneInfo = _authZoneManager.FindAuthZoneInfo(appResourceRecord.Name); + bool dnssecOk = request.DnssecOk && (zoneInfo.Type == AuthZoneType.Primary) && (zoneInfo.ApexZone.DnssecStatus != AuthZoneDnssecStatus.Unsigned); DnsDatagram appResponse = await appRecordRequestHandler.ProcessRequestAsync(request, remoteEP, protocol, isRecursionAllowed, zoneInfo.Name, appResourceRecord.Name, appResourceRecord.TTL, appRecord.Data); if (appResponse is null) @@ -4013,13 +4131,73 @@ private async Task ProcessAPPAsync(DnsDatagram request, DnsDatagram else rcode = DnsResponseCode.NxDomain; - authority = zoneInfo.ApexZone.GetRecords(DnsResourceRecordType.SOA); + authority = zoneInfo.ApexZone.QueryRecords(DnsResourceRecordType.SOA, dnssecOk); + + if (dnssecOk) + { + //add proof of non existence (NODATA) for the APP record's own owner name; the + //dynamic answer's actual content has no static NSEC/NSEC3 coverage of its own, + //but nonexistence of any record at this exact name is already provable statically + IReadOnlyList nsecRecords = _authZoneManager.GetNSecProofOfNonExistenceNoData(request.Question[0].Name); + if (nsecRecords.Count > 0) + { + List newAuthority = new List(authority.Count + nsecRecords.Count); + + newAuthority.AddRange(authority); + newAuthority.AddRange(nsecRecords); + + authority = newAuthority; + } + } } return new DnsDatagram(request.Identifier, true, request.OPCODE, false, false, request.RecursionDesired, isRecursionAllowed, false, request.CheckingDisabled, rcode, request.Question, null, authority) { Tag = DnsServerResponseType.Authoritative }; } else { + if (dnssecOk && appResponse.AuthoritativeAnswer && (appResponse.Answer.Count > 0)) + { + IReadOnlyList signedAnswer = SignDynamicAnswer(zoneInfo.ApexZone, appResourceRecord.Name, request.Question[0].Name, appResponse.Answer); + if (signedAnswer.Count > appResponse.Answer.Count) + { + IReadOnlyList signedAuthority = appResponse.Authority; + + if (appResourceRecord.Name.StartsWith('*')) + { + //RFC 4035 5.3.4: prove the exact qname does not exist as a literal zone entry, + //so the validator can tell this wildcard expansion apart from a spoofed answer + IReadOnlyList wildcardProof = _authZoneManager.GetNSecProofOfWildcardAnswer(request.Question[0].Name); + if (wildcardProof.Count > 0) + { + List newAuthority = new List(appResponse.Authority.Count + wildcardProof.Count); + + newAuthority.AddRange(appResponse.Authority); + newAuthority.AddRange(wildcardProof); + + signedAuthority = newAuthority; + } + } + + appResponse = new DnsDatagram(appResponse.Identifier, appResponse.IsResponse, appResponse.OPCODE, appResponse.AuthoritativeAnswer, appResponse.Truncation, appResponse.RecursionDesired, appResponse.RecursionAvailable, appResponse.AuthenticData, appResponse.CheckingDisabled, appResponse.RCODE, appResponse.Question, signedAnswer, signedAuthority, appResponse.Additional, appResponse.EDNS is null ? ushort.MinValue : appResponse.EDNS.UdpPayloadSize, appResponse.EDNS is null ? EDnsHeaderFlags.None : appResponse.EDNS.Flags, appResponse.EDNS?.Options); + } + } + else if (dnssecOk && appResponse.AuthoritativeAnswer && (appResponse.RCODE == DnsResponseCode.NoError) && (appResponse.Answer.Count == 0)) + { + //some apps (e.g. the NO DATA app) construct their own explicit NODATA response + //instead of returning null - secure it the same way as the null-appResponse NODATA + //path above: signed SOA + NSEC/NSEC3 proof of non existence at the queried name + IReadOnlyList signedSoa = zoneInfo.ApexZone.QueryRecords(DnsResourceRecordType.SOA, dnssecOk); + IReadOnlyList nsecRecords = _authZoneManager.GetNSecProofOfNonExistenceNoData(request.Question[0].Name); + + List newAuthority = new List(appResponse.Authority.Count + signedSoa.Count + nsecRecords.Count); + + newAuthority.AddRange(appResponse.Authority); + newAuthority.AddRange(signedSoa); + newAuthority.AddRange(nsecRecords); + + appResponse = new DnsDatagram(appResponse.Identifier, appResponse.IsResponse, appResponse.OPCODE, appResponse.AuthoritativeAnswer, appResponse.Truncation, appResponse.RecursionDesired, appResponse.RecursionAvailable, appResponse.AuthenticData, appResponse.CheckingDisabled, appResponse.RCODE, appResponse.Question, appResponse.Answer, newAuthority, appResponse.Additional, appResponse.EDNS is null ? ushort.MinValue : appResponse.EDNS.UdpPayloadSize, appResponse.EDNS is null ? EDnsHeaderFlags.None : appResponse.EDNS.Flags, appResponse.EDNS?.Options); + } + if (appResponse.AuthoritativeAnswer) appResponse.Tag = DnsServerResponseType.Authoritative; @@ -4474,6 +4652,7 @@ async Task> ResolveANAMEAsync(DnsResourceRecord DnsResponseCode rcode = DnsResponseCode.NoError; IReadOnlyList authority = null; + IReadOnlyList finalAnswer = responseAnswer; if (responseAnswer.Count == 0) { @@ -4483,6 +4662,8 @@ async Task> ResolveANAMEAsync(DnsResourceRecord } else { + //authority (SOA, and NSEC/NSEC3 proof when signed) was already resolved by the caller in + //AuthZoneManager.InternalQuery for use with this NODATA response authority = response.Authority; //update last used on @@ -4492,8 +4673,18 @@ async Task> ResolveANAMEAsync(DnsResourceRecord record.GetAuthGenericRecordInfo().LastUsedOn = utcNow; } } + else if (request.DnssecOk) + { + AuthZoneInfo zoneInfo = _authZoneManager.FindAuthZoneInfo(request.Question[0].Name); + if ((zoneInfo is not null) && (zoneInfo.Type == AuthZoneType.Primary) && (zoneInfo.ApexZone.DnssecStatus != AuthZoneDnssecStatus.Unsigned)) + { + finalAnswer = SignAnameAnswer(zoneInfo.ApexZone, responseAnswer, out IReadOnlyList wildcardProofAuthority); + if (wildcardProofAuthority is not null) + authority = wildcardProofAuthority; + } + } - return new DnsDatagram(request.Identifier, true, DnsOpcode.StandardQuery, true, false, request.RecursionDesired, isRecursionAllowed, false, request.CheckingDisabled, rcode, request.Question, responseAnswer, authority) { Tag = response.Tag }; + return new DnsDatagram(request.Identifier, true, DnsOpcode.StandardQuery, true, false, request.RecursionDesired, isRecursionAllowed, false, request.CheckingDisabled, rcode, request.Question, finalAnswer, authority) { Tag = response.Tag }; } private async Task IsAllowedAsync(DnsDatagram request, IPEndPoint remoteEP, DnsTransportProtocol protocol) diff --git a/DnsServerCore/Dns/ZoneManagers/AuthZoneManager.cs b/DnsServerCore/Dns/ZoneManagers/AuthZoneManager.cs index f22175e52..10bd23ed0 100644 --- a/DnsServerCore/Dns/ZoneManagers/AuthZoneManager.cs +++ b/DnsServerCore/Dns/ZoneManagers/AuthZoneManager.cs @@ -805,6 +805,53 @@ internal ApexZone GetApexZone(string zoneName) return _root.GetApexZone(zoneName); } + internal IReadOnlyList GetNSecProofOfNonExistenceNoData(string qname) + { + //when qname only matches via a wildcard (e.g. a wildcard-owned APP record), FindZone returns the + //exact zone as null and provides the wildcard zone via closest instead - same fallback chain + //InternalQuery itself uses (see the closest.QueryRecords(APP, ...) / apexZone.QueryRecords(APP, ...) + //fallback above). FindNSecProofOfNonExistenceNoData/FindNSec3ProofOfNonExistenceNoData already detect + //a wildcard-named zone internally and add the extra proof-of-cover records for the exact qname. + AuthZone zone = _root.FindZone(qname, out SubDomainZone closest, out _, out ApexZone apexZone, out _); + AuthZone effectiveZone = zone ?? closest; + + if ((effectiveZone is null) || (apexZone is null) || (apexZone.DnssecStatus == AuthZoneDnssecStatus.Unsigned)) + return Array.Empty(); + + if (apexZone.DnssecStatus == AuthZoneDnssecStatus.SignedWithNSEC3) + return _root.FindNSec3ProofOfNonExistenceNoData(qname, effectiveZone, apexZone); + + return _root.FindNSecProofOfNonExistenceNoData(qname, effectiveZone); + } + + //RFC 4035 5.3.4: a positive answer synthesized from a wildcard must also include proof that the exact + //qname does not exist as a literal zone entry (otherwise a validator cannot tell the wildcard expansion + //apart from a spoofed answer, and rejects the response outright - confirmed empirically with delv, which + //fails "no valid NSEC" on a wildcard-owned APP/ANAME positive answer without this). Only relevant when + //recordOwnerName (the record's literal, possibly-wildcard owner from GetRecordOwnerName) starts with "*". + //Reuses the zone's existing static NSEC/NSEC3 chain, since whether a literal zone node exists at qname is + //a static fact independent of what the app/ANAME resolves per query - no new denial machinery needed. + internal IReadOnlyList GetNSecProofOfWildcardAnswer(string qname) + { + AuthZone zone = _root.FindZone(qname, out _, out _, out ApexZone apexZone, out _); + if ((apexZone is null) || (apexZone.DnssecStatus == AuthZoneDnssecStatus.Unsigned)) + return Array.Empty(); + + if (apexZone.DnssecStatus == AuthZoneDnssecStatus.SignedWithNSEC3) + return _root.FindNSec3ProofOfNonExistenceNxDomain(qname, true); + + return _root.FindNSecProofOfNonExistenceNxDomain(qname, true); + } + + //Returns the literal owner name of the AuthZone actually storing records at qname (e.g. "*.example.com" + //for a wildcard hit), as opposed to qname itself. Used to sign a dynamically resolved answer (ANAME/ALIAS) + //under its true wildcard owner so DnssecPrivateKey.SignRRSet computes the correct RFC 4035 Labels field. + internal string GetRecordOwnerName(string qname) + { + AuthZone zone = _root.FindZone(qname, out SubDomainZone closest, out _, out _, out _); + return (zone ?? closest)?.Name; + } + public bool NameExists(string zoneName, string domain) { ValidateIfDomainBelongsToZone(zoneName, domain); @@ -3226,7 +3273,7 @@ private DnsDatagram InternalQuery(DnsDatagram request, bool isRecursionAllowed, else { answer = null; - authority = closest.QueryRecords(DnsResourceRecordType.APP, false); + authority = closest.QueryRecords(DnsResourceRecordType.APP, dnssecOk); } } @@ -3241,7 +3288,7 @@ private DnsDatagram InternalQuery(DnsDatagram request, bool isRecursionAllowed, else { answer = null; - authority = apexZone.QueryRecords(DnsResourceRecordType.APP, false); + authority = apexZone.QueryRecords(DnsResourceRecordType.APP, dnssecOk); if (authority.Count == 0) { if ((apexZone is ForwarderZone) || (apexZone is SecondaryForwarderZone)) @@ -3359,7 +3406,7 @@ private DnsDatagram InternalQuery(DnsDatagram request, bool isRecursionAllowed, return GetReferralResponse(request, false, apexZone, apexZone); } - authority = zone.QueryRecords(DnsResourceRecordType.APP, false); + authority = zone.QueryRecords(DnsResourceRecordType.APP, dnssecOk); if (authority.Count == 0) { if ((apexZone is ForwarderZone) || (apexZone is SecondaryForwarderZone)) @@ -3435,7 +3482,31 @@ private DnsDatagram InternalQuery(DnsDatagram request, bool isRecursionAllowed, case DnsResourceRecordType.ANAME: case DnsResourceRecordType.ALIAS: - authority = apexZone.GetRecords(DnsResourceRecordType.SOA); //adding SOA for use with NO DATA response + { + //adding SOA (signed when applicable) for use with NO DATA response if ANAME/ALIAS resolution finds nothing + authority = apexZone.QueryRecords(DnsResourceRecordType.SOA, dnssecOk); + + if (dnssecOk) + { + //add proof of non existence (NODATA) to prove that no such type or record exists, in case ANAME/ALIAS resolution finds nothing + IReadOnlyList nsecRecords; + + if (apexZone.DnssecStatus == AuthZoneDnssecStatus.SignedWithNSEC3) + nsecRecords = _root.FindNSec3ProofOfNonExistenceNoData(question.Name, zone, apexZone); + else + nsecRecords = _root.FindNSecProofOfNonExistenceNoData(question.Name, zone); + + if (nsecRecords.Count > 0) + { + List newAuthority = new List(authority.Count + nsecRecords.Count); + + newAuthority.AddRange(authority); + newAuthority.AddRange(nsecRecords); + + authority = newAuthority; + } + } + } break; } } diff --git a/DnsServerCore/Dns/Zones/AuthZone.cs b/DnsServerCore/Dns/Zones/AuthZone.cs index e403baedf..05f60a7ce 100644 --- a/DnsServerCore/Dns/Zones/AuthZone.cs +++ b/DnsServerCore/Dns/Zones/AuthZone.cs @@ -576,6 +576,17 @@ internal virtual IReadOnlyList SignRRSet(IReadOnlyList GetUpdatedNSecRRSet(string nextDomainName, uint ttl) { List types = new List(_entries.Count); diff --git a/DnsServerCore/Dns/Zones/PrimarySubDomainZone.cs b/DnsServerCore/Dns/Zones/PrimarySubDomainZone.cs index 425761a92..fc5d02b71 100644 --- a/DnsServerCore/Dns/Zones/PrimarySubDomainZone.cs +++ b/DnsServerCore/Dns/Zones/PrimarySubDomainZone.cs @@ -57,20 +57,10 @@ public override void SetRecords(DnsResourceRecordType type, IReadOnlyList SignRRSet(IReadOnlyList _name.Length)) return Array.Empty(); //referrer NS records are not signed @@ -2509,20 +2505,10 @@ public override void SetRecords(DnsResourceRecordType type, IReadOnlyList Date: Thu, 20 Aug 2026 12:34:56 +0200 Subject: [PATCH 2/2] Fix wrong denial-of-existence proof on APP subdomain NXDOMAIN A non-wildcard APP record answering for a subdomain beneath its own owner name (via the closest-ancestor fallback in InternalQuery) that declines to answer returns NXDOMAIN, but was attaching the NODATA proof for the record's own owner name instead of a real NXDOMAIN proof of cover for the queried name. A validator has no way to accept that as valid, so the response was bogus - confirmed with delv (only one NSEC3 record was attached, and it didn't even cover the queried name). Adds AuthZoneManager.GetNSecProofOfNonExistenceNxDomain, generalized from the existing GetNSecProofOfWildcardAnswer (now a thin wrapper around it), and uses it from ProcessAPPAsync's NxDomain branch. Reuses the same static NSEC/NSEC3 chain machinery as the rest of the DNSSEC online signing work, no new denial-of-existence logic needed. Verified with delv against dnssectest.local on 10.0.7.1 under both NSEC and NSEC3: the fixed NXDOMAIN case now fully validates, and the existing positive/NODATA test matrix from this branch still validates cleanly (no regression). --- DnsServerCore/Dns/DnsServer.cs | 22 +++++++++++++++---- .../Dns/ZoneManagers/AuthZoneManager.cs | 16 ++++++++++++-- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/DnsServerCore/Dns/DnsServer.cs b/DnsServerCore/Dns/DnsServer.cs index 59a1303d3..919828e13 100644 --- a/DnsServerCore/Dns/DnsServer.cs +++ b/DnsServerCore/Dns/DnsServer.cs @@ -4135,10 +4135,24 @@ private async Task ProcessAPPAsync(DnsDatagram request, DnsDatagram if (dnssecOk) { - //add proof of non existence (NODATA) for the APP record's own owner name; the - //dynamic answer's actual content has no static NSEC/NSEC3 coverage of its own, - //but nonexistence of any record at this exact name is already provable statically - IReadOnlyList nsecRecords = _authZoneManager.GetNSecProofOfNonExistenceNoData(request.Question[0].Name); + IReadOnlyList nsecRecords; + + if (rcode == DnsResponseCode.NxDomain) + { + //non-wildcard APP record answering for a subdomain beneath its own owner + //name (closest-ancestor fallback) that the app declined to answer: qname + //itself does not exist, so this needs a real NXDOMAIN proof of cover, not + //the NODATA proof for the record's own owner name + nsecRecords = _authZoneManager.GetNSecProofOfNonExistenceNxDomain(request.Question[0].Name, false); + } + else + { + //add proof of non existence (NODATA) for the APP record's own owner name; the + //dynamic answer's actual content has no static NSEC/NSEC3 coverage of its own, + //but nonexistence of any record at this exact name is already provable statically + nsecRecords = _authZoneManager.GetNSecProofOfNonExistenceNoData(request.Question[0].Name); + } + if (nsecRecords.Count > 0) { List newAuthority = new List(authority.Count + nsecRecords.Count); diff --git a/DnsServerCore/Dns/ZoneManagers/AuthZoneManager.cs b/DnsServerCore/Dns/ZoneManagers/AuthZoneManager.cs index 10bd23ed0..fda366ea8 100644 --- a/DnsServerCore/Dns/ZoneManagers/AuthZoneManager.cs +++ b/DnsServerCore/Dns/ZoneManagers/AuthZoneManager.cs @@ -832,15 +832,27 @@ internal IReadOnlyList GetNSecProofOfNonExistenceNoData(strin //Reuses the zone's existing static NSEC/NSEC3 chain, since whether a literal zone node exists at qname is //a static fact independent of what the app/ANAME resolves per query - no new denial machinery needed. internal IReadOnlyList GetNSecProofOfWildcardAnswer(string qname) + { + return GetNSecProofOfNonExistenceNxDomain(qname, true); + } + + //General NXDOMAIN proof of non existence for a qname, for the case where a non-wildcard dynamic (APP) + //record answers on behalf of a subdomain beneath its own owner name (e.g. an APP record at + //"app.example.com" answering for "x.y.app.example.com" via the closest-ancestor fallback in + //InternalQuery) and the app declines to answer, so the response is a genuine NXDOMAIN rather than + //NODATA. Unlike GetNSecProofOfNonExistenceNoData (which proves "this exact name exists but the type + //doesn't", using the static NSEC/NSEC3 record stored at the record's own owner name), this proves the + //qname itself does not exist at all - the correct static-chain proof for that rcode. + internal IReadOnlyList GetNSecProofOfNonExistenceNxDomain(string qname, bool isWildcardAnswer) { AuthZone zone = _root.FindZone(qname, out _, out _, out ApexZone apexZone, out _); if ((apexZone is null) || (apexZone.DnssecStatus == AuthZoneDnssecStatus.Unsigned)) return Array.Empty(); if (apexZone.DnssecStatus == AuthZoneDnssecStatus.SignedWithNSEC3) - return _root.FindNSec3ProofOfNonExistenceNxDomain(qname, true); + return _root.FindNSec3ProofOfNonExistenceNxDomain(qname, isWildcardAnswer); - return _root.FindNSecProofOfNonExistenceNxDomain(qname, true); + return _root.FindNSecProofOfNonExistenceNxDomain(qname, isWildcardAnswer); } //Returns the literal owner name of the AuthZone actually storing records at qname (e.g. "*.example.com"