Files
punktfunk/crates/punktfunk-host/src/gamestream/tls.rs
T
enricobuehler 705a8fa94e chore(deps): drop unmaintained rustls-pemfile; axum-server 0.7 -> 0.8
axum-server was used only for the plain-HTTP nvhttp listener, but we enabled
its tls-rustls feature (HTTPS is hand-rolled over tokio-rustls) — and that
feature was what pulled the unmaintained rustls-pemfile (RUSTSEC-2025-0134).
Drop the feature, bump axum-server to 0.8 (0.8 also no longer pulls it), and
move our own PEM parsing in gamestream/tls.rs to rustls-pki-types' PemObject
(the same path punktfunk-core/quic.rs already uses), removing our direct
rustls-pemfile dep too.

Net: rustls-pemfile fully gone; dependency graph trimmed 547 -> 529 crates
(the tls-rustls feature also dragged in prettyplease + a wasm-tooling chain).
cargo audit now reports only audiopus_sys + paste (transitive, latest, no
successor). 108 host tests + clippy + fmt green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 06:32:58 +00:00

200 lines
8.1 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) => {
tracing::warn!(error = %e, "HTTPS accept failed");
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::aws_lc_rs::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))
}