0.3.5-alpha.2
This commit is contained in:
283
crates/common/game-realtime-webtransport-lib/src/webtransport.rs
Normal file
283
crates/common/game-realtime-webtransport-lib/src/webtransport.rs
Normal file
@@ -0,0 +1,283 @@
|
||||
// file: crates/common/game-realtime-webtransport-lib/src/webtransport.rs
|
||||
// version: 1
|
||||
|
||||
const CERTIFICATE_HASH_SIZE: usize = 32;
|
||||
const LOCAL_CERTIFICATE_CLOCK_SKEW: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
const LOCAL_CERTIFICATE_VALIDITY: std::time::Duration = std::time::Duration::from_secs(7 * 24 * 60 * 60);
|
||||
const TRACING_TARGET: &str = "games::realtime::webtransport";
|
||||
|
||||
/// SHA-256 fingerprint of one certificate accepted by the native WebTransport client.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct WebTransportCertificateHash {
|
||||
bytes: [u8; CERTIFICATE_HASH_SIZE],
|
||||
}
|
||||
|
||||
impl WebTransportCertificateHash {
|
||||
/// Creates a fingerprint from an already-computed SHA-256 digest.
|
||||
#[must_use]
|
||||
pub fn from_sha256(bytes: [u8; CERTIFICATE_HASH_SIZE]) -> Self {
|
||||
return Self { bytes };
|
||||
}
|
||||
|
||||
/// Returns the exact 32-byte SHA-256 digest.
|
||||
#[must_use]
|
||||
pub fn as_bytes(&self) -> &[u8; CERTIFICATE_HASH_SIZE] {
|
||||
return &self.bytes;
|
||||
}
|
||||
}
|
||||
|
||||
/// Self-contained certificate/private-key identity used by a native WebTransport server.
|
||||
pub struct WebTransportServerIdentity {
|
||||
certificate_der: Vec<u8>,
|
||||
private_key_pkcs8_der: Vec<u8>,
|
||||
certificate_hash: WebTransportCertificateHash,
|
||||
}
|
||||
|
||||
impl WebTransportServerIdentity {
|
||||
/// Generates a short-lived self-signed ECDSA P-256 identity for localhost and loopback addresses.
|
||||
pub fn generate_loopback() -> Result<Self, game_realtime_transport_lib::TransportError> {
|
||||
let now = std::time::SystemTime::now();
|
||||
let not_before = match now.checked_sub(LOCAL_CERTIFICATE_CLOCK_SKEW) {
|
||||
Some(value) => value,
|
||||
None => return Err(invalid_configuration("failed to compute local certificate not-before time")),
|
||||
};
|
||||
let not_after = match now.checked_add(LOCAL_CERTIFICATE_VALIDITY) {
|
||||
Some(value) => value,
|
||||
None => return Err(invalid_configuration("failed to compute local certificate not-after time")),
|
||||
};
|
||||
let subject_alt_names = vec!["localhost".to_owned(), "127.0.0.1".to_owned(), "::1".to_owned()];
|
||||
let mut params = match rcgen::CertificateParams::new(subject_alt_names) {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(invalid_configuration(error.to_string())),
|
||||
};
|
||||
params.not_before = not_before.into();
|
||||
params.not_after = not_after.into();
|
||||
let key_pair = match rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256) {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(invalid_configuration(error.to_string())),
|
||||
};
|
||||
let certificate = match params.self_signed(&key_pair) {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(invalid_configuration(error.to_string())),
|
||||
};
|
||||
let certificate_der = certificate.der().to_vec();
|
||||
let private_key_pkcs8_der = key_pair.serialize_der();
|
||||
return Self::from_pkcs8_der(certificate_der, private_key_pkcs8_der);
|
||||
}
|
||||
|
||||
/// Builds an identity from an X.509 certificate DER blob and its PKCS#8 private key DER blob.
|
||||
///
|
||||
/// Certificate/key compatibility is validated by the native TLS server builder when the listener is bound.
|
||||
pub fn from_pkcs8_der(certificate_der: Vec<u8>, private_key_pkcs8_der: Vec<u8>) -> Result<Self, game_realtime_transport_lib::TransportError> {
|
||||
if certificate_der.is_empty() {
|
||||
return Err(invalid_configuration("certificate DER must not be empty"));
|
||||
}
|
||||
if private_key_pkcs8_der.is_empty() {
|
||||
return Err(invalid_configuration("PKCS#8 private-key DER must not be empty"));
|
||||
}
|
||||
let certificate_hash = match certificate_hash(certificate_der.as_slice()) {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
return Ok(Self { certificate_der, private_key_pkcs8_der, certificate_hash });
|
||||
}
|
||||
|
||||
/// Returns the SHA-256 certificate fingerprint used for native hash pinning.
|
||||
#[must_use]
|
||||
pub fn certificate_hash(&self) -> &WebTransportCertificateHash {
|
||||
return &self.certificate_hash;
|
||||
}
|
||||
}
|
||||
|
||||
/// Native WebTransport client endpoint and pinned server-certificate fingerprint.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct WebTransportClientConfig {
|
||||
endpoint: url::Url,
|
||||
certificate_hash: WebTransportCertificateHash,
|
||||
}
|
||||
|
||||
impl WebTransportClientConfig {
|
||||
/// Parses and validates a secure WebTransport endpoint with one pinned SHA-256 certificate fingerprint.
|
||||
pub fn new(endpoint: &str, certificate_hash: WebTransportCertificateHash) -> Result<Self, game_realtime_transport_lib::TransportError> {
|
||||
let parsed = match url::Url::parse(endpoint) {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(invalid_configuration(error.to_string())),
|
||||
};
|
||||
if parsed.scheme() != "https" {
|
||||
return Err(invalid_configuration("WebTransport endpoint scheme must be https"));
|
||||
}
|
||||
if parsed.host().is_none() {
|
||||
return Err(invalid_configuration("WebTransport endpoint must contain a host"));
|
||||
}
|
||||
return Ok(Self { endpoint: parsed, certificate_hash });
|
||||
}
|
||||
|
||||
/// Returns the validated WebTransport endpoint URL.
|
||||
#[must_use]
|
||||
pub fn endpoint(&self) -> &str {
|
||||
return self.endpoint.as_str();
|
||||
}
|
||||
|
||||
/// Returns the pinned SHA-256 server-certificate fingerprint.
|
||||
#[must_use]
|
||||
pub fn certificate_hash(&self) -> &WebTransportCertificateHash {
|
||||
return &self.certificate_hash;
|
||||
}
|
||||
}
|
||||
|
||||
/// Native WebTransport server bind address and TLS identity.
|
||||
pub struct WebTransportServerConfig {
|
||||
bind_address: std::net::SocketAddr,
|
||||
identity: WebTransportServerIdentity,
|
||||
}
|
||||
|
||||
impl WebTransportServerConfig {
|
||||
/// Creates native server configuration for the requested bind address and TLS identity.
|
||||
#[must_use]
|
||||
pub fn new(bind_address: std::net::SocketAddr, identity: WebTransportServerIdentity) -> Self {
|
||||
return Self { bind_address, identity };
|
||||
}
|
||||
|
||||
/// Returns the requested UDP bind address.
|
||||
#[must_use]
|
||||
pub fn bind_address(&self) -> std::net::SocketAddr {
|
||||
return self.bind_address;
|
||||
}
|
||||
|
||||
/// Returns the server certificate fingerprint that clients must pin for this identity.
|
||||
#[must_use]
|
||||
pub fn certificate_hash(&self) -> &WebTransportCertificateHash {
|
||||
return self.identity.certificate_hash();
|
||||
}
|
||||
}
|
||||
|
||||
/// Established native WebTransport session before application-stream adaptation.
|
||||
pub struct WebTransportSession {
|
||||
inner: web_transport_quinn::Session,
|
||||
}
|
||||
|
||||
impl WebTransportSession {
|
||||
fn new(inner: web_transport_quinn::Session) -> Self {
|
||||
return Self { inner };
|
||||
}
|
||||
|
||||
/// Returns the remote UDP socket address backing the established QUIC connection.
|
||||
#[must_use]
|
||||
pub fn remote_addr(&self) -> std::net::SocketAddr {
|
||||
return self.inner.remote_address();
|
||||
}
|
||||
|
||||
/// Returns the HTTP/3 CONNECT URL used to establish this session when available.
|
||||
#[must_use]
|
||||
pub fn request_url(&self) -> Option<&str> {
|
||||
return match self.inner.request() {
|
||||
Some(request) => Some(request.url.as_str()),
|
||||
None => None,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Bound native WebTransport server endpoint that accepts HTTP/3 WebTransport sessions.
|
||||
pub struct WebTransportListener {
|
||||
server: web_transport_quinn::Server,
|
||||
local_addr: std::net::SocketAddr,
|
||||
}
|
||||
|
||||
impl WebTransportListener {
|
||||
/// Binds a native WebTransport server using TLS 1.3 and the configured certificate identity.
|
||||
pub fn bind(config: WebTransportServerConfig) -> Result<Self, game_realtime_transport_lib::TransportError> {
|
||||
let certificate = web_transport_quinn::quinn::rustls::pki_types::CertificateDer::from(config.identity.certificate_der);
|
||||
let private_key = web_transport_quinn::quinn::rustls::pki_types::PrivatePkcs8KeyDer::from(config.identity.private_key_pkcs8_der);
|
||||
let private_key = web_transport_quinn::quinn::rustls::pki_types::PrivateKeyDer::Pkcs8(private_key);
|
||||
let server = match web_transport_quinn::ServerBuilder::new().with_addr(config.bind_address).with_certificate(vec![certificate], private_key) {
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
let mapped = transport_error(game_realtime_transport_lib::TransportErrorKind::Bind, error.to_string());
|
||||
tracing::warn!(target: TRACING_TARGET, address = %config.bind_address, detail = mapped.detail(), "WebTransport listener bind failed");
|
||||
return Err(mapped);
|
||||
},
|
||||
};
|
||||
let local_addr = match server.local_addr() {
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
let mapped = transport_error(game_realtime_transport_lib::TransportErrorKind::Bind, error.to_string());
|
||||
tracing::warn!(target: TRACING_TARGET, detail = mapped.detail(), "bound WebTransport listener address lookup failed");
|
||||
return Err(mapped);
|
||||
},
|
||||
};
|
||||
tracing::info!(target: TRACING_TARGET, address = %local_addr, "WebTransport listener bound");
|
||||
return Ok(Self { server, local_addr });
|
||||
}
|
||||
|
||||
/// Returns the concrete UDP socket address, including an ephemeral port selected by the OS.
|
||||
#[must_use]
|
||||
pub fn local_addr(&self) -> std::net::SocketAddr {
|
||||
return self.local_addr;
|
||||
}
|
||||
|
||||
/// Accepts one native WebTransport CONNECT request and returns the established session.
|
||||
pub async fn accept(&mut self) -> Result<WebTransportSession, game_realtime_transport_lib::TransportError> {
|
||||
let request = match self.server.accept().await {
|
||||
Some(value) => value,
|
||||
None => {
|
||||
let error = transport_error(game_realtime_transport_lib::TransportErrorKind::Accept, "WebTransport server stopped accepting sessions");
|
||||
tracing::warn!(target: TRACING_TARGET, detail = error.detail(), "WebTransport accept ended");
|
||||
return Err(error);
|
||||
},
|
||||
};
|
||||
let peer = request.conn().remote_address();
|
||||
let session = match request.ok().await {
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
let mapped = transport_error(game_realtime_transport_lib::TransportErrorKind::Accept, error.to_string());
|
||||
tracing::warn!(target: TRACING_TARGET, peer = %peer, detail = mapped.detail(), "WebTransport server handshake failed");
|
||||
return Err(mapped);
|
||||
},
|
||||
};
|
||||
tracing::info!(target: TRACING_TARGET, peer = %peer, "WebTransport peer accepted");
|
||||
return Ok(WebTransportSession::new(session));
|
||||
}
|
||||
}
|
||||
|
||||
/// Establishes one native WebTransport session using an exact SHA-256 certificate pin.
|
||||
pub async fn connect(config: &WebTransportClientConfig) -> Result<WebTransportSession, game_realtime_transport_lib::TransportError> {
|
||||
let client = match web_transport_quinn::ClientBuilder::new().with_server_certificate_hashes(vec![config.certificate_hash.as_bytes().to_vec()]) {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(invalid_configuration(error.to_string())),
|
||||
};
|
||||
let session = match client.connect(config.endpoint.clone()).await {
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
let mapped = transport_error(game_realtime_transport_lib::TransportErrorKind::Connect, error.to_string());
|
||||
tracing::warn!(target: TRACING_TARGET, endpoint = config.endpoint.as_str(), detail = mapped.detail(), "WebTransport client connection failed");
|
||||
return Err(mapped);
|
||||
},
|
||||
};
|
||||
tracing::info!(target: TRACING_TARGET, endpoint = config.endpoint.as_str(), peer = %session.remote_address(), "WebTransport client connected");
|
||||
return Ok(WebTransportSession::new(session));
|
||||
}
|
||||
|
||||
fn certificate_hash(certificate_der: &[u8]) -> Result<WebTransportCertificateHash, game_realtime_transport_lib::TransportError> {
|
||||
let certificate = web_transport_quinn::quinn::rustls::pki_types::CertificateDer::from(certificate_der.to_vec());
|
||||
let provider = web_transport_quinn::crypto::default_provider();
|
||||
let digest = web_transport_quinn::crypto::sha256(&provider, &certificate);
|
||||
let digest_bytes = digest.as_ref();
|
||||
if digest_bytes.len() != CERTIFICATE_HASH_SIZE {
|
||||
return Err(invalid_configuration("WebTransport certificate SHA-256 digest has an unexpected length"));
|
||||
}
|
||||
let mut bytes = [0_u8; CERTIFICATE_HASH_SIZE];
|
||||
bytes.copy_from_slice(digest_bytes);
|
||||
return Ok(WebTransportCertificateHash::from_sha256(bytes));
|
||||
}
|
||||
|
||||
fn invalid_configuration(detail: impl Into<String>) -> game_realtime_transport_lib::TransportError {
|
||||
return transport_error(game_realtime_transport_lib::TransportErrorKind::InvalidConfiguration, detail);
|
||||
}
|
||||
|
||||
fn transport_error(kind: game_realtime_transport_lib::TransportErrorKind, detail: impl Into<String>) -> game_realtime_transport_lib::TransportError {
|
||||
return game_realtime_transport_lib::TransportError::new(kind, detail);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/webtransport.rs"]
|
||||
mod tests;
|
||||
Reference in New Issue
Block a user