From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) by lore.proxmox.com (Postfix) with ESMTPS id 3DED51FF13A for ; Wed, 29 Apr 2026 16:09:52 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 94FECFB86; Wed, 29 Apr 2026 16:09:51 +0200 (CEST) From: =?UTF-8?q?Fabian=20Gr=C3=BCnbichler?= To: pbs-devel@lists.proxmox.com Subject: [PATCH proxmox-backup 3/6] push: add support for strict encryption checking Date: Wed, 29 Apr 2026 16:09:24 +0200 Message-ID: <20260429140941.3537494-4-f.gruenbichler@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260429140941.3537494-1-f.gruenbichler@proxmox.com> References: <20260429140941.3537494-1-f.gruenbichler@proxmox.com> MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1777471687887 X-SPAM-LEVEL: Spam detection results: 0 AWL -0.946 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment KAM_MAILER 2 Automated Mailer Tag Left in Email 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: 5MWCVJASSAEGUWGSYOOOQW7FEHXLK3IL X-Message-ID-Hash: 5MWCVJASSAEGUWGSYOOOQW7FEHXLK3IL X-MailFrom: f.gruenbichler@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 Backup Server development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: if enabled, this mode will refuse to sync already encrypted snapshots, unless they are encrypted using the active encryption key and carry a valid signature. since pushing now checks the manifest more closely, also log whether synced pre-encrypted snapshots use a matching key or not if the mode is disabled. Signed-off-by: Fabian Grünbichler --- src/api2/push.rs | 1 + src/server/push.rs | 41 ++++++++++++++++++++++++++++++++++++----- src/server/sync.rs | 1 + 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/api2/push.rs b/src/api2/push.rs index 44629de06..54dc1e4ee 100644 --- a/src/api2/push.rs +++ b/src/api2/push.rs @@ -176,6 +176,7 @@ async fn push( transfer_last, worker_threads, encryption_key, + None, ) .await?; diff --git a/src/server/push.rs b/src/server/push.rs index dac62c84a..c85654ed1 100644 --- a/src/server/push.rs +++ b/src/server/push.rs @@ -97,6 +97,8 @@ pub(crate) struct PushParameters { /// Encryption key to use for pushing unencrypted backup snapshots. Does not affect /// already encrypted snapshots. crypt_config: Option<(String, Arc)>, + /// Refuse to sync already encrypted snapshots + strict_encryption_mode: bool, } impl PushParameters { @@ -118,6 +120,7 @@ impl PushParameters { transfer_last: Option, worker_threads: Option, active_encryption_key: Option, + strict_encryption_mode: Option, ) -> Result { if let Some(max_depth) = max_depth { ns.check_max_depth(max_depth)?; @@ -126,6 +129,7 @@ impl PushParameters { let remove_vanished = remove_vanished.unwrap_or(false); let encrypted_only = encrypted_only.unwrap_or(false); let verified_only = verified_only.unwrap_or(false); + let strict_encryption_mode = strict_encryption_mode.unwrap_or(false); let lookup = crate::tools::lookup_with(store, Operation::Read); let store = DataStore::lookup_datastore(lookup)?; @@ -191,6 +195,7 @@ impl PushParameters { transfer_last, worker_threads, crypt_config, + strict_encryption_mode, }) } @@ -1075,7 +1080,7 @@ pub(crate) async fn push_snapshot( } let mut encrypt_using_key = None; - if params.crypt_config.is_some() { + if let Some((id, key)) = params.crypt_config.as_ref() { // Check if snapshot is fully encrypted or not encrypted at all: // refuse progress otherwise to upload partially unencrypted contents or mix encryption key. let files = source_manifest.files(); @@ -1103,10 +1108,36 @@ pub(crate) async fn push_snapshot( ).await?; return Ok(stats); } else { - log_sender.log( - Level::INFO, - format!("Snapshot '{snapshot}' already encrypted with client key, not re-encrypting with configured active encryption key"), - ).await?; + let correct_key = source_manifest + .fingerprint() + .ok() + .flatten() + .map(|fp| *fp.bytes()) + == Some(key.fingerprint()); + if correct_key && source_manifest.check_signature(&key).is_err() { + log_sender.log( + Level::WARN, + format!("Snapshot '{snapshot}' already encrypted with matching key {id}, but signature check failed"), + ).await?; + return Ok(stats); + } + if correct_key { + log_sender.log( + Level::INFO, + format!("Snapshot '{snapshot}' already encrypted with matching key {id}, syncing as-is"), + ).await?; + } else if params.strict_encryption_mode { + log_sender.log( + Level::INFO, + format!("Snapshot '{snapshot}' already encrypted with different key, strict encryption mode enabled, skip"), + ).await?; + return Ok(stats); + } else { + log_sender.log( + Level::INFO, + format!("Snapshot '{snapshot}' already encrypted with different key, not re-encrypting with configured active encryption key"), + ).await?; + } } } diff --git a/src/server/sync.rs b/src/server/sync.rs index f7d96811e..590ad01eb 100644 --- a/src/server/sync.rs +++ b/src/server/sync.rs @@ -733,6 +733,7 @@ pub fn do_sync_job( sync_job.transfer_last, sync_job.worker_threads, sync_job.active_encryption_key, + None, ) .await?; push_store(push_params).await? -- 2.47.3