summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLars Wirzenius <liw@liw.fi>2021-03-07 10:30:51 +0000
committerLars Wirzenius <liw@liw.fi>2021-03-07 10:30:51 +0000
commit104d9b84785c8a4cd55fb190fce5a1d4ddde5863 (patch)
tree8f8c33437771322c2c5c2c40d79151320beb2beb
parenta6f802fda57fc7e951c0374a268de2274718cd9d (diff)
parent4621b07522564f6a3c1c2ad0484fb88cf0e2ce49 (diff)
downloadvmadm-104d9b84785c8a4cd55fb190fce5a1d4ddde5863.tar.gz
Merge branch 'keys' into 'main'
feat: generate SSH key pairs, create host certificates See merge request larswirzenius/vmadm!11
-rw-r--r--README.md66
-rw-r--r--src/cloudinit.rs37
-rw-r--r--src/config.rs2
-rw-r--r--src/lib.rs1
-rw-r--r--src/spec.rs11
-rw-r--r--src/sshkeys.rs182
-rw-r--r--ssh/config2
-rw-r--r--vmadm.md21
8 files changed, 313 insertions, 9 deletions
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` &ndash; default size of new image for a VM, in GiB
* `default_memory_mib` &ndash; default amount of memory for a VM, in MiB
* `default_cpus` &ndash; default number of CPUs for a VM
+* `default_generate_host_certificate` &ndash; should SSH host
+ certificates be generated by default?
* `image_directory` &ndash; directory where VM image files are put
* `authorized_keys` &ndash; list of filenames to SSH public keys, to
be put into the default user's `authorized_keys` file in the VM
+* `ca_key` &ndash; path name to default CA *private* key
+
## Specification fields
@@ -73,5 +77,65 @@ all of which override some default from the configuration.
* `cpus` &ndash; overrides `default_cpus`
* `base` &ndash; overrides `default_base_image`
* `image` &ndash; overrides default image file name; must include
- pathname, is not put into the image directory by default
+* `image` &ndash; overrides default image file name; must include
+ path name, is not put into the image directory by default
+* `generate_host_certificate` &ndash; override host certification
+ setting
+* `ca_key` &ndash; overrides default CA key
+* `rsa_host_key` &ndash; RSA host key to install on host
+* `rsa_host_cert` &ndash; RSA host certificate to install on host
+* `dsa_host_key` &ndash; DSA host key to install on host
+* `dsa_host_cert` &ndash; DSA host certificate to install on host
+* `ecdsa_host_key` &ndash; ECDSA host key to install on host
+* `ecdsa_host_cert` &ndash; ECDSA host certificate to install on host
+* `ed25519_host_key` &ndash; Ed25519 host key to install on host
+* `ed25519_host_cert` &ndash; 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,6 +94,9 @@ 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),
@@ -99,6 +104,9 @@ pub enum CloudInitError {
SpecificationError(#[from] SpecificationError),
#[error(transparent)]
+ KeyError(#[from] KeyError),
+
+ #[error(transparent)]
IoError(#[from] std::io::Error),
#[error(transparent)]
@@ -148,7 +156,7 @@ impl Userdata {
fn from(spec: &Specification) -> Result<Self, CloudInitError> {
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<String>,
@@ -193,7 +201,7 @@ struct Hostkeys {
}
impl Hostkeys {
- fn from(spec: &Specification) -> Option<Self> {
+ fn from(spec: &Specification) -> Result<Option<Self>, 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<u64>,
pub default_memory_mib: Option<u64>,
pub default_cpus: Option<u64>,
+ pub default_generate_host_certificate: bool,
pub image_directory: Option<PathBuf>,
pub authorized_keys: Option<Vec<PathBuf>>,
+ pub ca_key: Option<PathBuf>,
}
#[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<u64>,
pub memory_mib: Option<u64>,
pub cpus: Option<u64>,
+ pub generate_host_certificate: Option<bool>,
+ pub ca_key: Option<PathBuf>,
}
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<PathBuf>,
}
#[derive(Debug, thiserror::Error)]
@@ -193,6 +197,11 @@ impl Specification {
) -> Result<Specification, SpecificationError> {
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<Self, KeyError> {
+ 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<String, KeyError> {
+ 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<Self, KeyError> {
+ let private = read_string(filename)?;
+ Ok(Self { private })
+ }
+
+ pub fn certify_host(&self, host_key: &KeyPair, hostname: &str) -> Result<String, KeyError> {
+ 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"