Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/skade

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");
}