diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f8dbc5..159447b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,13 @@ jobs: matrix: os: [ubuntu-latest, windows-latest, macOS-latest] rust: [1.78.0, stable, beta, nightly] + env: + RUSTFLAGS: "-D warnings" + # We use 'vcpkg' to install OpenSSL on Windows. + VCPKG_ROOT: "${{ github.workspace }}\\vcpkg" + VCPKGRS_TRIPLET: x64-windows-release + # Ensure that OpenSSL is dynamically linked. + VCPKGRS_DYNAMIC: 1 steps: - name: Checkout repository uses: actions/checkout@v1 @@ -17,6 +24,16 @@ jobs: uses: hecrj/setup-rust-action@v2 with: rust-version: ${{ matrix.rust }} + - if: matrix.os == 'ubuntu-latest' + run: sudo apt-get install -y libssl-dev + - if: matrix.os == 'windows-latest' + id: vcpkg + uses: johnwason/vcpkg-action@v6 + with: + pkgs: openssl + triplet: ${{ env.VCPKGRS_TRIPLET }} + token: ${{ github.token }} + github-binarycache: true - if: matrix.rust == 'stable' run: rustup component add clippy - if: matrix.rust == 'stable' @@ -36,6 +53,8 @@ jobs: uses: hecrj/setup-rust-action@v2 with: rust-version: "1.78.0" + - name: Install OpenSSL + run: sudo apt-get install -y libssl-dev - name: Install nightly Rust run: rustup install nightly - name: Check with minimal-versions diff --git a/Cargo.lock b/Cargo.lock index f3d1866..22639c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,15 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + [[package]] name = "allocator-api2" version = "0.2.20" @@ -249,6 +258,7 @@ dependencies = [ "clap", "domain", "lexopt", + "regex", "tempfile", "test_bin", ] @@ -256,15 +266,17 @@ dependencies = [ [[package]] name = "domain" version = "0.10.3" -source = "git+https://github.com/NLnetLabs/domain.git?branch=initial-nsec3-generation#e1c1db8e4103eed5f69d77d7b88581298cc00818" +source = "git+https://github.com/NLnetLabs/domain.git?branch=initial-nsec3-generation#250b52eeb9f6b0801b5c04d14e6674f96e774246" dependencies = [ "bytes", "futures-util", "hashbrown", "moka", "octseq", + "openssl", "rand", "ring", + "secrecy", "serde", "time", "tokio", @@ -308,6 +320,21 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "486f806e73c5707928240ddc295403b1b93c96a02038563881c4a2fd84b81ac4" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "futures-core" version = "0.3.31" @@ -514,6 +541,44 @@ version = "1.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" +[[package]] +name = "openssl" +version = "0.10.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6174bc48f102d208783c2c84bf931bb75927a617866870de8a4ea85597f871f5" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-sys" +version = "0.9.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45abf306cbf99debc8195b66b7346498d7b10c210de50418b5ccd7ceba08c741" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "parking" version = "2.2.1" @@ -555,6 +620,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkg-config" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" + [[package]] name = "powerfmt" version = "0.2.0" @@ -572,9 +643,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.89" +version = "1.0.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f139b0662de085916d1fb67d2b4169d1addddda1919e696f3252b740b629986e" +checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" dependencies = [ "unicode-ident", ] @@ -651,6 +722,35 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + [[package]] name = "ring" version = "0.17.8" @@ -700,6 +800,15 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + [[package]] name = "semver" version = "1.0.23" @@ -771,9 +880,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.87" +version = "2.0.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25aa4ce346d03a6dcd68dd8b4010bcb74e54e62c90c573f394c46eae99aba32d" +checksum = "44d46482f1c1c87acd84dea20c1bf5ebff4c757009ed6bf19cfd36fb10e92c4e" dependencies = [ "proc-macro2", "quote", @@ -910,9 +1019,9 @@ checksum = "859eb650cfee7434994602c3a68b25d77ad9e68c8a6cd491616ef86661382eb3" [[package]] name = "unicode-ident" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" +checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" [[package]] name = "untrusted" @@ -935,6 +1044,12 @@ dependencies = [ "getrandom", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" @@ -1130,3 +1245,9 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" diff --git a/Cargo.toml b/Cargo.toml index f5838fc..12d5f39 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,11 +8,25 @@ default-run = "dnst" name = "ldns" path = "src/bin/ldns.rs" +[features] +default = ["openssl", "ring"] + +# Cryptographic backends +openssl = ["domain/openssl"] +ring = ["domain/ring"] + [dependencies] clap = { version = "4.3.4", features = ["derive"] } -domain = { version = "0.10.3", git = "https://github.com/NLnetLabs/domain.git", branch = "initial-nsec3-generation", features = ["unstable-validator", "zonefile"] } +domain = { git = "https://github.com/NLnetLabs/domain.git", branch = "initial-nsec3-generation", features = [ + "bytes", + "zonefile", + "unstable-sign", + "unstable-validate", + "unstable-validator", +] } lexopt = "0.3.0" [dev-dependencies] test_bin = "0.4.0" tempfile = "3.14.0" +regex = "1.11.1" diff --git a/src/commands/keygen.rs b/src/commands/keygen.rs new file mode 100644 index 0000000..9a011ad --- /dev/null +++ b/src/commands/keygen.rs @@ -0,0 +1,609 @@ +use std::ffi::OsString; +use std::io::Write; +use std::path::Path; + +use clap::{builder::ValueParser, Args, ValueEnum}; +use domain::base::iana::{DigestAlg, SecAlg}; +use domain::base::name::Name; +use domain::base::zonefile_fmt::ZonefileFmt; +use domain::sign::{common, GenerateParams}; +use domain::validate::Key; +use lexopt::Arg; + +use crate::env::Env; +use crate::error::{Context, Error}; +use crate::parse::parse_name; + +use super::{parse_os, parse_os_with, Command, LdnsCommand}; + +#[derive(Clone, Debug, PartialEq, Eq, Args)] +pub struct Keygen { + /// The signature algorithm to generate for + /// + /// Possible values: + /// - RSASHA256[:]: An RSA SHA-256 key (algorithm 8) of the given size (default 2048) + /// - ECDSAP256SHA256: An ECDSA P-256 SHA-256 key (algorithm 13) + /// - ECDSAP384SHA384: An ECDSA P-384 SHA-384 key (algorithm 14) + /// - ED25519: An Ed25519 key (algorithm 15) + /// - ED448: An Ed448 key (algorithm 16) + #[arg( + short = 'a', + long = "algorithm", + value_name = "ALGORITHM", + value_parser = ValueParser::new(Keygen::parse_algorithm), + verbatim_doc_comment, + )] + algorithm: GenerateParams, + + /// Generate a key signing key instead of a zone signing key + #[arg(short = 'k')] + make_ksk: bool, + + /// Whether to create symlinks. + #[arg(short, long, value_enum, num_args = 0..=1, require_equals = true, default_missing_value = "yes", default_value = "no")] + symlink: SymlinkArg, + + /// The domain name to generate a key for + #[arg(value_name = "domain name", value_parser = ValueParser::new(parse_name))] + name: Name>, +} + +/// Symlinking behaviour. +#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)] +pub enum SymlinkArg { + /// Don't create symlinks. + No, + + /// Create symlinks, but don't overwrite existing ones. + Yes, + + /// Create symlinks, overwriting existing ones. + Force, +} + +impl SymlinkArg { + /// Whether symlinks should be created. + pub fn create(&self) -> bool { + matches!(self, Self::Yes | Self::Force) + } + + /// Whether symlinks should be forced. + pub fn force(&self) -> bool { + matches!(self, Self::Force) + } +} + +const LDNS_HELP: &str = "\ +ldns-keygen -a [-b bits] [-r /dev/random] [-s] [-f] [-v] domain + generate a new key pair for domain + -a use the specified algorithm (-a list to show a list) + -k set the flags to 257; key signing key + -b specify the keylength (only used for RSA keys) + -r randomness device (unused) + -s create additional symlinks with constant names + -f force override of existing symlinks + -v show the version and exit + The following files will be created: + K++.key Public key in RR format + K++.private Private key in key format + K++.ds DS in RR format (only for DNSSEC KSK keys) + The base name (K++) will be printed to stdout +"; + +impl LdnsCommand for Keygen { + const HELP: &'static str = LDNS_HELP; + + fn parse_ldns>(args: I) -> Result { + let mut algorithm = None; + let mut make_ksk = false; + let mut bits = 2048; + let mut create_symlinks = false; + let mut force_symlinks = false; + let mut name = None; + + let mut parser = lexopt::Parser::from_args(args); + + while let Some(arg) = parser.next()? { + match arg { + Arg::Short('a') => { + if algorithm.is_some() { + return Err("cannot specify algorithm (-a) more than once".into()); + } + + algorithm = parse_os_with("algorithm (-a)", &parser.value()?, |s| { + Ok(match s { + "list" => { + // TODO: Mock stdout and process exit? + println!("Possible algorithms:"); + println!(" - RSASHA256 (8)"); + println!(" - ECDSAP256SHA256 (13)"); + println!(" - ECDSAP384SHA384 (14)"); + println!(" - ED25519 (15)"); + println!(" - ED448 (16)"); + std::process::exit(0); + } + + "RSASHA256" | "8" => Some(SecAlg::RSASHA256), + "ECDSAP256SHA256" | "13" => Some(SecAlg::ECDSAP256SHA256), + "ECDSAP384SHA384" | "14" => Some(SecAlg::ECDSAP384SHA384), + "ED25519" | "15" => Some(SecAlg::ED25519), + "ED448" | "16" => Some(SecAlg::ED448), + + _ => { + return Err(format!("Invalid value {s:?} for algorithm (-a)")); + } + }) + })?; + } + + Arg::Short('k') => { + // NOTE: '-k' can be repeated, to no effect. + make_ksk = true; + } + + Arg::Short('b') => { + // NOTE: '-b' can be repeated; the last instance wins. + bits = parse_os("bits (-b)", &parser.value()?)?; + } + + Arg::Short('r') => { + // NOTE: '-r' can be repeated; we don't use it, so the order doesn't matter. + let _ = parser.value()?; + } + + Arg::Short('s') => { + // NOTE: '-s' can be repeated, to no effect. + create_symlinks = true; + } + + Arg::Short('f') => { + // NOTE: '-f' can be repeated, to no effect. + force_symlinks = true; + } + + // TODO: '-v' version argument? + Arg::Value(value) => { + if name.is_some() { + return Err("cannot specify multiple domain names".into()); + } + + name = Some(parse_os("domain name", &value)?); + } + + 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 algorithm = match algorithm { + Some(SecAlg::RSASHA256) => GenerateParams::RsaSha256 { bits }, + Some(SecAlg::ECDSAP256SHA256) => GenerateParams::EcdsaP256Sha256, + Some(SecAlg::ECDSAP384SHA384) => GenerateParams::EcdsaP384Sha384, + Some(SecAlg::ED25519) => GenerateParams::Ed25519, + Some(SecAlg::ED448) => GenerateParams::Ed448, + Some(_) => unreachable!(), + None => { + return Err("Missing algorithm (-a) option".into()); + } + }; + + let symlink = match (create_symlinks, force_symlinks) { + (true, true) => SymlinkArg::Force, + (true, false) => SymlinkArg::Yes, + // If only '-f' is specified, no symlinking is done. + (false, _) => SymlinkArg::No, + }; + + let Some(name) = name else { + return Err("Missing domain name argument".into()); + }; + + Ok(Self { + algorithm, + make_ksk, + symlink, + name, + }) + } +} + +impl From for Command { + fn from(value: Keygen) -> Self { + Self::Keygen(value) + } +} + +impl Keygen { + fn parse_algorithm(value: &str) -> Result { + match value { + "RSASHA256" => return Ok(GenerateParams::RsaSha256 { bits: 2048 }), + "ECDSAP256SHA256" => return Ok(GenerateParams::EcdsaP256Sha256), + "ECDSAP384SHA384" => return Ok(GenerateParams::EcdsaP384Sha384), + "ED25519" => return Ok(GenerateParams::Ed25519), + "ED448" => return Ok(GenerateParams::Ed448), + _ => {} + } + + // TODO: Remove attrs when more RSA algorithms are added. + #[allow(clippy::collapsible_match)] + if let Some((name, params)) = value.split_once(':') { + #[allow(clippy::single_match)] + match name { + "RSASHA256" => { + let bits: u32 = params.parse().map_err(|err| { + clap::Error::raw( + clap::error::ErrorKind::InvalidValue, + format!("invalid RSA key size '{params}': {err}"), + ) + })?; + return Ok(GenerateParams::RsaSha256 { bits }); + } + _ => {} + } + } + + Err(clap::Error::raw( + clap::error::ErrorKind::InvalidValue, + format!("unrecognized algorithm '{value}'"), + )) + } + + pub fn execute(self, env: impl Env) -> Result<(), Error> { + let mut stdout = env.stdout(); + + let params = self.algorithm; + + // The digest algorithm is selected based on the key algorithm. + let digest_alg = match params.algorithm() { + SecAlg::RSASHA256 => DigestAlg::SHA256, + SecAlg::ECDSAP256SHA256 => DigestAlg::SHA256, + SecAlg::ECDSAP384SHA384 => DigestAlg::SHA384, + SecAlg::ED25519 => DigestAlg::SHA256, + SecAlg::ED448 => DigestAlg::SHA256, + _ => unreachable!(), + }; + + // Generate the key. + // TODO: Attempt repeated generation to avoid key tag collisions. + let (secret_key, public_key) = common::generate(params) + .map_err(|err| format!("an implementation error occurred: {err}").into()) + .context("generating a cryptographic keypair")?; + // TODO: Add a high-level operation in 'domain' to select flags? + let flags = if self.make_ksk { 257 } else { 256 }; + let public_key = Key::new(self.name.clone(), flags, public_key); + let digest = self.make_ksk.then(|| { + public_key + .digest(digest_alg) + .expect("only supported digest algorithms are used") + }); + + let base = format!( + "K{}+{:03}+{:05}", + self.name.fmt_with_dot(), + public_key.algorithm().to_int(), + public_key.key_tag() + ); + + let secret_key_path = format!("{base}.private"); + let public_key_path = format!("{base}.key"); + let digest_file_path = self.make_ksk.then(|| format!("{base}.ds")); + + let mut secret_key_file = env.fs_create_new(&secret_key_path)?; + let mut public_key_file = env.fs_create_new(&public_key_path)?; + let mut digest_file = digest_file_path + .as_ref() + .map(|digest_file_path| env.fs_create_new(digest_file_path)) + .transpose()?; + + Self::symlink(&secret_key_path, ".private", self.symlink, &env)?; + Self::symlink(&public_key_path, ".key", self.symlink, &env)?; + if let Some(digest_file_path) = &digest_file_path { + Self::symlink(digest_file_path, ".ds", self.symlink, &env)?; + } + + // Prepare the contents to write. + let secret_key = secret_key.display_as_bind().to_string(); + let public_key = public_key.display_as_bind().to_string(); + let digest = digest.map(|digest| { + format!( + "{} IN DS {}\n", + self.name.fmt_with_dot(), + digest.display_zonefile(false) + ) + }); + + // Write the key files. + secret_key_file + .write_all(secret_key.as_bytes()) + .map_err(|err| { + format!("error while writing private key file '{base}.private': {err}") + })?; + public_key_file + .write_all(public_key.as_bytes()) + .map_err(|err| format!("error while writing public key file '{base}.key': {err}"))?; + if let Some(digest_file) = digest_file.as_mut() { + digest_file + .write_all(digest.unwrap().as_bytes()) + .map_err(|err| format!("error while writing digest file '{base}.ds': {err}"))?; + } + + // Let the user know what the base name of the files is. + writeln!(stdout, "{}", base); + + Ok(()) + } + + /// Create a symlink to the given location. + fn symlink( + target: impl AsRef, + link: impl AsRef, + how: SymlinkArg, + env: &impl Env, + ) -> Result<(), Error> { + #[cfg(unix)] + match how { + SymlinkArg::No => Ok(()), + SymlinkArg::Yes => env.fs_symlink(target, link), + SymlinkArg::Force => env.fs_symlink_force(target, link), + } + + #[cfg(not(unix))] + if how.create() { + Err("Symlinks can only be created on Unix platforms".into()) + } + } +} + +#[cfg(test)] +mod test { + use domain::sign::GenerateParams; + use regex::Regex; + + use crate::commands::Command; + use crate::env::fake::FakeCmd; + + use super::{Keygen, SymlinkArg}; + + #[track_caller] + fn parse(args: FakeCmd) -> Keygen { + let res = args.parse(); + let Command::Keygen(x) = res.unwrap().command else { + panic!("Not a Keygen!"); + }; + x + } + + #[test] + fn dnst_parse() { + let cmd = FakeCmd::new(["dnst", "keygen"]); + + // Algorithm and domain name are needed. + let _ = cmd.parse().unwrap_err(); + + // Multiple domain names cannot be provided. + let _ = cmd + .args(["foo.example.org", "bar.example.org"]) + .parse() + .unwrap_err(); + + let base = Keygen { + algorithm: GenerateParams::Ed25519, + make_ksk: false, + symlink: SymlinkArg::No, + name: "example.org".parse().unwrap(), + }; + + // The simplest invocation. + assert_eq!(parse(cmd.args(["-a", "ED25519", "example.org"])), base); + + // Test 'algorithm': + // - RSA-SHA256 uses 2048 bits by default. + assert_eq!( + parse(cmd.args(["-a", "RSASHA256", "example.org"])), + Keygen { + algorithm: GenerateParams::RsaSha256 { bits: 2048 }, + ..base.clone() + } + ); + // - RSA-SHA256 accepts other key sizes. + assert_eq!( + parse(cmd.args(["-a", "RSASHA256:1024", "example.org"])), + Keygen { + algorithm: GenerateParams::RsaSha256 { bits: 1024 }, + ..base.clone() + } + ); + + // Test 'make_ksk': + assert_eq!( + parse(cmd.args(["-a", "ED25519", "-k", "example.org"])), + Keygen { + make_ksk: true, + ..base.clone() + } + ); + + // Test 'symlink': + // - Symlinks can be disabled. + for symlink in ["-s=no", "--symlink=no"] { + assert_eq!( + parse(cmd.args(["-a", "ED25519", symlink, "example.org"])), + Keygen { + symlink: SymlinkArg::No, + ..base.clone() + } + ); + } + // - Symlinks can be enabled. + for symlink in ["-s", "-s=yes", "--symlink", "--symlink=yes"] { + assert_eq!( + parse(cmd.args(["-a", "ED25519", symlink, "example.org"])), + Keygen { + symlink: SymlinkArg::Yes, + ..base.clone() + } + ); + } + // - Symlinks can be enabled with overwriting. + for symlink in ["-s=force", "--symlink=force"] { + assert_eq!( + parse(cmd.args(["-a", "ED25519", symlink, "example.org"])), + Keygen { + symlink: SymlinkArg::Force, + ..base.clone() + } + ); + } + + // Test 'name': + // - Domain names can have a trailing dot. + assert_eq!(parse(cmd.args(["-a", "ED25519", "example.org."])), base); + } + + #[test] + fn ldns_parse() { + let cmd = FakeCmd::new(["ldns-keygen"]); + + // Algorithm and domain name are needed. + let _ = cmd.parse().unwrap_err(); + + // Multiple domain names cannot be provided. + let _ = cmd + .args(["foo.example.org", "bar.example.org"]) + .parse() + .unwrap_err(); + + let base = Keygen { + algorithm: GenerateParams::Ed25519, + make_ksk: false, + symlink: SymlinkArg::No, + name: "example.org".parse().unwrap(), + }; + + // The simplest invocation. + assert_eq!(parse(cmd.args(["-a", "ED25519", "example.org"])), base); + + // Test 'algorithm': + // - RSA-SHA256 uses 2048 bits by default. + assert_eq!( + parse(cmd.args(["-a", "RSASHA256", "example.org"])), + Keygen { + algorithm: GenerateParams::RsaSha256 { bits: 2048 }, + ..base.clone() + } + ); + // - RSA-SHA256 accepts other key sizes. + assert_eq!( + parse(cmd.args(["-a", "RSASHA256", "-b", "1024", "example.org"])), + Keygen { + algorithm: GenerateParams::RsaSha256 { bits: 1024 }, + ..base.clone() + } + ); + + // Test 'make_ksk': + assert_eq!( + parse(cmd.args(["-a", "ED25519", "-k", "example.org"])), + Keygen { + make_ksk: true, + ..base.clone() + } + ); + + // Test 'symlink': + // - Symlinks can be enabled. + assert_eq!( + parse(cmd.args(["-a", "ED25519", "-s", "example.org"])), + Keygen { + symlink: SymlinkArg::Yes, + ..base.clone() + } + ); + // - Symlinks can be enabled with overwriting. + assert_eq!( + parse(cmd.args(["-a", "ED25519", "-s", "-f", "example.org"])), + Keygen { + symlink: SymlinkArg::Force, + ..base.clone() + } + ); + // - '-f' without '-s' does not enable symlinks. + assert_eq!( + parse(cmd.args(["-a", "ED25519", "-f", "example.org"])), + Keygen { + symlink: SymlinkArg::No, + ..base.clone() + } + ); + + // Test 'name': + // - Domain names can have a trailing dot. + assert_eq!(parse(cmd.args(["-a", "ED25519", "example.org."])), base); + } + + #[test] + fn simple() { + let dir = tempfile::TempDir::new().unwrap(); + let res = FakeCmd::new(["dnst", "keygen", "-a", "ED25519", "example.org"]) + .cwd(&dir) + .run(); + + let name_regex = Regex::new(r"^Kexample\.org\.\+015\+[0-9]{5}$").unwrap(); + let public_key_regex = + Regex::new(r"^example.org. IN DNSKEY 256 3 15 [A-Za-z0-9/+=]+").unwrap(); + let secret_key_regex = Regex::new( + r"^Private-key-format: v1\.2\nAlgorithm: 15 \(ED25519\)\nPrivateKey: [A-Za-z0-9/+=]+\n$", + ) + .unwrap(); + + assert_eq!(res.exit_code, 0, "{res:?}"); + assert_eq!(res.stderr, ""); + + let name = res.stdout.trim(); + assert!(name_regex.is_match(name)); + + let public_key = std::fs::read_to_string(dir.path().join(format!("{name}.key"))).unwrap(); + assert!(public_key_regex.is_match(&public_key)); + + // The digest file must not be created. + assert!(!dir.path().join("{name}.ds").try_exists().unwrap()); + + let secret_key = + std::fs::read_to_string(dir.path().join(format!("{name}.private"))).unwrap(); + assert!(secret_key_regex.is_match(&secret_key)); + } + + #[test] + fn simple_ksk() { + let dir = tempfile::TempDir::new().unwrap(); + let res = FakeCmd::new(["dnst", "keygen", "-k", "-a", "ED25519", "example.org"]) + .cwd(&dir) + .run(); + + let name_regex = Regex::new(r"^Kexample\.org\.\+015\+[0-9]{5}$").unwrap(); + let public_key_regex = + Regex::new(r"^example.org. IN DNSKEY 257 3 15 [A-Za-z0-9/+=]+").unwrap(); + let digest_key_regex = + Regex::new(r"^example.org. IN DS [0-9]+ 15 2 [0-9a-fA-F]+\n$").unwrap(); + + assert_eq!(res.exit_code, 0, "{res:?}"); + assert_eq!(res.stderr, ""); + + let name = res.stdout.trim(); + assert!(name_regex.is_match(name)); + + let public_key = std::fs::read_to_string(dir.path().join(format!("{name}.key"))).unwrap(); + assert!(public_key_regex.is_match(&public_key)); + + let digest_key = std::fs::read_to_string(dir.path().join(format!("{name}.ds"))).unwrap(); + assert!(digest_key_regex.is_match(&digest_key)); + + assert!(dir + .path() + .join(format!("{name}.private")) + .try_exists() + .unwrap()); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 3b34f42..7e948cb 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,6 +1,7 @@ //! The command of _dnst_. pub mod help; pub mod key2ds; +pub mod keygen; pub mod nsec3hash; use std::ffi::{OsStr, OsString}; @@ -16,7 +17,33 @@ use super::error::Error; #[derive(Clone, Debug, clap::Subcommand)] pub enum Command { - /// Prints the NSEC3 hash of a given domain name + /// Generate a new key pair for a given domain name + /// + /// The following files will be created: + /// + /// - K++.key: The public key file + /// + /// This is a DNSKEY resource record in zone file format. + /// + /// - K++.private: The private key file + /// + /// This is a text file in the conventional BIND format which + /// contains fields describing the private key data. + /// + /// - K++.ds: The public key digest file + /// + /// This is a DS resource record in zone file format. + /// It is only created for key signing keys. + /// + /// is the fully-qualified owner name for the key (with a trailing dot). + /// is the algorithm number of the key, zero-padded to 3 digits. + /// is the 16-bit tag of the key, zero-padded to 5 digits. + /// + /// Upon completion, 'K++' will be printed. + #[command(name = "keygen", verbatim_doc_comment)] + Keygen(self::keygen::Keygen), + + /// Print the NSEC3 hash of a given domain name #[command(name = "nsec3-hash")] Nsec3Hash(self::nsec3hash::Nsec3Hash), @@ -35,6 +62,7 @@ pub enum Command { impl Command { pub fn execute(self, env: impl Env) -> Result<(), Error> { match self { + Self::Keygen(keygen) => keygen.execute(env), Self::Nsec3Hash(nsec3hash) => nsec3hash.execute(env), Self::Key2ds(key2ds) => key2ds.execute(env), Self::Help(help) => help.execute(), diff --git a/src/commands/nsec3hash.rs b/src/commands/nsec3hash.rs index 852b49b..8e87a13 100644 --- a/src/commands/nsec3hash.rs +++ b/src/commands/nsec3hash.rs @@ -3,13 +3,14 @@ use std::str::FromStr; use clap::builder::ValueParser; use domain::base::iana::nsec3::Nsec3HashAlg; -use domain::base::name::{self, Name}; +use domain::base::name::Name; use domain::rdata::nsec3::Nsec3Salt; use domain::validate::nsec3_hash; use lexopt::Arg; use crate::env::Env; use crate::error::Error; +use crate::parse::parse_name; use super::{parse_os, parse_os_with, LdnsCommand}; @@ -46,7 +47,7 @@ pub struct Nsec3Hash { salt: Nsec3Salt>, /// The domain name to hash - #[arg(value_name = "DOMAIN NAME", value_parser = ValueParser::new(Nsec3Hash::parse_name))] + #[arg(value_name = "DOMAIN NAME", value_parser = ValueParser::new(parse_name))] name: Name>, } @@ -113,10 +114,6 @@ impl LdnsCommand for Nsec3Hash { } impl Nsec3Hash { - pub fn parse_name(arg: &str) -> Result>, name::FromStrError> { - Name::from_str(&arg.to_lowercase()) - } - // Note: This function is only necessary until // https://github.com/NLnetLabs/domain/pull/431 is merged. pub fn parse_salt(arg: &str) -> Result>, Error> { diff --git a/src/env/mod.rs b/src/env/mod.rs index dd62dcb..1f3f7fa 100644 --- a/src/env/mod.rs +++ b/src/env/mod.rs @@ -1,6 +1,7 @@ use std::borrow::Cow; use std::ffi::OsString; use std::fmt; +use std::fs::File; use std::path::Path; mod real; @@ -10,6 +11,8 @@ pub mod fake; pub use real::RealEnv; +use crate::error::Result; + pub trait Env { // /// Make a network connection // fn make_connection(&self); @@ -35,7 +38,72 @@ pub trait Env { // /// Get a reference to stdin // fn stdin(&self) -> impl io::Read; + /// Make relative paths absolute. fn in_cwd<'a>(&self, path: &'a impl AsRef) -> Cow<'a, Path>; + + /// Create and open a file. + fn fs_create_new(&self, path: impl AsRef) -> Result { + let path = path.as_ref(); + let abs_path = self.in_cwd(&path); + File::create_new(abs_path) + .map_err(|err| format!("cannot create '{}': {err}", path.display()).into()) + } + + /// Rename a path. + fn fs_rename(&self, old: impl AsRef, new: impl AsRef) -> Result<()> { + let (old, new) = (old.as_ref(), new.as_ref()); + let abs_old = self.in_cwd(&old); + let abs_new = self.in_cwd(&new); + std::fs::rename(abs_old, abs_new).map_err(|err| { + format!( + "could not move '{}' to '{}': {err}", + old.display(), + new.display() + ) + .into() + }) + } + + /// Create a symlink. + #[cfg(unix)] + fn fs_symlink(&self, target: impl AsRef, link: impl AsRef) -> Result<()> { + let (target, link) = (target.as_ref(), link.as_ref()); + let target_path = self.in_cwd(&target); + let link_path = self.in_cwd(&link); + std::os::unix::fs::symlink(target_path, link_path).map_err(|err| { + format!( + "could not create symlink '{}' to '{}': {err}", + link.display(), + target.display(), + ) + .into() + }) + } + + /// Create a symlink, overwriting if it already exists. + #[cfg(unix)] + fn fs_symlink_force(&self, target: impl AsRef, link: impl AsRef) -> Result<()> { + use crate::error::in_context; + + let (target, link) = (target.as_ref(), link.as_ref()); + let mut temp = link.to_path_buf(); + temp.as_mut_os_string().push(".new"); + + in_context( + || { + format!( + "creating symlink '{}' to '{}'", + link.display(), + target.display() + ) + }, + || { + self.fs_symlink(target, &temp)?; + self.fs_rename(&temp, link)?; + Ok(()) + }, + ) + } } /// A type with an infallible `write_fmt` method for use with [`write!`] macros diff --git a/src/error.rs b/src/error.rs index 5d2e58c..2b9bb97 100644 --- a/src/error.rs +++ b/src/error.rs @@ -219,3 +219,11 @@ impl Context for Result { self.map_err(|err| err.context(&(context)())) } } + +/// Execute the given operation under the given context. +pub fn in_context( + context: impl FnOnce() -> String, + function: impl FnOnce() -> Result, +) -> Result { + (function)().with_context(context) +} diff --git a/src/lib.rs b/src/lib.rs index 7078fbd..d74ef70 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,7 +2,7 @@ use std::ffi::OsString; use std::path::Path; use clap::Parser; -use commands::{key2ds::Key2ds, nsec3hash::Nsec3Hash, LdnsCommand}; +use commands::{key2ds::Key2ds, keygen::Keygen, nsec3hash::Nsec3Hash, LdnsCommand}; use env::Env; use error::Error; @@ -12,6 +12,7 @@ pub mod args; pub mod commands; pub mod env; pub mod error; +pub mod parse; pub fn try_ldns_compatibility>( args: I, @@ -29,6 +30,7 @@ pub fn try_ldns_compatibility>( let res = match binary_name { "key2ds" => Key2ds::parse_ldns_args(args_iter), + "keygen" => Keygen::parse_ldns_args(args_iter), "nsec3-hash" => Nsec3Hash::parse_ldns_args(args_iter), _ => return Err(format!("Unrecognized ldns command 'ldns-{binary_name}'").into()), }; diff --git a/src/parse.rs b/src/parse.rs new file mode 100644 index 0000000..6aefb05 --- /dev/null +++ b/src/parse.rs @@ -0,0 +1,9 @@ +use core::str::FromStr; + +use domain::base::Name; + +use crate::error::Error; + +pub fn parse_name(arg: &str) -> Result>, Error> { + Name::from_str(&arg.to_lowercase()).map_err(|e| Error::from(e.to_string())) +}