Skip to main content

steamkit/util/
hardware_utils.rs

1//! The machine identity Steam uses to recognize a machine, sent with the logon
2//! as a `MessageObject`.
3//!
4//! Gathering the values is behind the [`IMachineInfoProvider`] trait, so you
5//! can plug in your own.
6
7use std::sync::{Arc, Mutex, OnceLock, Weak};
8
9use sha1::{Digest, Sha1};
10
11use crate::error::SteamError;
12use crate::steam::steam_client::configuration::i_machine_info_provider::IMachineInfoProvider;
13use crate::types::key_value::KeyValue;
14use crate::types::message_object::MessageObject;
15use crate::util::debug_log::DebugLog;
16use crate::util::utils::Utils;
17
18/// Picks the machine info provider for the current platform.
19#[derive(Debug)]
20pub struct MachineInfoProvider;
21
22impl MachineInfoProvider {
23    /// The provider for the current platform.
24    pub fn get_default_provider() -> Arc<dyn IMachineInfoProvider> {
25        #[cfg(target_os = "windows")]
26        {
27            Arc::new(WindowsMachineInfoProvider)
28        }
29
30        #[cfg(target_os = "macos")]
31        {
32            Arc::new(MacOSMachineInfoProvider)
33        }
34
35        #[cfg(target_os = "linux")]
36        {
37            Arc::new(LinuxMachineInfoProvider)
38        }
39
40        #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
41        {
42            Arc::new(DefaultMachineInfoProvider)
43        }
44    }
45}
46
47/// The provider for unknown platforms, also the fallback for any value a
48/// platform provider can't get.
49#[derive(Debug)]
50pub struct DefaultMachineInfoProvider;
51
52impl DefaultMachineInfoProvider {
53    /// The shared instance.
54    pub fn instance() -> Arc<dyn IMachineInfoProvider> {
55        static INSTANCE: OnceLock<Arc<dyn IMachineInfoProvider>> = OnceLock::new();
56
57        INSTANCE
58            .get_or_init(|| Arc::new(DefaultMachineInfoProvider))
59            .clone()
60    }
61
62    /// Combines MAC addresses into the 16 bytes the Steam client hashes. Each
63    /// address is padded with two zero bytes (so 6 bytes become 8), and two
64    /// zero blocks are appended in case there are fewer than two adapters.
65    pub fn get_combined_mac_address(addresses: Vec<Vec<u8>>) -> Vec<u8> {
66        let mut sequences: Vec<Vec<u8>> = addresses
67            .into_iter()
68            .map(|mut address| {
69                address.push(0);
70                address.push(0);
71                address
72            })
73            .collect();
74
75        sequences.push(vec![0u8; 8]);
76        sequences.push(vec![0u8; 8]);
77
78        let mut result = Vec::with_capacity(16);
79
80        for sequence in sequences.into_iter().take(2) {
81            result.extend(sequence);
82        }
83
84        result
85    }
86}
87
88impl IMachineInfoProvider for DefaultMachineInfoProvider {
89    fn machine_guid(&self) -> Option<Vec<u8>> {
90        Some(format!("{}-SteamKit", machine_name()).into_bytes())
91    }
92
93    fn mac_address(&self) -> Option<Vec<u8>> {
94        // Use the first Ethernet or wireless adapter. Only Linux exposes the
95        // addresses without OS calls (through sysfs).
96        let addresses = read_mac_addresses(false);
97
98        match addresses.into_iter().next() {
99            Some(address) => Some(address),
100            None => Some(b"SteamKit-MacAddress".to_vec()),
101        }
102    }
103
104    fn disk_id(&self) -> Option<Vec<u8>> {
105        Some(b"SteamKit-DiskId".to_vec())
106    }
107}
108
109/// The provider for Windows.
110#[derive(Debug)]
111pub struct WindowsMachineInfoProvider;
112
113impl IMachineInfoProvider for WindowsMachineInfoProvider {
114    fn machine_guid(&self) -> Option<Vec<u8>> {
115        query_machine_guid().map(|guid| guid.into_bytes())
116    }
117
118    fn mac_address(&self) -> Option<Vec<u8>> {
119        // Physical adapters are the ones whose PnP instance id starts with "PCI".
120        let addresses = read_physical_mac_addresses();
121
122        Some(DefaultMachineInfoProvider::get_combined_mac_address(addresses))
123    }
124
125    fn disk_id(&self) -> Option<Vec<u8>> {
126        crate::util::win32_helpers::Win32Helpers::get_boot_disk_serial_number()
127            .map(|serial_number| serial_number.into_bytes())
128    }
129}
130
131/// The provider for Linux.
132#[derive(Debug)]
133pub struct LinuxMachineInfoProvider;
134
135impl IMachineInfoProvider for LinuxMachineInfoProvider {
136    fn machine_guid(&self) -> Option<Vec<u8>> {
137        let machine_files = [
138            "/var/lib/dbus/machine-id",
139            "/etc/machine-id",
140            "/etc/hostname",
141        ];
142
143        for file_name in machine_files {
144            if let Ok(data) = std::fs::read(file_name) {
145                return Some(data);
146            }
147        }
148
149        None
150    }
151
152    fn mac_address(&self) -> Option<Vec<u8>> {
153        Some(DefaultMachineInfoProvider::get_combined_mac_address(
154            read_mac_addresses(true),
155        ))
156    }
157
158    fn disk_id(&self) -> Option<Vec<u8>> {
159        let boot_params = get_boot_options();
160
161        for param in ["root=UUID=", "root=PARTUUID="] {
162            if let Some(value) = get_param_value(&boot_params, param) {
163                if !value.is_empty() {
164                    return Some(value.into_bytes());
165                }
166            }
167        }
168
169        let disk_uuids = get_disk_uuids();
170
171        disk_uuids
172            .into_iter()
173            .next()
174            .map(|uuid| uuid.into_bytes())
175    }
176}
177
178/// The provider for macOS.
179#[derive(Debug)]
180pub struct MacOSMachineInfoProvider;
181
182impl IMachineInfoProvider for MacOSMachineInfoProvider {
183    fn machine_guid(&self) -> Option<Vec<u8>> {
184        // Read through `ioreg`, since calling IOKit directly would need `unsafe`.
185        match crate::util::mac_helpers::platform_serial_number() {
186            Some(serial_number) => Some(serial_number.into_bytes()),
187            None => DefaultMachineInfoProvider::instance().machine_guid(),
188        }
189    }
190
191    fn mac_address(&self) -> Option<Vec<u8>> {
192        // No disk id on macOS.
193        None
194    }
195
196    fn disk_id(&self) -> Option<Vec<u8>> {
197        match crate::util::mac_helpers::media_uuid("/") {
198            Some(uuid) => Some(uuid.into_bytes()),
199            None => DefaultMachineInfoProvider::instance().disk_id(),
200        }
201    }
202}
203
204/// Holds the machine id generated for a provider.
205#[derive(Debug, Default)]
206struct MachineIdSlot {
207    /// The machine id, computed on first use.
208    value: OnceLock<Option<Vec<u8>>>,
209}
210
211/// An entry in the generation table.
212#[derive(Debug)]
213struct GenerationEntry {
214    /// The provider this entry belongs to.
215    provider: Weak<dyn IMachineInfoProvider>,
216    /// Its machine id slot.
217    slot: Arc<MachineIdSlot>,
218}
219
220impl GenerationEntry {
221    /// Whether this entry belongs to `provider`.
222    fn matches(&self, provider: &Arc<dyn IMachineInfoProvider>) -> bool {
223        match self.provider.upgrade() {
224            Some(strong) => Arc::ptr_eq(&strong, provider),
225            None => false,
226        }
227    }
228}
229
230/// The generation table, created on first use.
231fn generation_table() -> &'static Mutex<Vec<GenerationEntry>> {
232    static TABLE: OnceLock<Mutex<Vec<GenerationEntry>>> = OnceLock::new();
233
234    TABLE.get_or_init(|| Mutex::new(Vec::new()))
235}
236
237/// Registers a provider and returns its machine id slot.
238fn register_provider(provider: &Arc<dyn IMachineInfoProvider>) -> Arc<MachineIdSlot> {
239    let mut table = generation_table()
240        .lock()
241        .unwrap_or_else(|error| error.into_inner());
242
243    if let Some(entry) = table.iter().find(|entry| entry.matches(provider)) {
244        return entry.slot.clone();
245    }
246
247    let slot = Arc::new(MachineIdSlot::default());
248
249    table.push(GenerationEntry {
250        provider: Arc::downgrade(provider),
251        slot: slot.clone(),
252    });
253
254    slot
255}
256
257/// Finds a provider's machine id slot.
258fn lookup_provider(provider: &Arc<dyn IMachineInfoProvider>) -> Option<Arc<MachineIdSlot>> {
259    let mut table = generation_table()
260        .lock()
261        .unwrap_or_else(|error| error.into_inner());
262
263    table.retain(|entry| entry.provider.strong_count() > 0);
264
265    table
266        .iter()
267        .find(|entry| entry.matches(provider))
268        .map(|entry| entry.slot.clone())
269}
270
271/// Generates the machine id for a provider.
272fn generate_machine_id(provider: &dyn IMachineInfoProvider) -> Result<Vec<u8>, SteamError> {
273    // The August 2015 CM update made well-formed machine MessageObjects
274    // required for logon. That was turned off shortly after, probably because
275    // of Linux clients on distros without a machine id, so a valid one isn't
276    // required right now. It might be again, so we send one.
277
278    let fallback = DefaultMachineInfoProvider::instance();
279
280    let mut message_object = MessageObject::new();
281
282    {
283        let key_values = message_object.key_values_mut();
284
285        key_values.set("BB3", KeyValue::new());
286        key_values.set("FF2", KeyValue::new());
287        key_values.set("3B3", KeyValue::new());
288    }
289
290    // Custom providers can fail for any field; fall back to the default
291    // provider then.
292    let bb3 = provider
293        .machine_guid()
294        .or_else(|| fallback.machine_guid())
295        .unwrap_or_default();
296    let ff2 = provider
297        .mac_address()
298        .or_else(|| fallback.mac_address())
299        .unwrap_or_default();
300    let three_b3 = provider
301        .disk_id()
302        .or_else(|| fallback.disk_id())
303        .unwrap_or_default();
304
305    set_child_value(&mut message_object, "BB3", &get_hex_string(&bb3));
306    set_child_value(&mut message_object, "FF2", &get_hex_string(&ff2));
307    set_child_value(&mut message_object, "3B3", &get_hex_string(&three_b3));
308
309    // 333 is some sort of user-supplied data and is currently unused.
310
311    let mut stream: Vec<u8> = Vec::new();
312    message_object.write_to_stream(&mut stream)?;
313
314    Ok(stream)
315}
316
317/// Sets the value of the named child.
318fn set_child_value(message_object: &mut MessageObject, key: &str, value: &str) {
319    let children = message_object.key_values_mut().children_mut();
320
321    if let Some(child) = children
322        .iter_mut()
323        .find(|child| child.name().is_some_and(|name| name.eq_ignore_ascii_case(key)))
324    {
325        child.set_value(Some(value.to_string()));
326    }
327}
328
329/// SHA-1 of `data`, as a lowercase hex string.
330fn get_hex_string(data: &[u8]) -> String {
331    let digest = Sha1::digest(data);
332
333    Utils::encode_hex_string(digest.as_slice())
334}
335
336/// The name of this machine.
337fn machine_name() -> String {
338    #[cfg(target_os = "windows")]
339    {
340        std::env::var("COMPUTERNAME").unwrap_or_else(|_| "unknown".to_string())
341    }
342
343    #[cfg(not(target_os = "windows"))]
344    {
345        std::env::var("HOSTNAME").unwrap_or_else(|_| "unknown".to_string())
346    }
347}
348
349/// Reads the machine GUID from the Windows registry.
350#[cfg(target_os = "windows")]
351fn query_machine_guid() -> Option<String> {
352    let output = std::process::Command::new("reg")
353        .args([
354            "query",
355            r"HKLM\SOFTWARE\Microsoft\Cryptography",
356            "/v",
357            "MachineGuid",
358        ])
359        .output()
360        .ok()?;
361
362    if !output.status.success() {
363        return None;
364    }
365
366    let text = String::from_utf8(output.stdout).ok()?;
367
368    for line in text.lines() {
369        let trimmed = line.trim();
370
371        if trimmed.starts_with("MachineGuid") {
372            return trimmed.split_whitespace().last().map(str::to_string);
373        }
374    }
375
376    None
377}
378
379/// PowerShell script that prints the MAC addresses of the physical network
380/// adapters (registry `Connection\PnpInstanceID` starting with "PCI").
381///
382/// It goes through .NET's `NetworkInterface.GetAllNetworkInterfaces()` on
383/// purpose: the addresses are hashed in enumeration order, and that's the
384/// order the Steam client sees. WMI's `Win32_NetworkAdapter` enumerates in a
385/// different order, which gives a different machine id.
386#[cfg(target_os = "windows")]
387const PHYSICAL_MAC_ADDRESSES_SCRIPT: &str = r#"
388$ErrorActionPreference = 'SilentlyContinue'
389$classKey = 'SYSTEM\CurrentControlSet\Control\Network\{4D36E972-E325-11CE-BFC1-08002BE10318}'
390[System.Net.NetworkInformation.NetworkInterface]::GetAllNetworkInterfaces() | ForEach-Object {
391    $adapter = $_
392    $connection = [Microsoft.Win32.Registry]::LocalMachine.OpenSubKey("$classKey\$($adapter.Id)\Connection")
393    if ($null -ne $connection) {
394        $instanceId = $connection.GetValue('PnpInstanceID', '')
395        $connection.Close()
396        if ($instanceId.Length -gt 3 -and $instanceId.StartsWith('PCI', [System.StringComparison]::Ordinal)) {
397            $address = $adapter.GetPhysicalAddress().GetAddressBytes()
398            if ($address.Length -gt 0) { -join ($address | ForEach-Object { $_.ToString('X2') }) } else { '-' }
399        }
400    }
401}
402"#;
403
404/// The MAC addresses of the physical network adapters, in the order Windows
405/// reports them (which is the order they're hashed in).
406#[cfg(target_os = "windows")]
407fn read_physical_mac_addresses() -> Vec<Vec<u8>> {
408    let text = match crate::util::win32_helpers::run_powershell(PHYSICAL_MAC_ADDRESSES_SCRIPT) {
409        Ok(text) => text,
410        Err(error) => {
411            DebugLog::write_line(
412                "HardwareUtils",
413                &format!("Failed to read the MAC addresses of the network adapters: {error}"),
414            );
415
416            return Vec::new();
417        }
418    };
419
420    text.lines()
421        .filter_map(|line| {
422            let hex: String = line
423                .trim()
424                .chars()
425                .filter(|character| character.is_ascii_hexdigit())
426                .collect();
427
428            if hex.len() % 2 != 0 {
429                return None;
430            }
431
432            let mut bytes = Vec::with_capacity(hex.len() / 2);
433
434            for pair in hex.as_bytes().chunks(2) {
435                let text = std::str::from_utf8(pair).ok()?;
436                bytes.push(u8::from_str_radix(text, 16).ok()?);
437            }
438
439            Some(bytes)
440        })
441        .collect()
442}
443
444/// The MAC addresses of the network adapters, from sysfs.
445#[cfg(target_os = "linux")]
446fn read_mac_addresses(check_for_physical_device: bool) -> Vec<Vec<u8>> {
447    let Ok(entries) = std::fs::read_dir("/sys/class/net") else {
448        return Vec::new();
449    };
450
451    let mut addresses = Vec::new();
452
453    for entry in entries.flatten() {
454        let interface = entry.file_name().to_string_lossy().into_owned();
455
456        if check_for_physical_device
457            && !std::path::Path::new(&format!("/sys/class/net/{interface}/device")).exists()
458        {
459            continue;
460        }
461
462        let Ok(text) = std::fs::read_to_string(format!("/sys/class/net/{interface}/address")) else {
463            continue;
464        };
465
466        let hex: String = text
467            .trim()
468            .chars()
469            .filter(|character| character.is_ascii_hexdigit())
470            .collect();
471
472        if hex.len() < 2 || hex.len() % 2 != 0 {
473            continue;
474        }
475
476        let mut bytes = Vec::with_capacity(hex.len() / 2);
477
478        let mut valid = true;
479
480        for pair in hex.as_bytes().chunks(2) {
481            match std::str::from_utf8(pair).ok().and_then(|text| u8::from_str_radix(text, 16).ok()) {
482                Some(byte) => bytes.push(byte),
483                None => {
484                    valid = false;
485                    break;
486                }
487            }
488        }
489
490        if valid {
491            addresses.push(bytes);
492        }
493    }
494
495    if check_for_physical_device && addresses.is_empty() {
496        return read_mac_addresses(false);
497    }
498
499    addresses
500}
501
502/// The MAC addresses of the network adapters. Only supported where they're
503/// readable from the file system; elsewhere there are none.
504#[cfg(not(target_os = "linux"))]
505fn read_mac_addresses(_check_for_physical_device: bool) -> Vec<Vec<u8>> {
506    Vec::new()
507}
508
509/// The kernel boot options.
510#[cfg(target_os = "linux")]
511fn get_boot_options() -> Vec<String> {
512    let Ok(text) = std::fs::read_to_string("/proc/cmdline") else {
513        return Vec::new();
514    };
515
516    text.split(' ')
517        .map(|option| option.to_string())
518        .collect()
519}
520
521/// The kernel boot options (none on this platform).
522#[cfg(not(target_os = "linux"))]
523fn get_boot_options() -> Vec<String> {
524    Vec::new()
525}
526
527/// The value of the first boot option starting with `param`.
528fn get_param_value(boot_options: &[String], param: &str) -> Option<String> {
529    let option = boot_options
530        .iter()
531        .find(|option| option.len() >= param.len() && option[..param.len()].eq_ignore_ascii_case(param))?;
532
533    Some(option[param.len()..].to_string())
534}
535
536/// The UUIDs of the attached disks, oldest first.
537#[cfg(target_os = "linux")]
538fn get_disk_uuids() -> Vec<String> {
539    let Ok(entries) = std::fs::read_dir("/dev/disk/by-uuid") else {
540        return Vec::new();
541    };
542
543    let mut files: Vec<(std::time::SystemTime, String)> = entries
544        .flatten()
545        .filter_map(|entry| {
546            let name = entry.file_name().to_string_lossy().into_owned();
547            let modified = entry.metadata().ok()?.modified().ok()?;
548
549            Some((modified, name))
550        })
551        .collect();
552
553    files.sort_by_key(|(modified, _)| *modified);
554
555    files.into_iter().map(|(_, name)| name).collect()
556}
557
558/// The UUIDs of the attached disks (none on this platform).
559#[cfg(not(target_os = "linux"))]
560fn get_disk_uuids() -> Vec<String> {
561    Vec::new()
562}
563
564/// Generates the machine id that identifies this machine to Steam.
565#[derive(Debug)]
566pub struct HardwareUtils;
567
568impl HardwareUtils {
569    /// Starts generating the machine id for `machine_info_provider` in the
570    /// background, so it's ready by the time we log on. Without a tokio runtime
571    /// it's generated on first use instead.
572    pub fn init(machine_info_provider: Arc<dyn IMachineInfoProvider>) {
573        let slot = register_provider(&machine_info_provider);
574
575        let Ok(handle) = tokio::runtime::Handle::try_current() else {
576            return;
577        };
578
579        let provider = machine_info_provider.clone();
580
581        handle.spawn(async move {
582            let _ = tokio::task::spawn_blocking(move || {
583                slot.value.get_or_init(|| generate_machine_id(provider.as_ref()).ok());
584            })
585            .await;
586        });
587    }
588
589    /// The machine id for the provider, or `None` if [`HardwareUtils::init`]
590    /// wasn't called for it.
591    pub fn get_machine_id(
592        machine_info_provider: &Arc<dyn IMachineInfoProvider>,
593    ) -> Option<Vec<u8>> {
594        let slot = lookup_provider(machine_info_provider)?;
595
596        let value = slot
597            .value
598            .get_or_init(|| generate_machine_id(machine_info_provider.as_ref()).ok());
599
600        value.clone()
601    }
602}
603
604/// Same as [`HardwareUtils::get_machine_id`].
605pub fn get_machine_id(
606    machine_info_provider: &Arc<dyn IMachineInfoProvider>,
607) -> Option<Vec<u8>> {
608    let Some(slot) = lookup_provider(machine_info_provider) else {
609        DebugLog::write_line("HardwareUtils", "GetMachineID() called before Init()");
610        return None;
611    };
612
613    let value = slot
614        .value
615        .get_or_init(|| generate_machine_id(machine_info_provider.as_ref()).ok());
616
617    value.clone()
618}