Files
punktfunk/crates/punktfunk-host/src/gamestream/tls.rs
T
enricobuehler 17a262aace
android / android (push) Has been cancelled
audit / bun-audit (push) Successful in 14s
apple / screenshots (push) Has been cancelled
apple / swift (push) Has been cancelled
arch / build-publish (push) Has been cancelled
audit / cargo-audit (push) Has been cancelled
deb / build-publish (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 21s
decky / build-publish (push) Successful in 32s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 21s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 19s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 20s
docker / deploy-docs (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
release / apple (push) Has been cancelled
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Has been cancelled
windows-host / package (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
fix(host/deps): ring-only crypto — drop aws-lc-sys (fails to build on the Windows CI)
The Windows host clippy CI broke building aws-lc-sys 0.41.0 (a heavy C dep that
cl.exe fails to compile on the runner). The host was the ONLY thing pulling it:
its rcgen used features=["aws_lc_rs"], and bare `rustls`/`tokio-rustls` take
the default (aws-lc-rs) backend, and gamestream/{mod,tls}.rs installed the
aws_lc_rs crypto provider explicitly — a pre-existing deviation from the
project's documented ring-only design (punktfunk-core + the client are ring).

Align the host to ring: rcgen/rustls/tokio-rustls pinned to the `ring` feature
(keeping tls12 for Moonlight/GameStream TLS-1.2 clients) and the two GameStream
provider installs switched to `rustls::crypto::ring::default_provider()`. Both
providers offer the same standard TLS 1.2/1.3 suites, so it's transparent to the
TLS paths; aws-lc-sys/aws-lc-rs leave the tree entirely (`cargo tree -i
aws-lc-sys` => no match). Not caused by the preceding log fix (that touched no
crypto deps / the lock).

Validated: cargo tree (aws-lc-sys gone, windows target, amf-qsv,qsv features) +
.21 host clippy -D warnings (--features nvenc) clean. Owed: GameStream on-glass
re-check with a real Moonlight client (low risk — same TLS suites, client is
already ring).
2026-07-17 22:21:23 +02:00

203 lines
8.4 KiB
Rust

//! TLS for the HTTPS nvhttp port (47984) and the management API. Moonlight does **mutual TLS** —
//! it presents its client cert and expects the server to request one — so a plain server-auth
//! config makes the post-pairing `pairchallenge` fail. This config requests the client cert and
//! verifies the client owns its key, but accepts any well-formed cert at the *handshake* (the
//! pairing ceremony is the real proof of identity). Authorization against the paired allow-list is
//! then enforced per-request: [`serve_https`] reads the verified peer cert and attaches its
//! fingerprint ([`PeerCertFingerprint`]) to each request, and the nvhttp/mgmt handlers reject
//! callers whose fingerprint is not pinned (mirroring Apollo's post-handshake `get_verified_cert`).
use anyhow::{Context, Result};
use axum::Router;
use rustls::client::danger::HandshakeSignatureValid;
use rustls::crypto::{verify_tls12_signature, verify_tls13_signature, CryptoProvider};
use rustls::pki_types::pem::PemObject;
use rustls::pki_types::{CertificateDer, PrivateKeyDer, UnixTime};
use rustls::server::danger::{ClientCertVerified, ClientCertVerifier};
use rustls::{DigitallySignedStruct, DistinguishedName, ServerConfig, SignatureScheme};
use std::net::SocketAddr;
use std::sync::Arc;
/// SHA-256 of the peer's client certificate (hex), injected per-connection into each request's
/// extensions by [`serve_https`]; `None` when the peer presented no client cert (plain HTTP, or a
/// browser falling back to a bearer token). Handlers authorize a request whose fingerprint is in
/// the paired store.
#[derive(Clone)]
pub(crate) struct PeerCertFingerprint(pub Option<String>);
/// The TCP source address of an HTTPS request, injected per-connection by [`serve_https`]. Used by
/// `/launch` to record which paired client owns the session so the unauthenticated RTSP/UDP media
/// plane can bind to that peer's IP (security-review 2026-06-28 #4).
#[derive(Clone, Copy)]
pub(crate) struct PeerAddr(pub SocketAddr);
/// HTTPS server that surfaces the verified client cert to handlers. `axum_server` can't expose the
/// peer cert, so this runs the rustls handshake itself (tokio-rustls), reads the peer certificate,
/// and serves the axum `Router` over hyper with the peer's fingerprint attached to every request as
/// a [`PeerCertFingerprint`] extension. Shared by the nvhttp HTTPS listener and the management API.
pub(crate) async fn serve_https(
bind: SocketAddr,
app: Router,
tls: Arc<ServerConfig>,
) -> Result<()> {
use tower::ServiceExt;
let acceptor = tokio_rustls::TlsAcceptor::from(tls);
let listener = tokio::net::TcpListener::bind(bind)
.await
.with_context(|| format!("bind HTTPS {bind}"))?;
loop {
let (tcp, peer) = match listener.accept().await {
Ok(v) => v,
Err(e) => {
// A persistent accept() error (fd exhaustion / EMFILE) would otherwise hot-spin
// this loop and storm the log; back off so a stuck accept can't burn a core.
tracing::warn!(error = %e, "HTTPS accept failed — backing off 100ms");
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
continue;
}
};
let acceptor = acceptor.clone();
let app = app.clone();
tokio::spawn(async move {
let tls_stream = match acceptor.accept(tcp).await {
Ok(s) => s,
// A failed handshake is routine (port scan, a browser bailing on the self-signed
// cert, a peer that hung up) — not fatal.
Err(_) => return,
};
// The verified peer cert (the verifier accepts any well-formed one; handlers authorize
// by fingerprint) → its SHA-256, matched against the paired store.
let fp = tls_stream
.get_ref()
.1
.peer_certificates()
.and_then(|c| c.first())
.map(|c| hex::encode(punktfunk_core::quic::endpoint::cert_fingerprint(c.as_ref())));
let fp = PeerCertFingerprint(fp);
let addr = PeerAddr(peer);
let svc =
hyper::service::service_fn(move |req: hyper::Request<hyper::body::Incoming>| {
let app = app.clone();
let fp = fp.clone();
async move {
let mut req = req.map(axum::body::Body::new);
req.extensions_mut().insert(fp);
req.extensions_mut().insert(addr);
app.oneshot(req).await // Router error is Infallible
}
});
let io = hyper_util::rt::TokioIo::new(tls_stream);
let _ =
hyper_util::server::conn::auto::Builder::new(hyper_util::rt::TokioExecutor::new())
.serve_connection_with_upgrades(io, svc)
.await;
});
}
}
/// Requests + signature-checks the client cert but accepts any (the pairing handshake is
/// the real proof). Pinning to the paired set is a hardening follow-up.
#[derive(Debug)]
struct AcceptAnyClientCert {
provider: Arc<CryptoProvider>,
/// nvhttp/pairing REQUIRES the client cert (mandatory); the mgmt API REQUESTS it but lets a
/// certless peer (a browser, falling back to a bearer token) through (optional).
mandatory: bool,
}
impl ClientCertVerifier for AcceptAnyClientCert {
fn offer_client_auth(&self) -> bool {
true
}
fn client_auth_mandatory(&self) -> bool {
self.mandatory
}
fn root_hint_subjects(&self) -> &[DistinguishedName] {
&[]
}
fn verify_client_cert(
&self,
_end_entity: &CertificateDer,
_intermediates: &[CertificateDer],
_now: UnixTime,
) -> Result<ClientCertVerified, rustls::Error> {
Ok(ClientCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &CertificateDer,
dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
verify_tls12_signature(
message,
cert,
dss,
&self.provider.signature_verification_algorithms,
)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &CertificateDer,
dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
verify_tls13_signature(
message,
cert,
dss,
&self.provider.signature_verification_algorithms,
)
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
self.provider
.signature_verification_algorithms
.supported_schemes()
}
}
/// Build a mutual-TLS `ServerConfig` presenting the host cert/key. nvhttp/pairing path: the
/// client cert is **mandatory**.
pub fn server_config(cert_pem: &str, key_pem: &str) -> Result<Arc<ServerConfig>> {
build_server_config(cert_pem, key_pem, true)
}
/// Like [`server_config`] but the client cert is **optional** — a certless peer (a browser using a
/// bearer token) still completes the handshake. Used by the management API's mTLS auth: a paired
/// client presents its cert (authorized by fingerprint), everyone else falls back to the token.
pub fn server_config_optional_client(cert_pem: &str, key_pem: &str) -> Result<Arc<ServerConfig>> {
build_server_config(cert_pem, key_pem, false)
}
fn build_server_config(
cert_pem: &str,
key_pem: &str,
mandatory: bool,
) -> Result<Arc<ServerConfig>> {
let provider = Arc::new(rustls::crypto::ring::default_provider());
// PEM parsing via rustls-pki-types (the same `PemObject` path punktfunk-core/quic.rs uses),
// so we don't pull the unmaintained `rustls-pemfile`.
let certs = CertificateDer::pem_slice_iter(cert_pem.as_bytes())
.collect::<std::result::Result<Vec<_>, _>>()
.context("parse host cert PEM")?;
let key = PrivateKeyDer::from_pem_slice(key_pem.as_bytes()).context("parse host key PEM")?;
let verifier = Arc::new(AcceptAnyClientCert {
provider: provider.clone(),
mandatory,
});
let config = ServerConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.context("rustls protocol versions")?
.with_client_cert_verifier(verifier)
.with_single_cert(certs, key)
.context("rustls server cert")?;
Ok(Arc::new(config))
}