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

nordisk/gunnar

crates/gunnar-grpc/tests/tls13_only.rs

//! **The TLS 1.3 floor, and the plaintext floor.**
//!
//! Two rulings are pinned here.
//!
//! * The owner ruled *WireGuard AND mTLS*, TLS 1.3 only, no downgrade and no
//!   negotiation surface. tonic 0.14 has no version knob — both halves call
//!   `with_safe_default_protocol_versions()`, which is 1.2 AND 1.3 — so the
//!   enforcement is the process-wide rustls provider, and these tests are what
//!   say it is really there.
//! * A gunnar-to-gunnar call never leaves a machine in the clear. `plain_channel`
//!   is loopback-only; the twin's replication lane, which carries the KEK as
//!   seq 1 of the log, is the caller this is about.
//!
//! **Every assertion below can fail**, which is the point the estate keeps
//! re-learning: the filter is checked against an unfiltered provider that
//! really does carry TLS 1.2 suites, and the loopback predicate is asserted in
//! both directions. A test that could only ever pass is the health line that
//! cannot fail.
//!
//! `refuse_unless_tls13_only` reads the PROCESS default, and `install_default`
//! is once per process — so its two answers live in two separate test binaries:
//! this one for the clean answer, `tls13_provider_lost.rs` for the refusal.

use gunnar_grpc::client_signer::{is_loopback_target, plain_channel, tls_domain_for};

/// The filter is not vacuous: `ring`'s default provider really does carry
/// suites below TLS 1.3, and [`gunnar_grpc::tls13_only_provider`] really does
/// remove them.
///
/// Without the first half this file would pass against a filter that did
/// nothing at all.
#[test]
fn the_filter_removes_suites_that_were_actually_there() {
    let unfiltered = rustls::crypto::ring::default_provider();
    let below: Vec<_> = unfiltered
        .cipher_suites
        .iter()
        .filter(|s| s.version().version != rustls::ProtocolVersion::TLSv1_3)
        .collect();
    assert!(
        !below.is_empty(),
        "ring's default provider carries no sub-TLS-1.3 suite, so filtering it proves nothing. \
         If rustls has dropped TLS 1.2 entirely this test should be deleted, not weakened."
    );

    let filtered = gunnar_grpc::tls13_only_provider();
    let still_below: Vec<_> = filtered
        .cipher_suites
        .iter()
        .filter(|s| s.version().version != rustls::ProtocolVersion::TLSv1_3)
        .map(|s| format!("{:?}", s.suite()))
        .collect();
    assert!(
        still_below.is_empty(),
        "tls13_only_provider still offers {still_below:?}, so a peer could negotiate TLS 1.2"
    );
    assert!(
        !filtered.cipher_suites.is_empty(),
        "a provider with no suites cannot handshake at all"
    );
}

/// The bulk-transfer preference the owner asked for, and it follows the CPU
/// rather than a constant.
#[test]
fn the_preferred_suite_follows_the_hardware() {
    let provider = gunnar_grpc::tls13_only_provider();
    let first = provider
        .cipher_suites
        .first()
        .expect("at least one TLS 1.3 suite")
        .suite();

    if gunnar_grpc::aes_is_accelerated() {
        assert_eq!(
            first,
            rustls::CipherSuite::TLS13_AES_128_GCM_SHA256,
            "this CPU has AES-NI and PCLMULQDQ, so AES-128-GCM must be preferred: it is the \
             hardware path, and WireGuard's ChaCha20 underneath then runs on different \
             execution units instead of queueing behind the record layer"
        );
    } else {
        assert_eq!(
            first,
            rustls::CipherSuite::TLS13_CHACHA20_POLY1305_SHA256,
            "no AES-NI on this CPU, so a software AES-GCM would be the slower half and the \
             layering argument inverts"
        );
    }
    // AES-256-GCM is kept but never preferred: same hardware path, more rounds.
    assert_ne!(first, rustls::CipherSuite::TLS13_AES_256_GCM_SHA384);
    assert!(
        gunnar_grpc::preferred_bulk_suite().contains(if gunnar_grpc::aes_is_accelerated() {
            "AES_128_GCM"
        } else {
            "CHACHA20"
        }),
        "the reported preference must be the one actually installed: {}",
        gunnar_grpc::preferred_bulk_suite()
    );
}

/// With the 1.3-only provider installed, the pre-handshake check passes.
///
/// This test owns the process's `install_default`, which is why nothing else in
/// this file builds a listener or a channel.
#[test]
fn the_check_passes_when_the_right_provider_won() {
    gunnar_grpc::ensure_crypto_provider();
    let offending = gunnar_grpc::non_tls13_suites().expect("a provider is installed");
    assert!(
        offending.is_empty(),
        "installed provider still offers {offending:?}"
    );
    gunnar_grpc::refuse_unless_tls13_only().expect("the 1.3-only provider must satisfy the check");
}

/// `plain_channel` serves this box and refuses every other one, **by name**.
///
/// The refusal names the reason (the KEK rides this log) and the way across
/// (`mutual_channel`), because a refusal that only says "no" gets worked around.
// A tokio test because the loopback half really builds a channel, and
// `connect_lazy` registers with the reactor. The refusal half needs no runtime
// — it never gets that far, which is itself the property.
#[tokio::test]
async fn plaintext_never_leaves_the_box() {
    for here in [
        "127.0.0.1:50051",
        "localhost:50051",
        "[::1]:50051",
        "http://127.0.0.1:50051",
        "127.0.0.1",
    ] {
        assert!(is_loopback_target(here), "{here} is this box");
        assert!(
            plain_channel(here).is_ok(),
            "{here} is loopback and must still be dialable in the clear"
        );
    }

    for elsewhere in [
        "10.77.0.1:50051",         // the WireGuard address of a primary
        "192.168.1.9:50051",       // the underlay
        "http://10.77.0.1:50051",  // a scheme does not make it local
        "primary.example:50051",   // a name is not evidence of anything
        "127.0.0.1.example:50051", // and neither is a name that looks like one
    ] {
        assert!(
            !is_loopback_target(elsewhere),
            "{elsewhere} is NOT this box"
        );
        let err =
            plain_channel(elsewhere).expect_err("a call off this box must not go in the clear");
        assert!(
            err.contains("mutual_channel"),
            "the refusal must name the way across: {err}"
        );
        assert!(
            err.contains("KEK"),
            "the refusal must say what is at stake: {err}"
        );
    }
}

/// The certificate is checked against a HOST, never `host:port`.
///
/// `10.77.0.1:50051` is in no certificate anywhere; passing it through would
/// turn every mutual dial into a name-mismatch nobody could explain.
#[test]
fn the_tls_domain_is_the_host_alone() {
    assert_eq!(tls_domain_for("10.77.0.1:50051"), "10.77.0.1");
    assert_eq!(tls_domain_for("https://10.77.0.1:50051"), "10.77.0.1");
    assert_eq!(tls_domain_for("primary.example:50051"), "primary.example");
    assert_eq!(tls_domain_for("10.77.0.1"), "10.77.0.1");
    assert_eq!(tls_domain_for("[fd00::1]:50051"), "fd00::1");
}