From 336abc9bc212a8aad7a973e971ee0494bf69a2cb Mon Sep 17 00:00:00 2001 From: Christian Decker Date: Thu, 20 Jan 2022 15:09:21 +0100 Subject: [PATCH] grpc-plugin: Generate mTLS certificates and use them in grpc --- plugins/grpc-plugin/src/main.rs | 19 ++++++ plugins/grpc-plugin/src/tls.rs | 107 ++++++++++++++++++++++++++++++++ tests/test_cln_rs.py | 52 +++++++++++++++- 3 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 plugins/grpc-plugin/src/tls.rs diff --git a/plugins/grpc-plugin/src/main.rs b/plugins/grpc-plugin/src/main.rs index cbbfa7d4c007..23ef2bbddf3a 100644 --- a/plugins/grpc-plugin/src/main.rs +++ b/plugins/grpc-plugin/src/main.rs @@ -5,10 +5,14 @@ use log::{debug, warn}; use std::net::SocketAddr; use std::path::{Path, PathBuf}; +mod tls; + #[derive(Clone, Debug)] struct PluginState { rpc_path: PathBuf, bind_address: SocketAddr, + identity: tls::Identity, + ca_cert: Vec, } #[tokio::main] @@ -18,9 +22,14 @@ async fn main() -> Result<()> { let path = Path::new("lightning-rpc"); let addr: SocketAddr = "0.0.0.0:50051".parse().unwrap(); + let directory = std::env::current_dir()?; + let (identity, ca_cert) = tls::init(&directory)?; + let state = PluginState { rpc_path: path.into(), bind_address: addr, + identity, + ca_cert, }; let (plugin, i) = Builder::new(state.clone(), tokio::io::stdin(), tokio::io::stdout()).build(); @@ -40,7 +49,17 @@ async fn run_interface(state: PluginState) -> Result<()> { "Connecting to {:?} and serving grpc on {:?}", &state.rpc_path, &state.bind_address ); + + let identity = state.identity.to_tonic_identity(); + let ca_cert = tonic::transport::Certificate::from_pem(state.ca_cert); + + let tls = tonic::transport::ServerTlsConfig::new() + .identity(identity) + .client_ca_root(ca_cert); + tonic::transport::Server::builder() + .tls_config(tls) + .context("configuring tls")? .add_service(NodeServer::new( cln_grpc::Server::new(&state.rpc_path) .await diff --git a/plugins/grpc-plugin/src/tls.rs b/plugins/grpc-plugin/src/tls.rs new file mode 100644 index 000000000000..28a2972f7737 --- /dev/null +++ b/plugins/grpc-plugin/src/tls.rs @@ -0,0 +1,107 @@ +//! Utilities to manage TLS certificates. +use anyhow::{Context, Result}; +use log::debug; +use rcgen::{Certificate, KeyPair}; +use std::path::Path; + +/// Just a wrapper around a certificate and an associated keypair. +#[derive(Clone, Debug)] +pub(crate) struct Identity { + key: Vec, + certificate: Vec, +} + +impl Identity { + fn to_certificate(&self) -> Result { + let keystr = String::from_utf8_lossy(&self.key); + let key = KeyPair::from_pem(&keystr)?; + let certstr = String::from_utf8_lossy(&self.certificate); + let params = rcgen::CertificateParams::from_ca_cert_pem(&certstr, key)?; + let cert = Certificate::from_params(params)?; + Ok(cert) + } + + pub fn to_tonic_identity(&self) -> tonic::transport::Identity { + tonic::transport::Identity::from_pem(&self.certificate, &self.key) + } +} + +/// Ensure that we have a certificate authority, and child keypairs +/// and certificates for the server and the client. It'll generate +/// them in the provided `directory`. The following files are +/// included: +/// +/// - `ca.pem`: The self-signed certificate of the CA +/// - `ca-key.pem`: The key used by the CA to sign certificates +/// - `server.pem`: The server certificate, signed by the CA +/// - `server-key.pem`: The server private key +/// - `client.pem`: The client certificate, signed by the CA +/// - `client-key.pem`: The client private key +/// +/// The `grpc-plugin` will use the `server.pem` certificate, while a +/// client is supposed to use the `client.pem` and associated +/// keys. Notice that this isn't strictly necessary since the server +/// will accept any client that is signed by the CA. In future we +/// might add runes, making the distinction more important. +/// +/// Returns the server identity and the root CA certificate. +pub(crate) fn init(directory: &Path) -> Result<(Identity, Vec)> { + let ca = generate_or_load_identity("cln Root CA", directory, "ca", None)?; + let server = generate_or_load_identity("cln grpc Server", directory, "server", Some(&ca))?; + let _client = generate_or_load_identity("cln grpc Client", directory, "client", Some(&ca))?; + Ok((server, ca.certificate)) +} + +/// Generate a given identity +fn generate_or_load_identity( + name: &str, + directory: &Path, + filename: &str, + parent: Option<&Identity>, +) -> Result { + // Just our naming convention here. + let cert_path = directory.join(format!("{}.pem", filename)); + let key_path = directory.join(format!("{}-key.pem", filename)); + // Did we have to generate a new key? In that case we also need to + // regenerate the certificate + if !key_path.exists() || !cert_path.exists() { + debug!( + "Generating a new keypair in {:?}, it didn't exist", + &key_path + ); + let keypair = KeyPair::generate(&rcgen::PKCS_ECDSA_P256_SHA256)?; + std::fs::write(&key_path, keypair.serialize_pem())?; + debug!( + "Generating a new certificate for key {:?} at {:?}", + &key_path, &cert_path + ); + + // Configure the certificate we want. + let subject_alt_names = vec!["cln".to_string(), "localhost".to_string()]; + let mut params = rcgen::CertificateParams::new(subject_alt_names); + params.key_pair = Some(keypair); + params.alg = &rcgen::PKCS_ECDSA_P256_SHA256; + if parent.is_none() { + params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + } else { + params.is_ca = rcgen::IsCa::SelfSignedOnly; + } + params + .distinguished_name + .push(rcgen::DnType::CommonName, name); + + let cert = Certificate::from_params(params)?; + std::fs::write( + &cert_path, + match parent { + None => cert.serialize_pem()?, + Some(ca) => cert.serialize_pem_with_signer(&ca.to_certificate()?)?, + }, + ) + .context("writing certificate to file")?; + } + + let key = std::fs::read(&key_path)?; + let certificate = std::fs::read(cert_path)?; + Ok(Identity { certificate, key }) +} diff --git a/tests/test_cln_rs.py b/tests/test_cln_rs.py index 7fb9b76ac471..21bee1502ffe 100644 --- a/tests/test_cln_rs.py +++ b/tests/test_cln_rs.py @@ -30,8 +30,23 @@ def test_rpc_client(node_factory): def test_grpc_connect(node_factory): """Attempts to connect to the grpc interface and call getinfo""" bin_path = Path.cwd() / "target" / "debug" / "grpc-plugin" - node_factory.get_node(options={"plugin": str(bin_path)}) - channel = grpc.insecure_channel("localhost:50051") + l1 = node_factory.get_node(options={"plugin": str(bin_path)}) + + p = Path(l1.daemon.lightning_dir) / TEST_NETWORK + cert_path = p / "client.pem" + key_path = p / "client-key.pem" + ca_cert_path = p / "ca.pem" + creds = grpc.ssl_channel_credentials( + root_certificates=ca_cert_path.open('rb').read(), + private_key=key_path.open('rb').read(), + certificate_chain=cert_path.open('rb').read() + ) + + channel = grpc.secure_channel( + "localhost:50051", + creds, + options=(('grpc.ssl_target_name_override', 'cln'),) + ) stub = NodeStub(channel) response = stub.Getinfo(nodepb.GetinfoRequest()) @@ -39,3 +54,36 @@ def test_grpc_connect(node_factory): response = stub.ListFunds(nodepb.ListfundsRequest()) print(response) + + +def test_grpc_generate_certificate(node_factory): + """Test whether we correctly generate the certificates. + + - If we have no certs, we need to generate them all + - If we have certs, we they should just get loaded + - If we delete one cert or its key it should get regenerated. + """ + bin_path = Path.cwd() / "target" / "debug" / "grpc-plugin" + l1 = node_factory.get_node(options={ + "plugin": str(bin_path), + }, start=False) + + p = Path(l1.daemon.lightning_dir) / TEST_NETWORK + files = [p / f for f in ['ca.pem', 'ca-key.pem', 'client.pem', 'client-key.pem', 'server-key.pem', 'server.pem']] + + # Before starting no files exist. + assert [f.exists() for f in files] == [False]*len(files) + + l1.start() + assert [f.exists() for f in files] == [True]*len(files) + + # The files exist, restarting should not change them + contents = [f.open().read() for f in files] + l1.restart() + assert contents == [f.open().read() for f in files] + + # Now we delete the last file, we should regenerate it as well as its key + files[-1].unlink() + l1.restart() + assert contents[-2] != files[-2].open().read() + assert contents[-1] != files[-1].open().read()