diff --git a/Cargo.lock b/Cargo.lock index 795ec0a755..a642150469 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1434,6 +1434,7 @@ checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ "curve25519-dalek", "ed25519", + "rand_core 0.6.4", "serde", "sha2", "subtle", diff --git a/crates/defguard_core/Cargo.toml b/crates/defguard_core/Cargo.toml index c141334dba..10bfa678c7 100644 --- a/crates/defguard_core/Cargo.toml +++ b/crates/defguard_core/Cargo.toml @@ -83,7 +83,7 @@ x25519-dalek = { workspace = true } strum = { workspace = true } strum_macros = { workspace = true } bytes = { workspace = true } -ed25519-dalek = "2.2.0" +ed25519-dalek = { version = "2.2.0", features = ["rand_core"] } # https://github.com/juhaku/utoipa/issues/1345 [dependencies.zip] diff --git a/crates/defguard_core/src/db/models/biometric_auth.rs b/crates/defguard_core/src/db/models/biometric_auth.rs index 74d06fb944..927f8ff947 100644 --- a/crates/defguard_core/src/db/models/biometric_auth.rs +++ b/crates/defguard_core/src/db/models/biometric_auth.rs @@ -2,8 +2,8 @@ use crate::{ db::{Id, NoId}, random::gen_alphanumeric, }; -use base64::Engine; use base64::engine::general_purpose; +use base64::{Engine, prelude::BASE64_STANDARD}; use ed25519_dalek::Verifier; use ed25519_dalek::{Signature, VerifyingKey}; use model_derive::Model; @@ -18,6 +18,16 @@ pub enum BiometricAuthError { InvalidSignature, #[error("Verification of submitted challenge failed. {0}")] ChallengeFailed(String), + #[error("Base64 decoding failed. {0}")] + Base64DecodeError(#[from] base64::DecodeError), + #[error("Challenge had no owner")] + ChallengeNotOwned, +} + +impl From for tonic::Status { + fn from(value: BiometricAuthError) -> Self { + Self::invalid_argument(value.to_string()) + } } #[derive(Model, Clone)] @@ -36,6 +46,13 @@ impl BiometricAuth { pub_key, } } + pub fn validate_pubkey(pub_key: &str) -> Result<(), BiometricAuthError> { + let decoded = BASE64_STANDARD.decode(pub_key)?; + if decoded.len() != ed25519_dalek::PUBLIC_KEY_LENGTH { + return Err(BiometricAuthError::InvalidPublicKey); + } + Ok(()) + } } impl BiometricAuth { @@ -78,7 +95,7 @@ pub struct BiometricChallenge { } fn decode_pub_key(public_key: &str) -> Result { - let pub_bytes: [u8; 32] = general_purpose::STANDARD + let pub_bytes: [u8; ed25519_dalek::PUBLIC_KEY_LENGTH] = general_purpose::STANDARD .decode(public_key) .map_err(|_| BiometricAuthError::InvalidPublicKey)? .try_into() @@ -100,35 +117,79 @@ impl BiometricChallenge { }) } - #[must_use] - pub fn verify(&self, signed_challenge: &str) -> bool { + pub fn verify(&self, signed_challenge: &str) -> Result<(), BiometricAuthError> { if let Some(auth_pub_key) = &self.auth_pub_key { - return match verify(signed_challenge, auth_pub_key.as_str(), &self.challenge) { - Ok(res) => res, - Err(e) => { - error!("Biometric auth verification failed!\n Reason: {e}"); - false - } - }; + return verify(signed_challenge, auth_pub_key.as_str(), &self.challenge); } - false + Err(BiometricAuthError::ChallengeNotOwned) } } fn verify( - signed_challenge: &str, + signature: &str, public_key: &str, original_challenge: &str, -) -> Result { +) -> Result<(), BiometricAuthError> { let verifying_key = decode_pub_key(public_key)?; - let sig_bytes: [u8; 64] = general_purpose::STANDARD - .decode(signed_challenge) + let sig_bytes: [u8; ed25519_dalek::SIGNATURE_LENGTH] = general_purpose::STANDARD + .decode(signature) .map_err(|_| BiometricAuthError::InvalidSignature)? .try_into() .map_err(|_| BiometricAuthError::InvalidSignature)?; let signature = Signature::from_bytes(&sig_bytes); - match verifying_key.verify(original_challenge.as_bytes(), &signature) { - Ok(()) => Ok(true), - Err(_) => Ok(false), + verifying_key + .verify(original_challenge.as_bytes(), &signature) + .map_err(|_| BiometricAuthError::InvalidSignature) +} + +#[cfg(test)] +mod test { + use super::*; + use base64::engine::general_purpose; + use ed25519_dalek::Signer; + use matches::assert_matches; + + #[test] + fn test_verify_valid_sig() { + let mut csprng = rand_core::OsRng; + let signing_key = ed25519_dalek::SigningKey::generate(&mut csprng); + let challenge = "test-challenge"; + let signed = signing_key.sign(challenge.as_bytes()); + let serialized_signature = BASE64_STANDARD.encode(signed.to_bytes()); + let serialized_pub_key = BASE64_STANDARD.encode(signing_key.verifying_key().as_bytes()); + + assert_matches!( + verify(&serialized_signature, &serialized_pub_key, challenge), + Ok(()) + ); + } + + #[test] + fn test_verify_invalid_signature() { + let mut csprng = rand_core::OsRng; + let signing_key = ed25519_dalek::SigningKey::generate(&mut csprng); + let challenge = "test-challenge"; + + let bad_signature = [0u8; ed25519_dalek::SIGNATURE_LENGTH]; + let signature_b64 = general_purpose::STANDARD.encode(bad_signature); + let public_key_b64 = + general_purpose::STANDARD.encode(signing_key.verifying_key().as_bytes()); + + let result = verify(&signature_b64, &public_key_b64, challenge); + + assert_matches!(result, Err(BiometricAuthError::InvalidSignature)); + } + + #[test] + fn test_verify_invalid_public_key() { + let challenge = "test-challenge"; + let signature = [0u8; ed25519_dalek::SIGNATURE_LENGTH]; + let signature_b64 = general_purpose::STANDARD.encode(signature); + + let bad_pub_key = general_purpose::STANDARD.encode([1, 2, 3]); + + let result = verify(&signature_b64, &bad_pub_key, challenge); + + assert_matches!(result, Err(BiometricAuthError::InvalidPublicKey)); } } diff --git a/crates/defguard_core/src/grpc/client_mfa.rs b/crates/defguard_core/src/grpc/client_mfa.rs index d92abb0f75..00a0a0a581 100644 --- a/crates/defguard_core/src/grpc/client_mfa.rs +++ b/crates/defguard_core/src/grpc/client_mfa.rs @@ -374,11 +374,15 @@ impl ClientMfaServer { if let Some(signed_challenge) = request.code { match challenge.verify(signed_challenge.as_str()) { // verification passed - true => { + Ok(()) => { debug!("Signed challenge verified successfully."); } // challenge rejected - false => { + Err(e) => { + error!( + "Verification of challenge for device {0} failed ! Reason {e}", + &device.name + ); self.emit_event(BidiStreamEvent { context, event: BidiStreamEventType::DesktopClientMfa(Box::new( diff --git a/crates/defguard_core/src/grpc/enrollment.rs b/crates/defguard_core/src/grpc/enrollment.rs index 5da67fa101..93914a20c4 100644 --- a/crates/defguard_core/src/grpc/enrollment.rs +++ b/crates/defguard_core/src/grpc/enrollment.rs @@ -313,6 +313,7 @@ impl EnrollmentServer { )); } }; + BiometricAuth::validate_pubkey(&request.device_pub_key)?; let mobile_auth = BiometricAuth::new(device.id, request.auth_pub_key); let _ = mobile_auth.save(&self.pool).await.map_err(|err| { error!("Failed to save mobile auth into db : {err}"); diff --git a/web/package.json b/web/package.json index 21e02132bb..40417bb1e0 100644 --- a/web/package.json +++ b/web/package.json @@ -140,7 +140,7 @@ "standard-version": "^9.5.0", "type-fest": "^4.41.0", "typescript": "~5.9.2", - "vite": "^7.0.6", + "vite": "^7.1.0", "vite-plugin-package-version": "^1.1.0" } } diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index fc7698f978..c588e235fd 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -263,7 +263,7 @@ importers: version: 1.8.8 '@vitejs/plugin-react-swc': specifier: ^3.11.0 - version: 3.11.0(vite@7.0.6(@types/node@24.2.0)(jiti@2.4.2)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1)) + version: 3.11.0(vite@7.1.0(@types/node@24.2.0)(jiti@2.4.2)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1)) autoprefixer: specifier: ^10.4.21 version: 10.4.21(postcss@8.5.6) @@ -298,11 +298,11 @@ importers: specifier: ~5.9.2 version: 5.9.2 vite: - specifier: ^7.0.6 - version: 7.0.6(@types/node@24.2.0)(jiti@2.4.2)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1) + specifier: ^7.1.0 + version: 7.1.0(@types/node@24.2.0)(jiti@2.4.2)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1) vite-plugin-package-version: specifier: ^1.1.0 - version: 1.1.0(vite@7.0.6(@types/node@24.2.0)(jiti@2.4.2)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1)) + version: 1.1.0(vite@7.1.0(@types/node@24.2.0)(jiti@2.4.2)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1)) packages: @@ -1530,8 +1530,8 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} - electron-to-chromium@1.5.197: - resolution: {integrity: sha512-m1xWB3g7vJ6asIFz+2pBUbq3uGmfmln1M9SSvBe4QIFWYrRHylP73zL/3nMjDmwz8V+1xAXQDfBd6+HPW0WvDQ==} + electron-to-chromium@1.5.198: + resolution: {integrity: sha512-G5COfnp3w+ydVu80yprgWSfmfQaYRh9DOxfhAxstLyetKaLyl55QrNjx8C38Pc/C+RaDmb1M0Lk8wPEMQ+bGgQ==} emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -2650,8 +2650,8 @@ packages: spdx-expression-parse@3.0.1: resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} - spdx-license-ids@3.0.21: - resolution: {integrity: sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==} + spdx-license-ids@3.0.22: + resolution: {integrity: sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ==} split2@3.2.2: resolution: {integrity: sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg==} @@ -2939,8 +2939,8 @@ packages: peerDependencies: vite: '>=2.0.0-beta.69' - vite@7.0.6: - resolution: {integrity: sha512-MHFiOENNBd+Bd9uvc8GEsIzdkn1JxMmEeYX35tI3fv0sJBUTfW5tQsoaOwuY4KhBI09A3dUJ/DXf2yxPVPUceg==} + vite@7.1.0: + resolution: {integrity: sha512-3jdAy3NhBJYsa/lCFcnRfbK4kNkO/bhijFCnv5ByUQk/eekYagoV2yQSISUrhpV+5JiY5hmwOh7jNnQ68dFMuQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -3773,11 +3773,11 @@ snapshots: '@use-gesture/core': 10.3.1 react: 18.3.1 - '@vitejs/plugin-react-swc@3.11.0(vite@7.0.6(@types/node@24.2.0)(jiti@2.4.2)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1))': + '@vitejs/plugin-react-swc@3.11.0(vite@7.1.0(@types/node@24.2.0)(jiti@2.4.2)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1))': dependencies: '@rolldown/pluginutils': 1.0.0-beta.27 '@swc/core': 1.13.3 - vite: 7.0.6(@types/node@24.2.0)(jiti@2.4.2)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1) + vite: 7.1.0(@types/node@24.2.0)(jiti@2.4.2)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1) transitivePeerDependencies: - '@swc/helpers' @@ -3856,7 +3856,7 @@ snapshots: browserslist@4.25.1: dependencies: caniuse-lite: 1.0.30001731 - electron-to-chromium: 1.5.197 + electron-to-chromium: 1.5.198 node-releases: 2.0.19 update-browserslist-db: 1.1.3(browserslist@4.25.1) @@ -4234,7 +4234,7 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 - electron-to-chromium@1.5.197: {} + electron-to-chromium@1.5.198: {} emoji-regex@8.0.0: {} @@ -5516,16 +5516,16 @@ snapshots: spdx-correct@3.2.0: dependencies: spdx-expression-parse: 3.0.1 - spdx-license-ids: 3.0.21 + spdx-license-ids: 3.0.22 spdx-exceptions@2.5.0: {} spdx-expression-parse@3.0.1: dependencies: spdx-exceptions: 2.5.0 - spdx-license-ids: 3.0.21 + spdx-license-ids: 3.0.22 - spdx-license-ids@3.0.21: {} + spdx-license-ids@3.0.22: {} split2@3.2.2: dependencies: @@ -5860,11 +5860,11 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite-plugin-package-version@1.1.0(vite@7.0.6(@types/node@24.2.0)(jiti@2.4.2)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1)): + vite-plugin-package-version@1.1.0(vite@7.1.0(@types/node@24.2.0)(jiti@2.4.2)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1)): dependencies: - vite: 7.0.6(@types/node@24.2.0)(jiti@2.4.2)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1) + vite: 7.1.0(@types/node@24.2.0)(jiti@2.4.2)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1) - vite@7.0.6(@types/node@24.2.0)(jiti@2.4.2)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1): + vite@7.1.0(@types/node@24.2.0)(jiti@2.4.2)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1): dependencies: esbuild: 0.25.8 fdir: 6.4.6(picomatch@4.0.3) diff --git a/web/src/i18n/i18n-types.ts b/web/src/i18n/i18n-types.ts index 7101a4be28..ca8c7751ba 100644 --- a/web/src/i18n/i18n-types.ts +++ b/web/src/i18n/i18n-types.ts @@ -4774,6 +4774,9 @@ type RootTranslation = { * B​a​s​e​d​ ​o​n​ ​t​h​i​s​ ​a​d​d​r​e​s​s​ ​V​P​N​ ​n​e​t​w​o​r​k​ ​a​d​d​r​e​s​s​ ​w​i​l​l​ ​b​e​ ​d​e​f​i​n​e​d​,​ ​e​g​.​ ​1​0​.​1​0​.​1​0​.​1​/​2​4​ ​(​a​n​d​ ​V​P​N​ ​n​e​t​w​o​r​k​ ​w​i​l​l​ ​b​e​:​ ​1​0​.​1​0​.​1​0​.​0​/​2​4​)​.​ ​Y​o​u​ ​c​a​n​ ​o​p​t​i​o​n​a​l​l​y​ ​s​p​e​c​i​f​y​ ​m​u​l​t​i​p​l​e​ ​a​d​d​r​e​s​s​e​s​ ​s​e​p​a​r​a​t​e​d​ ​b​y​ ​a​ ​c​o​m​m​a​.​ ​T​h​e​ ​f​i​r​s​t​ ​a​d​d​r​e​s​s​ ​i​s​ ​t​h​e​ ​p​r​i​m​a​r​y​ ​a​d​d​r​e​s​s​,​ ​a​n​d​ ​t​h​i​s​ ​o​n​e​ ​w​i​l​l​ ​b​e​ ​u​s​e​d​ ​f​o​r​ ​I​P​ ​a​d​d​r​e​s​s​ ​a​s​s​i​g​n​m​e​n​t​ ​f​o​r​ ​d​e​v​i​c​e​s​.​ ​T​h​e​ ​o​t​h​e​r​ ​I​P​ ​a​d​d​r​e​s​s​e​s​ ​a​r​e​ ​a​u​x​i​l​i​a​r​y​ ​a​n​d​ ​a​r​e​ ​n​o​t​ ​m​a​n​a​g​e​d​ ​b​y​ ​D​e​f​g​u​a​r​d​. */ address: string + /** + * P​u​b​l​i​c​ ​I​P​ ​a​d​d​r​e​s​s​ ​o​r​ ​d​o​m​a​i​n​ ​n​a​m​e​ ​t​o​ ​w​h​i​c​h​ ​t​h​e​ ​r​e​m​o​t​e​ ​p​e​e​r​s​/​u​s​e​r​s​ ​w​i​l​l​ ​c​o​n​n​e​c​t​ ​t​o​.​ ​T​h​i​s​ ​a​d​d​r​e​s​s​ ​w​i​l​l​ ​b​e​ ​u​s​e​d​ ​i​n​ ​t​h​e​ ​c​o​n​f​i​g​u​r​a​t​i​o​n​ ​f​o​r​ ​t​h​e​ ​c​l​i​e​n​t​s​,​ ​b​u​t​ ​D​e​f​g​u​a​r​d​ ​G​a​t​e​w​a​y​s​ ​d​o​ ​n​o​t​ ​b​i​n​d​ ​t​o​ ​t​h​i​s​ ​a​d​d​r​e​s​s​. + */ endpoint: string /** * G​a​t​e​w​a​y​ ​p​u​b​l​i​c​ ​a​d​d​r​e​s​s​,​ ​u​s​e​d​ ​b​y​ ​V​P​N​ ​u​s​e​r​s​ ​t​o​ ​c​o​n​n​e​c​t @@ -11370,6 +11373,9 @@ export type TranslationFunctions = { * Based on this address VPN network address will be defined, eg. 10.10.10.1/24 (and VPN network will be: 10.10.10.0/24). You can optionally specify multiple addresses separated by a comma. The first address is the primary address, and this one will be used for IP address assignment for devices. The other IP addresses are auxiliary and are not managed by Defguard. */ address: () => LocalizedString + /** + * Public IP address or domain name to which the remote peers/users will connect to. This address will be used in the configuration for the clients, but Defguard Gateways do not bind to this address. + */ endpoint: () => LocalizedString /** * Gateway public address, used by VPN users to connect