From 3501d5b287158045909c02c1a55179405a0febb5 Mon Sep 17 00:00:00 2001 From: Gabi Date: Wed, 2 Oct 2024 05:44:26 -0300 Subject: [PATCH] feat(clients): use hardware id for device verification (#6857) We want to associate additional device information for the device verification, these are all parameters that tries to uniquely identify the hardware. For that reason we read system information and send it as part of the query params to the portal, that way the portal can store this when device is verified and match against that later on. These are the parameters according to each platform: |Platform|Query Field|Field Meaning| |-----|----|-----| |MacOS|`device_serial`|Hardware's Serial| |MacOS| `device_uuid`|Hardware's UUID| |iOS|`identifier_for_vendor`| Identifier for vendor, resets only on uninstall/install| |Android|`firebase_installation_id`| Firebase installation ID, resets only on uninstall/install| |Windows|`device_serial`|Motherboard's Serial| |Linux|`device_serial`|Motherboard's Serial| Fixes #6837 --- kotlin/android/app/build.gradle.kts | 4 +- .../firezone/android/tunnel/ConnlibSession.kt | 1 + .../firezone/android/tunnel/TunnelService.kt | 61 +++++++++++---- rust/Cargo.lock | 45 +++++++++++ rust/connlib/clients/android/src/lib.rs | 7 ++ rust/connlib/clients/apple/src/lib.rs | 4 + rust/headless-client/Cargo.toml | 1 + rust/headless-client/src/device_id.rs | 22 ++++++ rust/headless-client/src/ipc_service.rs | 1 + rust/headless-client/src/main.rs | 1 + rust/phoenix-channel/src/lib.rs | 2 +- rust/phoenix-channel/src/login_url.rs | 29 +++++++- .../FirezoneKit/Helpers/DeviceMetadata.swift | 74 +++++++++++++++---- .../FirezoneNetworkExtension/Adapter.swift | 5 +- website/src/components/Changelog/Android.tsx | 3 + website/src/components/Changelog/Apple.tsx | 6 ++ website/src/components/Changelog/GUI.tsx | 3 + website/src/components/Changelog/Headless.tsx | 3 + 18 files changed, 238 insertions(+), 34 deletions(-) diff --git a/kotlin/android/app/build.gradle.kts b/kotlin/android/app/build.gradle.kts index cc8157048..398e726ec 100644 --- a/kotlin/android/app/build.gradle.kts +++ b/kotlin/android/app/build.gradle.kts @@ -180,6 +180,8 @@ dependencies { // Hilt implementation("com.google.dagger:hilt-android:2.52") implementation("androidx.browser:browser:1.8.0") + implementation("com.google.firebase:firebase-installations-ktx:18.0.0") + implementation("com.google.android.gms:play-services-tasks:18.2.0") kapt("androidx.hilt:hilt-compiler:1.2.0") kapt("com.google.dagger:hilt-android-compiler:2.52") // Instrumented Tests @@ -217,7 +219,7 @@ dependencies { androidTestImplementation("androidx.fragment:fragment-testing:1.8.2") // Import the BoM for the Firebase platform - implementation(platform("com.google.firebase:firebase-bom:33.2.0")) + implementation(platform("com.google.firebase:firebase-bom:33.3.0")) // Add the dependencies for the Crashlytics and Analytics libraries // When using the BoM, you don't specify versions in Firebase library dependencies diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/ConnlibSession.kt b/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/ConnlibSession.kt index f0c5fb752..9a85e1786 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/ConnlibSession.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/ConnlibSession.kt @@ -11,6 +11,7 @@ object ConnlibSession { logDir: String, logFilter: String, callback: Any, + deviceInfo: String, ): Long external fun disconnect(connlibSession: Long): Boolean diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/TunnelService.kt b/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/TunnelService.kt index 1adacd1a7..a0283a807 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/TunnelService.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/TunnelService.kt @@ -16,8 +16,13 @@ import android.os.Binder import android.os.Build import android.os.Bundle import android.os.IBinder +import android.util.Log import androidx.lifecycle.MutableLiveData +import com.google.android.gms.tasks.Tasks +import com.google.firebase.installations.FirebaseInstallations +import com.google.gson.FieldNamingPolicy import com.google.gson.Gson +import com.google.gson.GsonBuilder import com.squareup.moshi.Moshi import com.squareup.moshi.adapter import dagger.hilt.android.AndroidEntryPoint @@ -30,10 +35,14 @@ import dev.firezone.android.tunnel.model.Resource import dev.firezone.android.tunnel.model.isInternetResource import java.nio.file.Files import java.nio.file.Paths -import java.util.UUID +import java.util.concurrent.Executors import java.util.concurrent.locks.ReentrantLock import javax.inject.Inject +data class DeviceInfo( + var firebaseInstallationId: String? = null, +) + @AndroidEntryPoint @OptIn(ExperimentalStdlibApi::class) class TunnelService : VpnService() { @@ -317,20 +326,40 @@ class TunnelService : VpnService() { tunnelState = State.CONNECTING updateStatusNotification(TunnelStatusNotification.Connecting) - connlibSessionPtr = - ConnlibSession.connect( - apiUrl = config.apiUrl, - token = token, - deviceId = deviceId(), - deviceName = getDeviceName(), - osVersion = Build.VERSION.RELEASE, - logDir = getLogDir(), - logFilter = config.logFilter, - callback = callback, - ) + val executor = Executors.newSingleThreadExecutor() - startNetworkMonitoring() - startDisconnectMonitoring() + executor.execute { + val deviceInfo = DeviceInfo() + + runCatching { + Tasks.await(FirebaseInstallations.getInstance().id) + }.onSuccess { firebaseInstallationId -> + deviceInfo.firebaseInstallationId = firebaseInstallationId + }.onFailure { exception -> + Log.d(TAG, "Failed to obtain firebase installation id: $exception") + } + + val gson: Gson = + GsonBuilder() + .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) + .create() + + connlibSessionPtr = + ConnlibSession.connect( + apiUrl = config.apiUrl, + token = token, + deviceId = deviceId(), + deviceName = getDeviceName(), + osVersion = Build.VERSION.RELEASE, + logDir = getLogDir(), + logFilter = config.logFilter, + callback = callback, + deviceInfo = gson.toJson(deviceInfo), + ) + + startNetworkMonitoring() + startDisconnectMonitoring() + } } } @@ -404,10 +433,11 @@ class TunnelService : VpnService() { // Get the deviceId from the preferenceRepository, or save a new UUIDv4 and return that if it doesn't exist val deviceId = repo.getDeviceIdSync() ?: run { - val newDeviceId = UUID.randomUUID().toString() + val newDeviceId = java.util.UUID.randomUUID().toString() repo.saveDeviceIdSync(newDeviceId) newDeviceId } + return deviceId } @@ -441,6 +471,7 @@ class TunnelService : VpnService() { private const val SESSION_NAME: String = "Firezone Connection" private const val MTU: Int = 1280 + private const val TAG: String = "TunnelService" private val MANAGED_CONFIGURATIONS = arrayOf("token", "allowedApplications", "disallowedApplications", "deviceName") diff --git a/rust/Cargo.lock b/rust/Cargo.lock index e0dd25593..98b95ef24 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -2373,6 +2373,7 @@ dependencies = [ "serde", "serde_json", "serde_variant", + "smbios-lib", "tempfile", "thiserror", "tokio", @@ -2878,6 +2879,15 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "getopts" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5" +dependencies = [ + "unicode-width", +] + [[package]] name = "getrandom" version = "0.1.16" @@ -3840,6 +3850,16 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "io-kit-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b" +dependencies = [ + "core-foundation-sys", + "mach2", +] + [[package]] name = "ip-packet" version = "0.1.0" @@ -4299,6 +4319,15 @@ dependencies = [ "time", ] +[[package]] +name = "mach2" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b955cdeb2a02b9117f121ce63aa52d08ade45de53e48fe6a38b39c10f6f709" +dependencies = [ + "libc", +] + [[package]] name = "malloc_buf" version = "0.0.6" @@ -6841,6 +6870,22 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" +[[package]] +name = "smbios-lib" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c18320ad3d997a100cb948fc020111936c530eddfde947f467083730e39e72" +dependencies = [ + "core-foundation 0.10.0", + "core-foundation-sys", + "getopts", + "io-kit-sys", + "libc", + "mach2", + "serde", + "serde_json", +] + [[package]] name = "smithay-client-toolkit" version = "0.19.2" diff --git a/rust/connlib/clients/android/src/lib.rs b/rust/connlib/clients/android/src/lib.rs index 4149605a7..77280ef4a 100644 --- a/rust/connlib/clients/android/src/lib.rs +++ b/rust/connlib/clients/android/src/lib.rs @@ -332,6 +332,7 @@ fn connect( log_dir: JString, log_filter: JString, callback_handler: GlobalRef, + device_info: JString, ) -> Result { let api_url = string_from_jstring!(env, api_url); let secret = SecretString::from(string_from_jstring!(env, token)); @@ -340,6 +341,9 @@ fn connect( let os_version = string_from_jstring!(env, os_version); let log_dir = string_from_jstring!(env, log_dir); let log_filter = string_from_jstring!(env, log_filter); + let device_info = string_from_jstring!(env, device_info); + + let device_info = serde_json::from_str(&device_info).unwrap(); let handle = init_logging(&PathBuf::from(log_dir), log_filter); install_rustls_crypto_provider(); @@ -357,6 +361,7 @@ fn connect( device_id, Some(device_name), public_key.to_bytes(), + device_info, )?; let runtime = tokio::runtime::Builder::new_multi_thread() @@ -407,6 +412,7 @@ pub unsafe extern "system" fn Java_dev_firezone_android_tunnel_ConnlibSession_co log_dir: JString, log_filter: JString, callback_handler: JObject, + device_info: JString, ) -> *const SessionWrapper { let Ok(callback_handler) = env.new_global_ref(callback_handler) else { return std::ptr::null(); @@ -423,6 +429,7 @@ pub unsafe extern "system" fn Java_dev_firezone_android_tunnel_ConnlibSession_co log_dir, log_filter, callback_handler, + device_info, ) }); diff --git a/rust/connlib/clients/apple/src/lib.rs b/rust/connlib/clients/apple/src/lib.rs index 642ca64cf..b16f62bbb 100644 --- a/rust/connlib/clients/apple/src/lib.rs +++ b/rust/connlib/clients/apple/src/lib.rs @@ -48,6 +48,7 @@ mod ffi { log_dir: String, log_filter: String, callback_handler: CallbackHandler, + device_info: String, ) -> Result; fn reset(&mut self); @@ -187,11 +188,13 @@ impl WrappedSession { log_dir: String, log_filter: String, callback_handler: ffi::CallbackHandler, + device_info: String, ) -> Result { let logger = init_logging(log_dir.into(), log_filter)?; install_rustls_crypto_provider(); let secret = SecretString::from(token); + let device_info = serde_json::from_str(&device_info).unwrap(); let (private_key, public_key) = keypair(); let url = LoginUrl::client( @@ -200,6 +203,7 @@ impl WrappedSession { device_id, device_name_override, public_key.to_bytes(), + device_info, )?; let runtime = tokio::runtime::Builder::new_multi_thread() diff --git a/rust/headless-client/Cargo.toml b/rust/headless-client/Cargo.toml index a09c234c8..8bf160a27 100644 --- a/rust/headless-client/Cargo.toml +++ b/rust/headless-client/Cargo.toml @@ -26,6 +26,7 @@ secrecy = { workspace = true } serde = { version = "1.0.210", features = ["derive"] } serde_json = "1.0.125" serde_variant = "0.1.3" +smbios-lib = "0.9.2" thiserror = { version = "1.0", default-features = false } # This actually relies on many other features in Tokio, so this will probably # fail to build outside the workspace. diff --git a/rust/headless-client/src/device_id.rs b/rust/headless-client/src/device_id.rs index a6c1cb58b..a1c20f2a1 100644 --- a/rust/headless-client/src/device_id.rs +++ b/rust/headless-client/src/device_id.rs @@ -21,6 +21,28 @@ pub(crate) fn path() -> Result { Ok(path) } +fn device_serial() -> Option { + const DEFAULT_SERIAL: &str = "123456789"; + let data = smbioslib::table_load_from_device().ok()?; + + let serial = data.find_map(|sys_info: smbioslib::SMBiosSystemInformation| { + sys_info.serial_number().to_utf8_lossy() + })?; + + if serial == DEFAULT_SERIAL { + return None; + } + + Some(serial) +} + +pub fn device_info() -> phoenix_channel::DeviceInfo { + phoenix_channel::DeviceInfo { + device_serial: device_serial(), + ..Default::default() + } +} + /// Returns the device ID, generating it and saving it to disk if needed. /// /// Per and , diff --git a/rust/headless-client/src/ipc_service.rs b/rust/headless-client/src/ipc_service.rs index 0da123034..3d96ab4e6 100644 --- a/rust/headless-client/src/ipc_service.rs +++ b/rust/headless-client/src/ipc_service.rs @@ -533,6 +533,7 @@ impl<'a> Handler<'a> { device_id.id, None, public_key.to_bytes(), + device_id::device_info(), ) .map_err(|e| Error::LoginUrl(e.to_string()))?; diff --git a/rust/headless-client/src/main.rs b/rust/headless-client/src/main.rs index a68768df1..d9f5f7c5f 100644 --- a/rust/headless-client/src/main.rs +++ b/rust/headless-client/src/main.rs @@ -173,6 +173,7 @@ fn main() -> Result<()> { firezone_id, cli.firezone_name, public_key.to_bytes(), + device_id::device_info(), )?; if cli.check { diff --git a/rust/phoenix-channel/src/lib.rs b/rust/phoenix-channel/src/lib.rs index fad55ab07..9517aaab4 100644 --- a/rust/phoenix-channel/src/lib.rs +++ b/rust/phoenix-channel/src/lib.rs @@ -27,7 +27,7 @@ use tokio_tungstenite::{ }; use url::{Host, Url}; -pub use login_url::{LoginUrl, LoginUrlError}; +pub use login_url::{DeviceInfo, LoginUrl, LoginUrlError}; pub struct PhoenixChannel { state: State, diff --git a/rust/phoenix-channel/src/login_url.rs b/rust/phoenix-channel/src/login_url.rs index 6e59de115..32c81fb8b 100644 --- a/rust/phoenix-channel/src/login_url.rs +++ b/rust/phoenix-channel/src/login_url.rs @@ -1,5 +1,6 @@ use base64::{engine::general_purpose::STANDARD, Engine}; use secrecy::{CloneableSecret, ExposeSecret as _, SecretString, Zeroize}; +use serde::Deserialize; use sha2::Digest as _; use std::net::{Ipv4Addr, Ipv6Addr}; use url::Url; @@ -17,6 +18,14 @@ use uuid::Uuid; #[cfg(not(target_os = "windows"))] const HOST_NAME_MAX: usize = 256; +#[derive(Debug, Clone, Deserialize, Default)] +pub struct DeviceInfo { + pub device_uuid: Option, + pub device_serial: Option, + pub identifier_for_vendor: Option, + pub firebase_installation_id: Option, +} + #[derive(Clone)] pub struct LoginUrl { url: Url, @@ -44,8 +53,8 @@ impl LoginUrl { device_id: String, device_name: Option, public_key: [u8; 32], + device_info: DeviceInfo, ) -> Result> { - let external_id = hex::encode(sha2::Sha256::digest(device_id)); let device_name = device_name .or(get_host_name()) .unwrap_or_else(|| Uuid::new_v4().to_string()); @@ -55,11 +64,12 @@ impl LoginUrl { firezone_token, "client", Some(public_key), - Some(external_id), + Some(device_id), Some(device_name), None, None, None, + device_info, )?; Ok(LoginUrl { @@ -90,6 +100,7 @@ impl LoginUrl { None, None, None, + Default::default(), )?; Ok(LoginUrl { @@ -116,6 +127,7 @@ impl LoginUrl { Some(listen_port), ipv4_address, ipv6_address, + Default::default(), )?; Ok(LoginUrl { @@ -183,6 +195,7 @@ fn get_websocket_path( port: Option, ipv4_address: Option, ipv6_address: Option, + device_info: DeviceInfo, ) -> Result> { set_ws_scheme(&mut api_url)?; @@ -219,6 +232,18 @@ fn get_websocket_path( if let Some(port) = port { query_pairs.append_pair("port", &port.to_string()); } + if let Some(device_serial) = device_info.device_serial { + query_pairs.append_pair("device_serial", &device_serial); + } + if let Some(device_uuid) = device_info.device_uuid { + query_pairs.append_pair("device_uuid", &device_uuid); + } + if let Some(identifier_for_vendor) = device_info.identifier_for_vendor { + query_pairs.append_pair("identifier_for_vendor", &identifier_for_vendor); + } + if let Some(firebase_installation_id) = device_info.firebase_installation_id { + query_pairs.append_pair("firebase_installation_id", &firebase_installation_id); + } } Ok(api_url) diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/DeviceMetadata.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/DeviceMetadata.swift index 6200e00ee..71e75887e 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/DeviceMetadata.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Helpers/DeviceMetadata.swift @@ -46,24 +46,70 @@ public class DeviceMetadata { // if that doesn't exist. The Firezone ID is a UUIDv4 that is used to dedup this device // for upsert and identification in the admin portal. public static func getOrCreateFirezoneId() -> String { - let fileURL = SharedAccess.baseFolderURL.appendingPathComponent("firezone-id") - - do { - return try String(contentsOf: fileURL, encoding: .utf8) - } catch { - // Handle the error if the file doesn't exist or isn't readable - // Recreate the file, save a new UUIDv4, and return it - let newUUIDString = UUID().uuidString + let fileURL = SharedAccess.baseFolderURL.appendingPathComponent("firezone-id") do { - try newUUIDString.write(to: fileURL, atomically: true, encoding: .utf8) + return try String(contentsOf: fileURL, encoding: .utf8) } catch { - Log.app.error( - "\(#function): Could not save firezone-id file \(fileURL.path)! Error: \(error)" - ) - } + // Handle the error if the file doesn't exist or isn't readable + // Recreate the file, save a new UUIDv4, and return it + let newUUIDString = UUID().uuidString - return newUUIDString + do { + try newUUIDString.write(to: fileURL, atomically: true, encoding: .utf8) + } catch { + Log.app.error( + "\(#function): Could not save firezone-id file \(fileURL.path)! Error: \(error)" + ) + } + + return newUUIDString + } } + +#if os(iOS) + public static func deviceInfo() -> DeviceInfo { + return DeviceInfo(identifierForVendor: UIDevice.current.identifierForVendor!.uuidString) } +#else + public static func deviceInfo() -> DeviceInfo { + return DeviceInfo(deviceUuid: getDeviceUuid()!, deviceSerial: getDeviceSerial()!) + } +#endif } + +#if os(iOS) +public struct DeviceInfo: Encodable { + let identifierForVendor: String +} +#endif + +#if os(macOS) +import IOKit + +public struct DeviceInfo: Encodable { + let deviceUuid: String + let deviceSerial: String +} + +func getDeviceUuid() -> String? { + return getDeviceInfo(key: kIOPlatformUUIDKey as CFString) +} + +func getDeviceSerial() -> String? { + return getDeviceInfo(key: kIOPlatformSerialNumberKey as CFString) +} + +func getDeviceInfo(key: CFString) -> String? { + let matchingDict = IOServiceMatching("IOPlatformExpertDevice") + + let platformExpert = IOServiceGetMatchingService(kIOMainPortDefault, matchingDict) + defer { IOObjectRelease(platformExpert) } + + if let serial = IORegistryEntryCreateCFProperty(platformExpert, key, kCFAllocatorDefault, 0)?.takeUnretainedValue() as? String { + return serial + } + + return nil +} +#endif diff --git a/swift/apple/FirezoneNetworkExtension/Adapter.swift b/swift/apple/FirezoneNetworkExtension/Adapter.swift index 30993325a..30d9f7aec 100644 --- a/swift/apple/FirezoneNetworkExtension/Adapter.swift +++ b/swift/apple/FirezoneNetworkExtension/Adapter.swift @@ -129,6 +129,8 @@ class Adapter { Log.tunnel.log("Adapter.start: Starting connlib") do { + let jsonEncoder = JSONEncoder() + jsonEncoder.keyEncodingStrategy = .convertToSnakeCase // Grab a session pointer let session = try WrappedSession.connect( @@ -139,7 +141,8 @@ class Adapter { DeviceMetadata.getOSVersion(), connlibLogFolderPath, logFilter, - callbackHandler + callbackHandler, + String(data: jsonEncoder.encode(DeviceMetadata.deviceInfo()), encoding: .utf8)! ) // Start listening for network change events. The first few will be our diff --git a/website/src/components/Changelog/Android.tsx b/website/src/components/Changelog/Android.tsx index ce55e4a10..b375ded0f 100644 --- a/website/src/components/Changelog/Android.tsx +++ b/website/src/components/Changelog/Android.tsx @@ -20,6 +20,9 @@ export default function Android() { Fixes connectivity issues on idle connections by entering an always-on, low-power mode instead of closing them. + + Sends the Firebase Installation ID for device verification. + diff --git a/website/src/components/Changelog/Apple.tsx b/website/src/components/Changelog/Apple.tsx index 52559dcc8..dfcf31862 100644 --- a/website/src/components/Changelog/Apple.tsx +++ b/website/src/components/Changelog/Apple.tsx @@ -20,6 +20,12 @@ export default function Apple() { Fixes connectivity issues on idle connections by entering an always-on, low-power mode instead of closing them. + + MacOS: sends hardware's UUID for device verification. + + + iOS: sends Id for vendor for device verification. + diff --git a/website/src/components/Changelog/GUI.tsx b/website/src/components/Changelog/GUI.tsx index 41098716c..7b4b2d22a 100644 --- a/website/src/components/Changelog/GUI.tsx +++ b/website/src/components/Changelog/GUI.tsx @@ -29,6 +29,9 @@ export default function GUI({ title }: { title: string }) { Fixes a delay when closing the GUI. + + Tries to send motherboard's hardware ID for device verification. + diff --git a/website/src/components/Changelog/Headless.tsx b/website/src/components/Changelog/Headless.tsx index f1d9f2b8f..d16277edd 100644 --- a/website/src/components/Changelog/Headless.tsx +++ b/website/src/components/Changelog/Headless.tsx @@ -23,6 +23,9 @@ export default function Headless() { Adds always-on error reporting using sentry.io. + + Sends the motherboard's hardware ID for device verification. +