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
This commit is contained in:
Gabi
2024-10-02 05:44:26 -03:00
committed by GitHub
parent d4e9384a08
commit 3501d5b287
18 changed files with 238 additions and 34 deletions

View File

@@ -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

View File

@@ -11,6 +11,7 @@ object ConnlibSession {
logDir: String,
logFilter: String,
callback: Any,
deviceInfo: String,
): Long
external fun disconnect(connlibSession: Long): Boolean

View File

@@ -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")

45
rust/Cargo.lock generated
View File

@@ -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"

View File

@@ -332,6 +332,7 @@ fn connect(
log_dir: JString,
log_filter: JString,
callback_handler: GlobalRef,
device_info: JString,
) -> Result<SessionWrapper, ConnectError> {
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,
)
});

View File

@@ -48,6 +48,7 @@ mod ffi {
log_dir: String,
log_filter: String,
callback_handler: CallbackHandler,
device_info: String,
) -> Result<WrappedSession, String>;
fn reset(&mut self);
@@ -187,11 +188,13 @@ impl WrappedSession {
log_dir: String,
log_filter: String,
callback_handler: ffi::CallbackHandler,
device_info: String,
) -> Result<Self> {
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()

View File

@@ -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. <https://github.com/firezone/firezone/pull/4328#discussion_r1540342142>

View File

@@ -21,6 +21,28 @@ pub(crate) fn path() -> Result<PathBuf> {
Ok(path)
}
fn device_serial() -> Option<String> {
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 <https://github.com/firezone/firezone/issues/2697> and <https://github.com/firezone/firezone/issues/2711>,

View File

@@ -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()))?;

View File

@@ -173,6 +173,7 @@ fn main() -> Result<()> {
firezone_id,
cli.firezone_name,
public_key.to_bytes(),
device_id::device_info(),
)?;
if cli.check {

View File

@@ -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<TInitReq, TInboundMsg, TOutboundRes> {
state: State,

View File

@@ -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<String>,
pub device_serial: Option<String>,
pub identifier_for_vendor: Option<String>,
pub firebase_installation_id: Option<String>,
}
#[derive(Clone)]
pub struct LoginUrl {
url: Url,
@@ -44,8 +53,8 @@ impl LoginUrl {
device_id: String,
device_name: Option<String>,
public_key: [u8; 32],
device_info: DeviceInfo,
) -> Result<Self, LoginUrlError<E>> {
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<E>(
port: Option<u16>,
ipv4_address: Option<Ipv4Addr>,
ipv6_address: Option<Ipv6Addr>,
device_info: DeviceInfo,
) -> Result<Url, LoginUrlError<E>> {
set_ws_scheme(&mut api_url)?;
@@ -219,6 +232,18 @@ fn get_websocket_path<E>(
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)

View File

@@ -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

View File

@@ -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

View File

@@ -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.
</ChangeItem>
<ChangeItem pull="6857">
Sends the Firebase Installation ID for device verification.
</ChangeItem>
</Unreleased>
<Entry version="1.3.4" date={new Date("2024-09-26")}>
<ChangeItem pull="6809">

View File

@@ -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.
</ChangeItem>
<ChangeItem pull="6857">
MacOS: sends hardware's UUID for device verification.
</ChangeItem>
<ChangeItem pull="6857">
iOS: sends Id for vendor for device verification.
</ChangeItem>
</Unreleased>
<Entry version="1.3.5" date={new Date("2024-09-26")}>
<ChangeItem pull="6809">

View File

@@ -29,6 +29,9 @@ export default function GUI({ title }: { title: string }) {
<ChangeItem enable={title === "Windows"} pull="6874">
Fixes a delay when closing the GUI.
</ChangeItem>
<ChangeItem pull="6857">
Tries to send motherboard's hardware ID for device verification.
</ChangeItem>
</Unreleased>
<Entry version="1.3.6" date={new Date("2024-09-25")}>
<ChangeItem pull="6809">

View File

@@ -23,6 +23,9 @@ export default function Headless() {
<ChangeItem pull="6782">
Adds always-on error reporting using sentry.io.
</ChangeItem>
<ChangeItem pull="6857">
Sends the motherboard's hardware ID for device verification.
</ChangeItem>
</Unreleased>
<Entry version="1.3.3" date={new Date("2024-09-25")}>
<ChangeItem pull="6809">