Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,6 @@ unused_self = "allow"
needless_pass_by_value = "allow"
single_match_else = "allow"

[patch.crates-io]
# Use patched mdns-sd with configurable port for development
mdns-sd = { git = "https://github.com/kaidokert/mdns-sd", tag = "v0.17.0_custom_port_1" }

[workspace.dependencies]
# Core protocol (no_std)
openscreen-common = { path = "openscreen-common" }
Expand Down
2 changes: 1 addition & 1 deletion openscreen-discovery-mdns/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ async-trait = "0.1"
futures = "0.3"
async-stream = "0.3"
tokio = { version = "1", features = ["sync", "rt", "rt-multi-thread"] }
mdns-sd = "0.17"
mdns-sd = "0.19"
log = "0.4"
thiserror = "2.0"
hostname = "0.4"
Expand Down
25 changes: 24 additions & 1 deletion openscreen-quinn/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,10 @@ impl QuinnServer {
match tokio::time::timeout(AUTH_TIMEOUT, auth_future).await {
Ok(Ok(_)) => {
info!("[CONN:{}] Authentication successful", conn_id);
Ok(AuthenticatedConnection { connection })
Ok(AuthenticatedConnection {
connection,
peer_fingerprint,
})
}
Ok(Err(e)) => {
error!("[CONN:{}] Authentication failed: {:?}", conn_id, e);
Expand Down Expand Up @@ -557,9 +560,29 @@ impl QuinnServer {
/// Provides methods for sending and receiving application-layer messages.
pub struct AuthenticatedConnection {
connection: quinn::Connection,
/// SPKI SHA-256 fingerprint of the peer's client certificate, as
/// extracted from the TLS handshake and verified during SPAKE2.
peer_fingerprint: [u8; 32],
}

impl AuthenticatedConnection {
/// The peer's certificate SPKI fingerprint.
///
/// This is the fingerprint that was extracted from the peer's TLS
/// certificate and bound into SPAKE2 via `CryptoData::set_peer_fingerprint`
/// during authentication. After `accept()` returns, SPAKE2 has confirmed
/// that the peer holds the private key matching this certificate, so the
/// fingerprint is a verified stable identity for the peer per the W3C
/// OpenScreen spec and RFC 9382.
///
/// Receivers need this to key per-peer state (rate-limit counters,
/// presentation sessions, mDNS cross-references) because the fingerprint
/// is the only stable cryptographic identifier — IP, port, and mDNS
/// instance name can all churn while the fingerprint stays constant.
pub fn peer_fingerprint(&self) -> [u8; 32] {
self.peer_fingerprint
}

/// Receive the next application message from the peer
///
/// Blocks until a message is received or the connection is closed.
Expand Down
109 changes: 109 additions & 0 deletions openscreen-quinn/tests/peer_fingerprint_exposure.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// 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.

//! Integration test: server-side peer fingerprint exposure
//!
//! After SPAKE2 completes, `QuinnServer::accept` returns an
//! `AuthenticatedConnection` whose `peer_fingerprint()` accessor must return
//! the SPKI fingerprint of the *client's* certificate. This is the stable
//! cryptographic identity the receiver uses to key per-peer state and to
//! cross-reference the peer against mDNS discovery records.
//!
//! Prior to this being exposed, the server verified the client fingerprint
//! during SPAKE2 but then discarded it, forcing callers to treat the
//! authenticated peer as anonymous. This test locks in the contract that
//! the verified fingerprint is observable.
//!
//! The expected fingerprint is computed via `openscreen_discovery::Fingerprint`
//! rather than `openscreen_quinn`'s internal helper, so the assertion is a
//! genuine cross-crate cross-check, not a tautology over the same code path.

mod common;

use common::generate_test_cert;
use openscreen_crypto_rustcrypto::RustCryptoCryptoProvider;
use openscreen_discovery::Fingerprint;
use openscreen_quinn::{QuinnClient, QuinnServer};
use std::net::SocketAddr;

/// Drive both sides to a fully authenticated connection, then assert that
/// `AuthenticatedConnection::peer_fingerprint()` on the server side equals
/// the SPKI SHA-256 fingerprint of the client's certificate.
#[tokio::test(flavor = "multi_thread")]
async fn server_observes_verified_peer_fingerprint_after_spake2() {
// ---- Server ----
let server_addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
let (server_cert, server_key) = generate_test_cert("test-server.local");
let server = QuinnServer::bind(server_addr, "shared-psk", server_cert, server_key, None)
.await
.expect("Failed to start server");
let bound_addr = server.local_addr().expect("Failed to get server address");
let server_fingerprint = server.fingerprint();

// Spawn the acceptor so it drives SPAKE2 concurrently with the client.
// No `sleep` here: `QuinnServer::bind` binds the UDP socket synchronously,
// so packets from the client are buffered by the OS / Quinn's background
// driver even before `accept()` is polled.
let server_task = tokio::spawn(async move {
server
.accept()
.await
.expect("accept returned None")
.expect("accept returned an error")
});

// ---- Client ----
let (client_cert, client_key) = generate_test_cert("test-client.local");
// Independent cross-crate computation of the expected fingerprint.
let expected_client_fingerprint = Fingerprint::from_der_cert(&client_cert)
.expect("Failed to compute client SPKI fingerprint");
let expected_client_fingerprint = expected_client_fingerprint.as_bytes();

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,
client_cert.clone(),
client_key,
)
.expect("Failed to create client");
client.set_psk(b"shared-psk").expect("Failed to set PSK");

// Drive the client side to completion. If auth fails or times out, the
// server-side fingerprint is not observable, so the test is meaningless.
tokio::time::timeout(
tokio::time::Duration::from_secs(5),
client.connect(bound_addr, "localhost"),
)
.await
.expect("client connect timed out")
.expect("client connect failed");
assert!(
client.is_authenticated(),
"client must be authenticated before we inspect the server-side peer fingerprint"
);

let server_connection = tokio::time::timeout(tokio::time::Duration::from_secs(5), server_task)
.await
.expect("server accept timed out")
.expect("server task panicked");

assert_eq!(
server_connection.peer_fingerprint(),
*expected_client_fingerprint,
"server-side peer_fingerprint must equal the SPKI fingerprint of the client certificate"
);
}