diff --git a/openscreen-application/Cargo.toml b/openscreen-application/Cargo.toml index 3fedaaf..788dbdc 100644 --- a/openscreen-application/Cargo.toml +++ b/openscreen-application/Cargo.toml @@ -30,6 +30,7 @@ uuid = { version = "1.10", features = ["v4"], optional = true } base64 = { version = "0.22", optional = true } x509-parser = { version = "0.16", optional = true } hex = { version = "0.4", optional = true } +hostname = { version = "0.4", optional = true } [[bin]] name = "app-sender" @@ -42,7 +43,24 @@ required-features = ["bin"] [features] default = ["bin"] -bin = ["clap", "tokio", "anyhow", "colored", "tracing", "tracing-subscriber", "quinn", "rcgen", "pem", "uuid", "base64", "x509-parser", "hex", "openscreen-discovery", "openscreen-discovery-mdns"] +bin = [ + "anyhow", + "base64", + "clap", + "colored", + "hex", + "hostname", + "openscreen-discovery-mdns", + "openscreen-discovery", + "pem", + "quinn", + "rcgen", + "tokio", + "tracing-subscriber", + "tracing", + "uuid", + "x509-parser", + ] [dev-dependencies] tempfile = "3.8" diff --git a/openscreen-application/src/bin/app-receiver.rs b/openscreen-application/src/bin/app-receiver.rs index ba32812..276db88 100644 --- a/openscreen-application/src/bin/app-receiver.rs +++ b/openscreen-application/src/bin/app-receiver.rs @@ -104,9 +104,8 @@ async fn main() -> Result<()> { async fn run_receiver(args: &Args) -> Result<()> { // Step 1: Load or generate certificate println!("WAIT: Loading certificate..."); - let cert_key = - CertificateKey::load_or_generate(&args.cert_dir, "receiver", "_openscreen._tcp.local") - .context("Failed to load/generate certificate")?; + let cert_key = CertificateKey::load_or_generate(&args.cert_dir, &args.name, "local") + .context("Failed to load/generate certificate")?; println!("OK: Certificate loaded"); println!( @@ -161,19 +160,15 @@ async fn run_receiver(args: &Args) -> Result<()> { println!("WAIT: Initializing QUIC server..."); - // Convert rcgen certificate to Quinn format - let (cert_der, key_der) = ( - cert_key.cert.cert.der().to_vec(), - cert_key.cert.key_pair.serialize_der(), - ); + // Pass certificate and key to Quinn + let (cert_der, key_der) = (cert_key.cert_der, cert_key.key_der); // Pass auth token to server for validation let auth_token_bytes = Some(auth_token.as_str().as_bytes().to_vec()); - let server = - QuinnServer::bind_with_cert(bind_addr, &args.psk, cert_der, key_der, auth_token_bytes) - .await - .context("Failed to bind server")?; + let server = QuinnServer::bind(bind_addr, &args.psk, cert_der, key_der, auth_token_bytes) + .await + .context("Failed to bind server")?; println!("OK: Listening on {bind_addr}"); println!("{}", "Waiting for connections...".bright_cyan()); diff --git a/openscreen-application/src/bin/app-sender.rs b/openscreen-application/src/bin/app-sender.rs index 167ec7c..2c835ee 100644 --- a/openscreen-application/src/bin/app-sender.rs +++ b/openscreen-application/src/bin/app-sender.rs @@ -23,6 +23,7 @@ use anyhow::{Context, Result}; use clap::Parser; use colored::Colorize; +use openscreen_application::cert::CertificateKey; use openscreen_application::messages::{AgentInfoRequest, AgentInfoResponse}; use openscreen_crypto_rustcrypto::RustCryptoCryptoProvider; use openscreen_discovery::{DiscoveryBrowser, ServiceInfo}; @@ -237,19 +238,48 @@ async fn run_sender(args: &Args) -> Result<()> { print!("{}", "WAIT: Initializing QUIC client... ".bright_yellow()); std::io::Write::flush(&mut std::io::stdout())?; + // Generate ephemeral client certificate with device hostname + // Per W3C spec: both clients and servers need proper agent certificates with 160-bit serials + let client_instance_name = match hostname::get() { + Ok(s) => s.to_string_lossy().into_owned(), + Err(e) => { + debug!("Failed to get system hostname: {e}, using fallback"); + "openscreen-sender".to_string() + } + }; + + debug!("Using client instance name: {}", client_instance_name); + + // Generate ephemeral certificate (not persisted, per-session) + // This is THE agent certificate used in TLS handshake (not regenerated at QUIC layer) + let client_cert = CertificateKey::generate(&client_instance_name, "local") + .context("Failed to generate client certificate")?; + + debug!( + "Generated client certificate with hostname: {}", + client_cert.hostname + ); + let crypto_provider = RustCryptoCryptoProvider::new(); let bind_addr = "0.0.0.0:0".parse().unwrap(); // Get expected fingerprint from mDNS discovery (for MITM protection) let expected_fingerprint = *service_info.fingerprint.as_bytes(); - let mut client = QuinnClient::new(crypto_provider, bind_addr, expected_fingerprint) - .context("Failed to create Quinn client")?; + // Create client with spec-compliant agent certificate (160-bit serial) + let mut client = QuinnClient::new( + crypto_provider, + bind_addr, + expected_fingerprint, + client_cert.cert_der.clone(), + client_cert.key_der.clone(), + ) + .context("Failed to create Quinn client")?; println!("OK:"); debug!( - "Quinn client initialized with bind address {} and fingerprint verification", - bind_addr + "Quinn client initialized with hostname {} and fingerprint verification", + client_cert.hostname ); // Step 3: Configure PSK and auth token diff --git a/openscreen-application/src/cert.rs b/openscreen-application/src/cert.rs index bd23d60..1bec980 100644 --- a/openscreen-application/src/cert.rs +++ b/openscreen-application/src/cert.rs @@ -21,13 +21,7 @@ extern crate std; use anyhow::{Context, Result}; use openscreen_discovery::Fingerprint; -use std::{ - format, - path::Path, - string::{String, ToString}, - vec, - vec::Vec, -}; +use std::{format, path::Path, string::String, vec, vec::Vec}; use uuid::Uuid; /// OpenScreen certificate serial number (160 bits per W3C spec) @@ -166,9 +160,11 @@ fn sanitize_hostname_component(s: &str) -> String { } /// Certificate and private key pair +/// +/// Stores certificate and key as DER-encoded bytes pub struct CertificateKey { - pub cert: rcgen::CertifiedKey, pub cert_der: Vec, + pub key_der: Vec, pub fingerprint: Fingerprint, pub serial_number: SerialNumber, pub hostname: String, @@ -192,6 +188,7 @@ impl CertificateKey { // Generate ECDSA key pair let key_pair = rcgen::KeyPair::generate().context("Failed to generate key pair")?; + let key_der = key_pair.serialize_der(); // Create certificate parameters with hostname as Subject CN let mut params = rcgen::CertificateParams::new(vec![hostname.clone()]) @@ -213,13 +210,13 @@ impl CertificateKey { let cert_der = cert.der().to_vec(); - // Calculate fingerprint (SPKI per spec, not full cert - to be fixed in #1) + // Calculate fingerprint (SPKI SHA-256 per W3C spec) let fingerprint = Fingerprint::from_der_cert(&cert_der) .map_err(|e| anyhow::anyhow!("Failed to calculate fingerprint: {e:?}"))?; Ok(Self { - cert: rcgen::CertifiedKey { cert, key_pair }, cert_der, + key_der, fingerprint, serial_number, hostname, @@ -241,6 +238,7 @@ impl CertificateKey { .with_context(|| format!("Failed to read private key from {}", key_path.display()))?; let key_pair = rcgen::KeyPair::from_pem(&key_pem).context("Failed to parse private key")?; + let key_der = key_pair.serialize_der(); // Parse PEM to get DER bytes let cert_der = pem::parse(&cert_pem) @@ -248,7 +246,7 @@ impl CertificateKey { .contents() .to_vec(); - // Calculate fingerprint + // Calculate fingerprint (SPKI SHA-256 per W3C spec) let fingerprint = Fingerprint::from_der_cert(&cert_der) .map_err(|e| anyhow::anyhow!("Failed to calculate fingerprint: {e:?}"))?; @@ -258,18 +256,9 @@ impl CertificateKey { let hostname = compute_hostname(&serial_number, instance_name, domain); - // Reconstruct certificate params from the existing cert - // Note: We can't perfectly reconstruct all params, but we can create a basic one - let params = rcgen::CertificateParams::new(vec!["loaded-cert".to_string()]) - .context("Failed to create certificate params")?; - - let cert = params - .self_signed(&key_pair) - .context("Failed to create certificate")?; - Ok(Self { - cert: rcgen::CertifiedKey { cert, key_pair }, cert_der, + key_der, fingerprint, serial_number, hostname, @@ -286,8 +275,14 @@ impl CertificateKey { let cert_path = dir.join("cert.pem"); let key_path = dir.join("key.pem"); - let cert_pem = self.cert.cert.pem(); - let key_pem = self.cert.key_pair.serialize_pem(); + // Encode cert_der as PEM + let cert_pem_obj = pem::Pem::new("CERTIFICATE", self.cert_der.clone()); + let cert_pem = pem::encode(&cert_pem_obj); + + // Encode key_der as PEM + // The key is in PKCS#8 format, which uses the "PRIVATE KEY" tag + let key_pem_obj = pem::Pem::new("PRIVATE KEY", self.key_der.clone()); + let key_pem = pem::encode(&key_pem_obj); std::fs::write(&cert_path, cert_pem) .with_context(|| format!("Failed to write certificate to {}", cert_path.display()))?; @@ -321,6 +316,7 @@ impl CertificateKey { #[cfg(test)] mod tests { use super::*; + use std::string::ToString; #[test] fn test_serial_number_format() { diff --git a/openscreen-quinn/Cargo.toml b/openscreen-quinn/Cargo.toml index 53228c4..efc2125 100644 --- a/openscreen-quinn/Cargo.toml +++ b/openscreen-quinn/Cargo.toml @@ -10,14 +10,6 @@ workspace = true [lib] name = "openscreen_quinn" -[[bin]] -name = "openscreen-test" -path = "src/bin/main.rs" - -[[bin]] -name = "openscreen-receiver" -path = "src/bin/receiver.rs" - [dependencies] openscreen-network = { path = "../openscreen-network" } openscreen-crypto = { path = "../openscreen-crypto" } @@ -39,13 +31,8 @@ x509-parser = "0.16" thiserror = "2.0" anyhow = "1.0" -# CLI dependencies -clap = { version = "4.5", features = ["derive"] } -clap-verbosity-flag = "2.2" -colored = "2.1" -rpassword = "7.3" +# Logging tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter", "chrono"] } [dev-dependencies] openscreen-discovery = { path = "../openscreen-discovery" } diff --git a/openscreen-quinn/src/bin/main.rs b/openscreen-quinn/src/bin/main.rs deleted file mode 100644 index b559818..0000000 --- a/openscreen-quinn/src/bin/main.rs +++ /dev/null @@ -1,253 +0,0 @@ -// Copyright 2025 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//! OpenScreen QUIC Client - Testing Tool -//! -//! Command-line tool for testing connectivity and authentication with -//! OpenScreen receivers over QUIC. - -#![allow(clippy::items_after_statements, clippy::large_futures)] - -use anyhow::{Context, Result}; -use clap::Parser; -use colored::Colorize; -use openscreen_crypto_rustcrypto::RustCryptoCryptoProvider; -use openscreen_quinn::QuinnClient; -use std::net::ToSocketAddrs; -use tracing::{debug, error, info, warn}; - -#[derive(Parser, Debug)] -#[command(author, version, about, long_about = None)] -#[command(name = "openscreen-test")] -#[command(about = "Test OpenScreen QUIC connectivity and authentication", long_about = None)] -struct Cli { - /// The hostname or IP address of the OpenScreen receiver - #[arg(short = 'H', long)] - host: String, - - /// The port of the OpenScreen receiver - #[arg(short, long, default_value_t = 4433)] - port: u16, - - /// The Pre-Shared Key (PSK) for authentication. If not provided, you will be prompted. - #[arg(long)] - psk: Option, - - /// Optional authentication token from mDNS - #[arg(long)] - auth_token: Option, - - /// Authentication timeout in seconds - #[arg(short, long, default_value_t = 5)] - timeout: u64, - - /// Increase logging verbosity (-v, -vv, -vvv) - #[command(flatten)] - verbose: clap_verbosity_flag::Verbosity, -} - -#[tokio::main] -async fn main() -> Result<()> { - let cli = Cli::parse(); - - // Initialize tracing subscriber with verbosity level - // Respect both RUST_LOG environment variable AND -v flags - // If RUST_LOG is set, use EnvFilter (more flexible, module-specific) - // Otherwise, use -v flags for simple global level - - if std::env::var("RUST_LOG").is_ok() { - // RUST_LOG is set - use EnvFilter for module-specific control - tracing_subscriber::fmt() - .with_timer(tracing_subscriber::fmt::time::ChronoUtc::default()) - .with_target(true) // Show module paths when using RUST_LOG - .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) - .init(); - } else { - // RUST_LOG not set - use -v flags for global level - use tracing::Level; - let log_level = match cli.verbose.log_level_filter() { - clap_verbosity_flag::LevelFilter::Off => None, - clap_verbosity_flag::LevelFilter::Error => Some(Level::ERROR), - clap_verbosity_flag::LevelFilter::Warn => Some(Level::WARN), - clap_verbosity_flag::LevelFilter::Info => Some(Level::INFO), - clap_verbosity_flag::LevelFilter::Debug => Some(Level::DEBUG), - clap_verbosity_flag::LevelFilter::Trace => Some(Level::TRACE), - }; - - let subscriber = tracing_subscriber::fmt() - .with_timer(tracing_subscriber::fmt::time::ChronoUtc::default()) - .with_target(false); - - if let Some(level) = log_level { - subscriber.with_max_level(level).init(); - } else { - subscriber.init(); - } - } - - // Display banner - println!( - "{}", - "OpenScreen QUIC Client Test Tool".bright_cyan().bold() - ); - println!(); - - // Get PSK (prompt if not provided) - let psk = match cli.psk { - Some(p) => p, - None => { - print!("{}", "Enter PSK (password): ".bright_yellow()); - std::io::Write::flush(&mut std::io::stdout())?; - rpassword::read_password()? - } - }; - - if psk.is_empty() { - anyhow::bail!("PSK cannot be empty"); - } - - // Display connection info - println!( - "{} {}:{}", - "Connecting to".bright_green(), - cli.host.bright_white().bold(), - cli.port.to_string().bright_white().bold() - ); - if let Some(ref token) = cli.auth_token { - println!("{} {}", "Auth token:".bright_green(), token.bright_white()); - } - println!(); - - // Run the connection test - match run_connection_test(&cli.host, cli.port, &psk, cli.auth_token.as_deref()).await { - Ok(()) => { - println!(); - println!("{}", "✓ Test completed successfully!".bright_green().bold()); - Ok(()) - } - Err(e) => { - println!(); - // Use error! for logging, but also print to stderr for CLI visibility - error!("Test failed: {:?}", e); - println!("{} {:?}", "✗ Test failed:".bright_red().bold(), e); - std::process::exit(1); - } - } -} - -/// Run a connection test to an OpenScreen receiver -async fn run_connection_test( - host: &str, - port: u16, - psk: &str, - auth_token: Option<&str>, -) -> Result<()> { - // Step 1: Resolve hostname - print!("{}", "WAIT: Resolving hostname... ".bright_yellow()); - std::io::Write::flush(&mut std::io::stdout())?; - - let addr_string = format!("{host}:{port}"); - let mut addrs = addr_string - .to_socket_addrs() - .context("Failed to resolve hostname")?; - - let server_addr = addrs.next().context("No addresses found for hostname")?; - - println!("{} {}", "✓".bright_green(), server_addr); - info!("Resolved {} to {}", addr_string, server_addr); - - // Step 2: Prepare dummy fingerprint (insecure - for testing only!) - warn!("WARN: Using dummy fingerprint - Connection is vulnerable to MITM attacks!"); - let dummy_fingerprint = [0u8; 32]; - - // Step 3: Create Quinn client with expected fingerprint - print!("{}", "WAIT: Initializing QUIC client... ".bright_yellow()); - std::io::Write::flush(&mut std::io::stdout())?; - - let crypto_provider = RustCryptoCryptoProvider::new(); - let bind_addr = "0.0.0.0:0".parse().unwrap(); - let mut client = QuinnClient::new(crypto_provider, bind_addr, dummy_fingerprint) - .context("Failed to create Quinn client")?; - - println!("{}", "✓".bright_green()); - debug!( - "Quinn client initialized with bind address {} (dummy fingerprint)", - bind_addr - ); - - // Step 4: Configure PSK and auth token - print!("{}", "WAIT: Configuring authentication... ".bright_yellow()); - std::io::Write::flush(&mut std::io::stdout())?; - - client - .set_psk(psk.as_bytes()) - .map_err(|e| anyhow::anyhow!("Failed to set PSK: {e:?}"))?; - - if let Some(token) = auth_token { - client - .set_auth_token(token.as_bytes()) - .map_err(|e| anyhow::anyhow!("Failed to set auth token: {e:?}"))?; - } - - println!("{}", "✓".bright_green()); - debug!("PSK and auth token configured"); - - // Step 5: Attempt connection - // Fingerprint verification now happens during TLS handshake - print!( - "{}", - "WAIT: Connecting to receiver (QUIC + TLS)... ".bright_yellow() - ); - std::io::Write::flush(&mut std::io::stdout())?; - - match client.connect(server_addr, host).await { - Ok(()) => { - println!("{}", "✓".bright_green()); - info!("QUIC connection established"); - } - Err(e) => { - println!("{}", "✗".bright_red()); - warn!("Connection failed: {:?}", e); - return Err(anyhow::anyhow!("QUIC connection failed: {e:?}")); - } - } - - // Step 6: Check authentication status - // Note: The connect() method already handles authentication, - // but we check the final state here for display purposes - print!("{}", "WAIT: Authenticating... ".bright_yellow()); - std::io::Write::flush(&mut std::io::stdout())?; - - if client.is_authenticated() { - println!("{}", "✓".bright_green()); - info!("Authentication successful"); - } else { - println!("{}", "⚠".bright_yellow()); - warn!("Authentication did not complete - check receiver logs"); - } - - // Display authentication state - println!(); - println!( - "{} {}", - "Authenticated:".bright_cyan(), - if client.is_authenticated() { - "Yes" - } else { - "No" - } - ); - - Ok(()) -} diff --git a/openscreen-quinn/src/bin/receiver.rs b/openscreen-quinn/src/bin/receiver.rs deleted file mode 100644 index 9cd5ef4..0000000 --- a/openscreen-quinn/src/bin/receiver.rs +++ /dev/null @@ -1,180 +0,0 @@ -// Copyright 2025 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//! OpenScreen Receiver - Testing Server -//! -//! Acts as an OpenScreen receiver, accepting incoming QUIC connections -//! and authenticating clients using SPAKE2. - -#![allow( - clippy::items_after_statements, - clippy::match_same_arms, - clippy::large_futures -)] - -use anyhow::Result; -use clap::Parser; -use colored::Colorize; -use openscreen_quinn::QuinnServer; -use tracing::{error, info}; - -#[derive(Parser, Debug)] -#[command(author, version, about, long_about = None)] -#[command(name = "openscreen-receiver")] -#[command(about = "OpenScreen receiver for testing", long_about = None)] -struct Cli { - /// Port to listen on - #[arg(short, long, default_value_t = 4433)] - port: u16, - - /// The Pre-Shared Key (PSK) for authentication. If not provided, you will be prompted. - #[arg(long)] - psk: Option, - - /// Friendly name for this receiver - #[arg(long, default_value = "OpenScreen Test Receiver")] - name: String, - - /// Increase logging verbosity (-v, -vv, -vvv) - #[command(flatten)] - verbose: clap_verbosity_flag::Verbosity, -} - -#[tokio::main] -async fn main() -> Result<()> { - let cli = Cli::parse(); - - // Initialize tracing subscriber - // Priority: RUST_LOG env var > CLI -v flags > default (INFO) - use tracing_subscriber::EnvFilter; - - // Check if RUST_LOG is set - let env_filter = if std::env::var("RUST_LOG").is_ok() { - // Use RUST_LOG if set - EnvFilter::from_default_env() - } else { - // Fall back to CLI verbosity flags - use tracing::Level; - let log_level = match cli.verbose.log_level_filter() { - clap_verbosity_flag::LevelFilter::Off => Level::ERROR, - clap_verbosity_flag::LevelFilter::Error => Level::ERROR, - clap_verbosity_flag::LevelFilter::Warn => Level::WARN, - clap_verbosity_flag::LevelFilter::Info => Level::INFO, - clap_verbosity_flag::LevelFilter::Debug => Level::DEBUG, - clap_verbosity_flag::LevelFilter::Trace => Level::TRACE, - }; - EnvFilter::new(log_level.as_str()) - }; - - tracing_subscriber::fmt() - .with_timer(tracing_subscriber::fmt::time::ChronoUtc::default()) - .with_target(false) - .with_env_filter(env_filter) - .init(); - - // Display banner - println!("{}", "OpenScreen Receiver".bright_cyan().bold()); - println!("{}: {}", "Name".bright_green(), cli.name.bright_white()); - println!(); - - // Get PSK (prompt if not provided) - let psk = match cli.psk { - Some(p) => p, - None => { - print!("{}", "Enter PSK (password): ".bright_yellow()); - std::io::Write::flush(&mut std::io::stdout())?; - rpassword::read_password()? - } - }; - - if psk.is_empty() { - anyhow::bail!("PSK cannot be empty"); - } - - // Start receiver - println!("{} on port {}", "Listening".bright_green(), cli.port); - println!(); - - match run_receiver(cli.port, &psk).await { - Ok(()) => { - println!(); - println!("{}", "✓ Receiver stopped".bright_green().bold()); - Ok(()) - } - Err(e) => { - println!(); - error!("Receiver failed: {:?}", e); - println!("{} {:?}", "✗ Receiver failed:".bright_red().bold(), e); - std::process::exit(1) - } - } -} - -/// Run the OpenScreen receiver using QuinnServer -async fn run_receiver(port: u16, psk: &str) -> Result<()> { - let bind_addr = format!("0.0.0.0:{port}").parse::()?; - - println!("WAIT: Initializing server..."); - let server = QuinnServer::bind(bind_addr, psk).await?; - - println!("{}", "Waiting for connections...".bright_cyan()); - println!(); - - // Accept and handle connections - while let Some(result) = server.accept().await { - match result { - Ok(connection) => { - let remote_addr = connection.remote_address(); - println!( - "{} from {}", - "New connection".bright_cyan(), - remote_addr.to_string().bright_white() - ); - println!(" {} Client authenticated!", "✓".bright_green().bold()); - info!("Client {} authenticated", remote_addr); - - // Spawn handler for this connection - tokio::spawn(async move { - if let Err(e) = handle_connection(connection).await { - error!("Connection handler failed: {:?}", e); - } - }); - } - Err(e) => { - error!("Failed to authenticate connection: {:?}", e); - println!(" {} Auth failed: {:?}", "✗".bright_red(), e); - } - } - } - - Ok(()) -} - -/// Handle an authenticated connection -async fn handle_connection(connection: openscreen_quinn::AuthenticatedConnection) -> Result<()> { - let remote_addr = connection.remote_address(); - info!("Handling connection from {}", remote_addr); - - // For now, just keep connection alive - // Future: Handle application messages here - loop { - if connection.is_closed() { - info!("Connection from {} closed", remote_addr); - break; - } - tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; - } - - Ok(()) -} diff --git a/openscreen-quinn/src/lib.rs b/openscreen-quinn/src/lib.rs index 488479b..83f000f 100644 --- a/openscreen-quinn/src/lib.rs +++ b/openscreen-quinn/src/lib.rs @@ -116,12 +116,14 @@ pub struct QuinnClient { } impl QuinnClient { - /// Create a new Quinn client with fingerprint verification + /// Create a new Quinn client with a W3C-compliant certificate /// /// # Arguments /// * `crypto_provider` - Implementation of CryptoProvider for crypto operations /// * `bind_addr` - Local address to bind to (typically "0.0.0.0:0") /// * `expected_fingerprint` - Expected SPKI fingerprint from mDNS discovery (for MITM protection) + /// * `cert_der` - DER-encoded certificate (use `openscreen_application::cert::CertificateKey`) + /// * `key_der` - DER-encoded private key (PKCS#8 format) /// /// # Returns /// * `Ok(QuinnClient)` - Client successfully created @@ -131,19 +133,16 @@ impl QuinnClient { /// /// The `expected_fingerprint` MUST be the fingerprint from mDNS discovery (`fp=` TXT record). /// The TLS handshake will reject connections to servers with mismatched fingerprints. + /// + /// The certificate MUST have a W3C-compliant 160-bit serial number (use `CertificateKey::generate()`). pub fn new( crypto_provider: C, bind_addr: SocketAddr, expected_fingerprint: [u8; 32], + cert_der: Vec, + key_der: Vec, ) -> Result { - // Generate self-signed certificate for the client - // This is required for TLS fingerprint extraction per RFC 9382 - debug!("Generating self-signed client certificate"); - let cert = rcgen::generate_simple_self_signed(vec!["openscreen-client".into()]) - .map_err(|e| QuinnError::Tls(format!("Failed to generate certificate: {e}")))?; - - let cert_der = cert.cert.der().to_vec(); - let priv_key_der = cert.key_pair.serialize_der(); + debug!("Configuring TLS with provided certificate"); // Create TLS client config with ALPN "osp" // Use FingerprintVerifier to reject mismatched fingerprints during TLS handshake @@ -154,7 +153,7 @@ impl QuinnClient { ))) .with_client_auth_cert( vec![CertificateDer::from(cert_der.clone())], - rustls::pki_types::PrivateKeyDer::Pkcs8(priv_key_der.into()), + rustls::pki_types::PrivateKeyDer::Pkcs8(key_der.into()), ) .map_err(|e| QuinnError::Tls(format!("Failed to configure TLS: {e}")))?; @@ -890,11 +889,70 @@ mod tests { use super::*; use openscreen_crypto::MockCryptoProvider; + /// Helper to generate test certificates (tests only - not W3C compliant) + /// + /// Note: This is intentionally separate from tests/common/mod.rs because unit tests + /// in src/ and integration tests in tests/ typically have separate test utilities. + fn generate_test_cert(hostname: &str) -> (Vec, Vec) { + let key_pair = rcgen::KeyPair::generate().expect("Failed to generate key pair"); + let mut params = rcgen::CertificateParams::new(vec![hostname.to_string()]) + .expect("Failed to create certificate params"); + params + .distinguished_name + .push(rcgen::DnType::CommonName, hostname); + let cert = params + .self_signed(&key_pair) + .expect("Failed to self-sign certificate"); + (cert.der().to_vec(), key_pair.serialize_der()) + } + + #[tokio::test] + async fn test_client_cert_has_subject_cn() { + // Verify that certificates have Subject CN set per W3C spec + let crypto = MockCryptoProvider::new(b"test"); + let expected_fp = [0u8; 32]; + let hostname = "test-hostname.local"; + let (cert_der, key_der) = generate_test_cert(hostname); + let client = QuinnClient::new( + crypto, + "0.0.0.0:0".parse().unwrap(), + expected_fp, + cert_der, + key_der, + ) + .expect("Failed to create QuinnClient"); + + // Parse the client's certificate + use x509_parser::prelude::*; + let (_, x509_cert) = X509Certificate::from_der(&client.local_cert_der) + .expect("Failed to parse client certificate"); + + // Verify Subject CN is set per W3C spec (network.bs lines 358-361) + let subject = x509_cert.subject(); + let cn = subject + .iter_common_name() + .next() + .expect("Certificate must have Subject CN"); + + assert_eq!( + cn.as_str().expect("CN must be valid string"), + hostname, + "Subject CN must match agent hostname per W3C spec" + ); + } + #[tokio::test] async fn test_create_client() { let crypto = MockCryptoProvider::new(b"test"); let expected_fp = [0u8; 32]; // Dummy fingerprint for testing - let client = QuinnClient::new(crypto, "0.0.0.0:0".parse().unwrap(), expected_fp); + let (cert_der, key_der) = generate_test_cert("test.local"); + let client = QuinnClient::new( + crypto, + "0.0.0.0:0".parse().unwrap(), + expected_fp, + cert_der, + key_der, + ); assert!(client.is_ok()); } @@ -902,8 +960,15 @@ mod tests { async fn test_set_psk_and_token() { let crypto = MockCryptoProvider::new(b"test"); let expected_fp = [0u8; 32]; // Dummy fingerprint for testing - let mut client = - QuinnClient::new(crypto, "0.0.0.0:0".parse().unwrap(), expected_fp).unwrap(); + let (cert_der, key_der) = generate_test_cert("test.local"); + let mut client = QuinnClient::new( + crypto, + "0.0.0.0:0".parse().unwrap(), + expected_fp, + cert_der, + key_der, + ) + .unwrap(); assert!(client.set_psk(b"my-secret-password").is_ok()); assert!(client.set_auth_token(b"token-123").is_ok()); diff --git a/openscreen-quinn/src/server.rs b/openscreen-quinn/src/server.rs index 785bb38..a0c135b 100644 --- a/openscreen-quinn/src/server.rs +++ b/openscreen-quinn/src/server.rs @@ -99,7 +99,15 @@ impl rustls::server::danger::ClientCertVerifier for AcceptAnyClientCert { /// async fn main() -> Result<(), Box> { /// use std::net::SocketAddr; /// let addr: SocketAddr = "0.0.0.0:4433".parse()?; -/// let server = QuinnServer::bind(addr, "test-psk").await?; +/// +/// // Generate test certificate (for production, use W3C-compliant certificates) +/// let key_pair = rcgen::KeyPair::generate()?; +/// let mut params = rcgen::CertificateParams::new(vec!["test-server.local".to_string()])?; +/// params.distinguished_name.push(rcgen::DnType::CommonName, "test-server.local"); +/// let cert = params.self_signed(&key_pair)?; +/// let (cert_der, key_der) = (cert.der().to_vec(), key_pair.serialize_der()); +/// +/// let server = QuinnServer::bind(addr, "test-psk", cert_der, key_der, None).await?; /// /// while let Some(result) = server.accept().await { /// match result { @@ -127,64 +135,17 @@ pub struct QuinnServer { } impl QuinnServer { - /// Create and bind a new OpenScreen server + /// Create and bind a new OpenScreen server with a W3C-compliant certificate /// /// # Arguments /// * `bind_addr` - Socket address to bind to (e.g., "0.0.0.0:4433") /// * `psk` - Pre-shared key for SPAKE2 authentication - pub async fn bind(bind_addr: impl Into, psk: impl Into) -> Result { - let bind_addr = bind_addr.into(); - let psk = psk.into(); - - debug!("Generating self-signed certificate"); - let cert = rcgen::generate_simple_self_signed(vec!["localhost".into()]) - .context("Failed to generate certificate")?; - let cert_der = cert.cert.der().to_vec(); - let priv_key = cert.key_pair.serialize_der(); - - debug!("Configuring TLS with client cert verifier"); - let client_cert_verifier = Arc::new(AcceptAnyClientCert); - - let mut server_crypto = rustls::ServerConfig::builder() - .with_client_cert_verifier(client_cert_verifier) - .with_single_cert( - vec![rustls::pki_types::CertificateDer::from(cert_der.clone())], - rustls::pki_types::PrivateKeyDer::Pkcs8(priv_key.into()), - ) - .context("Failed to create TLS config")?; - - server_crypto.alpn_protocols = vec![b"osp".to_vec()]; - - let server_config = quinn::ServerConfig::with_crypto(Arc::new( - quinn::crypto::rustls::QuicServerConfig::try_from(server_crypto) - .context("Failed to create QUIC server config")?, - )); - - let endpoint = quinn::Endpoint::server(server_config, bind_addr) - .context("Failed to create endpoint")?; - - info!("QuinnServer bound to {}", bind_addr); - - Ok(Self { - endpoint, - psk, - cert_der, - auth_token: None, - }) - } - - /// Create and bind a new OpenScreen server with a provided certificate - /// - /// This variant allows you to provide your own certificate instead of generating one. - /// Useful for persistent identity and mDNS discovery integration. - /// - /// # Arguments - /// * `bind_addr` - Socket address to bind to (e.g., "0.0.0.0:4433") - /// * `psk` - Pre-shared key for SPAKE2 authentication - /// * `cert_der` - DER-encoded certificate + /// * `cert_der` - DER-encoded certificate (use `openscreen_application::cert::CertificateKey`) /// * `key_der` - DER-encoded private key (PKCS#8 format) /// * `auth_token` - Optional authentication token from mDNS (for off-network attack prevention) - pub async fn bind_with_cert( + /// + /// The certificate MUST have a W3C-compliant 160-bit serial number (use `CertificateKey::generate()`). + pub async fn bind( bind_addr: impl Into, psk: impl Into, cert_der: Vec, diff --git a/openscreen-quinn/tests/common/mod.rs b/openscreen-quinn/tests/common/mod.rs new file mode 100644 index 0000000..c805a39 --- /dev/null +++ b/openscreen-quinn/tests/common/mod.rs @@ -0,0 +1,32 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Common test utilities for openscreen-quinn tests + +/// Helper to generate test certificates (tests only - not W3C compliant) +/// +/// This generates simple self-signed certificates with Subject CN set. +/// For production use, use `openscreen_application::cert::CertificateKey` instead. +pub fn generate_test_cert(hostname: &str) -> (Vec, Vec) { + let key_pair = rcgen::KeyPair::generate().expect("Failed to generate key pair"); + let mut params = rcgen::CertificateParams::new(vec![hostname.to_string()]) + .expect("Failed to create certificate params"); + params + .distinguished_name + .push(rcgen::DnType::CommonName, hostname); + let cert = params + .self_signed(&key_pair) + .expect("Failed to self-sign certificate"); + (cert.der().to_vec(), key_pair.serialize_der()) +} diff --git a/openscreen-quinn/tests/fingerprint_verification_tests.rs b/openscreen-quinn/tests/fingerprint_verification_tests.rs index cf1bed7..770e799 100644 --- a/openscreen-quinn/tests/fingerprint_verification_tests.rs +++ b/openscreen-quinn/tests/fingerprint_verification_tests.rs @@ -19,6 +19,9 @@ //! //! Note: These tests verify TLS-level fingerprint verification only, not full SPAKE2 authentication. +mod common; + +use common::generate_test_cert; use openscreen_crypto_rustcrypto::RustCryptoCryptoProvider; use openscreen_quinn::{QuinnClient, QuinnServer}; use std::net::SocketAddr; @@ -28,7 +31,8 @@ use std::net::SocketAddr; async fn test_connection_succeeds_with_correct_fingerprint() { // Start a receiver (server) let server_addr: SocketAddr = "127.0.0.1:0".parse().unwrap(); - let server = QuinnServer::bind(server_addr, "test-psk") + let (cert_der, key_der) = generate_test_cert("test-server.local"); + let server = QuinnServer::bind(server_addr, "test-psk", cert_der, key_der, None) .await .expect("Failed to start server"); @@ -52,8 +56,15 @@ async fn test_connection_succeeds_with_correct_fingerprint() { // Create client with correct fingerprint and same PSK let crypto_provider = RustCryptoCryptoProvider::new(); let client_bind: SocketAddr = "127.0.0.1:0".parse().unwrap(); - let mut client = QuinnClient::new(crypto_provider, client_bind, server_fingerprint) - .expect("Failed to create client"); + let (cert_der, key_der) = generate_test_cert("test-client.local"); + let mut client = QuinnClient::new( + crypto_provider, + client_bind, + server_fingerprint, + cert_der, + key_der, + ) + .expect("Failed to create client"); client.set_psk(b"test-psk").expect("Failed to set PSK"); @@ -101,7 +112,8 @@ async fn test_connection_succeeds_with_correct_fingerprint() { async fn test_connection_fails_with_wrong_fingerprint() { // Start a receiver (server) let server_addr: SocketAddr = "127.0.0.1:0".parse().unwrap(); - let server = QuinnServer::bind(server_addr, "test-psk") + let (cert_der, key_der) = generate_test_cert("test-server.local"); + let server = QuinnServer::bind(server_addr, "test-psk", cert_der, key_der, None) .await .expect("Failed to start server"); @@ -121,8 +133,15 @@ async fn test_connection_fails_with_wrong_fingerprint() { let wrong_fingerprint = [0x42u8; 32]; // Definitely wrong let crypto_provider = RustCryptoCryptoProvider::new(); let client_bind: SocketAddr = "127.0.0.1:0".parse().unwrap(); - let mut client = QuinnClient::new(crypto_provider, client_bind, wrong_fingerprint) - .expect("Failed to create client"); + let (cert_der, key_der) = generate_test_cert("test-client.local"); + let mut client = QuinnClient::new( + crypto_provider, + client_bind, + wrong_fingerprint, + cert_der, + key_der, + ) + .expect("Failed to create client"); client.set_psk(b"test-psk").expect("Failed to set PSK"); @@ -171,7 +190,8 @@ async fn test_connection_fails_with_wrong_fingerprint() { async fn test_connection_fails_with_zero_fingerprint() { // Start a receiver (server) let server_addr: SocketAddr = "127.0.0.1:0".parse().unwrap(); - let server = QuinnServer::bind(server_addr, "test-psk") + let (cert_der, key_der) = generate_test_cert("test-server.local"); + let server = QuinnServer::bind(server_addr, "test-psk", cert_der, key_der, None) .await .expect("Failed to start server"); @@ -189,8 +209,15 @@ async fn test_connection_fails_with_zero_fingerprint() { let zero_fingerprint = [0u8; 32]; let crypto_provider = RustCryptoCryptoProvider::new(); let client_bind: SocketAddr = "127.0.0.1:0".parse().unwrap(); - let mut client = QuinnClient::new(crypto_provider, client_bind, zero_fingerprint) - .expect("Failed to create client"); + let (cert_der, key_der) = generate_test_cert("test-client.local"); + let mut client = QuinnClient::new( + crypto_provider, + client_bind, + zero_fingerprint, + cert_der, + key_der, + ) + .expect("Failed to create client"); client.set_psk(b"test-psk").expect("Failed to set PSK"); @@ -221,7 +248,8 @@ async fn test_connection_fails_with_zero_fingerprint() { async fn test_multiple_clients_same_fingerprint() { // Start a receiver let server_addr: SocketAddr = "127.0.0.1:0".parse().unwrap(); - let server = QuinnServer::bind(server_addr, "test-psk") + let (cert_der, key_der) = generate_test_cert("test-server.local"); + let server = QuinnServer::bind(server_addr, "test-psk", cert_der, key_der, None) .await .expect("Failed to start server"); @@ -241,8 +269,15 @@ async fn test_multiple_clients_same_fingerprint() { // Client 1 with correct fingerprint let crypto1 = RustCryptoCryptoProvider::new(); - let mut client1 = QuinnClient::new(crypto1, "127.0.0.1:0".parse().unwrap(), server_fingerprint) - .expect("Failed to create client1"); + let (cert_der1, key_der1) = generate_test_cert("test-client1.local"); + let mut client1 = QuinnClient::new( + crypto1, + "127.0.0.1:0".parse().unwrap(), + server_fingerprint, + cert_der1, + key_der1, + ) + .expect("Failed to create client1"); client1.set_psk(b"test-psk").expect("Failed to set PSK"); let handle1 = tokio::spawn(async move { @@ -270,8 +305,15 @@ async fn test_multiple_clients_same_fingerprint() { // Client 2 (different client, same server fingerprint) let crypto2 = RustCryptoCryptoProvider::new(); - let mut client2 = QuinnClient::new(crypto2, "127.0.0.1:0".parse().unwrap(), server_fingerprint) - .expect("Failed to create client2"); + let (cert_der2, key_der2) = generate_test_cert("test-client2.local"); + let mut client2 = QuinnClient::new( + crypto2, + "127.0.0.1:0".parse().unwrap(), + server_fingerprint, + cert_der2, + key_der2, + ) + .expect("Failed to create client2"); client2.set_psk(b"test-psk").expect("Failed to set PSK"); let handle2 = tokio::spawn(async move { diff --git a/openscreen-quinn/tests/tls_fingerprint_unit_tests.rs b/openscreen-quinn/tests/tls_fingerprint_unit_tests.rs index 946f626..63d1630 100644 --- a/openscreen-quinn/tests/tls_fingerprint_unit_tests.rs +++ b/openscreen-quinn/tests/tls_fingerprint_unit_tests.rs @@ -20,6 +20,9 @@ //! Unlike the integration tests, these tests focus ONLY on TLS-level //! fingerprint verification without requiring full SPAKE2 authentication. +mod common; + +use common::generate_test_cert; use openscreen_crypto_rustcrypto::RustCryptoCryptoProvider; use openscreen_quinn::QuinnClient; use std::net::SocketAddr; @@ -30,7 +33,8 @@ async fn create_test_server(psk: &str) -> (SocketAddr, [u8; 32], tokio::task::Jo use openscreen_quinn::QuinnServer; let server_addr: SocketAddr = "127.0.0.1:0".parse().unwrap(); - let server = QuinnServer::bind(server_addr, psk) + let (cert_der, key_der) = generate_test_cert("test-server.local"); + let server = QuinnServer::bind(server_addr, psk, cert_der, key_der, None) .await .expect("Failed to start test server"); @@ -61,8 +65,15 @@ async fn test_tls_accepts_correct_fingerprint() { // Create client with CORRECT fingerprint let crypto_provider = RustCryptoCryptoProvider::new(); let client_bind: SocketAddr = "127.0.0.1:0".parse().unwrap(); - let mut client = QuinnClient::new(crypto_provider, client_bind, server_fingerprint) - .expect("Failed to create client"); + let (cert_der, key_der) = generate_test_cert("test-client.local"); + let mut client = QuinnClient::new( + crypto_provider, + client_bind, + server_fingerprint, + cert_der, + key_der, + ) + .expect("Failed to create client"); client.set_psk(b"test-psk").expect("Failed to set PSK"); @@ -114,8 +125,15 @@ async fn test_tls_rejects_wrong_fingerprint() { let wrong_fingerprint = [0x42u8; 32]; // Definitely wrong! let crypto_provider = RustCryptoCryptoProvider::new(); let client_bind: SocketAddr = "127.0.0.1:0".parse().unwrap(); - let mut client = QuinnClient::new(crypto_provider, client_bind, wrong_fingerprint) - .expect("Failed to create client"); + let (cert_der, key_der) = generate_test_cert("test-client.local"); + let mut client = QuinnClient::new( + crypto_provider, + client_bind, + wrong_fingerprint, + cert_der, + key_der, + ) + .expect("Failed to create client"); client.set_psk(b"test-psk").expect("Failed to set PSK"); @@ -166,8 +184,15 @@ async fn test_tls_rejects_zero_fingerprint() { let zero_fingerprint = [0u8; 32]; let crypto_provider = RustCryptoCryptoProvider::new(); let client_bind: SocketAddr = "127.0.0.1:0".parse().unwrap(); - let mut client = QuinnClient::new(crypto_provider, client_bind, zero_fingerprint) - .expect("Failed to create client"); + let (cert_der, key_der) = generate_test_cert("test-client.local"); + let mut client = QuinnClient::new( + crypto_provider, + client_bind, + zero_fingerprint, + cert_der, + key_der, + ) + .expect("Failed to create client"); client.set_psk(b"test-psk").expect("Failed to set PSK"); @@ -209,8 +234,15 @@ async fn test_fingerprint_exact_match_required() { let crypto_provider = RustCryptoCryptoProvider::new(); let client_bind: SocketAddr = "127.0.0.1:0".parse().unwrap(); - let mut client = QuinnClient::new(crypto_provider, client_bind, almost_correct_fingerprint) - .expect("Failed to create client"); + let (cert_der, key_der) = generate_test_cert("test-client.local"); + let mut client = QuinnClient::new( + crypto_provider, + client_bind, + almost_correct_fingerprint, + cert_der, + key_der, + ) + .expect("Failed to create client"); client.set_psk(b"test-psk").expect("Failed to set PSK");