refactor(core): consolidate the fingerprint-pinning verifier into core::tls
Per plan §2.5: the security-critical rustls fingerprint-pinning ServerCertVerifier was hand-rolled three times — quic/endpoint.rs (PinVerify), pf-client-core library.rs, punktfunk-tray status.rs — drifting copies on a trust boundary. Add one canonical punktfunk_core::tls::PinVerify (+ cert_fingerprint) behind a light `tls` feature (rustls + sha2 only, no QUIC runtime); `quic` now depends on it, and quic::endpoint re-exports cert_fingerprint so that path stays byte-stable (gamestream + pf-client-core reach it there). - core::tls::PinVerify: new(pin) for the HTTP clients, with_observed(pin, slot) for the QUIC TOFU connect. Behavior-identical to all three originals (pin-check + real CertificateVerify signature verification; only hashes the leaf when a pin or observed slot needs it). Two focused unit tests anchor the boundary. - quic/endpoint.rs: drop the private PinVerify, wire client_pinned through tls::PinVerify::with_observed. - pf-client-core library.rs + tray status.rs: use the shared verifier; tray also routes load_pin through core cert_fingerprint and drops its direct sha2 dep, gaining only the light core `tls` feature (still no host dep, no QUIC runtime). Verified on Linux (home-worker-5): clippy 0/0 for core(quic), core(tls), pf-client-core, tray, host(nvenc,vulkan-encode,pyrowave); core 153 lib tests + loopback 7/7 (pinned handshake) + c_abi round-trip green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -125,7 +125,7 @@ pub fn agent(
|
|||||||
.with_safe_default_protocol_versions()
|
.with_safe_default_protocol_versions()
|
||||||
.map_err(|e| bad("tls config", &e))?
|
.map_err(|e| bad("tls config", &e))?
|
||||||
.dangerous()
|
.dangerous()
|
||||||
.with_custom_certificate_verifier(Arc::new(PinVerify { pin }));
|
.with_custom_certificate_verifier(Arc::new(punktfunk_core::tls::PinVerify::new(pin)));
|
||||||
let cert = rustls::pki_types::CertificateDer::from_pem_slice(identity.0.as_bytes())
|
let cert = rustls::pki_types::CertificateDer::from_pem_slice(identity.0.as_bytes())
|
||||||
.map_err(|e| bad("client cert pem", &e))?;
|
.map_err(|e| bad("client cert pem", &e))?;
|
||||||
let key = rustls::pki_types::PrivateKeyDer::from_pem_slice(identity.1.as_bytes())
|
let key = rustls::pki_types::PrivateKeyDer::from_pem_slice(identity.1.as_bytes())
|
||||||
@@ -251,71 +251,6 @@ fn classify(e: ureq::Error) -> LibraryError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fingerprint-pinning verifier — the client-HTTP twin of core's (private) QUIC
|
|
||||||
/// `PinVerify`: trust is the SHA-256 of the host's self-signed leaf cert. The handshake
|
|
||||||
/// signatures MUST still be verified for real: CertificateVerify is what proves the peer
|
|
||||||
/// *holds the pinned cert's private key* — skip it and an active MITM can replay the
|
|
||||||
/// host's (public) certificate, match the pin, and complete the handshake with its own key.
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct PinVerify {
|
|
||||||
pin: Option<[u8; 32]>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl rustls::client::danger::ServerCertVerifier for PinVerify {
|
|
||||||
fn verify_server_cert(
|
|
||||||
&self,
|
|
||||||
end_entity: &rustls::pki_types::CertificateDer<'_>,
|
|
||||||
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
|
|
||||||
_server_name: &rustls::pki_types::ServerName<'_>,
|
|
||||||
_ocsp: &[u8],
|
|
||||||
_now: rustls::pki_types::UnixTime,
|
|
||||||
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
|
||||||
if let Some(expected) = self.pin {
|
|
||||||
let fp = punktfunk_core::quic::endpoint::cert_fingerprint(end_entity.as_ref());
|
|
||||||
if fp != expected {
|
|
||||||
return Err(rustls::Error::InvalidCertificate(
|
|
||||||
rustls::CertificateError::ApplicationVerificationFailure,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn verify_tls12_signature(
|
|
||||||
&self,
|
|
||||||
message: &[u8],
|
|
||||||
cert: &rustls::pki_types::CertificateDer<'_>,
|
|
||||||
dss: &rustls::DigitallySignedStruct,
|
|
||||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
|
||||||
rustls::crypto::verify_tls12_signature(
|
|
||||||
message,
|
|
||||||
cert,
|
|
||||||
dss,
|
|
||||||
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn verify_tls13_signature(
|
|
||||||
&self,
|
|
||||||
message: &[u8],
|
|
||||||
cert: &rustls::pki_types::CertificateDer<'_>,
|
|
||||||
dss: &rustls::DigitallySignedStruct,
|
|
||||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
|
||||||
rustls::crypto::verify_tls13_signature(
|
|
||||||
message,
|
|
||||||
cert,
|
|
||||||
dss,
|
|
||||||
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
|
||||||
rustls::crypto::ring::default_provider()
|
|
||||||
.signature_verification_algorithms
|
|
||||||
.supported_schemes()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -17,9 +17,13 @@ crate-type = ["lib", "cdylib", "staticlib"]
|
|||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
|
# Cert-fingerprint pinning shared across the clients (the one `PinVerify` in `tls.rs`). Light:
|
||||||
|
# rustls + sha2 only, no QUIC runtime — so a lean consumer (the tray's loopback status poll) can
|
||||||
|
# pin the host cert without pulling tokio/quinn. The heavier `quic` feature builds on top of it.
|
||||||
|
tls = ["dep:rustls", "dep:sha2", "dep:rustls-pki-types"]
|
||||||
# Control-plane QUIC (pairing, config, reverse audio). tokio is permitted ONLY here,
|
# Control-plane QUIC (pairing, config, reverse audio). tokio is permitted ONLY here,
|
||||||
# never on the per-frame hot path. Off by default so the core stays runtime-free.
|
# never on the per-frame hot path. Off by default so the core stays runtime-free.
|
||||||
quic = ["dep:quinn", "dep:tokio", "dep:rustls", "dep:rcgen", "dep:rustls-pki-types", "dep:sha2", "dep:hmac", "dep:spake2", "dep:opus"]
|
quic = ["tls", "dep:quinn", "dep:tokio", "dep:rcgen", "dep:hmac", "dep:spake2", "dep:opus"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
reed-solomon-simd = "3.1" # GF(2^16) Leopard-RS, SIMD, O(n log n) — the wall-breaker (P2)
|
reed-solomon-simd = "3.1" # GF(2^16) Leopard-RS, SIMD, O(n log n) — the wall-breaker (P2)
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ pub mod reanchor;
|
|||||||
pub mod reject;
|
pub mod reject;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod stats;
|
pub mod stats;
|
||||||
|
#[cfg(feature = "tls")]
|
||||||
|
pub mod tls;
|
||||||
pub mod transport;
|
pub mod transport;
|
||||||
pub mod wol;
|
pub mod wol;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
//! Shared QUIC endpoint construction (host + client) and transport tuning — keep-alive and idle
|
//! Shared QUIC endpoint construction (host + client) and transport tuning — keep-alive and idle
|
||||||
//! timeout, certificate-fingerprint helpers, and the TOFU cert-pinning verifier (`PinVerify`).
|
//! timeout, plus the certificate-fingerprint helpers. The cert-pinning verifier itself is the
|
||||||
|
//! one shared [`crate::tls::PinVerify`]; this module only wires it into the client endpoint.
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
/// Shared QUIC transport tuning for BOTH the host and client endpoints. Keep-alive is the
|
/// Shared QUIC transport tuning for BOTH the host and client endpoints. Keep-alive is the
|
||||||
@@ -132,11 +133,10 @@ pub fn peer_fingerprint(conn: &quinn::Connection) -> Option<[u8; 32]> {
|
|||||||
certs.first().map(|c| cert_fingerprint(c.as_ref()))
|
certs.first().map(|c| cert_fingerprint(c.as_ref()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// SHA-256 of a certificate's DER encoding — the fingerprint clients pin.
|
/// SHA-256 of a certificate's DER encoding — the fingerprint clients pin. The implementation is
|
||||||
pub fn cert_fingerprint(cert_der: &[u8]) -> [u8; 32] {
|
/// [`crate::tls::cert_fingerprint`] (shared with the HTTP clients); re-exported here so callers
|
||||||
use sha2::Digest;
|
/// reaching it as `quic::endpoint::cert_fingerprint` keep working.
|
||||||
sha2::Sha256::digest(cert_der).into()
|
pub use crate::tls::cert_fingerprint;
|
||||||
}
|
|
||||||
|
|
||||||
/// Fingerprint of a PEM-encoded certificate (what a host logs/shows for pairing UX —
|
/// Fingerprint of a PEM-encoded certificate (what a host logs/shows for pairing UX —
|
||||||
/// must match what the client's verifier computes from the DER on the wire).
|
/// must match what the client's verifier computes from the DER on the wire).
|
||||||
@@ -180,10 +180,10 @@ pub fn client_pinned_with_identity(
|
|||||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||||
let builder = rustls::ClientConfig::builder()
|
let builder = rustls::ClientConfig::builder()
|
||||||
.dangerous()
|
.dangerous()
|
||||||
.with_custom_certificate_verifier(Arc::new(PinVerify {
|
.with_custom_certificate_verifier(Arc::new(crate::tls::PinVerify::with_observed(
|
||||||
pin,
|
pin,
|
||||||
observed: observed.clone(),
|
observed.clone(),
|
||||||
}));
|
)));
|
||||||
let mut rustls_cfg = match identity {
|
let mut rustls_cfg = match identity {
|
||||||
None => builder.with_no_client_auth(),
|
None => builder.with_no_client_auth(),
|
||||||
Some((cert_pem, key_pem)) => {
|
Some((cert_pem, key_pem)) => {
|
||||||
@@ -234,9 +234,6 @@ pub mod anyhow_result {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fingerprint-pinning verifier: trust is the SHA-256 of the host's (self-signed) leaf
|
|
||||||
/// cert, not a CA chain. With no pin it accepts any cert (TOFU) but still records what
|
|
||||||
/// it saw, so the embedder can persist the fingerprint and pin it from then on.
|
|
||||||
/// Server-side client-cert verifier: accept any (self-signed) client certificate but
|
/// Server-side client-cert verifier: accept any (self-signed) client certificate but
|
||||||
/// verify the handshake signature for real — possession of the presented cert's key is
|
/// verify the handshake signature for real — possession of the presented cert's key is
|
||||||
/// what makes the post-handshake fingerprint ([`peer_fingerprint`]) meaningful.
|
/// what makes the post-handshake fingerprint ([`peer_fingerprint`]) meaningful.
|
||||||
@@ -296,69 +293,3 @@ impl rustls::server::danger::ClientCertVerifier for AcceptAnyClientCert {
|
|||||||
.supported_schemes()
|
.supported_schemes()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct PinVerify {
|
|
||||||
pin: Option<[u8; 32]>,
|
|
||||||
observed: Arc<Mutex<Option<[u8; 32]>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl rustls::client::danger::ServerCertVerifier for PinVerify {
|
|
||||||
fn verify_server_cert(
|
|
||||||
&self,
|
|
||||||
end_entity: &rustls::pki_types::CertificateDer<'_>,
|
|
||||||
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
|
|
||||||
_server_name: &rustls::pki_types::ServerName<'_>,
|
|
||||||
_ocsp: &[u8],
|
|
||||||
_now: rustls::pki_types::UnixTime,
|
|
||||||
) -> std::result::Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
|
||||||
let fp = cert_fingerprint(end_entity.as_ref());
|
|
||||||
*self.observed.lock().unwrap() = Some(fp);
|
|
||||||
if let Some(expected) = self.pin {
|
|
||||||
if fp != expected {
|
|
||||||
return Err(rustls::Error::InvalidCertificate(
|
|
||||||
rustls::CertificateError::ApplicationVerificationFailure,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
|
||||||
}
|
|
||||||
|
|
||||||
// The handshake signatures MUST be verified for real even though we pin the cert:
|
|
||||||
// CertificateVerify is what proves the peer *holds the pinned cert's private key* —
|
|
||||||
// skip it and an active MITM can replay the host's (public) certificate, match the
|
|
||||||
// pin, and complete the handshake with its own key.
|
|
||||||
fn verify_tls12_signature(
|
|
||||||
&self,
|
|
||||||
message: &[u8],
|
|
||||||
cert: &rustls::pki_types::CertificateDer<'_>,
|
|
||||||
dss: &rustls::DigitallySignedStruct,
|
|
||||||
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
|
||||||
rustls::crypto::verify_tls12_signature(
|
|
||||||
message,
|
|
||||||
cert,
|
|
||||||
dss,
|
|
||||||
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn verify_tls13_signature(
|
|
||||||
&self,
|
|
||||||
message: &[u8],
|
|
||||||
cert: &rustls::pki_types::CertificateDer<'_>,
|
|
||||||
dss: &rustls::DigitallySignedStruct,
|
|
||||||
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
|
||||||
rustls::crypto::verify_tls13_signature(
|
|
||||||
message,
|
|
||||||
cert,
|
|
||||||
dss,
|
|
||||||
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
|
||||||
rustls::crypto::ring::default_provider()
|
|
||||||
.signature_verification_algorithms
|
|
||||||
.supported_schemes()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
//! Shared TLS trust primitives for the punktfunk clients: the certificate-fingerprint hash and
|
||||||
|
//! the one canonical fingerprint-pinning [`ServerCertVerifier`](rustls::client::danger::ServerCertVerifier)
|
||||||
|
//! (`PinVerify`). Trust across the whole system is the SHA-256 of the host's self-signed leaf cert
|
||||||
|
//! (TOFU-pinned), not a CA chain — and this verifier is what the QUIC connect, the game-library
|
||||||
|
//! HTTP client, and the tray status poll all share, instead of hand-rolling it three times on a
|
||||||
|
//! trust boundary. Behind the light `tls` feature (rustls + sha2 only — no QUIC runtime), which
|
||||||
|
//! the heavier `quic` feature pulls in.
|
||||||
|
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
/// SHA-256 of a certificate's DER encoding — the fingerprint clients pin. Re-exported as
|
||||||
|
/// `crate::quic::endpoint::cert_fingerprint` for callers that already reach it there.
|
||||||
|
pub fn cert_fingerprint(cert_der: &[u8]) -> [u8; 32] {
|
||||||
|
use sha2::Digest;
|
||||||
|
sha2::Sha256::digest(cert_der).into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fingerprint-pinning verifier: trust is the SHA-256 of the host's (self-signed) leaf cert,
|
||||||
|
/// not a CA chain.
|
||||||
|
///
|
||||||
|
/// - `pin = Some(sha256)` rejects any host whose leaf doesn't hash to `sha256`.
|
||||||
|
/// - `pin = None` accepts any leaf (trust-on-first-use) — pair with [`with_observed`](Self::with_observed)
|
||||||
|
/// to record what was seen so the embedder can persist it and pin it from then on.
|
||||||
|
///
|
||||||
|
/// The handshake signatures are ALWAYS verified for real even though the cert is pinned:
|
||||||
|
/// `CertificateVerify` is what proves the peer *holds the pinned cert's private key* — skip it and
|
||||||
|
/// an active MITM can replay the host's (public) certificate, match the pin, and complete the
|
||||||
|
/// handshake with its own key.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct PinVerify {
|
||||||
|
pin: Option<[u8; 32]>,
|
||||||
|
observed: Option<Arc<Mutex<Option<[u8; 32]>>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PinVerify {
|
||||||
|
/// A verifier that pins `pin` (or accepts any when `None`) without recording what it saw —
|
||||||
|
/// the HTTP clients, which connect with a known pin or accept-any and never need to persist
|
||||||
|
/// the observed fingerprint.
|
||||||
|
pub fn new(pin: Option<[u8; 32]>) -> Self {
|
||||||
|
Self {
|
||||||
|
pin,
|
||||||
|
observed: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [`new`](Self::new) but also writes the observed leaf fingerprint into `slot` during
|
||||||
|
/// the handshake, so a trust-on-first-use caller (the QUIC connect) can read it off the slot
|
||||||
|
/// and pin it on the next connect.
|
||||||
|
pub fn with_observed(pin: Option<[u8; 32]>, slot: Arc<Mutex<Option<[u8; 32]>>>) -> Self {
|
||||||
|
Self {
|
||||||
|
pin,
|
||||||
|
observed: Some(slot),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl rustls::client::danger::ServerCertVerifier for PinVerify {
|
||||||
|
fn verify_server_cert(
|
||||||
|
&self,
|
||||||
|
end_entity: &rustls::pki_types::CertificateDer<'_>,
|
||||||
|
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
|
||||||
|
_server_name: &rustls::pki_types::ServerName<'_>,
|
||||||
|
_ocsp: &[u8],
|
||||||
|
_now: rustls::pki_types::UnixTime,
|
||||||
|
) -> std::result::Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||||||
|
// Only hash the leaf when the result depends on it: a pin to check and/or a slot to
|
||||||
|
// record into. Accept-any-without-recording (an un-pinned HTTP agent) skips it.
|
||||||
|
if self.pin.is_some() || self.observed.is_some() {
|
||||||
|
let fp = cert_fingerprint(end_entity.as_ref());
|
||||||
|
if let Some(slot) = &self.observed {
|
||||||
|
*slot.lock().unwrap() = Some(fp);
|
||||||
|
}
|
||||||
|
if let Some(expected) = self.pin {
|
||||||
|
if fp != expected {
|
||||||
|
return Err(rustls::Error::InvalidCertificate(
|
||||||
|
rustls::CertificateError::ApplicationVerificationFailure,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_tls12_signature(
|
||||||
|
&self,
|
||||||
|
message: &[u8],
|
||||||
|
cert: &rustls::pki_types::CertificateDer<'_>,
|
||||||
|
dss: &rustls::DigitallySignedStruct,
|
||||||
|
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||||
|
rustls::crypto::verify_tls12_signature(
|
||||||
|
message,
|
||||||
|
cert,
|
||||||
|
dss,
|
||||||
|
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_tls13_signature(
|
||||||
|
&self,
|
||||||
|
message: &[u8],
|
||||||
|
cert: &rustls::pki_types::CertificateDer<'_>,
|
||||||
|
dss: &rustls::DigitallySignedStruct,
|
||||||
|
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||||
|
rustls::crypto::verify_tls13_signature(
|
||||||
|
message,
|
||||||
|
cert,
|
||||||
|
dss,
|
||||||
|
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||||
|
rustls::crypto::ring::default_provider()
|
||||||
|
.signature_verification_algorithms
|
||||||
|
.supported_schemes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use rustls::client::danger::ServerCertVerifier;
|
||||||
|
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
|
||||||
|
|
||||||
|
/// Drive the pin check against `cert_bytes`. `verify_server_cert` only hashes the leaf, so
|
||||||
|
/// arbitrary bytes stand in for a DER certificate here.
|
||||||
|
fn verify(v: &PinVerify, cert_bytes: &[u8]) -> std::result::Result<(), rustls::Error> {
|
||||||
|
let der = CertificateDer::from(cert_bytes.to_vec());
|
||||||
|
let name = ServerName::try_from("punktfunk").unwrap();
|
||||||
|
v.verify_server_cert(
|
||||||
|
&der,
|
||||||
|
&[],
|
||||||
|
&name,
|
||||||
|
&[],
|
||||||
|
UnixTime::since_unix_epoch(std::time::Duration::ZERO),
|
||||||
|
)
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn matching_pin_accepts_and_mismatch_is_rejected() {
|
||||||
|
let cert = b"the-host-leaf-cert";
|
||||||
|
let good = cert_fingerprint(cert);
|
||||||
|
assert!(verify(&PinVerify::new(Some(good)), cert).is_ok());
|
||||||
|
|
||||||
|
let mut wrong = good;
|
||||||
|
wrong[0] ^= 0xff;
|
||||||
|
match verify(&PinVerify::new(Some(wrong)), cert) {
|
||||||
|
Err(rustls::Error::InvalidCertificate(
|
||||||
|
rustls::CertificateError::ApplicationVerificationFailure,
|
||||||
|
)) => {}
|
||||||
|
other => panic!("a pin mismatch must be rejected, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_pin_accepts_any_and_records_the_observed_fingerprint() {
|
||||||
|
let cert = b"whatever-the-host-presents";
|
||||||
|
let slot = Arc::new(Mutex::new(None));
|
||||||
|
let v = PinVerify::with_observed(None, slot.clone());
|
||||||
|
assert!(verify(&v, cert).is_ok());
|
||||||
|
assert_eq!(*slot.lock().unwrap(), Some(cert_fingerprint(cert)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,7 +28,11 @@ serde_json = "1"
|
|||||||
# pins the ring provider explicitly anyway).
|
# pins the ring provider explicitly anyway).
|
||||||
ureq = { version = "2", default-features = false, features = ["tls"] }
|
ureq = { version = "2", default-features = false, features = ["tls"] }
|
||||||
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
|
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
|
||||||
sha2 = "0.10"
|
# The one shared cert-fingerprint pin verifier (`punktfunk_core::tls::PinVerify`) + fingerprint
|
||||||
|
# hash, instead of the tray hand-rolling its own copy on a trust boundary. The light `tls` feature
|
||||||
|
# is rustls + sha2 only (no QUIC runtime / tokio), so this stays a lean helper; core is a pure-Rust
|
||||||
|
# leaf (no C toolchain), unlike the host dependency ruled out above.
|
||||||
|
punktfunk-core = { path = "../punktfunk-core", default-features = false, features = ["tls"] }
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
# SCM QUERY_STATUS works unprivileged — the service-state probe. Same crate the host service uses.
|
# SCM QUERY_STATUS works unprivileged — the service-state probe. Same crate the host service uses.
|
||||||
|
|||||||
@@ -257,10 +257,9 @@ fn fetch_summary(agent: &ureq::Agent, url: &str) -> Option<Summary> {
|
|||||||
/// a port-squatter gains nothing but a fake "streaming" tooltip on an already-compromised box.
|
/// a port-squatter gains nothing but a fake "streaming" tooltip on an already-compromised box.
|
||||||
fn load_pin() -> Option<[u8; 32]> {
|
fn load_pin() -> Option<[u8; 32]> {
|
||||||
use rustls::pki_types::pem::PemObject;
|
use rustls::pki_types::pem::PemObject;
|
||||||
use sha2::Digest;
|
|
||||||
let pem = std::fs::read(punktfunk_config_dir()?.join("cert.pem")).ok()?;
|
let pem = std::fs::read(punktfunk_config_dir()?.join("cert.pem")).ok()?;
|
||||||
let der = rustls::pki_types::CertificateDer::from_pem_slice(&pem).ok()?;
|
let der = rustls::pki_types::CertificateDer::from_pem_slice(&pem).ok()?;
|
||||||
Some(sha2::Sha256::digest(der.as_ref()).into())
|
Some(punktfunk_core::tls::cert_fingerprint(der.as_ref()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The host's config dir, mirroring `gamestream::config_dir()` without linking the host crate:
|
/// The host's config dir, mirroring `gamestream::config_dir()` without linking the host crate:
|
||||||
@@ -297,7 +296,7 @@ fn agent(pin: Option<[u8; 32]>) -> ureq::Agent {
|
|||||||
.with_safe_default_protocol_versions()
|
.with_safe_default_protocol_versions()
|
||||||
.expect("rustls default protocol versions")
|
.expect("rustls default protocol versions")
|
||||||
.dangerous()
|
.dangerous()
|
||||||
.with_custom_certificate_verifier(Arc::new(PinVerify { pin }))
|
.with_custom_certificate_verifier(Arc::new(punktfunk_core::tls::PinVerify::new(pin)))
|
||||||
.with_no_client_auth();
|
.with_no_client_auth();
|
||||||
ureq::AgentBuilder::new()
|
ureq::AgentBuilder::new()
|
||||||
.tls_config(Arc::new(cfg))
|
.tls_config(Arc::new(cfg))
|
||||||
@@ -306,69 +305,6 @@ fn agent(pin: Option<[u8; 32]>) -> ureq::Agent {
|
|||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Trust = the SHA-256 of the host's self-signed leaf (or any cert when un-pinned). Handshake
|
|
||||||
/// signatures are still verified for real — CertificateVerify proves the peer holds the key.
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct PinVerify {
|
|
||||||
pin: Option<[u8; 32]>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl rustls::client::danger::ServerCertVerifier for PinVerify {
|
|
||||||
fn verify_server_cert(
|
|
||||||
&self,
|
|
||||||
end_entity: &rustls::pki_types::CertificateDer<'_>,
|
|
||||||
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
|
|
||||||
_server_name: &rustls::pki_types::ServerName<'_>,
|
|
||||||
_ocsp: &[u8],
|
|
||||||
_now: rustls::pki_types::UnixTime,
|
|
||||||
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
|
||||||
use sha2::Digest;
|
|
||||||
if let Some(expected) = self.pin {
|
|
||||||
let fp: [u8; 32] = sha2::Sha256::digest(end_entity.as_ref()).into();
|
|
||||||
if fp != expected {
|
|
||||||
return Err(rustls::Error::InvalidCertificate(
|
|
||||||
rustls::CertificateError::ApplicationVerificationFailure,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn verify_tls12_signature(
|
|
||||||
&self,
|
|
||||||
message: &[u8],
|
|
||||||
cert: &rustls::pki_types::CertificateDer<'_>,
|
|
||||||
dss: &rustls::DigitallySignedStruct,
|
|
||||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
|
||||||
rustls::crypto::verify_tls12_signature(
|
|
||||||
message,
|
|
||||||
cert,
|
|
||||||
dss,
|
|
||||||
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn verify_tls13_signature(
|
|
||||||
&self,
|
|
||||||
message: &[u8],
|
|
||||||
cert: &rustls::pki_types::CertificateDer<'_>,
|
|
||||||
dss: &rustls::DigitallySignedStruct,
|
|
||||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
|
||||||
rustls::crypto::verify_tls13_signature(
|
|
||||||
message,
|
|
||||||
cert,
|
|
||||||
dss,
|
|
||||||
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
|
||||||
rustls::crypto::ring::default_provider()
|
|
||||||
.signature_verification_algorithms
|
|
||||||
.supported_schemes()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Service-manager probe ───────────────────────────────────────────────────────────────────────
|
// ── Service-manager probe ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// The SCM name registered by `punktfunk-host service install` (windows/service.rs SERVICE_NAME).
|
/// The SCM name registered by `punktfunk-host service install` (windows/service.rs SERVICE_NAME).
|
||||||
|
|||||||
Reference in New Issue
Block a user