From 4621b07522564f6a3c1c2ad0484fb88cf0e2ce49 Mon Sep 17 00:00:00 2001 From: Lars Wirzenius Date: Sun, 7 Mar 2021 09:01:23 +0200 Subject: feat: generate SSH key pairs, create host certificates --- README.md | 66 +++++++++++++++++++- src/cloudinit.rs | 37 +++++++++-- src/config.rs | 2 + src/lib.rs | 1 + src/spec.rs | 11 ++++ src/sshkeys.rs | 182 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ ssh/config | 2 +- vmadm.md | 21 ++++++- 8 files changed, 313 insertions(+), 9 deletions(-) create mode 100644 src/sshkeys.rs diff --git a/README.md b/README.md index 9408291..5525589 100644 --- a/README.md +++ b/README.md @@ -58,9 +58,13 @@ following fields: * `default_image_gib` – default size of new image for a VM, in GiB * `default_memory_mib` – default amount of memory for a VM, in MiB * `default_cpus` – default number of CPUs for a VM +* `default_generate_host_certificate` – should SSH host + certificates be generated by default? * `image_directory` – directory where VM image files are put * `authorized_keys` – list of filenames to SSH public keys, to be put into the default user's `authorized_keys` file in the VM +* `ca_key` – path name to default CA *private* key + ## Specification fields @@ -73,5 +77,65 @@ all of which override some default from the configuration. * `cpus` – overrides `default_cpus` * `base` – overrides `default_base_image` * `image` – overrides default image file name; must include - pathname, is not put into the image directory by default +* `image` – overrides default image file name; must include + path name, is not put into the image directory by default +* `generate_host_certificate` – override host certification + setting +* `ca_key` – overrides default CA key +* `rsa_host_key` – RSA host key to install on host +* `rsa_host_cert` – RSA host certificate to install on host +* `dsa_host_key` – DSA host key to install on host +* `dsa_host_cert` – DSA host certificate to install on host +* `ecdsa_host_key` – ECDSA host key to install on host +* `ecdsa_host_cert` – ECDSA host certificate to install on host +* `ed25519_host_key` – Ed25519 host key to install on host +* `ed25519_host_cert` – Ed25519 host certificate to install on host + +The various `host_key` and `host_cert` fields specify *private* host +keys and certificates to be installed in the new VM. The public key is +computed from the private key, so there's no need to specify it +explicitly. The fields should contain the text of the key or +certificate, not its filename. + +If *any* host key is specified, no host certificate is generated: the +`generate_host_certificate` setting is ignored. If no host keys is +specified, an Ed25519 key is generated and signed with the specified +CA certificate. The generated key and certificate are installed in the +new VM. + +In other words, if you specify any host keys, you get to do everything +by hand. If you want to keep things easy, don't specify any host keys +and let vmadm generate a host key and host certificate for a VM. + +# Using host certificates + +Host certificates allow you to access a newly created VM without +having to accept its host key. This is especially useful the VM gets +recreated and the host key changes. You need to configure your SSH +client to trust certificates made with a given SSH CA key, but that is +a one-time operation. + +You need to create an SSH key used as a CA key for host certification. +Run this command: + +~~~sh +$ mkdir -m 0700 ~/.ssh/ca +$ ssh-keygen -f ~/.ssh/ca/vmadm_ca -t ed25519 -N '' +~~~ + +This creates a key **without a passphrase**, because vmadm does not +currently support CA keys with passphrases. + +Keep the CA key secure. Don't use it for anything else. + +Add the following to the `known_hosts` file your SSH client uses, all +on one one: + +~~~ +@cert-authority * XXXX +~~~ +where `XXX` is the public key part of the CA key, as stored in +`~/.ssh/ca/vmadm_ca.pub` in the example above. This tells your client +that the CA key on the line should be accepted for all hosts (`*`). +You can restrict it to only some hosts if you prefer. diff --git a/src/cloudinit.rs b/src/cloudinit.rs index 6e62cc5..842baa7 100644 --- a/src/cloudinit.rs +++ b/src/cloudinit.rs @@ -1,7 +1,9 @@ use crate::spec::{Specification, SpecificationError}; +use crate::sshkeys::{CaKey, KeyError, KeyKind, KeyPair}; use log::debug; use serde::{Deserialize, Serialize}; use shell_words::quote; +use std::default::Default; use std::fs::write; // use std::os::unix::fs::PermissionsExt; use std::path::Path; @@ -92,12 +94,18 @@ logfile.close() #[derive(Debug, thiserror::Error)] pub enum CloudInitError { + #[error("Host certificate requested, but no CA key specified")] + NoCAKey, + #[error("failed to create ISO image with genisoimage: {0}")] IsoFailed(String), #[error(transparent)] SpecificationError(#[from] SpecificationError), + #[error(transparent)] + KeyError(#[from] KeyError), + #[error(transparent)] IoError(#[from] std::io::Error), @@ -148,7 +156,7 @@ impl Userdata { fn from(spec: &Specification) -> Result { Ok(Self { ssh_authorized_keys: spec.ssh_keys.clone(), - ssh_keys: Hostkeys::from(spec), + ssh_keys: Hostkeys::from(spec)?, runcmd: vec![ format!("python3 -c {}", quote(SCRIPT)), "systemctl reload ssh".to_string(), @@ -165,7 +173,7 @@ impl Userdata { } } -#[derive(Clone, Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize, Serialize, Default)] struct Hostkeys { #[serde(skip_serializing_if = "Option::is_none")] rsa_private: Option, @@ -193,7 +201,7 @@ struct Hostkeys { } impl Hostkeys { - fn from(spec: &Specification) -> Option { + fn from(spec: &Specification) -> Result, CloudInitError> { let rsa = spec.rsa_host_key.clone(); let rsa_cert = spec.rsa_host_cert.clone(); @@ -207,7 +215,7 @@ impl Hostkeys { let ed25519_cert = spec.ed25519_host_cert.clone(); if rsa.is_some() || dsa.is_some() || ecdsa.is_some() || ed25519.is_some() { - Some(Self { + Ok(Some(Self { rsa_private: rsa, rsa_certificate: rsa_cert, dsa_private: dsa, @@ -216,9 +224,26 @@ impl Hostkeys { ecdsa_certificate: ecdsa_cert, ed25519_private: ed25519, ed25519_certificate: ed25519_cert, - }) + })) + } else if spec.generate_host_certificate { + if spec.ca_key.is_none() { + return Err(CloudInitError::NoCAKey); + } + if let Some(filename) = &spec.ca_key { + let ca = CaKey::from_file(&filename)?; + let pair = KeyPair::generate(KeyKind::Ed25519)?; + let cert = ca.certify_host(&pair, &spec.name)?; + debug!("generated Ed25519 host certificate {:?}", cert); + Ok(Some(Self { + ed25519_private: Some(pair.public().to_string()), + ed25519_certificate: Some(cert.to_string()), + ..Self::default() + })) + } else { + Ok(None) + } } else { - None + Ok(None) } } } diff --git a/src/config.rs b/src/config.rs index a13c989..132f1f6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -10,8 +10,10 @@ pub struct Configuration { pub default_image_gib: Option, pub default_memory_mib: Option, pub default_cpus: Option, + pub default_generate_host_certificate: bool, pub image_directory: Option, pub authorized_keys: Option>, + pub ca_key: Option, } #[derive(Debug, thiserror::Error)] diff --git a/src/lib.rs b/src/lib.rs index b4c07e9..bddc1ad 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,3 +4,4 @@ pub mod config; pub mod image; pub mod install; pub mod spec; +pub mod sshkeys; diff --git a/src/spec.rs b/src/spec.rs index d01abcf..928628e 100644 --- a/src/spec.rs +++ b/src/spec.rs @@ -26,6 +26,8 @@ struct OneVmInputSpecification { pub image_size_gib: Option, pub memory_mib: Option, pub cpus: Option, + pub generate_host_certificate: Option, + pub ca_key: Option, } impl OneVmInputSpecification { @@ -131,6 +133,8 @@ pub struct Specification { pub image_size_gib: u64, pub memory_mib: u64, pub cpus: u64, + pub generate_host_certificate: bool, + pub ca_key: Option, } #[derive(Debug, thiserror::Error)] @@ -193,6 +197,11 @@ impl Specification { ) -> Result { let key_filenames = input.ssh_key_files(config, name)?; let ssh_keys = ssh_keys(&key_filenames)?; + let ca_key = if let Some(filename) = &input.ca_key { + Some(filename.clone()) + } else { + config.ca_key.clone() + }; let spec = Specification { name: name.to_string(), @@ -210,6 +219,8 @@ impl Specification { image_size_gib: input.image_size_gib(config, name)?, memory_mib: input.memory_mib(config, name)?, cpus: input.cpus(config, name)?, + generate_host_certificate: input.generate_host_certificate.or(Some(false)).unwrap(), + ca_key: ca_key, }; debug!("specification as with defaults applied: {:#?}", spec); diff --git a/src/sshkeys.rs b/src/sshkeys.rs new file mode 100644 index 0000000..207f6fe --- /dev/null +++ b/src/sshkeys.rs @@ -0,0 +1,182 @@ +use std::fs::{read, File, Permissions}; +use std::io::Write; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; +use std::process::Command; +use tempfile::tempdir; + +#[derive(Debug, thiserror::Error)] +pub enum KeyError { + #[error("ssh-keygen failed to generate a key: {0}")] + KeyGen(String), + + #[error("ssh-keygen failed to certify a key: {0}")] + CertError(String), + + #[error(transparent)] + IoError(#[from] std::io::Error), + + #[error(transparent)] + Utf8Error(#[from] std::string::FromUtf8Error), +} + +pub enum KeyKind { + RSA(u32), + DSA, + ECDSA256, + ECDSA384, + ECDSA521, + Ed25519, +} + +impl KeyKind { + pub fn as_str(&self) -> &str { + match self { + Self::RSA(_) => "rsa", + Self::DSA => "dsa", + Self::ECDSA256 => "ecdsa", + Self::ECDSA384 => "ecdsa", + Self::ECDSA521 => "ecdsa", + Self::Ed25519 => "ed25519", + } + } + + pub fn bits(&self) -> u32 { + match self { + Self::RSA(bits) => *bits, + Self::DSA => 1024, + Self::ECDSA256 => 256, + Self::ECDSA384 => 384, + Self::ECDSA521 => 521, + Self::Ed25519 => 1024, + } + } +} + +pub struct KeyPair { + public: String, + private: String, +} + +impl KeyPair { + pub fn from_str(public: String, private: String) -> Self { + Self { + private: private, + public: public, + } + } + + pub fn generate(kind: KeyKind) -> Result { + let dirname = tempdir()?; + let private_key = dirname.path().join("key"); + let output = Command::new("ssh-keygen") + .arg("-f") + .arg(&private_key) + .arg("-t") + .arg(kind.as_str()) + .arg("-b") + .arg(format!("{}", kind.bits())) + .arg("-N") + .arg("") + .output()?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + return Err(KeyError::KeyGen(stderr)); + } + + let public_key = private_key.with_extension("pub"); + + Ok(Self::from_str( + read_string(&public_key)?, + read_string(&private_key)?, + )) + } + + pub fn public(&self) -> &str { + &self.public + } + pub fn private(&self) -> &str { + &self.private + } +} + +fn read_string(filename: &Path) -> Result { + let bytes = read(filename)?; + Ok(String::from_utf8(bytes)?) +} + +pub struct CaKey { + private: String, +} + +impl CaKey { + pub fn from(pair: KeyPair) -> Self { + Self { + private: pair.private().to_string(), + } + } + + pub fn from_file(filename: &Path) -> Result { + let private = read_string(filename)?; + Ok(Self { private }) + } + + pub fn certify_host(&self, host_key: &KeyPair, hostname: &str) -> Result { + let dirname = tempdir()?; + let ca_key = dirname.path().join("ca"); + let host_key_pub = dirname.path().join("host.pub"); + let cert = dirname.path().join("host-cert.pub"); + + write_string(&ca_key, &self.private)?; + write_string(&host_key_pub, host_key.public())?; + + let output = Command::new("ssh-keygen") + .arg("-s") + .arg(&ca_key) + .arg("-h") + .arg("-n") + .arg(hostname) + .arg("-I") + .arg(format!("host key for {}", hostname)) + .arg(&host_key_pub) + .output()?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + return Err(KeyError::CertError(stderr)); + } + + Ok(read_string(&cert)?) + } +} + +fn write_string(filename: &Path, s: &str) -> Result<(), KeyError> { + let mut file = File::create(filename)?; + let ro_user = Permissions::from_mode(0o600); + file.set_permissions(ro_user)?; + file.write_all(s.as_bytes())?; + Ok(()) +} + +#[cfg(test)] +mod keypair_test { + use super::{CaKey, KeyKind, KeyPair}; + + #[test] + fn generate_key() { + let pair = KeyPair::generate(KeyKind::Ed25519).unwrap(); + assert_ne!(pair.public(), ""); + assert_ne!(pair.private(), ""); + assert_ne!(pair.private(), pair.public()); + } + + #[test] + fn certify_host_key() { + let ca = KeyPair::generate(KeyKind::Ed25519).unwrap(); + let ca = CaKey::from(ca); + let host = KeyPair::generate(KeyKind::Ed25519).unwrap(); + let cert = ca.certify_host(&host, "dummy").unwrap(); + assert_ne!(cert, ""); + } +} diff --git a/ssh/config b/ssh/config index b4cbdec..347d0ff 100644 --- a/ssh/config +++ b/ssh/config @@ -1,5 +1,5 @@ host * - userknownhostsfile=/dev/null + userknownhostsfile=ssh/known_hosts stricthostkeychecking=accept-new identityfile=ssh/test.key identitiesonly=yes diff --git a/vmadm.md b/vmadm.md index 73b4a52..0ba4f12 100644 --- a/vmadm.md +++ b/vmadm.md @@ -42,6 +42,8 @@ default_base_image: base.qcow2 default_image_gib: 5 default_memory_mib: 2048 default_cpus: 1 +default_generate_host_certificate: true +ca_key: ca_key authorized_keys: - .ssh/id_rsa.pub ~~~ @@ -50,14 +52,29 @@ authorized_keys: ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQChZ6mVuGLBpW7SarFU/Tu6TemquNxatbMUZuTk8RqVtbkvTKeWFZ5h5tntWPHgST8ykYFaIrr8eYuKQkKdBxHW7H8kejTNwRu/rDbRYX5wxTn4jw4RVopGTpxMlGrWeu5CkWPoLAhQtIzzUAnrDGp9sqG6P1G4ohI61wZMFQta9R2uNxXnnes+e2r4Y78GxmlQH/o0ouI8fBnsxRK0IoSfFs2LutO6wjyzR59FdC9TT7wufd5kXMRzxsmPGeXzNcaqvHGxBvRucGFclCkqSRwk3GNEpXZQhlCIoTIoRu0IPAp/430tlx9zJMhhwDlZsOOXRrFYpdWVMSTAAKECLSYx liw@exolobe1 ~~~ +~~~{#ca_key .file} +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACABAgbX2ZOvZUO42nZDbKYOaovzfaSH1uiXKjBFydy2igAAAJBWl8ZtVpfG +bQAAAAtzc2gtZWQyNTUxOQAAACABAgbX2ZOvZUO42nZDbKYOaovzfaSH1uiXKjBFydy2ig +AAAECD6VUD9Cl/oDBtGumplYGWkbYCWXTFDAb6CaeXyf1ErQECBtfZk69lQ7jadkNspg5q +i/N9pIfW6JcqMEXJ3LaKAAAADGxpd0BleG9sb2JlMQE= +-----END OPENSSH PRIVATE KEY----- +~~~ + ~~~{#ssh_config .file} host * - userknownhostsfile=/dev/null + userknownhostsfile=ssh/known_hosts stricthostkeychecking=accept-new identityfile=.ssh/id_rsa + identitiesonly=yes passwordauthentication=no ~~~ +~~~{#known_hosts .file} +@cert-authority * ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAECBtfZk69lQ7jadkNspg5qi/N9pIfW6JcqMEXJ3LaK +~~~ + # Cloud-init configuration This scenario verifies that vmadm creates the cloud-init configuration @@ -129,9 +146,11 @@ given an installed vmadm given a Debian 10 OpenStack cloud image given file smoke.yaml given file config.yaml +given file ca_key given file .ssh/id_rsa from ssh_key given file .ssh/id_rsa.pub from ssh_key_pub given file .ssh/config from ssh_config +given file .ssh/known_hosts from known_hosts when I invoke vmadm new --config config.yaml smoke.yaml when I invoke ssh -F .ssh/config debian@smoke hostname then stdout contains "smoke" -- cgit v1.2.1