Files
NLnetLabs-dnst/src/commands/signzone.rs
T

2338 lines
90 KiB
Rust

use core::cmp::Ordering;
use core::fmt::Write;
use core::ops::Add;
use core::str::FromStr;
use std::cmp::min;
use std::collections::{HashMap, HashSet};
use std::ffi::OsString;
use std::fmt;
use std::fs::File;
use std::hash::RandomState;
use std::io::{self, BufWriter};
use std::path::{Path, PathBuf};
use bytes::{BufMut, Bytes, BytesMut};
use clap::builder::ValueParser;
use domain::base::iana::nsec3::Nsec3HashAlg;
use domain::base::iana::zonemd::{ZonemdAlg, ZonemdScheme};
use domain::base::name::FlattenInto;
use domain::base::zonefile_fmt::ZonefileFmt;
use domain::base::{Name, NameBuilder, Record, Rtype, Serial, Ttl};
use domain::rdata::dnssec::Timestamp;
use domain::rdata::nsec3::Nsec3Salt;
use domain::rdata::{Dnskey, Nsec3, Nsec3param, Soa, ZoneRecordData, Zonemd};
use domain::sign::common::{FromBytesError, KeyPair};
use domain::sign::records::{
DefaultSigningKeyUsageStrategy, DnssecSigningKey, Family, FamilyName, IntendedKeyPurpose,
Nsec3OptOut, Nsec3Records, RecordsIter, Signer, SigningKeyUsageStrategy, SortedRecords, Sorter,
};
use domain::sign::{SecretKeyBytes, SigningKey};
use domain::validate::Key;
use domain::zonefile::inplace::{self, Entry};
use domain::zonetree::types::StoredRecordData;
use domain::zonetree::{StoredName, StoredRecord};
use lexopt::Arg;
use octseq::builder::with_infallible;
use ring::digest;
use crate::env::{Env, Stream};
use crate::error::Error;
use crate::{Args, DISPLAY_KIND};
use super::nsec3hash::Nsec3Hash;
use super::{parse_os, parse_os_with, Command, LdnsCommand};
//------------ Constants -----------------------------------------------------
const FOUR_WEEKS: u32 = 2419200;
//------------ SignZone ------------------------------------------------------
#[derive(Clone, Debug, clap::Args, PartialEq)]
#[clap(
after_help = "Keys must be specified by their base name (usually K<name>+<alg>+<id>), i.e. WITHOUT the .private or .key extension.
If the public part of the key is not present in the zone, the DNSKEY RR will be read from the file called <base name>.key.
A date can be a timestamp (seconds since the epoch), or of the form <YYYYMMdd[hhmmss]>
"
)]
pub struct SignZone {
// -----------------------------------------------------------------------
// Original ldns-signzone options in ldns-signzone -h order:
// -----------------------------------------------------------------------
/// Use layout in signed zone and print comments on DNSSEC records
#[arg(short = 'b', default_value_t = false)]
extra_comments: bool,
/// Used keys are not added to the zone
#[arg(short = 'd', default_value_t = false)]
do_not_add_keys_to_zone: bool,
/// Expiration date [default: 4 weeks from now]
// Default is not documented in ldns-signzone -h or man ldns-signzone but
// in code (see ldns/dnssec_sign.c::ldns_create_empty_rrsig()) LDNS uses
// now + 4 weeks if no expiration timestamp is specified.
#[arg(
short = 'e',
value_name = "date",
default_value_t = Timestamp::now().into_int().add(FOUR_WEEKS).into(),
hide_default_value = true,
value_parser = ValueParser::new(SignZone::parse_timestamp),
)]
expiration: Timestamp,
/// Output zone to file [default: <zonefile>.signed]
///
/// Use '-f -' to output to stdout.
#[arg(short = 'f', value_name = "file")]
out_file: Option<PathBuf>,
/// Inception date [default: now]
// Default is not documented in ldns-signzone -h or man ldns-signzone but
// in code (see ldns/dnssec_sign.c::ldns_create_empty_rrsig()) LDNS uses
// now if no inception timestamp is specified.
#[arg(
short = 'i',
value_name = "date",
default_value_t = Timestamp::now(),
hide_default_value = true,
value_parser = ValueParser::new(SignZone::parse_timestamp),
)]
inception: Timestamp,
/// Origin for the zone (for zonefiles with relative names and no $ORIGIN)
#[arg(short = 'o', value_name = "domain")]
origin: Option<Name<Bytes>>,
/// Set SOA serial to the number of seconds since Jan 1st 1970
///
/// If this would NOT result in the SOA serial increasing it will be
/// incremented instead.
#[arg(short = 'u', default_value_t = false)]
set_soa_serial_to_epoch_time: bool,
// SKIPPED: -v
// This should be handled at the dnst top level, not per subcommand.
/// Add a ZONEMD resource record
///
/// <hash> currently supports "SHA384" (1) or "SHA512" (2).
/// <scheme> currently only supports "SIMPLE" (1).
///
/// Can occur more than once, but only one per unique scheme and hash
/// tuple will be added.
#[arg(
short = 'z',
value_name = "[scheme:]hash",
value_parser = Self::parse_zonemd_tuple,
action = clap::ArgAction::Append
)]
// Clap doesn't support HashSet (without complex workarounds), therefore
// the uniqueness of the tuples need to be checked at runtime.
zonemd: Vec<ZonemdTuple>,
/// Allow ZONEMDs to be added without signing
#[arg(short = 'Z', requires = "zonemd")]
allow_zonemd_without_signing: bool,
// Sign DNSKEYs with all keys instead of minimal
#[arg(short = 'A', default_value_t = false)]
sign_dnskeys_with_all_keys: bool,
// Sign with every unique algorithm in the provided keys
#[arg(short = 'U', default_value_t = false)]
sign_with_every_unique_algorithm: bool,
/// Use NSEC3 instead of NSEC
#[arg(short = 'n', default_value_t = false, group = "nsec3")]
use_nsec3: bool,
/// Hashing algorithm
#[arg(
help_heading = Some("NSEC3 (when using '-n')"),
short = 'a',
value_name = "algorithm",
default_value = "SHA-1",
value_parser = ValueParser::new(Nsec3Hash::parse_nsec3_alg),
requires = "nsec3"
)]
algorithm: Nsec3HashAlg,
/// Number of hash iterations
#[arg(
help_heading = Some("NSEC3 (when using '-n')"),
short = 't',
value_name = "number",
default_value_t = 0,
requires = "nsec3"
)]
iterations: u16,
/// Salt
#[arg(
help_heading = Some("NSEC3 (when using '-n')"),
short = 's',
value_name = "string",
default_value_t = Nsec3Salt::empty(),
requires = "nsec3"
)]
salt: Nsec3Salt<Bytes>,
/// Set the opt-out flag on all NSEC3 RRs
#[arg(
help_heading = Some("NSEC3 (when using '-n')"),
short = 'p',
default_value_t = false,
requires = "nsec3",
conflicts_with = "nsec3_opt_out"
)]
nsec3_opt_out_flags_only: bool,
// -----------------------------------------------------------------------
// Extra options not supported by the original ldns-signzone:
// -----------------------------------------------------------------------
/// Set the opt-out flag on all NSEC3 RRs and skip unsigned delegations
#[arg(
help_heading = Some("NSEC3 (when using '-n')"),
short = 'P',
default_value_t = false,
requires = "nsec3",
conflicts_with = "nsec3_opt_out_flags_only"
)]
nsec3_opt_out: bool,
/// Hash only, don't sign
#[arg(short = 'H', default_value_t = false)]
hash_only: bool,
/// Do not require that key names match the apex.
#[arg(short = 'M', default_value_t = false)]
no_require_keys_match_apex: bool,
// -----------------------------------------------------------------------
// Original ldns-signzone positional arguments in position order:
// -----------------------------------------------------------------------
/// The zonefile to sign
#[arg(value_name = "zonefile")]
zonefile_path: PathBuf,
/// The keys to sign the zone with
// May be omitted if -Z or -H are given
#[arg(value_name = "key", required_unless_present_any = ["allow_zonemd_without_signing", "hash_only"])]
key_paths: Vec<PathBuf>,
// -----------------------------------------------------------------------
// Non-command line argument fields:
// -----------------------------------------------------------------------
/// Whether or not we were invoked as `ldns-signzone`.
#[arg(skip)]
invoked_as_ldns: bool,
}
const LDNS_HELP: &str = r###"ldns-signzone [OPTIONS] zonefile key [key [key]]
signs the zone with the given key(s)
-b use layout in signed zone and print comments DNSSEC records
-d used keys are not added to the zone
-e <date> expiration date
-f <file> output zone to file (default <name>.signed)
-i <date> inception date
-o <domain> origin for the zone
-u set SOA serial to the number of seconds since 1-1-1970
-v print version and exit
-z <[scheme:]hash> Add ZONEMD resource record
<scheme> should be "simple" (or 1)
<hash> should be "sha384" or "sha512" (or 1 or 2)
this option can be given more than once
-Z Allow ZONEMDs to be added without signing
-A sign DNSKEY with all keys instead of minimal
-U Sign with every unique algorithm in the provided keys
-n use NSEC3 instead of NSEC.
If you use NSEC3, you can specify the following extra options:
-a [algorithm] hashing algorithm
-t [number] number of hash iterations
-s [string] salt
-p set the opt-out flag on all nsec3 rrs
keys must be specified by their base name (usually K<name>+<alg>+<id>),
i.e. WITHOUT the .private extension.
If the public part of the key is not present in the zone, the DNSKEY RR
will be read from the file called <base name>.key.
A date can be a timestamp (seconds since the epoch), or of
the form <YYYYMMdd[hhmmss]>
"###;
impl LdnsCommand for SignZone {
const NAME: &'static str = "signzone";
const HELP: &'static str = LDNS_HELP;
const COMPATIBLE_VERSION: &'static str = "1.8.4";
fn parse_ldns<I: IntoIterator<Item = OsString>>(args: I) -> Result<Args, Error> {
let mut extra_comments = false;
let mut do_not_add_keys_to_zone = false;
let mut expiration = Timestamp::now().into_int().add(FOUR_WEEKS).into();
let mut out_file = Option::<PathBuf>::None;
let mut inception = Timestamp::now();
let mut origin = Option::<Name<Bytes>>::None;
let mut set_soa_serial_to_epoch_time = false;
let mut zonemd = Vec::new();
let mut allow_zonemd_without_signing = false;
let mut sign_dnskeys_with_all_keys = false;
let mut sign_with_every_unique_algorithm = false;
let mut use_nsec3 = false;
let mut algorithm = Nsec3HashAlg::SHA1;
let mut iterations = 1u16;
let mut salt = Nsec3Salt::<Bytes>::empty();
let mut nsec3_opt_out_flags_only = false;
let mut key_paths = Vec::<PathBuf>::new();
let mut zonefile = Option::<PathBuf>::None;
let mut parser = lexopt::Parser::from_args(args);
while let Some(arg) = parser.next()? {
match arg {
Arg::Short('b') => {
extra_comments = true;
}
Arg::Short('d') => {
do_not_add_keys_to_zone = true;
}
Arg::Short('e') => {
let val = parser.value()?;
// LDNS treats 0 as unset.
let val_as_num = usize::from_str(val.to_str().unwrap_or_default());
if val_as_num.is_err() || val_as_num.unwrap() > 0 {
expiration = parse_os_with("-e", &val, SignZone::parse_timestamp)?;
}
}
Arg::Short('f') => {
let val = parser.value()?;
out_file = Some(parse_os("-f", &val)?);
}
Arg::Short('i') => {
let val = parser.value()?;
// LDNS treats 0 as unset.
let val_as_num = usize::from_str(val.to_str().unwrap_or_default());
if val_as_num.is_err() || val_as_num.unwrap() > 0 {
inception = parse_os_with("-e", &val, SignZone::parse_timestamp)?;
}
}
Arg::Short('o') => {
let val = parser.value()?;
origin = Some(parse_os("-o", &val)?);
}
Arg::Short('u') => {
set_soa_serial_to_epoch_time = true;
}
Arg::Short('z') => {
let val = parser.value()?;
zonemd.push(parse_os_with(
"-z",
&val,
SignZone::parse_zonemd_tuple_ldns,
)?);
}
Arg::Short('Z') => {
allow_zonemd_without_signing = true;
}
Arg::Short('A') => {
sign_dnskeys_with_all_keys = true;
}
Arg::Short('U') => {
sign_with_every_unique_algorithm = true;
}
Arg::Short('v') => {
return Ok(Self::report_version());
}
Arg::Short('n') => {
use_nsec3 = true;
}
Arg::Short('a') => {
let val = parser.value()?;
algorithm = parse_os_with("-a", &val, Nsec3Hash::parse_nsec3_alg)?;
}
Arg::Short('t') => {
let val = parser.value()?;
iterations = parse_os("-t", &val)?;
}
Arg::Short('s') => {
let val = parser.value()?;
salt = parse_os("-s", &val)?;
}
Arg::Short('p') => {
nsec3_opt_out_flags_only = true;
}
Arg::Value(val) => {
if zonefile.is_none() {
zonefile = Some(parse_os("zonefile", &val)?);
} else {
key_paths.push(parse_os("key", &val)?);
}
}
Arg::Short(x) => return Err(format!("Invalid short option: -{x}").into()),
Arg::Long(x) => {
return Err(format!("Long options are not supported, but `--{x}` given").into())
}
}
}
let Some(zonefile_path) = zonefile else {
return Err("Missing zonefile argument".into());
};
if let Some(out_file) = &out_file {
if out_file.as_os_str() == "-" {
extra_comments = false;
}
}
// Logically this should also check that zonemd flags are given, but
// ldns-signzone just copies the unsigned zone (without comments) when
// using only -Z (without -z).
if key_paths.is_empty() && !allow_zonemd_without_signing {
return Err("Missing key argument".into());
};
Ok(Args::from(Command::SignZone(Self {
extra_comments,
do_not_add_keys_to_zone,
expiration,
out_file,
inception,
origin,
set_soa_serial_to_epoch_time,
zonemd,
allow_zonemd_without_signing,
sign_dnskeys_with_all_keys,
sign_with_every_unique_algorithm,
use_nsec3,
algorithm,
iterations,
salt,
nsec3_opt_out_flags_only,
nsec3_opt_out: false,
hash_only: false,
zonefile_path,
key_paths,
no_require_keys_match_apex: false,
invoked_as_ldns: true,
})))
}
}
impl SignZone {
fn parse_zonemd_tuple(arg: &str) -> Result<ZonemdTuple, Error> {
let scheme;
let hash_alg;
if let Some((s, h)) = arg.split_once(':') {
scheme = if let Ok(num) = s.parse() {
Self::num_to_zonemd_scheme(num)
} else {
ZonemdScheme::from_mnemonic(s.as_bytes()).ok_or("unknown ZONEMD scheme mnemonic")
}?;
hash_alg = h;
} else {
scheme = ZonemdScheme::SIMPLE;
hash_alg = arg
};
let hash_alg = if let Ok(num) = hash_alg.parse() {
Self::num_to_zonemd_alg(num)
} else {
ZonemdAlg::from_mnemonic(hash_alg.as_bytes()).ok_or("unknown ZONEMD algorithm mnemonic")
}?;
Ok(ZonemdTuple(scheme, hash_alg))
}
pub fn num_to_zonemd_alg(num: u8) -> Result<ZonemdAlg, &'static str> {
let alg = ZonemdAlg::from_int(num);
match alg.to_mnemonic() {
Some(_) => Ok(alg),
None => Err("unknown ZONEMD algorithm number"),
}
}
pub fn num_to_zonemd_scheme(num: u8) -> Result<ZonemdScheme, &'static str> {
let alg = ZonemdScheme::from_int(num);
match alg.to_mnemonic() {
Some(_) => Ok(alg),
None => Err("unknown ZONEMD scheme number"),
}
}
fn parse_zonemd_tuple_ldns(arg: &str) -> Result<ZonemdTuple, Error> {
let scheme;
let hash_alg;
fn parse_zonemd_scheme_ldns(s: &str) -> Result<ZonemdScheme, Error> {
match s.to_lowercase().as_str() {
"simple" | "1" => Ok(ZonemdScheme::SIMPLE),
_ => Err("unknown ZONEMD scheme name or number".into()),
}
}
fn parse_zonemd_hash_alg_ldns(h: &str) -> Result<ZonemdAlg, Error> {
match h.to_lowercase().as_str() {
"sha384" | "1" => Ok(ZonemdAlg::SHA384),
"sha512" | "2" => Ok(ZonemdAlg::SHA512),
_ => Err("unknown ZONEMD algorithm name or number".into()),
}
}
if let Some((s, h)) = arg.split_once(':') {
scheme = parse_zonemd_scheme_ldns(s)?;
hash_alg = parse_zonemd_hash_alg_ldns(h)?;
} else {
scheme = ZonemdScheme::SIMPLE;
hash_alg = parse_zonemd_hash_alg_ldns(arg)?;
};
Ok(ZonemdTuple(scheme, hash_alg))
}
pub fn parse_timestamp(arg: &str) -> Result<Timestamp, Error> {
// We can't just use Timestamp::from_str from the domain crate because
// ldns-signzone treats YYYYMMDD as a special case and domain does
// not. For invalid values this YYYYMMDDD prevents use of valid Unix
// timestamps that have the same value, e.g. ldns-signzone complains
// that for 99999999 "The month must be in the range 1 to 12". There's
// also no checking that an expiration timestamp is in the future of
// an inception timestamp (which for serial numbers is hard to say for
// sure but for YYYYMMDD or YYYYMMDDHHmmSS we could check).
let res = if arg.len() == 8 && arg.parse::<u32>().is_ok() {
// This can give strange errors, e.g. 99999999 warns about illegal
// signature time, but the alternative would be to add a
// dependency on chrono and parse the value ourselves in order to
// produce a better error message. Given that this only happens
// for very old or far future Unix timestamps we don't attempt to
// do better than this for now.
Timestamp::from_str(&format!("{arg}000000"))
} else {
Timestamp::from_str(arg)
};
res.map_err(|err| Error::from(format!("Invalid timestamp: {err}")))
}
pub fn execute(self, env: impl Env) -> Result<(), Error> {
// Post-process arguments.
// TODO: Can Clap do this for us?
let opt_out = if self.nsec3_opt_out {
Nsec3OptOut::OptOut
} else if self.nsec3_opt_out_flags_only {
Nsec3OptOut::OptOutFlagsOnly
} else {
Nsec3OptOut::NoOptOut
};
let signing_mode = if self.hash_only {
SigningMode::HashOnly
} else if self.key_paths.is_empty() {
if self.allow_zonemd_without_signing {
SigningMode::None
} else {
return Err("Missing key argument".into());
}
} else {
SigningMode::HashAndSign
};
let out_file = if let Some(out_file) = &self.out_file {
out_file.clone()
} else {
let out_file = format!("{}.signed", self.zonefile_path.display());
PathBuf::from_str(&out_file)
.map_err(|err| format!("Cannot write to {out_file}: {err}"))?
};
let writer = if out_file.as_os_str() == "-" {
FileOrStdout::Stdout(env.stdout())
} else {
let file = File::create(env.in_cwd(&out_file))?;
let file = BufWriter::new(file);
FileOrStdout::File(file)
};
// ldns-signzone only shows these warnings if verbosity < 1 but offers
// no way to configure the verbosity level. I assume the intent was to
// add support for a -q (--quiet) option or similar but that was never
// done.
match self.iterations {
500.. => Self::write_extreme_iterations_warning(&env),
100.. if self.invoked_as_ldns => Self::write_large_iterations_warning(&env),
1.. if !self.invoked_as_ldns => Self::write_non_zero_iterations_warning(&env),
_ => { /* Good, nothing to warn about */ }
}
// Read the zone file.
let records = self.load_zone(&env)?;
// Extract the SOA RR from the loaded zone.
let Some(soa_rr) = records.find_soa() else {
return Err(format!(
"Zone file '{}' does not contain a SOA record",
self.zonefile_path.display()
)
.into());
};
let ZoneRecordData::Soa(_) = soa_rr.first().data() else {
return Err(format!(
"Zone file '{}' contains an invalid SOA record",
self.zonefile_path.display()
)
.into());
};
// Extract and validate the DNSKEY RRs from the loaded zone.
let mut found_public_keys = vec![];
for rr in records.iter() {
if let ZoneRecordData::Dnskey(dnskey) = rr.data() {
// Create a public key object from the found DNSKEY RR.
let public_key =
Key::from_dnskey(rr.owner().clone(), dnskey.clone()).map_err(|err| {
Error::from(format!(
"Zone file '{}' DNSKEY record '{dnskey}' is invalid: {err}",
self.zonefile_path.display()
))
})?;
found_public_keys.push(public_key);
}
}
// Load the specified private keys, match them against the found
// public keys, failing that load a DNSKEY RR from the corresponding
// public key file and validate that its owner matches that of the
// zone apex. Unlike ldns-signzone we don't use a generated public key
// if these attempts fail.
let mut signing_keys: Vec<DnssecSigningKey<Bytes, KeyPair>> = vec![];
'next_key_path: for key_path in &self.key_paths {
let key_path = env.in_cwd(key_path).into_owned();
// Load the private key.
let private_key_path = Self::mk_private_key_path(&key_path);
let private_key = Self::load_private_key(&private_key_path)?;
// Note: Our behaviour differs to that of the original
// ldns-signzone because we are unable at the time of writing to
// generate a public key from a private key. As such we cannot
// compare the key tag of any found DNSKEY RRs to that of the
// public key generated from the private key. Instead we attempt
// to construct a key pair from the found public key and each
// private key which tests that they match.
for public_key in &found_public_keys {
// Attempt to create a key pair from this public key and every
// private key that we have.
if let Ok(signing_key) = self.mk_signing_key(&private_key, public_key.clone()) {
// Match found, keep the created signing key.
// TODO: Log here.
// TODO: Check the key tag against the key tag in the key file name?
// println!(
// "DNSKEY RR with key tag {} matches loaded private key '{}'",
// public_key.key_tag(),
// private_key_path.display()
// );
signing_keys.push(DnssecSigningKey::inferred(signing_key));
continue 'next_key_path;
}
}
// No matching public key found, try to load the public key
// instead.
let public_key_path = Self::mk_public_key_path(&key_path);
let public_key = Self::load_public_key(&public_key_path)?;
// Verify that the owner of the public key matches the apex of the
// zone.
if public_key.owner() != soa_rr.owner() {
return Err(format!(
"Zone apex ({}) does not match the expected apex ({})",
soa_rr.owner(),
public_key.owner()
)
.into());
}
// Attempt to crate a key pair from the loaded private and public
// keys.
let signing_key = self
.mk_signing_key(&private_key, public_key.clone())
.map_err(|err| {
format!(
"Unable to create key pair from '{}' and '{}': {}",
public_key_path.display(),
private_key_path.display(),
err
)
})?;
// Store the created signing key.
signing_keys.push(DnssecSigningKey::inferred(signing_key));
// TODO: Log
// println!(
// "Loaded public key with key tag {} from '{}' for private key '{}'",
// public_key.key_tag(),
// public_key_path.display(),
// private_key_path.display()
// );
}
if self.sign_dnskeys_with_all_keys {
let signer = Signer::<Bytes, KeyPair, AllKeyStrat>::new();
self.go_further(
signer,
records,
signing_mode,
opt_out,
&signing_keys,
writer,
)
} else if self.sign_with_every_unique_algorithm {
let signer = Signer::<Bytes, KeyPair, AllUniqStrat>::new();
self.go_further(
signer,
records,
signing_mode,
opt_out,
&signing_keys,
writer,
)
} else {
let signer = Signer::<Bytes, KeyPair, FallbackStrat>::new();
self.go_further(
signer,
records,
signing_mode,
opt_out,
&signing_keys,
writer,
)
}
}
fn go_further<Strat: SigningKeyUsageStrategy<Bytes, KeyPair>>(
&self,
signer: Signer<Bytes, KeyPair, Strat>,
mut records: SortedRecords<Name<Bytes>, ZoneRecordData<Bytes, Name<Bytes>>>,
signing_mode: SigningMode,
opt_out: Nsec3OptOut,
signing_keys: &[DnssecSigningKey<Bytes, KeyPair>],
mut writer: FileOrStdout<BufWriter<File>, impl Write>,
) -> Result<(), Error> {
// Make sure, zonemd arguments are unique
let zonemd: HashSet<ZonemdTuple> = HashSet::from_iter(self.zonemd.clone());
// Change the SOA serial.
if self.set_soa_serial_to_epoch_time {
Self::bump_soa_serial(&mut records)?;
}
// Find the apex.
let (apex, ttl, soa_serial) = Self::find_apex(&records).unwrap();
if !zonemd.is_empty() {
Self::replace_apex_zonemd_with_placeholder(&mut records, &apex, soa_serial, ttl);
}
// Hash the zone with NSEC or NSEC3, unless only ZONEMD is done.
let hashes = if matches!(
signing_mode,
SigningMode::HashOnly | SigningMode::HashAndSign
) {
if self.use_nsec3 {
let params = Nsec3param::new(self.algorithm, 0, self.iterations, self.salt.clone());
let Nsec3Records {
recs,
param,
hashes,
} = records
.nsec3s::<_, BytesMut>(
&apex,
ttl,
params,
opt_out,
!self.do_not_add_keys_to_zone,
self.extra_comments,
)
.unwrap();
records.extend(recs.into_iter().map(Record::from_record));
records.insert(Record::from_record(param)).unwrap();
hashes
} else {
let nsecs = records.nsecs::<Bytes>(&apex, ttl, !self.do_not_add_keys_to_zone);
records.extend(nsecs.into_iter().map(Record::from_record));
None
}
} else {
None
};
// Sign the zone unless disabled.
if signing_mode == SigningMode::HashAndSign {
let extra_records = signer
.sign(
&apex,
records.families(),
signing_keys,
!self.do_not_add_keys_to_zone,
)
.map_err(|_| "Signing failed")?;
records.extend(extra_records.into_iter().map(Record::from_record));
}
if !zonemd.is_empty() {
// Remove existing ZONEMD RRs at apex (the placeholder is no longer needed)
let _ = records.remove_first_by_name_class_rtype(
apex.owner().clone(),
None,
Some(Rtype::ZONEMD),
);
let zonemd_rrs =
Self::create_zonemd_digest_and_records(&records, &apex, &zonemd, soa_serial, ttl)?;
// Add ZONEMD RRs to output records
records.extend(zonemd_rrs.clone().into_iter().map(Record::from_record));
self.update_zonemd_rrsig(&signer, &mut records, &apex, signing_keys, zonemd_rrs);
}
// The signed RRs are in DNSSEC canonical order by owner name. For
// 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.
//
// Note: Family refers to the underlying record data, so while we are
// creating a new Vec, it only contains references to the original
// data so it's indiividual are not the records themselves.
let mut families;
let family_iter: AnyFamiliesIter = if self.extra_comments && hashes.is_some() {
families = records.families().collect::<Vec<_>>();
let Some(hashes) = hashes.as_ref() else {
unreachable!();
};
families.sort_unstable_by(|a, b| {
let mut hashed_count = 0;
let unhashed_a = if let Some(unhashed_owner) = hashes.get(a.owner()) {
hashed_count += 1;
unhashed_owner
} else {
a.owner()
};
let unhashed_b = if let Some(unhashed_owner) = hashes.get(b.owner()) {
hashed_count += 2;
unhashed_owner
} else {
b.owner()
};
match unhashed_a.cmp(unhashed_b) {
Ordering::Less => Ordering::Less,
Ordering::Equal => match hashed_count {
0 | 3 => Ordering::Equal,
1 => Ordering::Greater,
2 => Ordering::Less,
_ => unreachable!(),
},
Ordering::Greater => Ordering::Greater,
}
});
families.iter().into()
} else {
records.families().into()
};
// Output the resulting zone, with comments if enabled.
if self.extra_comments {
writer.write_fmt(format_args!(";; Zone: {}\n;\n", apex.owner()))?;
}
if let Some(record) = records.iter().find(|r| r.rtype() == Rtype::SOA) {
writer.write_fmt(format_args!("{}\n", record.display_zonefile(DISPLAY_KIND)))?;
if let Some(record) = records.iter().find(|r| {
if let ZoneRecordData::Rrsig(rrsig) = r.data() {
rrsig.type_covered() == Rtype::SOA
} else {
false
}
}) {
writer.write_fmt(format_args!("{}\n", record.display_zonefile(DISPLAY_KIND)))?;
}
if self.extra_comments {
writer.write_str(";\n")?;
}
}
let nsec3_cs = Nsec3CommentState {
hashes: hashes.as_ref(),
apex: &apex,
};
for family in family_iter {
if self.extra_comments {
if let Some(hashes) = hashes.as_ref() {
// If this is family contains an NSEC3 RR and the number
// of RRs in the RRSET of the unhashed owner name is zero,
// then the NSEC3 was generated for an empty non-terminal.
if family.rrsets().any(|rrset| rrset.rtype() == Rtype::NSEC3) {
if let Some(unhashed_name) = hashes.get(family.owner()) {
if !records
.families()
.any(|family| family.owner() == unhashed_name)
{
writer.write_fmt(format_args!(
";; Empty nonterminal: {unhashed_name}\n"
))?;
}
} else {
// ??? Every hashed name must correspond to an
// unhashed name?
unreachable!();
}
}
}
}
// The SOA is output separately above as the very first RRset so
// we skip that, and we skip RRSIGs as they are output only after
// the RRset that they cover.
if self.extra_comments {
for rrset in family
.rrsets()
.filter(|rrset| !matches!(rrset.rtype(), Rtype::SOA | Rtype::RRSIG))
{
for rr in rrset.iter() {
writer.write_fmt(format_args!("{}", rr.display_zonefile(DISPLAY_KIND)))?;
match rr.data() {
ZoneRecordData::Nsec3(nsec3) => {
nsec3.comment(&mut writer, rr, nsec3_cs)?
}
ZoneRecordData::Dnskey(dnskey) => {
dnskey.comment(&mut writer, rr, ())?
}
_ => {
// Nothing to do. We do not support Bubble Babble
// output for DS records.
//
// See:
// https://bohwaz.net/archives/web/Bubble_Babble.html
}
}
writer.write_str("\n")?;
}
// Now attempt to print the RRSIGs that covers the RTYPE of this RRSET.
for covering_rrsigs in family
.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())))
{
for covering_rrsig_rr in covering_rrsigs {
writer.write_fmt(format_args!("{}", covering_rrsig_rr.display_zonefile(DISPLAY_KIND)))?;
writer.write_str("\n")?;
if self.extra_comments {
writer.write_str(";\n")?;
}
}
}
}
} else {
for rrset in family.rrsets().filter(|rrset| rrset.rtype() != Rtype::SOA) {
for rr in rrset.iter() {
// Only output the key tag comment if running as LDNS.
// When running as DNST we assume without `-b` that speed
// is wanted, not human readable comments.
if self.invoked_as_ldns {
writer
.write_fmt(format_args!("{}", rr.display_zonefile(DISPLAY_KIND)))?;
if let ZoneRecordData::Dnskey(dnskey) = rr.data() {
dnskey.comment(&mut writer, rr, ())?
}
writer.write_str("\n")?;
} else {
writer.write_fmt(format_args!(
"{}\n",
rr.display_zonefile(DISPLAY_KIND)
))?;
}
}
}
}
}
Ok(())
}
fn load_zone(
&self,
env: &impl Env,
) -> Result<SortedRecords<StoredName, StoredRecordData>, Error> {
// Don't use Zonefile::load() as it knows nothing about the size of
// the original file so uses default allocation which allocates more
// bytes than are needed. Instead control the allocation size based on
// our knowledge of the file size.
let mut zone_file = File::open(env.in_cwd(&self.zonefile_path))?;
let zone_file_len = zone_file.metadata()?.len();
let mut buf = inplace::Zonefile::with_capacity(zone_file_len as usize).writer();
std::io::copy(&mut zone_file, &mut buf)?;
let mut reader = buf.into_inner();
let mut records = SortedRecords::new();
if let Some(origin) = &self.origin {
reader.set_origin(origin.clone());
}
for entry in reader {
let entry = entry.map_err(|err| format!("Invalid zone file: {err}"))?;
match entry {
Entry::Record(record) => {
let record: StoredRecord = record.flatten_into();
// Ignore any existing NSEC(3) and RRSIG RRs from the
// loaded zone as we only support signing an unsigned
// zone. We do not ignore DNSKEY RRs as we match given
// keys against those.
//
// TODO: RFC 5155 DNS Security (DNSSEC) Hashed
// Authenticated Denial of Existence says in section 10
// says that to safely transition between NSEC <-> NSEC3
// one must be able to have both RR types in the zone at
// once, while our current implementation only supports
// having either NSEC or NSEC3 in the zone at any one
// time.
//
// TODO: NSEC3PARAM and ZONEMD should only be ignored at
// the apex (the only place RFCs define them to be valid).
if !matches!(
record.rtype(),
Rtype::NSEC
| Rtype::NSEC3
| Rtype::NSEC3PARAM
| Rtype::RRSIG
| Rtype::ZONEMD
) {
let _ = records.insert(record);
}
}
Entry::Include { .. } => {
return Err(Error::from(
"Invalid zone file: $INCLUDE directive is not supported",
));
}
}
}
Ok(records)
}
fn find_apex(
records: &SortedRecords<StoredName, StoredRecordData>,
) -> Result<(FamilyName<Name<Bytes>>, Ttl, Serial), Error> {
let soa = match records.find_soa() {
Some(soa) => soa,
None => {
return Err(Error::from("Invalid zone file: Cannot find SOA record"));
}
};
let (ttl, serial) = match *soa.first().data() {
ZoneRecordData::Soa(ref soa_data) => {
// RFC 9077 updated RFC 4034 (NSEC) and RFC 5155 (NSEC3) to
// say that the "TTL of the NSEC(3) RR that is returned MUST be
// the lesser of the MINIMUM field of the SOA record and the
// TTL of the SOA itself".
(min(soa_data.minimum(), soa.ttl()), soa_data.serial())
}
_ => unreachable!(),
};
Ok((soa.family_name().cloned(), ttl, serial))
}
fn bump_soa_serial(
records: &mut SortedRecords<Name<Bytes>, ZoneRecordData<Bytes, Name<Bytes>>>,
) -> Result<(), Error> {
// SAFETY: Already checked before this point.
let old_soa_rr = records.find_soa().unwrap();
let ZoneRecordData::Soa(old_soa) = old_soa_rr.first().data() else {
unreachable!();
};
// Undocumented behaviour in ldns-signzone: it doesn't just set the
// SOA serial to the current unix timestamp as is documented for '-u'
// but rather only does that if the resulting value would be larger
// than the current unix timestamp, otherwise it increments it. I
// assume it does that to ensure that the SOA serial advances on zone
// change per expectations defined in RFC 1034, though it is assuming
// that the SOA serial can be interpreted as a unix timestamp which
// may not be the intention of the zone owner.
let now = Serial::now();
let new_serial = if now > old_soa.serial() {
now
} else {
old_soa.serial().add(1)
};
let new_soa = Soa::new(
old_soa.mname().clone(),
old_soa.rname().clone(),
new_serial,
old_soa.refresh(),
old_soa.retry(),
old_soa.expire(),
old_soa.minimum(),
);
records.replace_soa(new_soa);
Ok(())
}
fn load_private_key(key_path: &Path) -> Result<SecretKeyBytes, Error> {
let private_data = std::fs::read_to_string(key_path).map_err(|err| {
format!(
"Unable to load private key from file '{}': {}",
key_path.display(),
err
)
})?;
// Note: Compared to the original ldns-signzone there is a minor
// regression here because at the time of writing the error returned
// from parsing indicates broadly the type of parsing failure but does
// note indicate the line number at which parsing failed.
let secret_key = SecretKeyBytes::parse_from_bind(&private_data).map_err(|err| {
format!(
"Unable to parse BIND formatted private key file '{}': {}",
key_path.display(),
err
)
})?;
Ok(secret_key)
}
fn load_public_key(key_path: &Path) -> Result<Key<Bytes>, Error> {
let public_data = std::fs::read_to_string(key_path).map_err(|err| {
format!(
"Unable to load public key from file '{}': {}",
key_path.display(),
err
)
})?;
// Note: Compared to the original ldns-signzone there is a minor
// regression here because at the time of writing the error returned
// from parsing indicates broadly the type of parsing failure but does
// note indicate the line number at which parsing failed.
let public_key_info = Key::parse_from_bind(&public_data).map_err(|err| {
format!(
"Unable to parse BIND formatted public key file '{}': {}",
key_path.display(),
err
)
})?;
Ok(public_key_info)
}
fn mk_public_key_path(key_path: &Path) -> PathBuf {
if key_path.extension().and_then(|ext| ext.to_str()) == Some("key") {
key_path.to_path_buf()
} else {
PathBuf::from(format!("{}.key", key_path.display()))
}
}
fn mk_private_key_path(key_path: &Path) -> PathBuf {
if key_path.extension().and_then(|ext| ext.to_str()) == Some("private") {
key_path.to_path_buf()
} else {
PathBuf::from(format!("{}.private", key_path.display()))
}
}
fn mk_signing_key(
&self,
private_key: &SecretKeyBytes,
public_key: Key<Bytes>,
) -> Result<SigningKey<Bytes, KeyPair>, FromBytesError> {
let key_pair = KeyPair::from_bytes(private_key, public_key.raw_public_key())?;
let signing_key = SigningKey::new(public_key.owner().clone(), public_key.flags(), key_pair)
.with_validity(self.inception, self.expiration);
Ok(signing_key)
}
fn write_extreme_iterations_warning(env: &impl Env) {
Self::write_iterations_warning(
env,
"NSEC3 iterations larger than 500 may cause validating resolvers to return SERVFAIL!",
);
}
fn write_large_iterations_warning(env: &impl Env) {
Self::write_iterations_warning(env, "NSEC3 iterations larger than 100 may cause validating resolvers to return insecure responses!");
}
fn write_non_zero_iterations_warning(env: &impl Env) {
Self::write_iterations_warning(env, "NSEC3 iterations larger than 0 increases performance cost while providing only moderate protection!");
}
fn write_iterations_warning(env: &impl Env, text: &str) {
Error::write_warning(&mut env.stderr(), text);
writeln!(
env.stderr(),
"See: https://www.rfc-editor.org/rfc/rfc9276.html"
);
}
/// Create the ZONEMD digest for the SIMPLE scheme.
/// The records need to be in DNSSEC canonical ordering,
/// with same owner RRs sorted numerically by RTYPE.
///
/// [RFC 8976] Section 3.3.1. The SIMPLE Scheme
/// ```text
/// 3.3.1. The SIMPLE Scheme
///
/// For the SIMPLE scheme, the digest is calculated over the zone as a
/// whole. This means that a change to a single RR in the zone requires
/// iterating over all RRs in the zone to recalculate the digest. SIMPLE
/// is a good choice for zones that are small and/or stable, but it is
/// probably not good for zones that are large and/or dynamic.
///
/// Calculation of a zone digest requires RRs to be processed in a
/// consistent format and ordering. This specification uses DNSSEC's
/// canonical on-the-wire RR format (without name compression) and
/// ordering as specified in Sections 6.1, 6.2, and 6.3 of [RFC4034] with
/// the additional provision that RRsets having the same owner name MUST
/// be numerically ordered, in ascending order, by their numeric RR TYPE.
///
/// 3.3.1.1. SIMPLE Scheme Inclusion/Exclusion Rules
///
/// When iterating over records in the zone, the following inclusion/
/// exclusion rules apply:
///
/// * All records in the zone, including glue records, MUST be included
/// unless excluded by a subsequent rule.
///
/// * Occluded data ([RFC5936], Section 3.5) MUST be included.
///
/// * If there are duplicate RRs with equal owner, class, type, and
/// RDATA, only one instance is included ([RFC4034], Section 6.3) and
/// the duplicates MUST be omitted.
///
/// * The placeholder apex ZONEMD RR(s) MUST NOT be included.
///
/// * If the zone is signed, DNSSEC RRs MUST be included, except:
///
/// * The RRSIG covering the apex ZONEMD RRset MUST NOT be included
/// because the RRSIG will be updated after all digests have been
/// calculated.
///
/// 3.3.1.2. SIMPLE Scheme Digest Calculation
///
/// A zone digest using the SIMPLE scheme is calculated by concatenating
/// all RRs in the zone, in the format and order described in
/// Section 3.3.1 subject to the inclusion/exclusion rules described in
/// Section 3.3.1.1, and then applying the chosen hash algorithm:
///
/// digest = hash( RR(1) | RR(2) | RR(3) | ... )
///
/// where "|" denotes concatenation.
/// ```
///
/// [RFC 8976]: https://www.rfc-editor.org/rfc/rfc8976.html
/// [RFC 4034]: https://www.rfc-editor.org/rfc/rfc4034.html
fn create_zonemd_digest_simple(
apex: &FamilyName<Name<Bytes>>,
records: &SortedRecords<StoredName, StoredRecordData>,
algorithm: ZonemdAlg,
) -> Result<digest::Digest, Error> {
// TODO: optimize by using multiple digest'ers at once, instead of
// looping over the whole zone per digest algorithm.
let mut buf: Vec<u8> = Vec::new();
let mut ctx = match algorithm {
ZonemdAlg::SHA384 => digest::Context::new(&digest::SHA384),
ZonemdAlg::SHA512 => digest::Context::new(&digest::SHA512),
_ => {
// This should be caught by the argument parsing, but in case...
return Err("unsupported zonemd hash algorithm".into());
}
};
for family in records.families() {
if !family.is_in_zone(apex) {
continue;
}
// From RFC 8976:
// ```text
// * All records in the zone, including glue records, MUST be included
// unless excluded by a subsequent rule.
// * Occluded data ([RFC5936], Section 3.5) MUST be included.
// * If there are duplicate RRs with equal owner, class, type, and
// RDATA, only one instance is included ([RFC4034], Section 6.3) and
// the duplicates MUST be omitted.
// * The placeholder apex ZONEMD RR(s) MUST NOT be included.
// * If the zone is signed, DNSSEC RRs MUST be included, except:
// * The RRSIG covering the apex ZONEMD RRset MUST NOT be included
// because the RRSIG will be updated after all digests have been
// calculated.
// ```
// The first three rules are currently implemented by the SortedRecords type.
for record in family.records() {
buf.clear();
if record.rtype() == Rtype::ZONEMD && record.owner() == apex.owner() {
// Skip placeholder ZONEMD at apex
continue;
} else if record.rtype() == Rtype::RRSIG && record.owner() == apex.owner() {
// Skip RRSIG for ZONEMD at apex
if let ZoneRecordData::Rrsig(rrsig) = record.data() {
if rrsig.type_covered() == Rtype::ZONEMD {
continue;
}
};
}
with_infallible(|| record.compose_canonical(&mut buf));
ctx.update(&buf);
}
}
Ok(ctx.finish())
}
fn replace_apex_zonemd_with_placeholder(
records: &mut SortedRecords<Name<Bytes>, ZoneRecordData<Bytes, Name<Bytes>>>,
apex: &FamilyName<Name<Bytes>>,
soa_serial: Serial,
ttl: Ttl,
) {
// Remove existing ZONEMD RRs at apex for any class (it's class independent).
let _ =
records.remove_all_by_name_class_rtype(apex.owner().clone(), None, Some(Rtype::ZONEMD));
// Insert placeholder ZONEMD at apex for
// correct NSEC(3) bitmap (will be replaced later).
let placeholder_zonemd = ZoneRecordData::Zonemd(Zonemd::new(
soa_serial,
ZonemdScheme::from_int(0),
ZonemdAlg::from_int(0),
Bytes::default(),
));
let _ = records.insert(Record::new(
apex.owner().clone(),
apex.class(),
ttl,
placeholder_zonemd,
));
}
fn create_zonemd_digest_and_records(
records: &SortedRecords<Name<Bytes>, ZoneRecordData<Bytes, Name<Bytes>>>,
apex: &FamilyName<Name<Bytes>>,
zonemd: &HashSet<ZonemdTuple>,
soa_serial: Serial,
ttl: Ttl,
) -> Result<Vec<Record<StoredName, StoredRecordData>>, Error> {
let mut zonemd_rrs = Vec::new();
for z in zonemd {
// For now, only the SIMPLE scheme for ZONEMD is defined
if z.0 != ZonemdScheme::SIMPLE {
return Err("unsupported zonemd scheme (only SIMPLE is supported)".into());
}
let digest = Self::create_zonemd_digest_simple(apex, records, z.1)?;
// Create actual ZONEMD RR
let tmp_zrr = ZoneRecordData::Zonemd(Zonemd::new(
soa_serial,
z.0,
z.1,
Bytes::copy_from_slice(digest.as_ref()),
));
zonemd_rrs.push(Record::new(
apex.owner().clone(),
apex.class(),
ttl,
tmp_zrr,
));
}
Ok(zonemd_rrs)
}
fn update_zonemd_rrsig<KeyStrat, Sort>(
&self,
signer: &Signer<Bytes, KeyPair, KeyStrat, Sort>,
records: &mut SortedRecords<Name<Bytes>, ZoneRecordData<Bytes, Name<Bytes>>, Sort>,
apex: &FamilyName<Name<Bytes>>,
keys: &[DnssecSigningKey<Bytes, KeyPair>],
zonemd_rrs: Vec<Record<StoredName, StoredRecordData>>,
) where
KeyStrat: SigningKeyUsageStrategy<Bytes, KeyPair>,
Sort: Sorter,
{
// Sign only ZONEMD RRs
let zonemd_rrs: SortedRecords<StoredName, StoredRecordData> =
SortedRecords::from(zonemd_rrs);
// No need to check for keys, as SortedRecords::sign just doesn't do anything without keys.
let mut zonemd_rrsig = signer
.sign(apex, zonemd_rrs.families(), keys, false)
.unwrap();
// Replace original ZONEMD RRSIG with newly generated one
if let Some(rrsig) = zonemd_rrsig.pop() {
if let ZoneRecordData::Rrsig(rrsig) = rrsig.data() {
records.replace_rrsig_for_apex_zonemd(rrsig.clone(), apex);
}
}
}
}
fn next_owner_hash_to_name(
next_owner_hash_hex: &str,
apex: &FamilyName<Name<Bytes>>,
) -> Result<Name<Bytes>, ()> {
let mut builder = NameBuilder::new_bytes();
builder
.append_chars(next_owner_hash_hex.chars())
.map_err(|_| ())?;
let next_owner_name = builder.append_origin(apex.owner()).map_err(|_| ())?;
Ok(next_owner_name)
}
//------------ SigningMode ---------------------------------------------------
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
enum SigningMode {
/// Both hash (NSEC/NSEC3) and sign zone records.
#[default]
HashAndSign,
/// Only hash (NSEC/NSEC3) zone records, don't sign them.
HashOnly,
// /// Only sign zone records, assume they are already hashed.
// SignOnly,
/// Neither hash or sign zone records (e.g. when just using ZONEMD).
None,
}
//------------ ZonemdTuple ---------------------------------------------------
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
struct ZonemdTuple(ZonemdScheme, ZonemdAlg);
//------------ FileOrStdout --------------------------------------------------
enum FileOrStdout<T: io::Write, U: fmt::Write> {
File(T),
Stdout(Stream<U>),
}
impl<T: io::Write, U: fmt::Write> fmt::Write for FileOrStdout<T, U> {
fn write_str(&mut self, s: &str) -> std::fmt::Result {
match self {
FileOrStdout::File(f) => f.write_all(s.as_bytes()).map_err(|_| fmt::Error),
FileOrStdout::Stdout(o) => {
o.write_str(s);
Ok(())
}
}
}
fn write_fmt(&mut self, args: fmt::Arguments<'_>) -> fmt::Result {
match self {
FileOrStdout::File(f) => f.write_fmt(args).map_err(|_| fmt::Error),
FileOrStdout::Stdout(o) => {
o.write_fmt(args);
Ok(())
}
}
}
}
//------------ Commented -----------------------------------------------------
/// Support for RTYPE specific zonefile comment generation.
///
/// Intended to be used to enable behaviour to be matched to that of the LDNS
/// `ldns_rr2buffer_str_fmt()` function.
trait Commented<T> {
fn comment<W: fmt::Write>(
&self,
writer: &mut W,
record: &Record<Name<Bytes>, ZoneRecordData<Bytes, Name<Bytes>>>,
metadata: T,
) -> Result<(), fmt::Error>;
}
impl Commented<()> for Dnskey<Bytes> {
fn comment<W: fmt::Write>(
&self,
writer: &mut W,
record: &Record<Name<Bytes>, ZoneRecordData<Bytes, Name<Bytes>>>,
_metadata: (),
) -> Result<(), fmt::Error> {
writer.write_fmt(format_args!(" ;{{id = {}", self.key_tag()))?;
if self.is_secure_entry_point() {
writer.write_str(" (ksk)")?;
} else if self.is_zone_key() {
writer.write_str(" (zsk)")?;
}
let owner = record.owner().clone();
let key = domain::validate::Key::from_dnskey(owner, self.clone()).unwrap();
let key_size = key.key_size();
writer.write_fmt(format_args!(", size = {key_size}b}}"))
}
}
#[derive(Copy, Clone)]
struct Nsec3CommentState<'a> {
hashes: Option<&'a HashMap<Name<Bytes>, Name<Bytes>, RandomState>>,
apex: &'a FamilyName<Name<Bytes>>,
}
impl<'b, O: AsRef<[u8]>> Commented<Nsec3CommentState<'b>> for Nsec3<O> {
fn comment<'a, W: fmt::Write>(
&self,
writer: &mut W,
record: &'a Record<Name<Bytes>, ZoneRecordData<Bytes, Name<Bytes>>>,
state: Nsec3CommentState<'b>,
) -> Result<(), fmt::Error> {
if let Some(hashes) = state.hashes {
// TODO: For ldns-signzone backward compatibilty we output
// " ;{... <domain>.}" but I find the spacing ugly and
// would prefer for dnst to output " ; {... <domain>. }"
// instead.
writer.write_str(" ;{ flags: ")?;
if self.opt_out() {
writer.write_str("optout")?;
} else {
writer.write_str("-")?;
}
let next_owner_hash_hex = format!("{}", self.next_owner());
let next_owner_name = next_owner_hash_to_name(&next_owner_hash_hex, state.apex);
let from = hashes
.get(record.owner())
.map(|n| format!("{}", n.fmt_with_dot()))
.unwrap_or_default();
let to = if let Ok(next_owner_name) = next_owner_name {
hashes
.get(&next_owner_name)
.map(|n| format!("{}", n.fmt_with_dot()))
.unwrap_or_else(|| format!("<unknown hash: {next_owner_hash_hex}>"))
} else {
format!("<invalid name: {next_owner_hash_hex}>")
};
writer.write_fmt(format_args!(", from: {from}, to: {to}}}"))?;
}
Ok(())
}
}
//------------ AnyFamiliesIter -----------------------------------------------
type FamilyIterByValue<'a> =
std::slice::Iter<'a, Family<'a, Name<Bytes>, ZoneRecordData<Bytes, Name<Bytes>>>>;
type FamilyIterByRef<'a> = RecordsIter<'a, Name<Bytes>, ZoneRecordData<Bytes, Name<Bytes>>>;
/// An iterator over a collection of [`Family`], whether by reference or not.
enum AnyFamiliesIter<'a> {
VecIter(FamilyIterByValue<'a>),
FamiliesIter(FamilyIterByRef<'a>),
}
impl<'a> Iterator for AnyFamiliesIter<'a>
where
Family<'a, Name<Bytes>, ZoneRecordData<Bytes, Name<Bytes>>>: Clone,
{
type Item = Family<'a, Name<Bytes>, ZoneRecordData<Bytes, Name<Bytes>>>;
fn next(&mut self) -> Option<Self::Item> {
match self {
AnyFamiliesIter::VecIter(it) => it.next().cloned(),
AnyFamiliesIter::FamiliesIter(it) => it.next(),
}
}
}
//--- From<std::slice::Iter<'a, Family<'a, N, D>>>
impl<'a> From<std::slice::Iter<'a, Family<'a, Name<Bytes>, ZoneRecordData<Bytes, Name<Bytes>>>>>
for AnyFamiliesIter<'a>
{
fn from(
iter: std::slice::Iter<'a, Family<'a, Name<Bytes>, ZoneRecordData<Bytes, Name<Bytes>>>>,
) -> Self {
Self::VecIter(iter)
}
}
//--- From<RecordsIter<'a, N, D>>
impl<'a> From<RecordsIter<'a, Name<Bytes>, ZoneRecordData<Bytes, Name<Bytes>>>>
for AnyFamiliesIter<'a>
{
fn from(iter: RecordsIter<'a, Name<Bytes>, ZoneRecordData<Bytes, Name<Bytes>>>) -> Self {
Self::FamiliesIter(iter)
}
}
struct FallbackStrat;
impl SigningKeyUsageStrategy<Bytes, KeyPair> for FallbackStrat {
const NAME: &'static str = "Fallback to ZSKs/KSKs if the other is empty";
fn select_signing_keys_for_rtype(
candidate_keys: &[DnssecSigningKey<Bytes, KeyPair>],
rtype: Option<Rtype>,
) -> HashSet<usize> {
match rtype {
// TODO: Do we need to treat CDS and CDNSKEY RRs like DNSKEY RRs?
Some(Rtype::DNSKEY) => {
// Use the default keys for signing DNSKEY RRs, i.e. keys
// intended to be used as KSKs.
let keys = DefaultSigningKeyUsageStrategy::select_signing_keys_for_rtype(
candidate_keys,
rtype,
);
// But if there are no such keys, fallback to using the keys
// used to sign other record types, i.e. keys intended to be
// used as ZSKs.
if keys.is_empty() {
Self::select_signing_keys_for_rtype(candidate_keys, None)
} else {
keys
}
}
_ => {
// Use the default keys for signing non-DNSKEY RRs, i.e. keys
// intended to be used as ZSKs.
let keys = DefaultSigningKeyUsageStrategy::select_signing_keys_for_rtype(
candidate_keys,
rtype,
);
// But if there are no such keys, fallback to using the keys
// used to sign DNSKEY RRs, i.e. keys intended to be used as
// KSKs.
if keys.is_empty() {
Self::select_signing_keys_for_rtype(candidate_keys, Some(Rtype::DNSKEY))
} else {
keys
}
}
}
}
}
struct AllKeyStrat;
impl SigningKeyUsageStrategy<Bytes, KeyPair> for AllKeyStrat {
const NAME: &'static str = "All keys (KSK and ZSK)";
fn select_signing_keys_for_rtype(
candidate_keys: &[DnssecSigningKey<Bytes, KeyPair>],
rtype: Option<Rtype>,
) -> HashSet<usize> {
match rtype {
Some(Rtype::DNSKEY) => {
let mut keys = DefaultSigningKeyUsageStrategy::select_signing_keys_for_rtype(
candidate_keys,
rtype,
);
keys.extend(
DefaultSigningKeyUsageStrategy::select_signing_keys_for_rtype(
candidate_keys,
None,
),
);
keys
}
_ => FallbackStrat::select_signing_keys_for_rtype(candidate_keys, rtype),
}
}
}
#[derive(Default)]
struct AllUniqStrat;
impl SigningKeyUsageStrategy<Bytes, KeyPair> for AllUniqStrat {
const NAME: &'static str = "Unique algorithms (all KSK + unique ZSK)";
fn select_signing_keys_for_rtype(
candidate_keys: &[DnssecSigningKey<Bytes, KeyPair>],
rtype: Option<Rtype>,
) -> HashSet<usize> {
match rtype {
Some(Rtype::DNSKEY) => {
let mut seen_algs = HashSet::new();
candidate_keys
.iter()
.enumerate()
.filter_map(|(i, k)| {
let new_alg = seen_algs.insert(k.key().algorithm());
match k.purpose() {
IntendedKeyPurpose::KSK | IntendedKeyPurpose::CSK => true,
IntendedKeyPurpose::ZSK => new_alg,
_ => false,
}
.then_some(i)
})
.collect::<HashSet<_>>()
}
_ => FallbackStrat::select_signing_keys_for_rtype(candidate_keys, rtype),
}
}
}
//------------ Tests ---------------------------------------------------------
// TODO: Maybe resolve the Timestamp issue differently? When running the tests
// and the base struct get's constructed at say time "12:30:29" and the command
// parsing for an assertion get's executed at "12:30:30", then the timestamps
// don't match and the tests fails. This creates a flaky test without actual
// errors in the code. Right now it is solved by recreating the expiration and
// inception fields during the assertion. However, this means we need to
// remember adding that for every assertion.
#[cfg(test)]
mod test {
use std::fs::File;
use std::io::Write;
use std::ops::Add;
use std::path::PathBuf;
use std::str::FromStr;
use domain::base::iana::{Nsec3HashAlg, ZonemdAlg, ZonemdScheme};
use domain::base::Name;
use domain::rdata::dnssec::Timestamp;
use domain::rdata::nsec3::Nsec3Salt;
use tempfile::TempDir;
use crate::commands::signzone::{ZonemdTuple, FOUR_WEEKS};
use crate::commands::Command;
use crate::env::fake::FakeCmd;
use super::SignZone;
#[track_caller]
fn parse(args: FakeCmd) -> SignZone {
let res = args.parse().unwrap();
let Command::SignZone(x) = res.command else {
panic!("Not a SignZone!");
};
x
}
#[test]
fn dnst_parse_failures() {
let cmd = FakeCmd::new(["dnst", "signzone"]);
cmd.parse().unwrap_err();
// Missing keys
cmd.args(["example.org.zone"]).parse().unwrap_err();
// Missing ZONEMD arguments
cmd.args(["-Z", "example.org.zone"]).parse().unwrap_err();
// Invalid ZONEMD arguments
cmd.args(["-z", "3", "example.org.zone", "anykey"])
.parse()
.unwrap_err();
cmd.args(["-z", "0:0", "example.org.zone", "anykey"])
.parse()
.unwrap_err();
// Invalid NSEC3 arguments
cmd.args(["-na", "MD5", "example.org.zone", "anykey"])
.parse()
.unwrap_err();
cmd.args(["-ns", "NOBASE64", "example.org.zone", "anykey"])
.parse()
.unwrap_err();
// Conflicting NSEC3 optout options
cmd.args(["-nPp", "example.org.zone", "anykey"])
.parse()
.unwrap_err();
}
#[test]
fn dnst_parse_successes() {
let cmd = FakeCmd::new(["dnst", "signzone"]);
let base = SignZone {
extra_comments: false,
do_not_add_keys_to_zone: false,
expiration: Timestamp::now().into_int().add(FOUR_WEEKS).into(),
out_file: None,
inception: Timestamp::now(),
origin: None,
set_soa_serial_to_epoch_time: false,
zonemd: Vec::new(),
allow_zonemd_without_signing: false,
sign_dnskeys_with_all_keys: false,
use_nsec3: false,
sign_with_every_unique_algorithm: false,
algorithm: Nsec3HashAlg::SHA1,
iterations: 0,
salt: Nsec3Salt::empty(),
nsec3_opt_out_flags_only: false,
nsec3_opt_out: false,
hash_only: false,
no_require_keys_match_apex: false,
zonefile_path: PathBuf::from("example.org.zone"),
key_paths: Vec::from([PathBuf::from("anykey")]),
invoked_as_ldns: false,
};
// Check the defaults
assert_eq!(parse(cmd.args(["example.org.zone", "anykey"])), base);
// The switches (TODO: missing -A and -U)
assert_eq!(
parse(cmd.args(["-bdunpM", "example.org.zone", "anykey"])),
SignZone {
extra_comments: true,
do_not_add_keys_to_zone: true,
set_soa_serial_to_epoch_time: true,
use_nsec3: true,
nsec3_opt_out_flags_only: true,
no_require_keys_match_apex: true,
expiration: Timestamp::now().into_int().add(FOUR_WEEKS).into(),
inception: Timestamp::now(),
..base.clone()
}
);
assert_eq!(
parse(cmd.args(["-H", "example.org.zone"])),
SignZone {
hash_only: true,
key_paths: Vec::new(),
expiration: Timestamp::now().into_int().add(FOUR_WEEKS).into(),
inception: Timestamp::now(),
..base.clone()
}
);
// ZONEMD arguments
assert_eq!(
parse(cmd.args(["-z", "SIMPLE:SHA512", "example.org.zone", "anykey"])),
SignZone {
zonemd: Vec::from([ZonemdTuple(ZonemdScheme::SIMPLE, ZonemdAlg::SHA512)]),
expiration: Timestamp::now().into_int().add(FOUR_WEEKS).into(),
inception: Timestamp::now(),
..base.clone()
}
);
assert_eq!(
parse(cmd.args(["-z", "simple:sha512", "example.org.zone", "anykey"])),
SignZone {
zonemd: Vec::from([ZonemdTuple(ZonemdScheme::SIMPLE, ZonemdAlg::SHA512)]),
expiration: Timestamp::now().into_int().add(FOUR_WEEKS).into(),
inception: Timestamp::now(),
..base.clone()
}
);
assert_eq!(
parse(cmd.args(["-z", "sha512", "example.org.zone", "anykey"])),
SignZone {
zonemd: Vec::from([ZonemdTuple(ZonemdScheme::SIMPLE, ZonemdAlg::SHA512)]),
expiration: Timestamp::now().into_int().add(FOUR_WEEKS).into(),
inception: Timestamp::now(),
..base.clone()
}
);
// NSEC3 arguments
assert_eq!(
parse(cmd.args([
"-n",
"-s",
"BABABA",
"-t",
"15",
"example.org.zone",
"anykey"
])),
SignZone {
use_nsec3: true,
salt: Nsec3Salt::from_str("BABABA").unwrap(),
iterations: 15,
expiration: Timestamp::now().into_int().add(FOUR_WEEKS).into(),
inception: Timestamp::now(),
..base.clone()
}
);
// Timestamps
assert_eq!(
parse(cmd.args([
"-i",
"20240101020202",
"-e",
"20240101050505",
"example.org.zone",
"anykey"
])),
SignZone {
expiration: Timestamp::from_str("20240101050505").unwrap(),
inception: Timestamp::from_str("20240101020202").unwrap(),
..base.clone()
}
);
// Output file
assert_eq!(
parse(cmd.args(["-f-", "example.org.zone", "anykey"])),
SignZone {
out_file: Some(PathBuf::from("-")),
expiration: Timestamp::now().into_int().add(FOUR_WEEKS).into(),
inception: Timestamp::now(),
..base.clone()
}
);
assert_eq!(
parse(cmd.args(["-f", "output", "example.org.zone", "anykey"])),
SignZone {
out_file: Some(PathBuf::from("output")),
expiration: Timestamp::now().into_int().add(FOUR_WEEKS).into(),
inception: Timestamp::now(),
..base.clone()
}
);
// Origin
assert_eq!(
parse(cmd.args(["-o", "origin.test", "example.org.zone", "anykey"])),
SignZone {
origin: Some(Name::from_str("origin.test.").unwrap()),
expiration: Timestamp::now().into_int().add(FOUR_WEEKS).into(),
inception: Timestamp::now(),
..base.clone()
}
);
}
#[test]
fn ldns_parse_failures() {
let cmd = FakeCmd::new(["ldns-signzone"]);
cmd.parse().unwrap_err();
// Missing keys
cmd.args(["example.org.zone"]).parse().unwrap_err();
// Invalid ZONEMD arguments
cmd.args(["-z", "3", "example.org.zone", "anykey"])
.parse()
.unwrap_err();
cmd.args(["-z", "0:0", "example.org.zone", "anykey"])
.parse()
.unwrap_err();
// Invalid NSEC3 arguments
cmd.args(["-na", "MD5", "example.org.zone", "anykey"])
.parse()
.unwrap_err();
cmd.args(["-ns", "NOBASE64", "example.org.zone", "anykey"])
.parse()
.unwrap_err();
}
#[test]
fn ldns_parse_successes() {
let cmd = FakeCmd::new(["ldns-signzone"]);
let base = SignZone {
extra_comments: false,
do_not_add_keys_to_zone: false,
expiration: Timestamp::now().into_int().add(FOUR_WEEKS).into(),
out_file: None,
inception: Timestamp::now(),
origin: None,
set_soa_serial_to_epoch_time: false,
zonemd: Vec::new(),
allow_zonemd_without_signing: false,
sign_dnskeys_with_all_keys: false,
sign_with_every_unique_algorithm: false,
use_nsec3: false,
algorithm: Nsec3HashAlg::SHA1,
iterations: 1,
salt: Nsec3Salt::empty(),
nsec3_opt_out_flags_only: false,
nsec3_opt_out: false,
hash_only: false,
no_require_keys_match_apex: false,
zonefile_path: PathBuf::from("example.org.zone"),
key_paths: Vec::from([PathBuf::from("anykey")]),
invoked_as_ldns: true,
};
// Check the defaults
assert_eq!(parse(cmd.args(["example.org.zone", "anykey"])), base);
// The switches (TODO: missing -A and -U)
assert_eq!(
parse(cmd.args(["-bdunp", "example.org.zone", "anykey"])),
SignZone {
extra_comments: true,
do_not_add_keys_to_zone: true,
set_soa_serial_to_epoch_time: true,
use_nsec3: true,
nsec3_opt_out_flags_only: true,
expiration: Timestamp::now().into_int().add(FOUR_WEEKS).into(),
inception: Timestamp::now(),
..base.clone()
}
);
// ZONEMD arguments
assert_eq!(
parse(cmd.args(["-Z", "example.org.zone", "anykey"])),
SignZone {
allow_zonemd_without_signing: true,
expiration: Timestamp::now().into_int().add(FOUR_WEEKS).into(),
inception: Timestamp::now(),
..base.clone()
}
);
assert_eq!(
parse(cmd.args(["-z", "SIMPLE:SHA512", "example.org.zone", "anykey"])),
SignZone {
zonemd: Vec::from([ZonemdTuple(ZonemdScheme::SIMPLE, ZonemdAlg::SHA512)]),
expiration: Timestamp::now().into_int().add(FOUR_WEEKS).into(),
inception: Timestamp::now(),
..base.clone()
}
);
assert_eq!(
parse(cmd.args(["-z", "simple:sha512", "example.org.zone", "anykey"])),
SignZone {
zonemd: Vec::from([ZonemdTuple(ZonemdScheme::SIMPLE, ZonemdAlg::SHA512)]),
expiration: Timestamp::now().into_int().add(FOUR_WEEKS).into(),
inception: Timestamp::now(),
..base.clone()
}
);
assert_eq!(
parse(cmd.args(["-z", "sha512", "example.org.zone", "anykey"])),
SignZone {
zonemd: Vec::from([ZonemdTuple(ZonemdScheme::SIMPLE, ZonemdAlg::SHA512)]),
expiration: Timestamp::now().into_int().add(FOUR_WEEKS).into(),
inception: Timestamp::now(),
..base.clone()
}
);
assert_eq!(
parse(cmd.args(["-z", "1", "example.org.zone", "anykey"])),
SignZone {
zonemd: Vec::from([ZonemdTuple(ZonemdScheme::SIMPLE, ZonemdAlg::SHA384)]),
expiration: Timestamp::now().into_int().add(FOUR_WEEKS).into(),
inception: Timestamp::now(),
..base.clone()
}
);
// NSEC3 arguments
assert_eq!(
parse(cmd.args([
"-n",
"-s",
"BABABA",
"-t",
"15",
"example.org.zone",
"anykey"
])),
SignZone {
use_nsec3: true,
salt: Nsec3Salt::from_str("BABABA").unwrap(),
iterations: 15,
expiration: Timestamp::now().into_int().add(FOUR_WEEKS).into(),
inception: Timestamp::now(),
..base.clone()
}
);
// Timestamps
assert_eq!(
parse(cmd.args([
"example.org.zone",
"-i",
"20240101020202",
"-e",
"20240101050505",
"anykey"
])),
SignZone {
expiration: Timestamp::from_str("20240101050505").unwrap(),
inception: Timestamp::from_str("20240101020202").unwrap(),
..base.clone()
}
);
// Output file
assert_eq!(
parse(cmd.args(["-f-", "example.org.zone", "anykey"])),
SignZone {
out_file: Some(PathBuf::from("-")),
expiration: Timestamp::now().into_int().add(FOUR_WEEKS).into(),
inception: Timestamp::now(),
..base.clone()
}
);
assert_eq!(
parse(cmd.args(["-f", "output", "example.org.zone", "anykey"])),
SignZone {
out_file: Some(PathBuf::from("output")),
expiration: Timestamp::now().into_int().add(FOUR_WEEKS).into(),
inception: Timestamp::now(),
..base.clone()
}
);
// Origin
assert_eq!(
parse(cmd.args(["-o", "origin.test", "example.org.zone", "anykey"])),
SignZone {
origin: Some(Name::from_str("origin.test.").unwrap()),
expiration: Timestamp::now().into_int().add(FOUR_WEEKS).into(),
inception: Timestamp::now(),
..base.clone()
}
);
// Version
assert!(matches!(
cmd.args(["-v"]).parse().unwrap().command,
Command::Report(_)
));
}
fn create_file_with_content(dir: &TempDir, filename: &str, content: &[u8]) {
let mut file = File::create(dir.path().join(filename)).unwrap();
file.write_all(content).unwrap();
}
fn run_setup() -> TempDir {
let dir = tempfile::TempDir::new().unwrap();
create_file_with_content(&dir, "ksk1.key", b"example.org. IN DNSKEY 257 3 15 6VdB0mk5qwjHWNC5TTOw1uHTzA0m3Xadg7aYVbcRn8Y= ;{id = 38873 (ksk), size = 256b}\n");
create_file_with_content(&dir, "ksk1.ds", b"example.org. IN DS 38873 15 2 e195b1a7d31c878993ad0095d723592a1e5ea55c90b229fc35e4c549ef406f6c\n");
create_file_with_content(&dir, "ksk1.private", b"Private-key-format: v1.2\nAlgorithm: 15 (ED25519)\nPrivateKey: /e7bFDFF88sdC949PC2YoHX9KJ5eEak3bk/Tub2vIng=\n");
create_file_with_content(&dir, "zsk1.key", b"example.org. IN DNSKEY 256 3 15 fPzhX3Tq/w3ncwsWYIRsK8rHLNtkVv1O3kXYAMdBQUk= ;{id = 44471 (zsk), size = 256b}");
create_file_with_content(&dir, "zsk1.private", b"Private-key-format: v1.2\nAlgorithm: 15 (ED25519)\nPrivateKey: mc2xW8JiES5Ub6UPP2xoHT0KyD6Lvi6fnjugjnRzBJU=");
create_file_with_content(&dir, "zonemd1_example.org.zone", b"\
example.org. 240 IN SOA example.net. hostmaster.example.net. 1234567890 28800 7200 604800 240\n\
example.org. 240 IN NS example.net.\n\
; Will be replaced when using ZONEMD option\n\
example.org. 240 IN ZONEMD 1234567890 1 1 ABABABABABABABABABABABABABABABABABABABABABABABAB ABABABABABABABABABABABABABABABABABABABABABABABAB\n\
example.org. 240 IN ZONEMD 1234567890 1 2 ABABABABABABABABABABABABABABABABABABABABABABABAB ABABABABABABABABABABABABABABABABABABABABABABABAB ABABABABABABABABABABABABABABABAB\n\
example.org. 240 IN A 128.140.76.106\n\
*.example.org. 240 IN A 1.2.3.4\n\
deleg.example.org. 240 IN NS example.com.\n\
occluded.deleg.example.org. 240 IN A 1.2.3.4\n\
");
create_file_with_content(&dir, "nsec3_optout1_example.org.zone", b"\
example.org. 240 IN SOA example.net. hostmaster.example.net. 1234567890 28800 7200 604800 240\n\
example.org. 240 IN NS example.net.\n\
example.org. 240 IN A 128.140.76.106\n\
insecure-deleg.example.org. 240 IN NS example.com.\n\
occluded.insecure-deleg.example.org. 240 IN A 1.2.3.4\n\
secure-deleg.example.org. 240 IN NS example.com.\n\
secure-deleg.example.org. 240 IN DS 3120 15 2 0675d8c4a90ecd25492e4c4c6583afcef7c3b910b7a39162803058e6e7393a19\n\
");
dir
}
/// Filter a string slice for lines containing at least one of the provided patterns.
#[allow(dead_code)]
fn filter_lines_containing_any(src: &str, patterns: &[&str]) -> String {
if patterns.is_empty() {
// For consistency with str::contains() and filter_lines_containing_all()
String::from(src)
} else {
src.split_inclusive('\n')
.filter(|s| {
for p in patterns {
if s.contains(p) {
return true;
}
}
false
})
.collect()
}
}
/// Filter a string slice for lines containing all provided patterns.
fn filter_lines_containing_all(src: &str, patterns: &[&str]) -> String {
src.split_inclusive('\n')
.filter(|s| {
for p in patterns {
if !s.contains(p) {
return false;
}
}
true
})
.collect()
}
#[test]
fn zonemd_digest_and_replacing_existing_at_apex() {
let dir = run_setup();
let res1 = FakeCmd::new([
"dnst",
"signzone",
"-Z",
"-z",
"SIMPLE:SHA384",
"-f",
"-",
"zonemd1_example.org.zone",
])
.cwd(&dir)
.run();
assert_eq!(res1.exit_code, 0);
assert_eq!(
res1.stdout,
"example.org.\t240\tIN\tSOA\texample.net.\thostmaster.example.net.\t1234567890\t28800\t7200\t604800\t240\n\
example.org.\t240\tIN\tA\t128.140.76.106\n\
example.org.\t240\tIN\tNS\texample.net.\n\
example.org.\t240\tIN\tZONEMD\t1234567890\t1\t1\tD2D125EE8B4DDAD944FD7EE437908A5D4D5A7DB7C2F948C5A051146FC75D124666033DF7D1BA1653CF490E89F9A454F3\n\
*.example.org.\t240\tIN\tA\t1.2.3.4\n\
deleg.example.org.\t240\tIN\tNS\texample.com.\n\
occluded.deleg.example.org.\t240\tIN\tA\t1.2.3.4\n"
);
assert_eq!(res1.stderr, "");
let res2 = FakeCmd::new([
"dnst",
"signzone",
"-Z",
"-z",
"SIMPLE:SHA384",
"-f",
"-",
"zonemd1_example.org.zone",
])
.cwd(&dir)
.run();
assert_eq!(res2.exit_code, 0);
assert_eq!(res2.stdout, res1.stdout);
assert_eq!(res2.stderr, "");
}
#[test]
fn zonemd_and_sign() {
let dir = run_setup();
let res = FakeCmd::new([
"dnst",
"signzone",
"-z",
"1:1",
"-f",
"-",
"-e",
"20241127162422",
"-i",
"20241127162422",
"zonemd1_example.org.zone",
"ksk1",
])
.cwd(&dir)
.run();
assert_eq!(res.exit_code, 0);
assert_eq!(
res.stdout,
"\
example.org.\t240\tIN\tSOA\texample.net.\thostmaster.example.net.\t1234567890\t28800\t7200\t604800\t240\n\
example.org.\t240\tIN\tRRSIG\tSOA\t15\t2\t240\t1732724662\t1732724662\t38873\texample.org.\t2Jp7z/VMHlUvZoXApvsolX78ZzH9BmI8jznVHjagpmjOto/tAb1bL7AaTcOG2Ihk+uSSvDmIExaax0dbtL8CAg==\n\
example.org.\t240\tIN\tA\t128.140.76.106\n\
example.org.\t240\tIN\tRRSIG\tA\t15\t2\t240\t1732724662\t1732724662\t38873\texample.org.\tdVrR1Ay58L3cDaRIial45keWp/X8roeirciEqJqVZcqWO4AkSaILqDYIpfNRf3i9WvDzio0BLZT5K4r2krmyCA==\n\
example.org.\t240\tIN\tNS\texample.net.\n\
example.org.\t240\tIN\tRRSIG\tNS\t15\t2\t240\t1732724662\t1732724662\t38873\texample.org.\tJJDRuXMuv9yiJAFN+15/7DBbaBHepA20QxLruqrjSJZsgzRcPb1UTyGozlsq9BdCq3oxZm8lea5DcIi2tyGVDQ==\n\
example.org.\t240\tIN\tNSEC\t*.example.org.\tA\tNS\tSOA\tRRSIG\tNSEC\tDNSKEY\tZONEMD\n\
example.org.\t240\tIN\tRRSIG\tNSEC\t15\t2\t240\t1732724662\t1732724662\t38873\texample.org.\tbL1aldkxI/a0P9Oo3FUJfGspDchBs8B476AnKS4O5g43KZ5Oy+Xvb5UimyzFQ2f5gXL47cdt8EMmuy2iRhUpBg==\n\
example.org.\t240\tIN\tDNSKEY\t257\t3\t15\t6VdB0mk5qwjHWNC5TTOw1uHTzA0m3Xadg7aYVbcRn8Y= ;{id = 38873 (ksk), size = 256b}\n\
example.org.\t240\tIN\tRRSIG\tDNSKEY\t15\t2\t240\t1732724662\t1732724662\t38873\texample.org.\tUPk13WDbN2MLjSwgV82084DrNUdJFmS9bthBw52X0rfiBMAvrQJJhSYbq72G5j11SFp2DnUyml8stScKJyMlCQ==\n\
example.org.\t240\tIN\tZONEMD\t1234567890\t1\t1\t97FCF584F87A42EA94F7C0DE25F3BA581A48D5FC4C5F1DD0FB275B9634EFE68A268606B6AB92A5D95062AB563B58196A\n\
example.org.\t240\tIN\tRRSIG\tZONEMD\t15\t2\t240\t1732724662\t1732724662\t38873\texample.org.\tf2VO/ROXqwgZdQNmTcu3Cc6zYbsFNRwiJsdYcfX1e+mdgIBt8PFsa5OOUy7VJHZnFD4/5Gq6n/6/FkWF/5iNDg==\n\
*.example.org.\t240\tIN\tA\t1.2.3.4\n\
*.example.org.\t240\tIN\tRRSIG\tA\t15\t2\t240\t1732724662\t1732724662\t38873\texample.org.\t1eLPyREltQqUClcAuT4SkqdWXL8D4C3K0mnotLv8d1x6kh/ARcac9l99ulLwtxvmJb+61+zv4vFgX35Yqbm1BA==\n\
*.example.org.\t240\tIN\tNSEC\tdeleg.example.org.\tA\tRRSIG\tNSEC\n\
*.example.org.\t240\tIN\tRRSIG\tNSEC\t15\t2\t240\t1732724662\t1732724662\t38873\texample.org.\tFgRwrOd36au9ijKnx3AxsyN5Ar4mwt4AALTye3/IqravMHa2pTTP8h0Z2GXgu3YPmP3RXpPTwza5960KwE8YCQ==\n\
deleg.example.org.\t240\tIN\tNS\texample.com.\n\
deleg.example.org.\t240\tIN\tNSEC\texample.org.\tNS\tRRSIG\tNSEC\n\
deleg.example.org.\t240\tIN\tRRSIG\tNSEC\t15\t3\t240\t1732724662\t1732724662\t38873\texample.org.\tm/j7UOa1SvFw0rz5pBXVWS62gX328rxveNeD+Gd7husNcvbYhW2rLLYfTCG6LNvUP4fG2rJ45OhY3g3Trx2iBQ==\n\
occluded.deleg.example.org.\t240\tIN\tA\t1.2.3.4\n\
"
);
assert_eq!(res.stderr, "");
}
#[test]
/// Test NSEC3 optout behaviour with signing
fn ldns_nsec3_optout() {
// TODO: maybe make these strings a regex match of some kind for better flexibility with
// layout changes that don't affect the zonefile semantics?
let dir = run_setup();
// (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.\t240\tIN\tNSEC3PARAM\t1\t0\t1\t-\n\
example.org.\t240\tIN\tRRSIG\tNSEC3PARAM\t15\t2\t240\t1732724662\t1732724662\t38873\texample.org.\tdOrhLIWhrQm2OunlTWrSsELkx1kKYo4jTkF5pEwrvZxjhUI9DBKdkloaVsTKcdrmffidC5pE9GoY9ckaoHpGCA==\n\
93u63bg57ppj6649al2n31l92iedkjd6.example.org.\t240\tIN\tNSEC3\t1\t1\t1\t-\tK71KU6AICR5JPDJOE9J7CDNLK6D5C3UE\tA\tNS\tSOA\tRRSIG\tDNSKEY\tNSEC3PARAM\n\
93u63bg57ppj6649al2n31l92iedkjd6.example.org.\t240\tIN\tRRSIG\tNSEC3\t15\t3\t240\t1732724662\t1732724662\t38873\texample.org.\tz4ceUmbSZiSnluFj8CDJ7B9fukCR2flTWgca4GE2xrw48+fiieH/04xCKhJmDRJUJTVkKtIYpB4p0Q4m60M1Cg==\n\
k71ku6aicr5jpdjoe9j7cdnlk6d5c3ue.example.org.\t240\tIN\tNSEC3\t1\t1\t1\t-\tOJICMHRI4VP8PO7H2KVEJ99SKLQNJ5P2\tNS\n\
k71ku6aicr5jpdjoe9j7cdnlk6d5c3ue.example.org.\t240\tIN\tRRSIG\tNSEC3\t15\t3\t240\t1732724662\t1732724662\t38873\texample.org.\tHUrf7tOm3simXqpZj1oZeKX/P3eWoTTKc3fsyqfuLD6sGssXrBfpv1/LINBR9eEBjJ9rFbQXILgweS6huBL/Ag==\n\
ojicmhri4vp8po7h2kvej99sklqnj5p2.example.org.\t240\tIN\tNSEC3\t1\t1\t1\t-\t93U63BG57PPJ6649AL2N31L92IEDKJD6\tNS\tDS\tRRSIG\n\
ojicmhri4vp8po7h2kvej99sklqnj5p2.example.org.\t240\tIN\tRRSIG\tNSEC3\t15\t3\t240\t1732724662\t1732724662\t38873\texample.org.\tNG/8jk3UHht1ZYNEjUZ4swaEHea1amF4l3jZ893oARi95oxtPVLKoinVbBbfVuoanicOgeZxUPpKWHMBR12XDA==\n\
";
let res = FakeCmd::new([
"ldns-signzone",
"-np",
"-f-",
"-e",
"20241127162422",
"-i",
"20241127162422",
"nsec3_optout1_example.org.zone",
"ksk1",
])
.cwd(&dir)
.run();
assert_eq!(res.exit_code, 0);
assert_eq!(
filter_lines_containing_all(&res.stdout, &["NSEC3"]),
ldns_dnst_output_stripped
);
assert_eq!(res.stderr, "");
}
}