grpc/tests/tls_roundtrip.rs
//! The gRPC front over **TLS and mutual TLS**, both ends of this crate, a test
//! PKI made here (rcgen): a server certificate for `localhost` / 127.0.0.1 and
//! a client certificate, both signed by one test CA.
//!
//! Run: `cargo test --features tls --test tls_roundtrip`.
#![cfg(feature = "tls")]
use std::collections::HashMap;
use std::sync::Arc;
use iceberg::io::LocalFsStorageFactory;
use iceberg::{Catalog, CatalogBuilder, NamespaceIdent};
use rcgen::{BasicConstraints, CertificateParams, DnType, ExtendedKeyUsagePurpose, IsCa, Issuer, KeyPair, SanType};
use skade_katalog::{RedbCatalog, RedbCatalogBuilder};
use skade_katalog_grpc::ServerTls;
use skade_katalog_grpc::client::{GrpcCatalog, GrpcTls};
use tempfile::TempDir;
struct Pki {
ca: String,
server_cert: String,
server_key: String,
client_cert: String,
client_key: String,
}
fn pki() -> Pki {
let ca_key = KeyPair::generate().unwrap();
let mut ca_params = CertificateParams::new(Vec::<String>::new()).unwrap();
ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
ca_params.distinguished_name.push(DnType::CommonName, "skade-katalog test ca");
let ca = ca_params.self_signed(&ca_key).unwrap();
let issuer = Issuer::from_params(&ca_params, &ca_key);
let server_key = KeyPair::generate().unwrap();
let mut sp = CertificateParams::new(vec!["localhost".to_string()]).unwrap();
sp.subject_alt_names.push(SanType::IpAddress("127.0.0.1".parse().unwrap()));
let server = sp.signed_by(&server_key, &issuer).unwrap();
let client_key = KeyPair::generate().unwrap();
let mut cp = CertificateParams::new(Vec::<String>::new()).unwrap();
cp.distinguished_name.push(DnType::CommonName, "bifrost");
cp.extended_key_usages = vec![ExtendedKeyUsagePurpose::ClientAuth];
let client = cp.signed_by(&client_key, &issuer).unwrap();
Pki {
ca: ca.pem(),
server_cert: server.pem(),
server_key: server_key.serialize_pem(),
client_cert: client.pem(),
client_key: client_key.serialize_pem(),
}
}
async fn embedded(tmp: &TempDir) -> RedbCatalog {
let warehouse = tmp.path().join("warehouse");
std::fs::create_dir_all(&warehouse).unwrap();
RedbCatalogBuilder::default()
.db_path(tmp.path().join("catalog.redb"))
.warehouse_location(format!("file://{}", warehouse.display()))
.with_storage_factory(Arc::new(LocalFsStorageFactory))
.load("skade", HashMap::new())
.await
.unwrap()
}
fn server_tls(p: &Pki, mutual: bool) -> ServerTls {
ServerTls {
cert_pem: p.server_cert.clone().into_bytes(),
key_pem: p.server_key.clone().into_bytes(),
client_ca_pem: mutual.then(|| p.ca.clone().into_bytes()),
}
}
fn client_tls(p: &Pki, with_cert: bool) -> GrpcTls {
GrpcTls {
ca_pem: Some(p.ca.clone().into_bytes()),
identity_pem: with_cert.then(|| (p.client_cert.clone().into_bytes(), p.client_key.clone().into_bytes())),
domain: Some("localhost".into()),
}
}
/// One round trip: create a namespace, list it back.
async fn round_trip(c: &GrpcCatalog) -> iceberg::Result<Vec<NamespaceIdent>> {
let ns = NamespaceIdent::new("db".into());
if !c.namespace_exists(&ns).await? {
c.create_namespace(&ns, HashMap::new()).await?;
}
c.list_namespaces(None).await
}
#[tokio::test]
async fn tls_front_serves_a_client_that_trusts_its_ca() {
let p = pki();
let tmp = TempDir::new().unwrap();
let bound = skade_katalog_grpc::spawn_with(embedded(&tmp).await, "127.0.0.1:0".parse().unwrap(), Some(server_tls(&p, false)))
.await
.unwrap();
let uri = format!("https://127.0.0.1:{}", bound.port());
let c = GrpcCatalog::connect_with(&uri, Arc::new(LocalFsStorageFactory), Some(client_tls(&p, false))).await.unwrap();
assert_eq!(round_trip(&c).await.unwrap(), vec![NamespaceIdent::new("db".into())]);
// A client that does not trust the CA (webpki roots) does not get in.
let stranger = GrpcCatalog::lazy_with(&uri, Arc::new(LocalFsStorageFactory), Some(GrpcTls { domain: Some("localhost".into()), ..Default::default() })).unwrap();
assert!(round_trip(&stranger).await.is_err(), "a server the client cannot verify is refused");
// Plain HTTP/2 to a TLS front fails too.
let plain = GrpcCatalog::lazy(&format!("http://127.0.0.1:{}", bound.port()), Arc::new(LocalFsStorageFactory)).unwrap();
assert!(round_trip(&plain).await.is_err(), "plain HTTP/2 against a TLS front");
}
#[tokio::test]
async fn mutual_tls_front_takes_only_a_client_with_a_certificate() {
let p = pki();
let tmp = TempDir::new().unwrap();
let bound = skade_katalog_grpc::spawn_with(embedded(&tmp).await, "127.0.0.1:0".parse().unwrap(), Some(server_tls(&p, true)))
.await
.unwrap();
let uri = format!("https://127.0.0.1:{}", bound.port());
let with_cert = GrpcCatalog::connect_with(&uri, Arc::new(LocalFsStorageFactory), Some(client_tls(&p, true))).await.unwrap();
assert_eq!(round_trip(&with_cert).await.unwrap(), vec![NamespaceIdent::new("db".into())]);
let without = GrpcCatalog::lazy_with(&uri, Arc::new(LocalFsStorageFactory), Some(client_tls(&p, false))).unwrap();
assert!(round_trip(&without).await.is_err(), "mutual TLS refuses a client without a certificate");
}