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

nordisk/holger

server/lib/tests/integration_oci_tls.rs

//! End-to-end: a writable OCI registry served over TLS.
//!
//! Drives the OCI Distribution v2 push/pull flow (the modern `helm push oci://`
//! path) with a rustls HTTPS client, and verifies that write access is gated on
//! auth while reads and health probes stay open.

use std::io::Write;
use traits::test_support::hold_port;

use holger_server_lib::{read_ron_config, wire_holger, Holger};

fn sha256_hex(data: &[u8]) -> String {
    use sha2::{Digest, Sha256};
    let mut h = Sha256::new();
    h.update(data);
    hex::encode(h.finalize())
}

/// Self-signed server cert + key (PEM) valid for localhost / 127.0.0.1.
fn server_cert_pem() -> (String, String) {
    let ck =
        rcgen::generate_simple_self_signed(vec!["localhost".to_string(), "127.0.0.1".to_string()])
            .unwrap();
    (ck.cert.pem(), ck.signing_key.serialize_pem())
}

fn write_temp(dir: &std::path::Path, name: &str, contents: &str) -> String {
    let p = dir.join(name);
    std::fs::write(&p, contents).unwrap();
    std::fs::set_permissions(
        &p,
        <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o600),
    )
    .unwrap();
    p.to_string_lossy().into_owned()
}

struct Running {
    _data: tempfile::TempDir,
    _certs: tempfile::TempDir,
    _cfg: tempfile::NamedTempFile,
    base: String,
    /// The started server, held so its doors stay up for the test.
    _holger: Holger,
}

async fn start(auth_ron: &str, https_port: u16, grpc_port: u16) -> Running {
    let data = tempfile::tempdir().unwrap();
    let certs = tempfile::tempdir().unwrap();
    let (cert_pem, key_pem) = server_cert_pem();
    let cert_path = write_temp(certs.path(), "tls.crt", &cert_pem);
    let key_path = write_temp(certs.path(), "tls.key", &key_pem);

    let config = format!(
        r#"(
    repositories: [
        (
            ron_name: "charts",
            ron_repo_type: "oci",
            ron_upstreams: [],
            ron_in: None,
            ron_out: Some((
                ron_storage_endpoint: "storage",
                ron_exposed_endpoint: "default",
            )),
            ron_data_dir: Some("{data}"),
        ),
    ],
    exposed_endpoints: [
        (
            ron_name: "default",
            ron_url: "127.0.0.1:{grpc}",
            ron_http_url: Some("127.0.0.1:{https}"),
            ron_tls: Some((cert: "{cert}", key: "{key}")),
        ),
    ],
    storage_endpoints: [
        (ron_name: "storage", ron_storage_type: "znippy", ron_path: "{data}"),
    ],
    {auth}
)"#,
        data = data.path().display(),
        grpc = grpc_port,
        https = https_port,
        cert = cert_path,
        key = key_path,
        auth = auth_ron,
    );

    let mut cfg = tempfile::NamedTempFile::new().unwrap();
    cfg.write_all(config.as_bytes()).unwrap();

    let mut holger: Holger = read_ron_config(cfg.path()).unwrap();
    holger.instantiate_backends().unwrap();
    wire_holger(&mut holger).unwrap();
    holger.start().unwrap();

    let base = format!("https://127.0.0.1:{https_port}");
    Running {
        _data: data,
        _certs: certs,
        _cfg: cfg,
        base,
        _holger: holger,
    }
}

fn client() -> reqwest::Client {
    // reqwest's rustls client also needs the process default provider.
    holger_server_lib::exposed::tls::ensure_crypto_provider();
    tls_roots(reqwest::Client::builder())
        .danger_accept_invalid_certs(true) // self-signed test cert
        .build()
        .unwrap()
}

async fn wait_healthy(c: &reqwest::Client, base: &str) {
    for _ in 0..100 {
        if let Ok(r) = c.get(format!("{base}/healthz")).send().await {
            if r.status() == 200 {
                return;
            }
        }
        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
    }
    panic!("gateway never became healthy at {base}");
}

/// Push a blob via the POST→PUT session flow, return its `sha256:…` digest.
async fn push_blob(c: &reqwest::Client, base: &str, image: &str, data: &[u8]) -> String {
    let digest = format!("sha256:{}", sha256_hex(data));
    let start = c
        .post(format!("{base}/v2/{image}/blobs/uploads/"))
        .send()
        .await
        .unwrap();
    assert_eq!(start.status(), 202, "blob upload start");
    let location = start
        .headers()
        .get("location")
        .unwrap()
        .to_str()
        .unwrap()
        .to_string();
    let put = c
        .put(format!("{base}{location}?digest={digest}"))
        .body(data.to_vec())
        .send()
        .await
        .unwrap();
    assert_eq!(put.status(), 201, "blob upload finalize");
    digest
}

#[tokio::test(flavor = "multi_thread")]
async fn oci_push_pull_over_tls() {
    let r = start("", hold_port(), hold_port()).await;
    let c = client();
    wait_healthy(&c, &r.base).await;

    // Version probe.
    let v2 = c.get(format!("{}/v2/", r.base)).send().await.unwrap();
    assert_eq!(v2.status(), 200);
    assert_eq!(
        v2.headers().get("docker-distribution-api-version").unwrap(),
        "registry/2.0"
    );

    // Push a Helm config blob and a chart-content blob.
    let config_blob = br#"{"name":"njord","version":"0.1.0"}"#;
    let chart_blob = b"fake-chart-tarball-bytes";
    let config_digest = push_blob(&c, &r.base, "charts/njord", config_blob).await;
    let chart_digest = push_blob(&c, &r.base, "charts/njord", chart_blob).await;

    // Push the manifest referencing both blobs.
    let manifest = format!(
        r#"{{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","config":{{"mediaType":"application/vnd.cncf.helm.config.v1+json","digest":"{config_digest}","size":{cs}}},"layers":[{{"mediaType":"application/vnd.cncf.helm.chart.content.v1.tar+gzip","digest":"{chart_digest}","size":{ls}}}]}}"#,
        cs = config_blob.len(),
        ls = chart_blob.len(),
    );
    let put = c
        .put(format!("{}/v2/charts/njord/manifests/0.1.0", r.base))
        .header("Content-Type", "application/vnd.oci.image.manifest.v1+json")
        .body(manifest.clone())
        .send()
        .await
        .unwrap();
    assert_eq!(put.status(), 201, "manifest put");

    // Pull manifest by tag (what `helm pull oci://…` resolves first).
    let got = c
        .get(format!("{}/v2/charts/njord/manifests/0.1.0", r.base))
        .send()
        .await
        .unwrap();
    assert_eq!(got.status(), 200);
    assert_eq!(got.text().await.unwrap(), manifest);

    // Pull the chart blob back, verify bytes.
    let blob = c
        .get(format!("{}/v2/charts/njord/blobs/{chart_digest}", r.base))
        .send()
        .await
        .unwrap();
    assert_eq!(blob.status(), 200);
    assert_eq!(blob.bytes().await.unwrap().as_ref(), chart_blob);

    // Tags list.
    let tags = c
        .get(format!("{}/v2/charts/njord/tags/list", r.base))
        .send()
        .await
        .unwrap()
        .text()
        .await
        .unwrap();
    assert!(tags.contains("\"0.1.0\""), "{tags}");
}

#[tokio::test(flavor = "multi_thread")]
async fn writes_blocked_without_auth_reads_open() {
    // OIDC issuer that can't be reached: any token fails, so writes must be
    // rejected; reads and health stay open.
    //
    // The fixture's intent is an IdP that cannot be REACHED, not one that cannot be
    // NAMED — the 401 assertions below only need "no token authenticates".
    //
    // It carries an inline, EMPTY key set rather than only a `jwks_uri`, because a
    // uri-only method fetches keys over the network, and a method with no
    // audience is refused at boot (A34-1, `auth::refuse_unbound_oidc`). An
    // empty `keys` array binds the method authoritatively and verifies nothing, so
    // every token is rejected offline and never reaches the network at all. That
    // is a sharper version of what this fixture always wanted.
    let auth = r#"access: (reads: Open, front: Open), auth: Some((methods: [Oidc(issuer_url: "http://127.0.0.1:1/nope", audience: Some("holger"), jwks: Some("{\"keys\":[]}"))],)),"#;
    let r = start(auth, hold_port(), hold_port()).await;
    let c = client();
    wait_healthy(&c, &r.base).await;

    // The version probe CHALLENGES a credentialless client on a server that has
    // an auth method — deliberately, since `ab65d75`: a 200 here made podman
    // conclude the registry wants nothing and never send the credential it had
    // stored. A 401 with `WWW-Authenticate` is a successful v2 detection to a
    // distribution client, not a closed door.
    let probe = c.get(format!("{}/v2/", r.base)).send().await.unwrap();
    assert_eq!(probe.status(), 401, "the probe must challenge");
    assert!(
        probe.headers().contains_key("www-authenticate"),
        "a challenge without WWW-Authenticate is just a refusal"
    );

    // Read path open: an anonymous READ of a repository is still answered on
    // its merits (here: nothing pushed, so not-found) and never refused for
    // want of a credential. `require_auth_for_reads` is off in this fixture.
    let read = c
        .get(format!("{}/v2/charts/njord/tags/list", r.base))
        .send()
        .await
        .unwrap()
        .status();
    assert_ne!(
        read, 401,
        "an anonymous read was refused for want of a credential"
    );
    assert_ne!(read, 403, "an anonymous read was forbidden");

    // Write (blob upload start is a POST) rejected without creds.
    let resp = c
        .post(format!("{}/v2/charts/njord/blobs/uploads/", r.base))
        .send()
        .await
        .unwrap();
    assert_eq!(resp.status(), 401);

    // Manifest PUT also rejected.
    let resp = c
        .put(format!("{}/v2/charts/njord/manifests/0.1.0", r.base))
        .body("{}")
        .send()
        .await
        .unwrap();
    assert_eq!(resp.status(), 401);
}

/// reqwest 0.13 with reqwest 0.12 `rustls-tls` behaviour (2026-10-02): the ring
/// provider and the bundled Mozilla roots (webpki), not 0.13's aws-lc-rs + OS
/// trust store. Roots added later on the builder are merged in, as in 0.12.
#[allow(dead_code)]
fn tls_roots(b: reqwest::ClientBuilder) -> reqwest::ClientBuilder {
    let _ = rustls::crypto::ring::default_provider().install_default();
    b.tls_certs_only(
        webpki_root_certs::TLS_SERVER_ROOT_CERTS
            .iter()
            .filter_map(|c| reqwest::Certificate::from_der(c).ok()),
    )
}

// ── TLS 1.3 only, on the booted listeners (stinky SB #39) ──────────────────────

/// Accepts any server certificate: these handshakes test the protocol version a
/// booted door agrees to, not its identity (the self-signed test certificate).
#[derive(Debug)]
struct AnyServer(Arc<rustls::crypto::CryptoProvider>);

use std::sync::Arc;

impl rustls::client::danger::ServerCertVerifier for AnyServer {
    fn verify_server_cert(
        &self,
        _end_entity: &rustls::pki_types::CertificateDer<'_>,
        _intermediates: &[rustls::pki_types::CertificateDer<'_>],
        _server_name: &rustls::pki_types::ServerName<'_>,
        _ocsp_response: &[u8],
        _now: rustls::pki_types::UnixTime,
    ) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
        Ok(rustls::client::danger::ServerCertVerified::assertion())
    }
    fn verify_tls12_signature(
        &self,
        message: &[u8],
        cert: &rustls::pki_types::CertificateDer<'_>,
        dss: &rustls::DigitallySignedStruct,
    ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
        rustls::crypto::verify_tls12_signature(
            message,
            cert,
            dss,
            &self.0.signature_verification_algorithms,
        )
    }
    fn verify_tls13_signature(
        &self,
        message: &[u8],
        cert: &rustls::pki_types::CertificateDer<'_>,
        dss: &rustls::DigitallySignedStruct,
    ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
        rustls::crypto::verify_tls13_signature(
            message,
            cert,
            dss,
            &self.0.signature_verification_algorithms,
        )
    }
    fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
        self.0.signature_verification_algorithms.supported_schemes()
    }
}

/// One TLS handshake with the listener on `port`, offering only `version`: the
/// version agreed, or why the handshake failed.
fn handshake(
    port: u16,
    version: &'static rustls::SupportedProtocolVersion,
    alpn: &[&[u8]],
) -> Result<rustls::ProtocolVersion, String> {
    let provider = Arc::new(rustls::crypto::ring::default_provider());
    let mut cfg = rustls::ClientConfig::builder_with_provider(Arc::clone(&provider))
        .with_protocol_versions(&[version])
        .map_err(|e| e.to_string())?
        .dangerous()
        .with_custom_certificate_verifier(Arc::new(AnyServer(provider)))
        .with_no_client_auth();
    cfg.alpn_protocols = alpn.iter().map(|a| a.to_vec()).collect();
    let name = rustls::pki_types::ServerName::try_from("127.0.0.1").unwrap();
    let mut conn = rustls::ClientConnection::new(Arc::new(cfg), name).map_err(|e| e.to_string())?;
    let mut sock =
        std::net::TcpStream::connect(("127.0.0.1", port)).map_err(|e| format!("connect: {e}"))?;
    sock.set_read_timeout(Some(std::time::Duration::from_secs(5)))
        .unwrap();
    while conn.is_handshaking() {
        conn.complete_io(&mut sock).map_err(|e| e.to_string())?;
    }
    conn.protocol_version()
        .ok_or_else(|| "no version agreed".into())
}

const HTTP1: &[&[u8]] = &[b"http/1.1"];
const H2: &[&[u8]] = &[b"h2"];

/// The listener on `port` accepts a TCP connection (the gRPC door binds after the HTTP one).
async fn wait_listening(port: u16) {
    for _ in 0..250 {
        if std::net::TcpStream::connect(("127.0.0.1", port)).is_ok() {
            return;
        }
        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
    }
    panic!("nothing listens on 127.0.0.1:{port}");
}

/// ★ **The booted server's doors speak TLS 1.3 only** (stinky SB #39), through the
/// listeners `Holger::start` opens from the RON's `ron_tls`, not the rustls configs
/// alone (`exposed::tls`'s unit test). The HTTPS gateway and the gRPC door both
/// refuse a client that offers TLS 1.2 at most, and both agree TLS 1.3 with a client
/// that offers it, over the same listener, right after the refusal.
#[tokio::test(flavor = "multi_thread")]
async fn the_booted_https_and_grpc_doors_refuse_tls_1_2_and_speak_tls_1_3() {
    let (https, grpc) = (hold_port(), hold_port());
    let r = start("", https, grpc).await;
    let c = client();
    wait_healthy(&c, &r.base).await;
    wait_listening(grpc).await;
    for (door, port, alpn) in [
        ("the HTTPS gateway", https, HTTP1),
        ("the gRPC door", grpc, H2),
    ] {
        let refused =
            tokio::task::spawn_blocking(move || handshake(port, &rustls::version::TLS12, alpn))
                .await
                .unwrap();
        assert!(refused.is_err(), "{door} agreed TLS 1.2: {refused:?}");
        let spoken =
            tokio::task::spawn_blocking(move || handshake(port, &rustls::version::TLS13, alpn))
                .await
                .unwrap();
        assert_eq!(
            spoken,
            Ok(rustls::ProtocolVersion::TLSv1_3),
            "{door} speaks TLS 1.3"
        );
    }
    // The door still serves after the refused handshakes.
    assert_eq!(
        c.get(format!("{}/healthz", r.base))
            .send()
            .await
            .unwrap()
            .status(),
        200
    );
    // HOL-NCSC (NCSC P1 item 5): each refused handshake left an audit row, per door.
    let dir = r._holger.resolved_audit_dir();
    let mut rows = String::new();
    for _ in 0..50 {
        rows = std::fs::read_dir(&dir)
            .into_iter()
            .flatten()
            .flatten()
            .filter_map(|e| std::fs::read_to_string(e.path()).ok())
            .collect();
        if rows.contains("TLS handshake refused")
            && rows.contains("gRPC door")
            && rows.contains("HTTPS door")
        {
            break;
        }
        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
    }
    for door in ["gRPC door", "HTTPS door"] {
        assert!(
            rows.lines().any(|l| l.contains("TLS handshake refused")
                && l.contains(door)
                && l.contains("127.0.0.1")),
            "no audit row for the {door}'s refused handshake: {rows}"
        );
    }
}