From 46ebcc85362d214e37ae67d1c3feb752a0d9c9cd Mon Sep 17 00:00:00 2001 From: Claudio Carvalho Date: Wed, 31 Jul 2024 00:02:10 +0300 Subject: [PATCH] kbs/resource: Add nebula plugin The nebula plugin can be used to deliver credentials for nodes (confidential PODs or VMs) to join a Nebula overlay network. Within the nebula network, the communication between nodes is automatically encrypted by Nebula. A nebula credential can be requested using the kbs-client: kbs-client --url http://127.0.0.1:8080 \ get-resource \ --path 'plugin/nebula/credential?ip[ip]=10.11.12.13&ip[netbits]=21&name=pod1' at least the IPv4 address (in CIDR notation) and the name of the node must be provided in the query string. The other parameters supported can be found in the struct NebulaCredentialParams. After receiving a credential request, the nebula plugin will call the nebula-cert binary to create a key pair and sign a certificate using the Nebula CA. The generated node.crt and node.key, as well as the ca.rt are then returned to the caller. During the nebula-plugin initialization, a self signed Nebula CA can be created if 'ca_generation_policy = 1' in the nebula-config.toml, the file contains all parameters supported. Another option is to pre-install a ca.key and ca.crt, and set 'ca_generation_policy = 2'. The nebula-plugin cargo feature is set by default, however the plugin itself is not initialized by default. In order to initialize it, you need to add 'nebula' to 'manager_plugin_config.enabled_plugins' in the kbs-config.toml. Closes #396 Signed-off-by: Claudio Carvalho --- Cargo.lock | 12 + Cargo.toml | 1 + docker-compose.yml | 1 + kbs/Cargo.toml | 6 +- kbs/config/plugin/nebula-config.toml | 69 ++++ kbs/docker/coco-as-grpc/Dockerfile | 7 +- kbs/docs/config.md | 1 + kbs/src/resource/plugin/nebula.rs | 469 +++++++++++++++++++++++++++ 8 files changed, 563 insertions(+), 3 deletions(-) create mode 100644 kbs/config/plugin/nebula-config.toml create mode 100644 kbs/src/resource/plugin/nebula.rs diff --git a/Cargo.lock b/Cargo.lock index ca9cf35d2..ba6a2e3e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2753,6 +2753,7 @@ dependencies = [ "semver", "serde", "serde_json", + "serde_qs", "strum", "tempfile", "thiserror", @@ -4785,6 +4786,17 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_qs" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd34f36fe4c5ba9654417139a9b3a20d2e1de6012ee678ad14d240c22c78d8d6" +dependencies = [ + "percent-encoding", + "serde", + "thiserror", +] + [[package]] name = "serde_spanned" version = "0.6.6" diff --git a/Cargo.toml b/Cargo.toml index 3d7bd6bbc..91c958e84 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ reqwest = "0.12" rstest = "0.18.1" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0.89" +serde_qs = "0.13.0" serde_with = { version = "1.11.0", features = ["base64", "hex"] } serial_test = "0.9.0" sha2 = "0.10" diff --git a/docker-compose.yml b/docker-compose.yml index 956c36c13..312dbfbbd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,7 @@ services: - ./kbs/data/kbs-plugin:/opt/confidential-containers/kbs/plugin:rw - ./kbs/config/public.pub:/opt/confidential-containers/kbs/user-keys/public.pub - ./kbs/config/docker-compose/kbs-config.toml:/etc/kbs/kbs-config.toml + - ./kbs/config/plugin/nebula-config.toml:/etc/kbs/plugin/nebula-config.toml depends_on: - as diff --git a/kbs/Cargo.toml b/kbs/Cargo.toml index a983769c5..731c71066 100644 --- a/kbs/Cargo.toml +++ b/kbs/Cargo.toml @@ -41,6 +41,7 @@ rustls = ["actix-web/rustls", "dep:rustls", "dep:rustls-pemfile"] # Use openssl crypto stack for KBS openssl = ["actix-web/openssl", "dep:openssl"] +nebula-plugin = [] # Use aliyun KMS as KBS backend aliyun = ["kms/aliyun"] @@ -76,16 +77,17 @@ semver = "1.0.16" serde = { workspace = true, features = ["derive"] } serde_json.workspace = true strum.workspace = true +serde_qs.workspace = true thiserror.workspace = true time = { version = "0.3.23", features = ["std"] } tokio.workspace = true tonic = { workspace = true, optional = true } uuid = { version = "1.2.2", features = ["serde", "v4"] } openssl = { version = "0.10.46", optional = true } +tempfile.workspace = true [dev-dependencies] -tempfile.workspace = true rstest.workspace = true [build-dependencies] -tonic-build = { workspace = true, optional = true } \ No newline at end of file +tonic-build = { workspace = true, optional = true } diff --git a/kbs/config/plugin/nebula-config.toml b/kbs/config/plugin/nebula-config.toml new file mode 100644 index 000000000..18faf09b0 --- /dev/null +++ b/kbs/config/plugin/nebula-config.toml @@ -0,0 +1,69 @@ +# Required: +# CA certificate path +crt_path = "/opt/confidential-containers/kbs/plugin/nebula/ca/ca.crt" + +# Required: +# CA key path +key_path = "/opt/confidential-containers/kbs/plugin/nebula/ca/ca.key" + +# Required: +# Certificate Authority generation policy +# +# 1 = Create a self signed CA only if +# crt_path/key_path not found +# +# 2 = Never generate self signed CA as +# both crt_path and key_path are pre-installed +ca_generation_policy = 1 + +[self_signed_ca_config] + +# Required: +# Name of the certificate authority +name = "Nebula CA for Trustee KBS" + +# Optional: +# Argon2 iterations parameter used for encrypted +# private key passphrase (default 1) +## argon_iterations = 1 + +# Optional: +# Argon2 memory parameter (in KiB) used for encrypted +# private key passphrase (default 2097152) +## argon_memory = 2097152 + +# Optional: +# Argon2 parallelism parameter used for encrypted private +# key passphrase (default 4) +## argon_parallelism = 4 + +# Optional: +# EdDSA/ECDSA Curve (25519, P256) (default "25519") +## curve = "25519" + +# Optional: +# Amount of time the certificate should be valid for. +# Valid time units are seconds: +# "s", minutes: "m", hours: "h" (default 8760h0m0s) +## duration = "8760h0m0s" + +# Optional: +# Comma separated list of groups. This will limit which +# groups subordinate certs can use +## groups = "servers,ssh" + +# Optional: +# Comma separated list of ipv4 address and network +# in CIDR notation. This will limit which ipv4 addresses and +# networks subordinate certs can use for ip addresses +## ips = "192.168.100.10/24" + +# Optional: +# Path to write a QR code image (png) of the certificate +## out_qr = "/opt/confidential-containers/kbs/plugin/nebula/ca/ca.png" + +# Optional: +# Comma separated list of ipv4 address and network +# in CIDR notation. This will limit which ipv4 addresses and +# networks subordinate certs can use in subnets +## subnets = "192.168.86.0/24" \ No newline at end of file diff --git a/kbs/docker/coco-as-grpc/Dockerfile b/kbs/docker/coco-as-grpc/Dockerfile index 6c5ba703e..0ec85c1dd 100644 --- a/kbs/docker/coco-as-grpc/Dockerfile +++ b/kbs/docker/coco-as-grpc/Dockerfile @@ -2,7 +2,7 @@ FROM rust:latest as builder ARG ARCH=x86_64 ARG HTTPS_CRYPTO=rustls ARG ALIYUN=false -ARG PLUGINS="" +ARG PLUGINS="nebula-plugin" WORKDIR /usr/src/kbs COPY . . @@ -13,8 +13,13 @@ RUN apt-get update && apt install -y protobuf-compiler git RUN cd kbs && make AS_FEATURE=coco-as-grpc HTTPS_CRYPTO=${HTTPS_CRYPTO} POLICY_ENGINE=opa ALIYUN=${ALIYUN} PLUGINS=${PLUGINS} && \ make install-kbs +# Install Nebula +RUN wget https://github.com/slackhq/nebula/releases/download/v1.8.2/nebula-linux-amd64.tar.gz +RUN tar -C /usr/local/bin -xzf nebula-linux-amd64.tar.gz + FROM ubuntu:22.04 LABEL org.opencontainers.image.source="https://github.com/confidential-containers/trustee/kbs" COPY --from=builder /usr/local/bin/kbs /usr/local/bin/kbs +COPY --from=builder /usr/local/bin/nebula-cert /usr/local/bin/nebula-cert diff --git a/kbs/docs/config.md b/kbs/docs/config.md index 97c47f2ed..62681c2e0 100644 --- a/kbs/docs/config.md +++ b/kbs/docs/config.md @@ -92,6 +92,7 @@ List of supported plugins that can be added to `enabled_plugins`. | Plugin name | Plugin Description | Available Cargo Features | |-----------------------|--------------------------------------------------|-------------------------------| +| `nebula` | Provide resources to support the creation of a Nebula encrypted overlay network between nodes. | `nebula-plugin` | ### Native Attestation diff --git a/kbs/src/resource/plugin/nebula.rs b/kbs/src/resource/plugin/nebula.rs new file mode 100644 index 000000000..42aa33c57 --- /dev/null +++ b/kbs/src/resource/plugin/nebula.rs @@ -0,0 +1,469 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright (c) 2024 by IBM Inc. + +//! The nebula plugin allows the KBS to deliver resources required to create +//! an encrypted overlay network between nodes using [Nebula](https://github.com/slackhq/nebula), +//! +//! Within the Nebula overlay network, all communications between nodes are +//! automatically encrypted. + +use anyhow::{anyhow, bail, Context, Result}; +use serde_qs; +use std::ffi::OsString; +use std::fmt; +use std::fs; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::Arc; +use tempfile::{tempdir_in, TempDir}; +use tokio::sync::RwLock; + +use super::{RepositoryPlugin, RepositoryPluginBuild}; + +pub const PLUGIN_NAME: &str = "nebula"; + +const NEBULA_CONFIG_PATH: &str = "/etc/kbs/plugin/nebula-config.toml"; +const CRT_FILENAME: &str = "node.crt"; +const KEY_FILENAME: &str = "node.key"; + +// Required binaries must be in PATH +const NEBULA_CERT_BIN: &str = "nebula-cert"; + +fn create_dirname(path: &Path) -> Result<()> { + if let Some(dir) = path.parent() { + if !dir.exists() { + fs::create_dir_all(dir).with_context(|| format!("Create {} dir", dir.display()))?; + } + } + Ok(()) +} + +fn remove_file(path: &Path) -> Result<()> { + if path.exists() { + fs::remove_file(path).with_context(|| format!("Remove {} file", path.display()))?; + } + Ok(()) +} + +fn read_file_to_vec(path: &Path) -> Result> { + let mut file = fs::File::open(path).with_context(|| format!("Open CA crt file"))?; + let size: u64 = file + .metadata() + .context(format!("Get {} metadata", path.display()))? + .len(); + let mut v: Vec = Vec::with_capacity(size as usize); + file.read_to_end(&mut v) + .context(format!("Read {} to end", path.display()))?; + + Ok(v) +} + +/// Policies that define when a Nebula CA must be generated. +/// They are documented in the nebula plugin config toml file +#[repr(u32)] +pub enum CaGenerationPolicy { + GenerateIfNotFound = 1, + NeverGenerate = 2, +} + +/// Plugin configuration +/// It is documented in the nebula plugin config toml file +#[derive(Debug, Default, serde::Deserialize)] +pub struct NebulaPluginConfig { + crt_path: String, + key_path: String, + ca_generation_policy: u32, + self_signed_ca_config: Option, +} + +impl RepositoryPluginBuild for NebulaPluginConfig { + fn get_plugin_name(&self) -> &str { + PLUGIN_NAME + } + + fn create_plugin( + &self, + work_dir: &str, + ) -> Result>> { + let config = Self::try_from(Path::new(NEBULA_CONFIG_PATH))?; + + let ca = NebulaCa { + crt: PathBuf::from(config.crt_path), + key: PathBuf::from(config.key_path), + work_dir: PathBuf::from(work_dir), + }; + + create_dirname(&ca.work_dir.as_path())?; + + match config.ca_generation_policy { + x if x == CaGenerationPolicy::GenerateIfNotFound as u32 => { + if !ca.crt.exists() || !ca.key.exists() { + remove_file(ca.crt.as_path())?; + remove_file(ca.key.as_path())?; + + create_dirname(ca.crt.as_path())?; + create_dirname(ca.key.as_path())?; + + let ca_config = config.self_signed_ca_config.ok_or(anyhow!( + "self_signed_ca_config not found in {}", + NEBULA_CONFIG_PATH + ))?; + + let mut params: Vec = Vec::from(&ca_config); + params.push("-out-crt".into()); + params.push(ca.crt.as_path().into()); + params.push("-out-key".into()); + params.push(ca.key.as_path().into()); + + let status = Command::new(NEBULA_CERT_BIN) + .args(params) + .status() + .context("nebula-cert ca run")?; + + if !status.success() { + bail!("nebula-cert ca status"); + } + log::info!("Nebula CA generated"); + } else { + log::info!("Nebula CA already exists, loading it") + } + } + x if x == CaGenerationPolicy::NeverGenerate as u32 => { + if !ca.crt.exists() || !ca.key.exists() { + bail!("Nebula CA not found"); + } else { + log::info!("Nebula CA found, loading it") + } + } + x => { + bail!("CaGenerationPolicy {x} not supported"); + } + }; + + log::info!("nebula-cert binary: {}", ca.get_version()?.trim()); + ca.test_all()?; + + Ok(Arc::new(RwLock::new(NebulaPlugin { ca })) + as Arc>) + } +} + +impl TryFrom<&Path> for NebulaPluginConfig { + type Error = anyhow::Error; + + fn try_from(config_path: &Path) -> Result { + log::info!("Loading plugin config file {}", config_path.display()); + let config = config::Config::builder() + .add_source(config::File::with_name( + config_path + .to_str() + .expect("Nebula config path is not valid unicode"), + )) + .build()?; + config + .try_deserialize() + .map_err(|e| anyhow!("invalid config: {}", e.to_string())) + } +} + +/// Configuration to generate a Nebula self signed CA. Further information +/// on these fields can be found running "nebula-cert ca --help", or +/// just looking at nebula plugin toml file. +#[derive(Debug, serde::Deserialize)] +struct SelfSignedCaConfig { + name: String, + argon_iterations: Option, + argon_memory: Option, + argon_parallelism: Option, + curve: Option, + duration: Option, + groups: Option, + ips: Option, + out_qr: Option, + subnets: Option, +} + +impl From<&SelfSignedCaConfig> for Vec { + fn from(config: &SelfSignedCaConfig) -> Self { + let mut v: Vec = Vec::new(); + + v.push("ca".into()); + v.push("-name".into()); + v.push((&config.name).into()); + + if let Some(value) = &config.argon_iterations { + v.push("-argon-iterations".into()); + v.push(value.to_string().into()); + } + if let Some(value) = &config.argon_memory { + v.push("-argon-memory".into()); + v.push(value.to_string().into()); + } + if let Some(value) = &config.argon_parallelism { + v.push("-argon-parallelism".into()); + v.push(value.to_string().into()); + } + if let Some(value) = &config.curve { + v.push("-curve".into()); + v.push(value.into()); + } + if let Some(value) = &config.duration { + v.push("-duration".into()); + v.push(value.into()); + } + if let Some(value) = &config.groups { + v.push("-groups".into()); + v.push(value.into()); + } + if let Some(value) = &config.ips { + v.push("-ips".into()); + v.push(format!("{}", value).into()); + } + if let Some(value) = &config.out_qr { + v.push("-out-qr".into()); + v.push(value.into()); + } + if let Some(value) = &config.subnets { + v.push("-subnets".into()); + v.push(format!("{}", value).into()); + } + + v + } +} + +#[derive(Debug, PartialEq, serde::Deserialize)] +struct Ipv4CidrList { + list: Vec, +} + +impl fmt::Display for Ipv4CidrList { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + for v in self.list.iter().take(1) { + write!(f, "{v}")?; + } + for v in self.list.iter().skip(1) { + write!(f, ",{v}")?; + } + Ok(()) + } +} + +#[derive(Debug, PartialEq, serde::Deserialize)] +struct Ipv4Cidr { + ip: String, + netbits: String, +} + +impl fmt::Display for Ipv4Cidr { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}/{}", self.ip, self.netbits) + } +} + +/// Parameters taken by "nebula-cert sign" to create a credential +/// for a node to join a Nebula overlay network. These fields are +/// received as a query string in the get-resource URI +#[derive(Debug, PartialEq, serde::Deserialize)] +struct NebulaCredentialParams { + /// Required: ipv4 address and network in CIDR notation to assign the cert + ip: Ipv4Cidr, + /// Required: name of the cert, usually hostname or podname + name: String, + /// Optional: how long the cert should be valid for. + /// The default is 1 second before the signing cert expires. + /// Valid time units are seconds: "s", minutes: "m", hours: "h". + duration: Option, + /// Optional: comma separated list of groups. + groups: Option, + /// Optional: comma separated list of ipv4 address and network in CIDR notation. + /// Subnets this cert can serve for + subnets: Option, +} + +impl From<&NebulaCredentialParams> for Vec { + fn from(params: &NebulaCredentialParams) -> Self { + let mut v: Vec = Vec::new(); + + v.push("sign".into()); + v.push("-name".into()); + v.push((¶ms.name).into()); + v.push("-ip".into()); + v.push((¶ms.ip.to_string()).into()); + + if let Some(value) = ¶ms.duration { + v.push("-duration".into()); + v.push(value.into()); + } + if let Some(value) = ¶ms.groups { + v.push("-groups".into()); + v.push(value.into()); + } + if let Some(value) = ¶ms.subnets { + v.push("-subnets".into()); + v.push(value.to_string().into()); + } + + v + } +} + +#[derive(Debug, serde::Serialize)] +pub struct CredentialResource { + pub node_crt: Vec, + pub node_key: Vec, + pub ca_crt: Vec, +} + +/// Credential for a Nebula overlay network +/// It is created in a temporary directory to prevent +/// the same file from being accessed by multiple threads +#[derive(Debug)] +struct Credential { + _temp_dir: TempDir, + crt: PathBuf, + key: PathBuf, +} + +impl Credential { + pub fn new(work_dir: &Path) -> Result { + let temp_dir = tempdir_in(work_dir)?; + + let crt: PathBuf = temp_dir.path().join(CRT_FILENAME); + let key: PathBuf = temp_dir.path().join(KEY_FILENAME); + + Ok(Self { + _temp_dir: temp_dir, + crt, + key, + }) + } + + /// Run "nebula-cert sign" to generate a credential + pub fn generate( + &self, + ca_key: &Path, + ca_crt: &Path, + params: &NebulaCredentialParams, + ) -> Result<&Self> { + let mut args: Vec = Vec::from(params); + + args.push("-ca-key".into()); + args.push(ca_key.into()); + args.push("-ca-crt".into()); + args.push(ca_crt.into()); + args.push("-out-key".into()); + args.push(self.key.as_path().into()); + args.push("-out-crt".into()); + args.push(self.crt.as_path().into()); + + let status = Command::new(NEBULA_CERT_BIN) + .args(args) + .status() + .context("nebula-cert sign run")?; + + if !status.success() { + bail!("nebula-cert sign status"); + } + + Ok(self) + } +} + +/// The temp_dir is auto-deleted when it goes out-of-scope, but before that +/// we need to delete the generated credential +impl Drop for Credential { + fn drop(&mut self) { + if let Err(e) = remove_file(self.crt.as_path()) { + log::warn!("{}", e.to_string()); + } + if let Err(e) = remove_file(self.key.as_path()) { + log::warn!("{}", e.to_string()); + } + } +} + +/// Nebula Certificate Authority +#[derive(Debug, Default)] +struct NebulaCa { + key: PathBuf, + crt: PathBuf, + work_dir: PathBuf, +} + +impl NebulaCa { + pub fn get_credential_resource(&self, params: &NebulaCredentialParams) -> Result> { + let cred = Credential::new(self.work_dir.as_path())?; + + cred.generate(self.key.as_path(), self.crt.as_path(), params)?; + + let resource = CredentialResource { + node_crt: read_file_to_vec(cred.crt.as_path())?, + node_key: read_file_to_vec(cred.key.as_path())?, + ca_crt: read_file_to_vec(self.crt.as_path())?, + }; + + Ok(serde_json::to_vec(&resource)?) + } + + pub fn get_version(&self) -> Result { + let output = Command::new(NEBULA_CERT_BIN).arg("--version").output()?; + Ok(String::from_utf8(output.stdout)?) + } + + pub fn test_all(&self) -> Result<()> { + self.test_nebula_cert_sign() + } + + pub fn test_nebula_cert_sign(&self) -> Result<()> { + let params = NebulaCredentialParams { + ip: Ipv4Cidr { + ip: "10.10.10.10".to_string(), + netbits: "21".to_string(), + }, + name: "node-test".to_string(), + duration: None, + groups: None, + subnets: None, + }; + + let _ = Credential::new(self.work_dir.as_path())?.generate( + self.key.as_path(), + self.crt.as_path(), + ¶ms, + )?; + + Ok(()) + } +} + +/// Nebula plugin +#[derive(Default, Debug)] +pub struct NebulaPlugin { + ca: NebulaCa, +} + +#[async_trait::async_trait] +impl RepositoryPlugin for NebulaPlugin { + async fn get_name(&self) -> &str { + PLUGIN_NAME + } + + async fn get_plugin_resource(&self, resource: &str, query_string: &str) -> Result> { + let response: Vec = match resource { + // plugin/nebula/credential?{query_string} + // e.g. plugin/nebula/credential?ip[ip]=10.11.12.13&ip[netbits]=21&name=node1 + // the query_string will be used to generate the credential + "credential" => { + let params: NebulaCredentialParams = serde_qs::from_str(query_string)?; + self.ca.get_credential_resource(¶ms)? + } + // resource not supported + e => bail!("Nebula plugin resource {e} not supported"), + }; + + Ok(response) + } +}