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. +