From 3f84c1b26c0eb9e27dcf9e5a0cf9bcd3f53f8a94 Mon Sep 17 00:00:00 2001 From: Jannik Peters Date: Fri, 8 Aug 2025 12:38:36 +0200 Subject: [PATCH] Match ldns-signzone NSEC/NSEC3 RR ordering and NSEC3 formatting (#109) --- src/commands/signzone.rs | 143 +++++++++++++++++++++++++++++++++------ 1 file changed, 123 insertions(+), 20 deletions(-) diff --git a/src/commands/signzone.rs b/src/commands/signzone.rs index 58a07ce..29896c4 100644 --- a/src/commands/signzone.rs +++ b/src/commands/signzone.rs @@ -498,8 +498,8 @@ impl LdnsCommand for SignZone { hash_only: false, use_yyyymmddhhmmss_rrsig_format: true, preceed_zone_with_hash_list, - order_rrsigs_after_the_rtype_they_cover: extra_comments, - order_nsec3_rrs_by_unhashed_owner_name: extra_comments, + order_rrsigs_after_the_rtype_they_cover: true, + order_nsec3_rrs_by_unhashed_owner_name: true, zonefile_path, key_paths, invoked_as_ldns: true, @@ -970,7 +970,11 @@ impl SignZone { let mut nsec3_hashes: Option = None; - if self.use_nsec3 && (self.extra_comments || self.preceed_zone_with_hash_list) { + if self.use_nsec3 + && (self.extra_comments + || self.preceed_zone_with_hash_list + || self.order_nsec3_rrs_by_unhashed_owner_name) + { // Create a collection of NSEC3 hashes that can later be used for // debug output. let mut hash_provider = Nsec3HashMap::new(); @@ -1153,9 +1157,6 @@ impl SignZone { // compatibility with ldns-signzone, re-order them to be in canonical // order by unhashed owner name and so that hashed names come after // equivalent unhashed names. - // - // INCOMAPATIBILITY WARNING: Unlike ldns-signzone, we only apply this - // ordering if `-b` is specified. let mut owner_rrs; let owner_rrs_iter: AnyOwnerRrsIter = if self.order_nsec3_rrs_by_unhashed_owner_name && nsec3_hashes.is_some() { @@ -1248,12 +1249,22 @@ impl SignZone { // we skip that, and we skip RRSIGs as they are output only after // the RRset that they cover. if self.order_rrsigs_after_the_rtype_they_cover { - for rrset in owner_rrs - .rrsets() - .filter(|rrset| !matches!(rrset.rtype(), Rtype::SOA | Rtype::RRSIG)) - { + for rrset in owner_rrs.rrsets().filter(|rrset| { + !(matches!(rrset.rtype(), Rtype::SOA | Rtype::RRSIG) + // If run as ldns-signzone we want to list the NSEC RR + // at the end of the RRset of the apex. By default, + // the NSEC RR would preceed the DNSKEY RRset, so we + // need to filter it out here to manually reinsert it + // later. This is only necessary for the NSEC RR at + // the apex, as the ordering issue doesn't appear at + // other locations than the apex. + || (self.invoked_as_ldns + && rrset.rtype() == Rtype::NSEC + && rrset.owner() == apex)) + }) { for rr in rrset.iter() { self.write_rr(&mut writer, rr)?; + match rr.data() { ZoneRecordData::Nsec3(nsec3) if self.extra_comments => { nsec3.comment(&mut writer, rr, nsec3_cs)? @@ -1276,13 +1287,52 @@ impl SignZone { for covering_rrsigs in owner_rrs .rrsets() .filter(|this_rrset| this_rrset.rtype() == Rtype::RRSIG) - .map(|this_rrset| this_rrset.iter().filter(|rr| matches!(rr.data(), ZoneRecordData::Rrsig(rrsig) if rrsig.type_covered() == rrset.rtype()))) + .map(|this_rrset| { + this_rrset.iter().filter(|rr| { + matches!(rr.data(), ZoneRecordData::Rrsig(rrsig) + if rrsig.type_covered() == rrset.rtype() + && if self.invoked_as_ldns && rr.owner() == apex { + // Withhold an RRSIG that covers the NSEC of the apex + // as we' reinserting them at the end of the apex' RRsets + rrsig.type_covered() != Rtype::NSEC + } else { true } + ) + }) + }) { for covering_rrsig_rr in covering_rrsigs { self.writeln_rr(&mut writer, covering_rrsig_rr)?; } } } + + // If running as ldns-signzone, we've been withholding the NSEC and NSEC's RRSIG at + // the apex above to reinsert them after all other RRsets at the apex. By default, + // the DNSKEY RRset and it's RRSIG would take the rear of the RRsets at the apex. + // This doesn't apply, if we're using NSEC3. Additionally, the NSEC RRs at other + // places than the apex do not have the ordering issue. + if self.invoked_as_ldns && !self.use_nsec3 && owner_rrs.owner() == apex { + if let Some(nsec_rrset) = owner_rrs + .rrsets() + .find(|this_rrset| this_rrset.rtype() == Rtype::NSEC) + { + self.writeln_rr(&mut writer, nsec_rrset.first())?; + } + + if let Some(rrsig_rrset) = owner_rrs + .rrsets() + .find(|this_rrset| this_rrset.rtype() == Rtype::RRSIG) + { + for rr in rrsig_rrset.iter() { + if matches!(rr.data(), ZoneRecordData::Rrsig(rrsig) if rrsig.type_covered() == Rtype::NSEC) + { + self.writeln_rr(&mut writer, rr)?; + break; + } + } + } + } + if self.extra_comments { writer.write_str(";\n")?; } @@ -1327,6 +1377,13 @@ impl SignZone { } } + if self.invoked_as_ldns { + if let ZoneRecordData::Nsec3(nsec3) = rr.data() { + let rr = Record::new(rr.owner(), rr.class(), rr.ttl(), LdnsNsec3(nsec3)); + return writer.write_fmt(format_args!("{}", rr.display_zonefile(DISPLAY_KIND))); + } + } + writer.write_fmt(format_args!("{}", rr.display_zonefile(DISPLAY_KIND))) } @@ -2026,6 +2083,52 @@ impl RecordData for YyyyMmDdHhMMSsRrsig<'_, O, N> { } } +//------------ LdnsNsec3 ----------------------------------------------------- + +/// A wrapper around Nsec3 to print the Nsec3 data in the exact format used by +/// ldns-signzone with all its quirks. +struct LdnsNsec3<'a, O>(&'a Nsec3); + +impl> ZonefileFmt for LdnsNsec3<'_, O> { + fn fmt(&self, p: &mut impl Formatter) -> zonefile_fmt::Result { + // This block of code was copied from the `domain` crate impl of + // `Zonefilefmt` for domain::rdata::nsec3::Nsec3 and adapted for + // ldns output format. + p.block(|p| { + p.write_show(self.0.hash_algorithm())?; + p.write_token(self.0.flags())?; + p.write_comment(format_args!( + "flags: {}", + if self.0.opt_out() { + "opt-out" + } else { + "" + } + ))?; + p.write_token(self.0.iterations())?; + p.write_comment("iterations")?; + p.write_show(self.0.salt())?; + p.write_token(format!( + " {}", + domain::utils::base32::encode_display_hex(&self.0.next_owner()) + .to_string() + .to_lowercase() + ))?; + p.write_show(self.0.types())?; + // ldns-signzone ends its NSEC3 rtype bitmap with a trailing + // space. Adding an empty token, because the formatter will add + // a space as a delimiter. + p.write_token("") + }) + } +} + +impl RecordData for LdnsNsec3<'_, O> { + fn rtype(&self) -> Rtype { + Rtype::NSEC3 + } +} + //-------------- Nsec3HashMap ------------------------------------------------ #[derive(Debug)] @@ -2420,8 +2523,8 @@ mod test { hash_only: false, use_yyyymmddhhmmss_rrsig_format: true, preceed_zone_with_hash_list: false, - order_rrsigs_after_the_rtype_they_cover: false, - order_nsec3_rrs_by_unhashed_owner_name: false, + order_rrsigs_after_the_rtype_they_cover: true, + order_nsec3_rrs_by_unhashed_owner_name: true, zonefile_path: PathBuf::from("example.org.zone"), key_paths: Vec::from([PathBuf::from("anykey")]), invoked_as_ldns: true, @@ -2960,14 +3063,14 @@ m.root-servers.net.\t3600000\tIN\tAAAA\t2001:dc3::35 // (dnst) ldns-signzone -np -f - -e 20241127162422 -i 20241127162422 nsec3_optout1_example.org.zone ksk1 | grep NSEC3 let ldns_dnst_output_stripped: &str = "\ - example.org.\t3600\tIN\tRRSIG\tNSEC3PARAM 15 2 3600 20241127162422 20241127162422 38873 example.org. 0XdDm1l2Mm8dyhtzbyQb91CmyNONs8lc9d22FUGvpjfqo8T2h0xs04x5MIfP0DjmiVnNqIyPK6sipnDqf6tCDg==\n\ example.org.\t3600\tIN\tNSEC3PARAM\t1 1 1 -\n\ + example.org.\t3600\tIN\tRRSIG\tNSEC3PARAM 15 2 3600 20241127162422 20241127162422 38873 example.org. 0XdDm1l2Mm8dyhtzbyQb91CmyNONs8lc9d22FUGvpjfqo8T2h0xs04x5MIfP0DjmiVnNqIyPK6sipnDqf6tCDg==\n\ + 93u63bg57ppj6649al2n31l92iedkjd6.example.org.\t240\tIN\tNSEC3\t1 1 1 - k71ku6aicr5jpdjoe9j7cdnlk6d5c3ue A NS SOA RRSIG DNSKEY NSEC3PARAM \n\ 93u63bg57ppj6649al2n31l92iedkjd6.example.org.\t240\tIN\tRRSIG\tNSEC3 15 3 240 20241127162422 20241127162422 38873 example.org. z4ceUmbSZiSnluFj8CDJ7B9fukCR2flTWgca4GE2xrw48+fiieH/04xCKhJmDRJUJTVkKtIYpB4p0Q4m60M1Cg==\n\ - 93u63bg57ppj6649al2n31l92iedkjd6.example.org.\t240\tIN\tNSEC3\t1 1 1 - K71KU6AICR5JPDJOE9J7CDNLK6D5C3UE A NS SOA RRSIG DNSKEY NSEC3PARAM\n\ + k71ku6aicr5jpdjoe9j7cdnlk6d5c3ue.example.org.\t240\tIN\tNSEC3\t1 1 1 - ojicmhri4vp8po7h2kvej99sklqnj5p2 NS \n\ k71ku6aicr5jpdjoe9j7cdnlk6d5c3ue.example.org.\t240\tIN\tRRSIG\tNSEC3 15 3 240 20241127162422 20241127162422 38873 example.org. HUrf7tOm3simXqpZj1oZeKX/P3eWoTTKc3fsyqfuLD6sGssXrBfpv1/LINBR9eEBjJ9rFbQXILgweS6huBL/Ag==\n\ - k71ku6aicr5jpdjoe9j7cdnlk6d5c3ue.example.org.\t240\tIN\tNSEC3\t1 1 1 - OJICMHRI4VP8PO7H2KVEJ99SKLQNJ5P2 NS\n\ + ojicmhri4vp8po7h2kvej99sklqnj5p2.example.org.\t240\tIN\tNSEC3\t1 1 1 - 93u63bg57ppj6649al2n31l92iedkjd6 NS DS RRSIG \n\ ojicmhri4vp8po7h2kvej99sklqnj5p2.example.org.\t240\tIN\tRRSIG\tNSEC3 15 3 240 20241127162422 20241127162422 38873 example.org. NG/8jk3UHht1ZYNEjUZ4swaEHea1amF4l3jZ893oARi95oxtPVLKoinVbBbfVuoanicOgeZxUPpKWHMBR12XDA==\n\ - ojicmhri4vp8po7h2kvej99sklqnj5p2.example.org.\t240\tIN\tNSEC3\t1 1 1 - 93U63BG57PPJ6649AL2N31L92IEDKJD6 NS DS RRSIG\n\ "; let res = FakeCmd::new([ @@ -2996,14 +3099,14 @@ m.root-servers.net.\t3600000\tIN\tAAAA\t2001:dc3::35 fn ldns_signzone_disables_minus_b_when_output_is_to_stdout() { let expected_output = r###"example.org.\t239\tIN\tSOA\texample.net. hostmaster.example.net. 1234567890 28800 7200 604800 238 example.org.\t239\tIN\tRRSIG\tSOA 8 2 239 20241127162422 20241127162422 51331 example.org. XD5+Exk0KLfvLYA7y+Qs6jhF+JeESFONqZAjkSvznXdjod80W6cv9C77XeHqqod+5glGHlw9bXmVhuJ/5n056BbnDcMWF+AV4taFc/RrDcZb5A0tS6LnRWbpO9puKeLVK10FeAChCygct6/+GNiE12DDLnzKJFuyMuu+nLa2p88= -example.org.\t238\tIN\tRRSIG\tNSEC 8 2 238 20241127162422 20241127162422 51331 example.org. AT4PDLEolpApcrYi7mcTXrqCQ6psXeZNdmFub08m6BJRs2jeW07fM11Amft53FXKgqbT23WILkEM7Raai8E8qPJoSdDCys6zYXW/NCU9Cf/oXIKdD4nxQXXWbnX4GCMN4XJy382dYnxTDssQK6lNIKKi4OvGYIxVUPthaLKJFU0= +example.org.\t239\tIN\tDNSKEY\t257 3 8 AwEAAckp/oMmocs+pv4KsCkCciazIl2+SohAZ2/bH2viAMg3tHAPjw5YfPNErUBqMGvN4c23iBCnt9TktT5bVoQdpXyCJ+ZwmWrFxlXvXIqG8rpkwHi1xFoXWVZLrG9XYCqLVMq2cB+FgMIaX504XMGk7WQydtV1LAqLgP3B8JA2Fc1j ;{id = 51331 (ksk), size = 1024b} example.org.\t239\tIN\tRRSIG\tDNSKEY 8 2 239 20241127162422 20241127162422 51331 example.org. rLwqlu9fYkzAy0jM9crtw5du4rUaDVH9PI4m06lRwjSKhu1VQ1AHjRhlKy1OgUee/5LovXSRGcgNZi4wiTS5ZULTJw7UQTBRXaaNhVACENX/MoVw9SmYuDSTyvQboChmFmYSMch3Q/02VhgN+BT8F7+OdDVgsWqZUEKPVNixk/0= example.org.\t238\tIN\tNSEC\tsome.example.org. SOA RRSIG NSEC DNSKEY -example.org.\t239\tIN\tDNSKEY\t257 3 8 AwEAAckp/oMmocs+pv4KsCkCciazIl2+SohAZ2/bH2viAMg3tHAPjw5YfPNErUBqMGvN4c23iBCnt9TktT5bVoQdpXyCJ+ZwmWrFxlXvXIqG8rpkwHi1xFoXWVZLrG9XYCqLVMq2cB+FgMIaX504XMGk7WQydtV1LAqLgP3B8JA2Fc1j ;{id = 51331 (ksk), size = 1024b} +example.org.\t238\tIN\tRRSIG\tNSEC 8 2 238 20241127162422 20241127162422 51331 example.org. AT4PDLEolpApcrYi7mcTXrqCQ6psXeZNdmFub08m6BJRs2jeW07fM11Amft53FXKgqbT23WILkEM7Raai8E8qPJoSdDCys6zYXW/NCU9Cf/oXIKdD4nxQXXWbnX4GCMN4XJy382dYnxTDssQK6lNIKKi4OvGYIxVUPthaLKJFU0= some.example.org.\t240\tIN\tA\t1.2.3.4 some.example.org.\t240\tIN\tRRSIG\tA 8 3 240 20241127162422 20241127162422 51331 example.org. xdVbhbaMXEyMySCOKy2yYQgU2URAOnu+jLU5py+4R8R3yVVvdl6yMjzdUD3vyxprHitJ+xLrXU/wHSQvtjSwmxVL53ztu+9wrnrhQm6nqXGLW+iw58LepdLVRlppz2WlV0CJAlLIQPJ8rw4hND3NYLJojnO8OdrgpHL89ajD4II= -some.example.org.\t238\tIN\tRRSIG\tNSEC 8 3 238 20241127162422 20241127162422 51331 example.org. PP4tH4Y6JNymWSJebPd3zjvDrjyZXVBF8QTKxKAmbmtPacbWyIcRuI0L8+8Z1folAN2U5cUZmCaIbt5Ylaj6ab4UAYHiy0BrcF/zbNIeLRSTz4hOteencIooTDvqIqYuI9/xTVXcfJ+gVzzlIh2dJK2GW5O4+B1xR+CINLNJ/j8= some.example.org.\t238\tIN\tNSEC\texample.org. A RRSIG NSEC +some.example.org.\t238\tIN\tRRSIG\tNSEC 8 3 238 20241127162422 20241127162422 51331 example.org. PP4tH4Y6JNymWSJebPd3zjvDrjyZXVBF8QTKxKAmbmtPacbWyIcRuI0L8+8Z1folAN2U5cUZmCaIbt5Ylaj6ab4UAYHiy0BrcF/zbNIeLRSTz4hOteencIooTDvqIqYuI9/xTVXcfJ+gVzzlIh2dJK2GW5O4+B1xR+CINLNJ/j8= "###.replace("\\t", "\t"); let zone_file_path =