diff --git a/Cargo.toml b/Cargo.toml index 95b94af..9607b3d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ # file: Cargo.toml -# version: 95 +# version: 96 [workspace] resolver = "3" @@ -16,6 +16,7 @@ members = [ "crates/common/game-logging-lib", "crates/common/game-realtime-transport-lib", "crates/common/game-realtime-websocket-lib", + "crates/common/game-realtime-webtransport-lib", "crates/apps/game-android-entrypoint", "crates/apps/game-reflex-poc-tauri", "crates/apps/game-reflex-poc-wasm", @@ -24,7 +25,7 @@ members = [ ] [workspace.package] -version = "0.3.5-alpha.1" +version = "0.3.5-alpha.2" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/games" @@ -33,6 +34,7 @@ publish = false [workspace.dependencies] futures-util = { version = "0.3.34", default-features = false } +rcgen = { version = "0.14.10", default-features = false } serde = { version = "1", features = ["derive"] } sdl3 = "^0.20" tracing = "0.1.44" @@ -44,7 +46,9 @@ tauri-build = "2" tauri-plugin-tracing = "^0.3" tokio = "1.53.1" tokio-tungstenite = { version = "0.30.0", default-features = false } +url = "2.5.8" wasm-bindgen = "0.2" +web-transport-quinn = { version = "0.12.1", default-features = false } [workspace.lints.rust] missing_docs = "warn" diff --git a/README.md b/README.md index 42d216c..ac3d6cf 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ - + # games.sasedev @@ -27,9 +27,9 @@ Workspace expérimental puis productif pour des jeux multiplateformes principale Version stable de référence : `0.3.4`. -Version active : `0.3.5-alpha.1`. `0.3.2` reste différée. +Version active : `0.3.5-alpha.2`. `0.3.2` reste différée. -La stable `0.3.4` livre la première baseline realtime : contrat binaire transport-neutral, backend WebSocket Tokio/tokio-tungstenite, limites et deadlines, tests loopback/robustesse, smoke runtime localhost public et frontières de dépendances empêchant moteurs et gameplay de dépendre d'un backend concret. `0.3.5-alpha.1` ouvre le POC WebTransport/QUIC par un cadrage sans backend nouveau : audit de la stable, étude de l'écosystème actuel, choix de la famille `web-transport`, stratégie TLS/fallback, smoke tests et découpage fin avant implémentation. +La stable `0.3.4` livre la première baseline realtime : contrat binaire transport-neutral, backend WebSocket Tokio/tokio-tungstenite, limites et deadlines, tests loopback/robustesse, smoke runtime localhost public et frontières de dépendances empêchant moteurs et gameplay de dépendre d'un backend concret. `0.3.5-alpha.2` introduit le backend WebTransport natif minimal : Quinn/HTTP/3, identité TLS locale ECDSA P-256 courte ou injectée, pin SHA-256 et établissement client/server loopback. Streams applicatifs, framing fiable et adaptation `RealtimeConnection` restent réservés à la tranche suivante. Les deux premiers jeux sont des POC structurels : `game-reflex-poc` et `game-snake-poc`. Ils existent d'abord pour valider les frontières du workspace, le moteur, les assets et le packaging multiplateforme. @@ -45,4 +45,4 @@ Les deux premiers jeux sont des POC structurels : `game-reflex-poc` et `game-sna ## Diagnostics et tests -Les socles transverses `crates/common/game-assets-lib` et `crates/common/game-logging-lib` fournissent respectivement la résolution logique des assets et le tracing commun. Le realtime est séparé entre `game-realtime-transport-lib`, contrat binaire transport-neutral, et `game-realtime-websocket-lib`, backend Tokio/tokio-tungstenite ; leurs responsabilités et leur consommation sont documentées dans leurs README/USAGE locaux. `game-realtime-websocket-smoke` fournit la preuve runtime localhost hors harness de test. Les tests unitaires résident hors `src/` sous `unit_tests/`; les tests d’intégration/environnement résident sous `tests/`. +Les socles transverses `crates/common/game-assets-lib` et `crates/common/game-logging-lib` fournissent respectivement la résolution logique des assets et le tracing commun. Le realtime est séparé entre `game-realtime-transport-lib`, contrat binaire transport-neutral, `game-realtime-websocket-lib`, backend Tokio/tokio-tungstenite, et `game-realtime-webtransport-lib`, backend WebTransport/QUIC candidat dont la première frontière native couvre TLS/pinning et établissement de session. Leurs responsabilités sont documentées dans leurs README/USAGE locaux lorsqu'un guide d'usage est justifié. `game-realtime-websocket-smoke` fournit la preuve runtime localhost hors harness de test ; le smoke WebTransport public est planifié après le chemin fiable. Les tests unitaires résident hors `src/` sous `unit_tests/`; les tests d’intégration/environnement résident sous `tests/`. diff --git a/crates/common/game-realtime-webtransport-lib/Cargo.toml b/crates/common/game-realtime-webtransport-lib/Cargo.toml new file mode 100644 index 0000000..d44f124 --- /dev/null +++ b/crates/common/game-realtime-webtransport-lib/Cargo.toml @@ -0,0 +1,24 @@ +# file: crates/common/game-realtime-webtransport-lib/Cargo.toml +# version: 1 + +[package] +name = "game-realtime-webtransport-lib" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +publish.workspace = true + +[dependencies] +game-realtime-transport-lib = { path = "../game-realtime-transport-lib" } +rcgen = { workspace = true, features = ["ring"] } +tracing.workspace = true +url.workspace = true +web-transport-quinn = { workspace = true, features = ["ring"] } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt", "time"] } + +[lints] +workspace = true diff --git a/crates/common/game-realtime-webtransport-lib/README.md b/crates/common/game-realtime-webtransport-lib/README.md new file mode 100644 index 0000000..5010492 --- /dev/null +++ b/crates/common/game-realtime-webtransport-lib/README.md @@ -0,0 +1,35 @@ + + + +# game-realtime-webtransport-lib + +Backend WebTransport/QUIC candidat pour le realtime de `games.sasedev`. + +## Responsabilité + +La crate possède le transport WebTransport concret sans introduire de sémantique gameplay, room, joueur, tick ou snapshot. Son chemin natif repose sur `web-transport-quinn` et conserve les erreurs publiques dans `game-realtime-transport-lib`. + +La première frontière disponible couvre : + +- configuration client HTTPS avec pin SHA-256 exact ; +- identité serveur X.509 DER + clé privée PKCS#8 DER injectables ; +- génération locale d'une identité self-signed ECDSA P-256 à validité courte pour `localhost`, IPv4 loopback et IPv6 loopback ; +- bind UDP/QUIC sur adresse explicite ou port éphémère ; +- établissement HTTP/3 WebTransport client/server natif ; +- tracing sous `games::realtime::webtransport`. + +## TLS de développement + +`WebTransportServerIdentity::generate_loopback()` crée une identité en mémoire. La clé privée n'est ni écrite ni versionnée. Le certificat est valide sept jours, avec une petite marge de clock skew, et son SHA-256 est exposé à travers `WebTransportCertificateHash` afin que le client puisse utiliser le pinning fourni par `web-transport-quinn`. + +Une identité préexistante peut être injectée en DER avec `WebTransportServerIdentity::from_pkcs8_der(...)`. La compatibilité certificat/clé est alors vérifiée par le builder TLS au bind du serveur. + +Aucune option de désactivation globale de la vérification TLS n'est exposée. + +## Frontières actuelles + +Cette crate n'adapte pas encore une session vers `RealtimeConnection`. Elle n'ouvre pas encore le stream bidirectionnel applicatif principal et ne définit donc ni framing message, ni limites de payload, ni deadlines applicatives, ni datagram API commune. + +Ces responsabilités doivent rester séparées de la seule preuve d'établissement natif afin que l'introduction de QUIC/TLS demeure testable indépendamment du futur framing fiable. + +Le chemin natif s’exécute sous un runtime Tokio fourni par le consommateur ; la crate ne crée ni runtime ni thread privé. Le chemin navigateur/WASM est distinct : aucun `cfg` WASM ni dépendance navigateur n'est requis par le backend natif actuel. diff --git a/crates/common/game-realtime-webtransport-lib/src/lib.rs b/crates/common/game-realtime-webtransport-lib/src/lib.rs new file mode 100644 index 0000000..d4bf048 --- /dev/null +++ b/crates/common/game-realtime-webtransport-lib/src/lib.rs @@ -0,0 +1,25 @@ +// file: crates/common/game-realtime-webtransport-lib/src/lib.rs +// version: 1 + +#![warn(missing_docs)] +#![deny(unreachable_pub)] +#![forbid(unsafe_code)] + +//! Native Quinn-backed WebTransport establishment for the games.sasedev realtime transport POC. + +mod webtransport; + +/// Re-export of one SHA-256 certificate fingerprint accepted by the native WebTransport client. +pub use self::webtransport::WebTransportCertificateHash; +/// Re-export of native client establishment configuration. +pub use self::webtransport::WebTransportClientConfig; +/// Re-export of a bound native WebTransport server listener. +pub use self::webtransport::WebTransportListener; +/// Re-export of native WebTransport server establishment configuration. +pub use self::webtransport::WebTransportServerConfig; +/// Re-export of a generated or injected WebTransport server identity. +pub use self::webtransport::WebTransportServerIdentity; +/// Re-export of an established native WebTransport session. +pub use self::webtransport::WebTransportSession; +/// Re-export of the native WebTransport client session constructor. +pub use self::webtransport::connect; diff --git a/crates/common/game-realtime-webtransport-lib/src/webtransport.rs b/crates/common/game-realtime-webtransport-lib/src/webtransport.rs new file mode 100644 index 0000000..04144ad --- /dev/null +++ b/crates/common/game-realtime-webtransport-lib/src/webtransport.rs @@ -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, + private_key_pkcs8_der: Vec, + 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 { + 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, private_key_pkcs8_der: Vec) -> Result { + 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 { + 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 { + 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 { + 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 { + 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 { + 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) -> 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) -> game_realtime_transport_lib::TransportError { + return game_realtime_transport_lib::TransportError::new(kind, detail); +} + +#[cfg(test)] +#[path = "../unit_tests/webtransport.rs"] +mod tests; diff --git a/crates/common/game-realtime-webtransport-lib/tests/establishment.rs b/crates/common/game-realtime-webtransport-lib/tests/establishment.rs new file mode 100644 index 0000000..540a065 --- /dev/null +++ b/crates/common/game-realtime-webtransport-lib/tests/establishment.rs @@ -0,0 +1,67 @@ +// file: crates/common/game-realtime-webtransport-lib/tests/establishment.rs +// version: 1 + +//! Deterministic native loopback proof for WebTransport session establishment and SHA-256 pinning. + +const TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); + +#[tokio::test(flavor = "current_thread")] +async fn pinned_client_and_server_establish_a_loopback_session() { + let identity = match game_realtime_webtransport_lib::WebTransportServerIdentity::generate_loopback() { + Ok(value) => value, + Err(error) => panic!("loopback identity generation failed: {error}"), + }; + let certificate_hash = identity.certificate_hash().clone(); + let server_config = game_realtime_webtransport_lib::WebTransportServerConfig::new(std::net::SocketAddr::from(([127, 0, 0, 1], 0)), identity); + let mut listener = match game_realtime_webtransport_lib::WebTransportListener::bind(server_config) { + Ok(value) => value, + Err(error) => panic!("WebTransport listener bind failed: {error}"), + }; + let endpoint = format!("https://{}/establishment", listener.local_addr()); + let client_config = match game_realtime_webtransport_lib::WebTransportClientConfig::new(endpoint.as_str(), certificate_hash) { + Ok(value) => value, + Err(error) => panic!("WebTransport client configuration failed: {error}"), + }; + let pair = tokio::time::timeout(TEST_TIMEOUT, async { + return tokio::join!(listener.accept(), game_realtime_webtransport_lib::connect(&client_config)); + }) + .await; + let (server_session, client_session) = match pair { + Ok((Ok(server), Ok(client))) => (server, client), + Ok((Err(error), _)) => panic!("WebTransport server establishment failed: {error}"), + Ok((_, Err(error))) => panic!("WebTransport client establishment failed: {error}"), + Err(_) => panic!("WebTransport loopback establishment timed out"), + }; + assert_eq!(client_session.request_url(), Some(endpoint.as_str())); + assert_eq!(server_session.request_url(), Some(endpoint.as_str())); + assert_eq!(client_session.remote_addr(), listener.local_addr()); +} + +#[tokio::test(flavor = "current_thread")] +async fn incorrect_certificate_pin_rejects_establishment() { + let identity = match game_realtime_webtransport_lib::WebTransportServerIdentity::generate_loopback() { + Ok(value) => value, + Err(error) => panic!("loopback identity generation failed: {error}"), + }; + let server_config = game_realtime_webtransport_lib::WebTransportServerConfig::new(std::net::SocketAddr::from(([127, 0, 0, 1], 0)), identity); + let mut listener = match game_realtime_webtransport_lib::WebTransportListener::bind(server_config) { + Ok(value) => value, + Err(error) => panic!("WebTransport listener bind failed: {error}"), + }; + let endpoint = format!("https://{}/wrong-pin", listener.local_addr()); + let wrong_hash = game_realtime_webtransport_lib::WebTransportCertificateHash::from_sha256([0_u8; 32]); + let client_config = match game_realtime_webtransport_lib::WebTransportClientConfig::new(endpoint.as_str(), wrong_hash) { + Ok(value) => value, + Err(error) => panic!("WebTransport client configuration failed: {error}"), + }; + let (server_result, client_result) = tokio::join!( + tokio::time::timeout(TEST_TIMEOUT, listener.accept()), + tokio::time::timeout(TEST_TIMEOUT, game_realtime_webtransport_lib::connect(&client_config)), + ); + match client_result { + Ok(Ok(_)) => panic!("WebTransport establishment unexpectedly accepted an incorrect certificate pin"), + Ok(Err(error)) => assert_eq!(error.kind(), game_realtime_transport_lib::TransportErrorKind::Connect), + Err(_) => panic!("incorrect-pin connection attempt timed out"), + } + assert!(matches!(server_result, Err(_) | Ok(Err(_)))); +} diff --git a/crates/common/game-realtime-webtransport-lib/unit_tests/webtransport.rs b/crates/common/game-realtime-webtransport-lib/unit_tests/webtransport.rs new file mode 100644 index 0000000..04cf81f --- /dev/null +++ b/crates/common/game-realtime-webtransport-lib/unit_tests/webtransport.rs @@ -0,0 +1,49 @@ +// file: crates/common/game-realtime-webtransport-lib/unit_tests/webtransport.rs +// version: 1 + +#[test] +fn certificate_hash_preserves_exact_sha256_bytes() { + let bytes = [7_u8; super::CERTIFICATE_HASH_SIZE]; + let hash = super::WebTransportCertificateHash::from_sha256(bytes); + assert_eq!(hash.as_bytes(), &bytes); +} + +#[test] +fn client_config_accepts_https_and_rejects_non_secure_schemes() { + let hash = super::WebTransportCertificateHash::from_sha256([1_u8; super::CERTIFICATE_HASH_SIZE]); + let secure = super::WebTransportClientConfig::new("https://127.0.0.1:4433/game", hash.clone()); + assert!(secure.is_ok()); + let insecure_http = super::WebTransportClientConfig::new("http://127.0.0.1:4433/game", hash.clone()); + match insecure_http { + Ok(_) => panic!("HTTP endpoint unexpectedly accepted"), + Err(error) => assert_eq!(error.kind(), game_realtime_transport_lib::TransportErrorKind::InvalidConfiguration), + } + let websocket = super::WebTransportClientConfig::new("ws://127.0.0.1:4433/game", hash); + match websocket { + Ok(_) => panic!("WebSocket endpoint unexpectedly accepted"), + Err(error) => assert_eq!(error.kind(), game_realtime_transport_lib::TransportErrorKind::InvalidConfiguration), + } +} + +#[test] +fn injected_identity_rejects_empty_certificate_or_key() { + let missing_certificate = super::WebTransportServerIdentity::from_pkcs8_der(Vec::new(), vec![1]); + match missing_certificate { + Ok(_) => panic!("empty certificate unexpectedly accepted"), + Err(error) => assert_eq!(error.kind(), game_realtime_transport_lib::TransportErrorKind::InvalidConfiguration), + } + let missing_key = super::WebTransportServerIdentity::from_pkcs8_der(vec![1], Vec::new()); + match missing_key { + Ok(_) => panic!("empty private key unexpectedly accepted"), + Err(error) => assert_eq!(error.kind(), game_realtime_transport_lib::TransportErrorKind::InvalidConfiguration), + } +} + +#[test] +fn generated_loopback_identity_has_sha256_fingerprint() { + let identity = match super::WebTransportServerIdentity::generate_loopback() { + Ok(value) => value, + Err(error) => panic!("loopback identity generation failed: {error}"), + }; + assert_eq!(identity.certificate_hash().as_bytes().len(), super::CERTIFICATE_HASH_SIZE); +} diff --git a/deltas/0.3.5/alpha.2.md b/deltas/0.3.5/alpha.2.md new file mode 100644 index 0000000..7515cea --- /dev/null +++ b/deltas/0.3.5/alpha.2.md @@ -0,0 +1,203 @@ + + + +# Delta 0.3.5-alpha.2 + +## Base + +Base : `0.3.5-alpha.1` validée par l'utilisateur le 2026-09-21. + +Cette tranche reste limitée à la fondation WebTransport native prévue par le plan : dépendances minimales, identité TLS, pin SHA-256, bind QUIC/HTTP3 et établissement d'une session client/server. Elle ne contient encore ni stream applicatif principal, ni framing `u32 + payload`, ni adaptation `RealtimeConnection`, ni datagram, ni fallback. + +## Historique fermé + +Ajout de : + +```text +history/0.3.5/alpha.1.md +``` + +L'entrée enregistre exactement la gate utilisateur reçue : fmt check, trois audits propres et `cargo check --workspace` propre sur `0.3.5-alpha.1`. Le plan révisé a été explicitement accepté avant le passage à cette tranche. + +## Version + +La version workspace passe de : + +```text +0.3.5-alpha.1 +``` + +à : + +```text +0.3.5-alpha.2 +``` + +Aucune version Android, npm ou Tauri indépendante n'est modifiée. + +## Nouvelle crate WebTransport + +Ajout de : + +```text +crates/common/game-realtime-webtransport-lib/Cargo.toml +crates/common/game-realtime-webtransport-lib/README.md +crates/common/game-realtime-webtransport-lib/src/lib.rs +crates/common/game-realtime-webtransport-lib/src/webtransport.rs +crates/common/game-realtime-webtransport-lib/unit_tests/webtransport.rs +crates/common/game-realtime-webtransport-lib/tests/establishment.rs +``` + +La crate est placée sous `crates/common/` au même niveau que le contrat transport-neutral et le backend WebSocket. Elle ne contient aucune sémantique de jeu. + +## Dépendances et features + +Les contraintes nouvelles sont centralisées dans `[workspace.dependencies]` : + +```text +rcgen = 0.14.10, default-features = false +url = 2.5.8 +web-transport-quinn = 0.12.1, default-features = false +``` + +La crate consommatrice active localement uniquement `ring` sur `rcgen` et `web-transport-quinn`. + +`web-transport-quinn` est utilisé directement dans cette tranche native. La façade multiplateforme `web-transport` reste différée jusqu'au chemin WASM, afin de ne pas introduire une dépendance sans consommateur réel. + +Le backend crypto par défaut `aws-lc-rs` de `web-transport-quinn` est donc désactivé. `ring` devient le provider unique du POC natif initial. + +## Identité TLS et pinning + +`WebTransportServerIdentity` accepte deux chemins : + +```text +generate_loopback() +from_pkcs8_der(certificate_der, private_key_pkcs8_der) +``` + +La génération loopback produit en mémoire : + +- une clé ECDSA P-256 ; +- un certificat self-signed avec SHA-256 ; +- les SAN `localhost`, `127.0.0.1` et `::1` ; +- une validité de sept jours avec 60 secondes de marge avant l'heure courante ; +- aucun PEM ni fichier de clé versionné. + +`WebTransportCertificateHash` porte exactement les 32 octets SHA-256 du certificat. Le client configure `ClientBuilder::with_server_certificate_hashes(...)` ; aucune option de TLS permissif n'est exposée. + +L'injection DER ne prétend pas parser ou certifier la cohérence clé/certificat avant le bind : le builder TLS natif reste l'autorité qui rejette une paire incompatible. + +## Configuration et établissement natif + +`WebTransportClientConfig` impose un endpoint `https://` valide et un hash épinglé. + +`WebTransportServerConfig` possède l'adresse UDP et l'identité TLS. + +`WebTransportListener::bind(...)` : + +- construit le serveur Quinn/WebTransport ; +- supporte le port `0` pour une allocation éphémère ; +- expose l'adresse effectivement bindée ; +- mappe les erreurs vers `TransportErrorKind::Bind`. + +`WebTransportListener::accept(...)` accepte le CONNECT HTTP/3 et retourne une `WebTransportSession`. + +`connect(...)` construit un client pinned et retourne également une `WebTransportSession`. Les erreurs d'établissement client sont mappées vers `Connect`; les erreurs serveur vers `Accept`. + +La session expose uniquement des diagnostics d'établissement (`remote_addr`, URL CONNECT). Le stream fiable applicatif appartient explicitement à `alpha.3`. + +## Tests ajoutés + +Les tests unitaires couvrent : + +- conservation exacte d'un SHA-256 de 32 octets ; +- acceptation d'un endpoint HTTPS ; +- rejet HTTP/WebSocket ; +- rejet d'une identité injectée sans certificat ou sans clé ; +- génération d'une identité loopback avec fingerprint SHA-256. + +Le test d'intégration `establishment.rs` couvre : + +- bind sur `127.0.0.1:0` ; +- génération d'identité éphémère ; +- pin SHA-256 transmis au client ; +- établissement client/server concurrent borné par timeout ; +- URL CONNECT observée des deux côtés ; +- rejet d'un mauvais pin côté client. + +Il ne transmet volontairement aucun payload : ce serait anticiper `alpha.3`. + +## Tracing + +Le nouveau backend utilise : + +```text +games::realtime::webtransport +``` + +pour bind, connexion, accept et diagnostics d'échec d'établissement. + +## Documentation et plan + +`README.md` racine annonce `0.3.5-alpha.2` et la nouvelle frontière native. + +Le README local documente la responsabilité de la crate, le TLS de développement et les frontières encore exclues. + +Le plan `005` est réconcilié avec les choix réellement fermés : dépendances natives directes, provider `ring`, identité sept jours et absence volontaire de façade `web-transport` avant le chemin WASM. + +`ROADMAP.md` et `CHANGELOG.md` restent inchangés : le scope macro de `0.3.5` ne change pas et cette alpha n'est pas un jalon de changelog. + +## Validation exécutée dans l'environnement de génération + +L'environnement de génération ne possède pas de toolchain Rust. Il ne doit donc attribuer aucun `cargo fmt`, `cargo check`, Clippy, test ou `cargo tree` à cette livraison. + +Les audits Python et contrôles statiques sont exécutés après constitution du delta. Sur la reconstruction locale issue du ZIP taggé `v0.3.4` puis du delta `alpha.1`, ils donnent : + +```text +General Rust rule audit: clean +Rust export completeness audit: 0 candidate(s) +games.sasedev workspace audit: clean +Markdown table audit: clean (5 table(s), 269 file(s)) +Distribution layout audit: clean (50 required path(s), 8 forbidden path(s) absent) +TOML/workspace consistency: clean +``` + +Le compteur de fichiers Markdown de cette reconstruction n'est pas utilisé comme référence pour le checkout utilisateur : la gate `alpha.1` de l'utilisateur comptait déjà davantage de fichiers (`282`) que la reconstruction autoritaire ZIP + delta. Seul le statut clean est comparé. Ces contrôles restent distincts de la gate Cargo utilisateur. + +## Validation utilisateur demandée + +Cette tranche modifie Rust et le graphe de dépendances ; la gate ciblée est donc : + +```bash +cargo fmt --all +cargo fmt --all -- --check + +python3 scripts/audit_rust_workspace_rules.py +python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates Android Web deltas history +python3 scripts/audit_distribution_layout.py + +cargo check --workspace +cargo clippy --workspace --all-targets --all-features -- -D warnings + +cargo test -p game-realtime-webtransport-lib --all-targets --all-features + +cargo tree -p game-realtime-webtransport-lib --edges normal +cargo tree -p game-realtime-webtransport-lib --edges features +cargo tree -i web-transport-quinn --workspace --edges normal +``` + +Le full `cargo test --workspace --all-targets --all-features` reste réservé au jalon large `beta.1` conformément au plan. + +Aucun smoke executable n'est requis dans `alpha.2` : le test d'intégration prouve l'établissement natif sous harness, tandis que le smoke WebTransport public reste la responsabilité explicite de `alpha.5` après le framing fiable et la robustesse. + +## Suite après validation + +Si la gate est propre, `0.3.5-alpha.3` peut : + +- ouvrir/ accepter le stream bidirectionnel principal ; +- ajouter le framing privé borné `u32 big-endian + payload` ; +- implémenter `RealtimeConnection`, sender et receiver ; +- prouver le round-trip binaire ordonné et plusieurs messages ; +- conserver datagrams, robustesse avancée et smoke public hors de cette tranche. + +Un défaut fermé de cette tranche produit d'abord `0.3.5-alpha.2.fix.N` au lieu d'ouvrir `alpha.3`. diff --git a/docs/plans/005-V0_3_5_WEBTRANSPORT_QUIC_POC_PLAN.md b/docs/plans/005-V0_3_5_WEBTRANSPORT_QUIC_POC_PLAN.md index 0abe664..d0e586f 100644 --- a/docs/plans/005-V0_3_5_WEBTRANSPORT_QUIC_POC_PLAN.md +++ b/docs/plans/005-V0_3_5_WEBTRANSPORT_QUIC_POC_PLAN.md @@ -1,11 +1,11 @@ - + # Plan 0.3.5 — POC WebTransport/QUIC et fallback WebSocket ## Statut -Plan actif créé pendant `0.3.5-alpha.1` à partir de l'archive taggée `v0.3.4`. +Plan actif créé pendant `0.3.5-alpha.1` à partir de l'archive taggée `v0.3.4`, puis réconcilié pour l'implémentation native de `0.3.5-alpha.2`. Le cadrage détaillé et la comparaison des stacks actuelles sont conservés dans `docs/studies/026-V0_3_5_WEBTRANSPORT_QUIC_STACK_AUDIT.md`. Le présent document porte les décisions opérationnelles, le scope, les gates et le forecast vivant de la version. @@ -57,6 +57,22 @@ La version exacte résolue par Cargo sera enregistrée dans le delta qui introdu `wtransport 0.7.x` reste le candidat de repli prioritaire si une exigence concrète bloque la famille primaire. Quinn/H3 brut n'est pas le premier choix. +### Fermeture de dépendances en alpha.2 + +La première tranche native utilise directement : + +```text +web-transport-quinn 0.12.1, default-features = false, feature ring +rcgen 0.14.10, default-features = false, feature ring +url 2.5.8 +``` + +La façade `web-transport` n'est pas ajoutée avant le chemin WASM : `alpha.2` ne possède qu'un backend natif et n'a pas besoin d'une abstraction multiplateforme encore inutilisée. `ring` est choisi explicitement et seul pour éviter le backend crypto par défaut `aws-lc-rs` de `web-transport-quinn` et garder le graphe POC plus petit et déterministe. + +L'identité locale générée est ECDSA P-256/SHA-256, contient les SAN `localhost`, `127.0.0.1` et `::1`, vit sept jours avec une petite marge de clock skew et reste uniquement en mémoire. Une identité X.509 DER + PKCS#8 DER peut aussi être injectée. Le client accepte uniquement le hash SHA-256 explicitement configuré ; aucune API de désactivation de validation TLS n'est exposée. + +`alpha.2` retourne une session WebTransport établie mais n'ouvre encore aucun stream applicatif et n'implémente pas `RealtimeConnection`. Cette séparation ferme la preuve QUIC/TLS avant le framing de `alpha.3`. + ### Ownership physique Nouveau backend durable candidat : diff --git a/history/0.3.5/alpha.1.md b/history/0.3.5/alpha.1.md new file mode 100644 index 0000000..dfe7b90 --- /dev/null +++ b/history/0.3.5/alpha.1.md @@ -0,0 +1,39 @@ + + + +# Historique 0.3.5-alpha.1 + +## Statut + +`0.3.5-alpha.1` a été validée par l'utilisateur le 2026-09-21. Le cadrage WebTransport/QUIC, le plan actif et la synchronisation de version sont acceptés sans fix. + +La suite peut ouvrir `0.3.5-alpha.2` pour introduire le backend natif minimal et prouver l'établissement TLS/WebTransport avec pinning de certificat. + +## Gates fournies + +Les commandes exécutées par l'utilisateur : + +```text +cargo fmt --all -- --check +python3 scripts/audit_rust_workspace_rules.py +python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates Android Web deltas history +python3 scripts/audit_distribution_layout.py +cargo check --workspace +``` + +ont terminé proprement avec : + +```text +General Rust rule audit: clean +Rust export completeness audit: 0 candidate(s) +games.sasedev workspace audit: clean +Markdown table audit: clean (5 table(s), 282 file(s)) +Distribution layout audit: clean (50 required path(s), 8 forbidden path(s) absent) +cargo check --workspace: clean +``` + +Le workspace compilé porte `0.3.5-alpha.1`, y compris les deux POC Tauri affichés dans la sortie finale. + +## Conséquence + +Le forecast révisé de `docs/plans/005-V0_3_5_WEBTRANSPORT_QUIC_POC_PLAN.md` est retenu. `alpha.2` reste limitée à la crate backend, aux dépendances/features minimales, à l'identité TLS/hash pinning et à l'établissement natif ; streams fiables, framing et contrat commun restent réservés à `alpha.3`.