From 22b306bdde5d64ed6172d299a5b1fb6b3e20bb15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Mon, 2 Nov 2026 18:16:41 +0100 Subject: [PATCH] Gate v2 logins on upstream's v2 encryption check --- src/api/identity.rs | 10 +++++----- src/db/models/user.rs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/src/api/identity.rs b/src/api/identity.rs index 9f448e8e..832a9d05 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -518,13 +518,13 @@ async fn password_login( authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await } -/// Like upstream's `ClientVersionValidator`, a V2 account logs in only from a client that sends its version and is at -/// least 2025.11.0 (upstream also refuses a missing header for V1 accounts). Called right after the credentials are -/// checked, like upstream, so that it doesn't tell anyone else which accounts are V2. +/// Like upstream's `ClientVersionValidator`, a V2 account ([`User::has_v2_encryption`]) logs in only from a client that +/// sends its version and is at least 2025.11.0 (upstream also refuses a missing header for V1 accounts). Called right +/// after the credentials are checked, like upstream, so that it doesn't tell anyone else which accounts are V2. /// -/// Ref: +/// Ref: fn check_client_version(user: &User, client_version: Option<&semver::Version>, ip: &ClientIp) -> EmptyResult { - let Some((error, description)) = client_version_error(user.is_v2(), client_version) else { + let Some((error, description)) = client_version_error(user.has_v2_encryption(), client_version) else { return Ok(()); }; Err(crate::error::Error::from(( diff --git a/src/db/models/user.rs b/src/db/models/user.rs index 8a137f46..20126001 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -194,6 +194,19 @@ impl User { self.signed_public_key.is_some() && self.security_state.is_some() && self.security_version.is_some() } + /// Upstream's `HasV2Encryption`: a private key wrapped as `CoseEncrypt0B64` (type 7) and security version 2. + /// Upstream only uses it to decide which accounts need a v2-capable client. + /// + /// Ref: + pub fn has_v2_encryption(&self) -> bool { + self.security_version == Some(2) + && self + .private_key + .as_deref() + .and_then(|private_key| private_key.split_once('.')) + .is_some_and(|(enc_type, _)| enc_type.parse::() == Ok(7)) + } + pub fn check_valid_password(&self, password: &str) -> bool { crypto::verify_password_hash( password.as_bytes(), @@ -751,4 +764,21 @@ mod tests { // An absent key id stays optional assert_eq!(serde_json::from_str::>("null").unwrap(), None); } + + #[test] + fn test_has_v2_encryption() { + let user = |private_key: Option<&str>, security_version: Option| { + let mut user = User::new("user@example.com", None); + user.private_key = private_key.map(String::from); + user.security_version = security_version; + user + }; + + assert!(user(Some("7.cose"), Some(2)).has_v2_encryption()); + assert!(!user(Some("7.cose"), Some(1)).has_v2_encryption()); + assert!(!user(Some("7.cose"), None).has_v2_encryption()); + assert!(!user(Some("2.iv|data|mac"), Some(2)).has_v2_encryption()); + assert!(!user(Some("7"), Some(2)).has_v2_encryption()); + assert!(!user(None, Some(2)).has_v2_encryption()); + } }