From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [IPv6:2a0f:8001:1:32::40]) by lore.proxmox.com (Postfix) with ESMTPS id 4AF631FF0DF for ; Fri, 28 Aug 2026 15:33:55 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 032EB21834; Fri, 28 Aug 2026 15:31:18 +0200 (CEST) From: Shannon Sterz To: pve-devel@lists.proxmox.com Subject: [PATCH installer 16/21] installer-common: add option to verify TLS connections via callback Date: Fri, 28 Aug 2026 15:30:25 +0200 Message-ID: <20260828133030.351140-17-s.sterz@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260828133030.351140-1-s.sterz@proxmox.com> References: <20260828133030.351140-1-s.sterz@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1787923825010 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.814 Adjusted score from AWL reputation of From: address DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) RCVD_IN_DNSWL_MED -2.3 Sender listed at https://www.dnswl.org/, medium trust SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: ICOQMWLTQ56CTVKKJYVGGCKMUKPUOORW X-Message-ID-Hash: ICOQMWLTQ56CTVKKJYVGGCKMUKPUOORW X-MailFrom: s.sterz@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: this allows more flexibility and can be useful when, for example, implementing an interactive check whether a fingerprint is correct. Signed-off-by: Shannon Sterz --- .../src/bin/proxmox-auto-installer.rs | 2 +- .../src/fetch_plugins/http.rs | 8 +- proxmox-installer-common/src/http.rs | 274 +++++++++++++----- proxmox-post-hook/src/main.rs | 2 +- 4 files changed, 216 insertions(+), 70 deletions(-) diff --git a/proxmox-auto-installer/src/bin/proxmox-auto-installer.rs b/proxmox-auto-installer/src/bin/proxmox-auto-installer.rs index 0ced7d4..54b7050 100644 --- a/proxmox-auto-installer/src/bin/proxmox-auto-installer.rs +++ b/proxmox-auto-installer/src/bin/proxmox-auto-installer.rs @@ -37,7 +37,7 @@ fn setup_first_boot_executable(first_boot: &FirstBootHookInfo) -> Result<()> { info!("Fetching first-boot hook from {url} .."); Some(http::get_as_bytes( url, - first_boot.cert_fingerprint.as_deref(), + first_boot.cert_fingerprint.as_deref().try_into()?, FIRST_BOOT_EXEC_MAX_SIZE, )?) } else { diff --git a/proxmox-fetch-answer/src/fetch_plugins/http.rs b/proxmox-fetch-answer/src/fetch_plugins/http.rs index 1251da6..ce5f648 100644 --- a/proxmox-fetch-answer/src/fetch_plugins/http.rs +++ b/proxmox-fetch-answer/src/fetch_plugins/http.rs @@ -103,8 +103,12 @@ impl FetchFromHTTP { ); } - let http::Response { body, content_type } = - http::post(&answer_url, fingerprint.as_deref(), headers, payload)?; + let http::Response { body, content_type } = http::post( + &answer_url, + fingerprint.as_deref().try_into()?, + headers, + payload, + )?; if let Some(ct) = content_type && ct == http::ContentType::Json diff --git a/proxmox-installer-common/src/http.rs b/proxmox-installer-common/src/http.rs index ca64a73..2fcc4f1 100644 --- a/proxmox-installer-common/src/http.rs +++ b/proxmox-installer-common/src/http.rs @@ -1,8 +1,10 @@ use anyhow::{Result, bail}; use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; -use rustls::{ClientConfig, ClientConnection, StreamOwned}; +use rustls::server::ParsedCertificate; +use rustls::{ClientConfig, ClientConnection, RootCertStore, StreamOwned}; use sha2::{Digest, Sha256}; use std::fmt; +use std::fmt::Debug; use std::io::{Read, Write}; use std::str::FromStr; use std::sync::Arc; @@ -18,45 +20,24 @@ use ureq::unversioned::transport::{ // Re-export for conviencence when using post() pub use ureq::http::header; -/// Builds an [`Agent`] with TLS suitable set up, depending whether a custom fingerprint was -/// supplied or not. If a fingerprint was supplied, only matching certificates will be accepted. -/// Otherwise, the system certificate store is loaded. +/// Builds an [Agent] with a suitable TLS setup, depending on the verification option that was +/// provided. Verification can either be done via a fingerprint, that needs to match the server +/// certificate's fingerprint, a callback, that can implement custom verification logic, or can be +/// delegated to the system's trust store. /// /// To gather the sha256 fingerprint you can use the following command: +/// /// ```no_compile /// openssl s_client -connect :443 < /dev/null 2>/dev/null | openssl x509 -fingerprint -sha256 -noout -in /dev/stdin /// ``` /// /// # Arguments -/// * `fingerprint` - SHA256 cert fingerprint if certificate pinning should be used. Optional. -fn build_agent(fingerprint: Option<&str>) -> Result { +/// * `verification_option` - Defines how the connection is verified. +fn build_agent(verification_option: VerificationOption) -> Result { const GLOBAL_TIMEOUT: Duration = Duration::from_secs(60); - if let Some(fingerprint) = fingerprint { - // If the user specified a custom TLS fingerprint, we must use a custom - // `rustls::ClientConfig`, which in turns means to use a custom - // `Connector`. - let crypto_provider = rustls::crypto::CryptoProvider::get_default() - .cloned() - .unwrap_or_else(|| Arc::new(rustls::crypto::ring::default_provider())); - - let tls_config = ClientConfig::builder_with_provider(crypto_provider) - .with_protocol_versions(rustls::ALL_VERSIONS)? - .dangerous() - .with_custom_certificate_verifier(VerifyCertFingerprint::new(fingerprint)?) - .with_no_client_auth(); - - let connector = UreqRustlsConnector::new(Arc::new(tls_config)); - - Ok(Agent::with_parts( - ureq::config::Config::builder() - .timeout_global(Some(GLOBAL_TIMEOUT)) - .build(), - TcpConnector::default().chain(connector), - DefaultResolver::default(), - )) - } else { - Ok(Agent::config_builder() + let agent = match verification_option { + VerificationOption::Verify => Agent::config_builder() .timeout_global(Some(GLOBAL_TIMEOUT)) .tls_config( ureq::tls::TlsConfig::builder() @@ -64,13 +45,38 @@ fn build_agent(fingerprint: Option<&str>) -> Result { .build(), ) .build() - .into()) - } + .into(), + opt => { + // If the user specified a custom TLS fingerprint or verification callback, we must use + // a custom `rustls::ClientConfig`, which in turn means to use a custom `Connector`. + let crypto_provider = rustls::crypto::CryptoProvider::get_default() + .cloned() + .unwrap_or_else(|| Arc::new(rustls::crypto::ring::default_provider())); + + let tls_config = ClientConfig::builder_with_provider(crypto_provider) + .with_protocol_versions(rustls::ALL_VERSIONS)? + .dangerous() + .with_custom_certificate_verifier(VerifyCertHelper::new(opt)?) + .with_no_client_auth(); + + let connector = UreqRustlsConnector::new(Arc::new(tls_config)); + + Agent::with_parts( + ureq::config::Config::builder() + .timeout_global(Some(GLOBAL_TIMEOUT)) + .build(), + TcpConnector::default().chain(connector), + DefaultResolver::default(), + ) + } + }; + + Ok(agent) } -/// Issues a GET request to the specified URL and fetches the response. Optionally a SHA256 -/// fingerprint can be used to check the certificate against it, instead of the regular certificate -/// validation. +/// Issues a GET request to the specified URL and fetches the response. TLS verification can either +/// be done via a fingerprint, that needs to match the server certificate's fingerprint, a callback, +/// that can implement custom verification logic, or can be delegated to the system's trust store. /// /// To gather the sha256 fingerprint you can use the following command: /// ```no_compile @@ -79,12 +85,19 @@ fn build_agent(fingerprint: Option<&str>) -> Result { /// /// # Arguments /// * `url` - URL to fetch -/// * `fingerprint` - SHA256 cert fingerprint if certificate pinning should be used. Optional. +/// * `verification_option` - Defines how the connection is verified. /// * `max_size` - Maximum amount of bytes that will be read. -pub fn get_as_bytes(url: &str, fingerprint: Option<&str>, max_size: usize) -> Result> { +pub fn get_as_bytes( + url: &str, + verification_option: VerificationOption, + max_size: usize, +) -> Result> { let mut result: Vec = Vec::new(); - let (_, body) = build_agent(fingerprint)?.get(url).call()?.into_parts(); + let (_, body) = build_agent(verification_option)? + .get(url) + .call()? + .into_parts(); body.into_reader() .take(max_size as u64) @@ -126,8 +139,10 @@ pub struct Response { pub content_type: Option, } -/// Issues a POST request with the payload (JSON). Optionally a SHA256 fingerprint can be used to -/// check the cert against it, instead of the regular cert validation. +/// Issues a POST request with the payload (JSON). TLS verification can either be done via a +/// fingerprint, that needs to match the server certificate's fingerprint, a callback, that can +/// implement custom verification logic, or can be delegated to the system's trust store. +/// /// To gather the sha256 fingerprint you can use the following command: /// ```no_compile /// openssl s_client -connect :443 < /dev/null 2>/dev/null | openssl x509 -fingerprint -sha256 -noout -in /dev/stdin @@ -137,7 +152,7 @@ pub struct Response { /// /// # Arguments /// * `url` - URL to call -/// * `fingerprint` - SHA256 cert fingerprint if certificate pinning should be used. Optional. +/// * `verification_option` - Defines how the connection is verified. /// * `headers` - Additional headers to add to the request. /// * `payload` - The payload to send to the server. Expected to be a JSON formatted string. /// @@ -147,13 +162,13 @@ pub struct Response { /// contents and the `Content-Type` header, if present. pub fn post( url: &str, - fingerprint: Option<&str>, + verification_option: VerificationOption, headers: header::HeaderMap, payload: String, ) -> Result { // TODO: read_to_string limits the size to 10 MB, should be increase that? - let mut request = build_agent(fingerprint)? + let mut request = build_agent(verification_option)? .post(url) .header("Content-Type", "application/json; charset=utf-8") .config() @@ -182,40 +197,148 @@ pub fn post( } } -#[derive(Debug)] -struct VerifyCertFingerprint { - cert_fingerprint: Vec, + +/// A callback used to validate a TLS connection via rustls. See +/// [rustls::client::danger::ServerCertVerifier::verify_server_cert] for an explanation of the +/// arguments. The last argument is `true` if the system's trust store contains a root certificate +/// that validates the certificate and the server name matches the certificate. +/// +/// If `true` is returned, no further checks are done and the connection is accepted. This can be +/// dangerous. +pub type RustlsCallback = dyn Fn(&CertificateDer, &[CertificateDer], &ServerName, &[u8], UnixTime, bool) -> bool + + Send + + Sync + + 'static; + +/// How TLS connections are verified. +#[derive(Default)] +pub enum VerificationOption { + /// Default TLS verification. + #[default] + Verify, + + /// Expect a specific fingerprint, can be used for certificate pinning. + Fingerprint(Vec), + + /// Use a custom callback to verify the connection, if it returns `true` the connection is + /// accepted. No further checks are carried out, this can be dangerous. + DangerousCallback(Box), } -impl VerifyCertFingerprint { - fn new>(cert_fingerprint: S) -> Result> { - let cert_fingerprint = cert_fingerprint.as_ref(); - let sanitized = cert_fingerprint.replace(':', ""); +impl TryFrom<&str> for VerificationOption { + type Error = anyhow::Error; + + fn try_from(value: &str) -> std::result::Result { + let sanitized = value.replace(':', ""); let decoded = hex::decode(sanitized)?; - Ok(std::sync::Arc::new(Self { - cert_fingerprint: decoded, - })) + Ok(VerificationOption::Fingerprint(decoded)) } } -impl rustls::client::danger::ServerCertVerifier for VerifyCertFingerprint { +impl TryFrom> for VerificationOption { + type Error = anyhow::Error; + + fn try_from(value: Option<&str>) -> std::result::Result { + match value { + Some(v) => v.try_into(), + None => Ok(VerificationOption::Verify), + } + } +} + +impl Debug for VerificationOption { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + VerificationOption::Verify => write!(f, "Verify"), + VerificationOption::Fingerprint(v) => write!(f, "Fingerprint({:?})", v), + VerificationOption::DangerousCallback(_) => { + write!(f, "DangerousCallback(Box)") + } + } + } +} + +#[derive(Debug)] +struct VerifyCertHelper { + option: VerificationOption, + store: RootCertStore, +} + +impl VerifyCertHelper { + fn new(option: VerificationOption) -> Result> { + let res = rustls_native_certs::load_native_certs()?; + let mut store = RootCertStore::empty(); + + // with rustls_native_certs 0.7 [1], a `Result, Error>` is returned + // right away so the below switches to: + // + // ``` + // let _ = store.add_parsable_certificates(res); + // ``` + // + // rustls_native_certs 0.8 [2] again changes the API to return a `CertificateResult` so + // loading the certificates becomes + // + // ``` + // let res = rustls_native_certs::load_native_certs(); + // let mut store = RootCertStore::empty(); + // let _ = store.add_parsable_certificates(res.certs); + // ``` + // + // [1]: https://github.com/rustls/rustls-native-certs/blob/v/0.7.0/src/lib.rs#L57 + // [2]: https://github.com/rustls/rustls-native-certs/blob/v/0.8.0/src/lib.rs#L120 + let _ = + store.add_parsable_certificates(res.iter().map(|c| CertificateDer::from(c.as_ref()))); + + Ok(Arc::new(Self { option, store })) + } +} + +impl rustls::client::danger::ServerCertVerifier for VerifyCertHelper { fn verify_server_cert( &self, end_entity: &CertificateDer, - _intermediates: &[CertificateDer], - _server_name: &ServerName, - _ocsp_response: &[u8], - _now: UnixTime, + intermediates: &[CertificateDer], + server_name: &ServerName, + ocsp_response: &[u8], + now: UnixTime, ) -> Result { - let mut hasher = Sha256::new(); - hasher.update(end_entity); - let result = hasher.finalize(); + match &self.option { + VerificationOption::Fingerprint(fp) => { + let mut hasher = Sha256::new(); + hasher.update(end_entity); + let result = hasher.finalize(); - if result.as_slice() == self.cert_fingerprint { - Ok(rustls::client::danger::ServerCertVerified::assertion()) - } else { - Err(rustls::Error::General("Fingerprint did not match!".into())) + if fp == result.as_slice() { + return Ok(rustls::client::danger::ServerCertVerified::assertion()); + } else { + return Err(rustls::Error::General("Fingerprint did not match!".into())); + } + } + VerificationOption::DangerousCallback(cb) => { + let pre_ok = + verify_server_cert(end_entity, &self.store, intermediates, now, server_name) + .is_ok(); + + if cb( + end_entity, + intermediates, + server_name, + ocsp_response, + now, + pre_ok, + ) { + return Ok(rustls::client::danger::ServerCertVerified::assertion()); + } + } + // `VerificationOption::Verify` does not require this verifier, so if we encounter it + // here, something went wrong. + _ => {} } + + Err(rustls::Error::General( + "Could not verify server certificate.".into(), + )) } fn verify_tls12_signature( @@ -356,3 +479,22 @@ impl fmt::Debug for UreqRustlsTransport { .finish() } } + +fn verify_server_cert( + cert: &CertificateDer, + store: &RootCertStore, + intermediates: &[CertificateDer], + now: UnixTime, + server_name: &ServerName, +) -> Result<(), rustls::Error> { + use rustls::client::{verify_server_cert_signed_by_trust_anchor, verify_server_name}; + + let supported_algs = rustls::crypto::ring::default_provider() + .signature_verification_algorithms + .all; + + let cert = ParsedCertificate::try_from(cert)?; + + verify_server_cert_signed_by_trust_anchor(&cert, store, intermediates, now, supported_algs)?; + verify_server_name(&cert, server_name) +} diff --git a/proxmox-post-hook/src/main.rs b/proxmox-post-hook/src/main.rs index 5f760b7..704af71 100644 --- a/proxmox-post-hook/src/main.rs +++ b/proxmox-post-hook/src/main.rs @@ -944,7 +944,7 @@ fn do_main() -> Result<()> { http::post( url, - cert_fingerprint.as_deref(), + cert_fingerprint.as_deref().try_into()?, HeaderMap::new(), serde_json::to_string(&body)?, )?; -- 2.47.3