Skip to main content

tauri_plugin_licensemanager/core/
fingerprint.rs

1//! Port of `fingerprint.ts`.
2
3use std::future::Future;
4use std::sync::Arc;
5
6use base64::engine::general_purpose::STANDARD;
7use base64::Engine;
8use futures::future::BoxFuture;
9use futures::FutureExt;
10use sha2::{Digest, Sha256};
11
12/// User-supplied machine fingerprint source, replacing the native machine id.
13/// The value is still hashed before use.
14#[derive(Clone)]
15pub enum MachineFingerprint {
16    /// Used verbatim as the raw machine value.
17    Static(String),
18    /// Awaited once; its result is the raw machine value.
19    Fn(Arc<dyn Fn() -> BoxFuture<'static, String> + Send + Sync>),
20}
21
22impl MachineFingerprint {
23    /// From an async function.
24    pub fn from_fn<F, Fut>(f: F) -> Self
25    where
26        F: Fn() -> Fut + Send + Sync + 'static,
27        Fut: Future<Output = String> + Send + 'static,
28    {
29        Self::Fn(Arc::new(move || f().boxed()))
30    }
31
32    /// From a sync function.
33    pub fn from_sync_fn<F>(f: F) -> Self
34    where
35        F: Fn() -> String + Send + Sync + 'static,
36    {
37        Self::Fn(Arc::new(move || futures::future::ready(f()).boxed()))
38    }
39}
40
41impl From<String> for MachineFingerprint {
42    fn from(value: String) -> Self {
43        Self::Static(value)
44    }
45}
46
47impl From<&str> for MachineFingerprint {
48    fn from(value: &str) -> Self {
49        Self::Static(value.to_owned())
50    }
51}
52
53impl std::fmt::Debug for MachineFingerprint {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        match self {
56            Self::Static(value) => f.debug_tuple("Static").field(value).finish(),
57            Self::Fn(_) => f.write_str("Fn(..)"),
58        }
59    }
60}
61
62/// Hash a raw machine value into a stable, opaque base64 digest.
63pub(crate) fn hash_machine_value(raw: &str) -> String {
64    STANDARD.encode(Sha256::digest(raw))
65}
66
67/// Resolve the machine component of the fingerprint from the user option,
68/// returning its base64 sha256 digest.
69pub(crate) async fn resolve_machine_fingerprint(option: Option<&MachineFingerprint>) -> String {
70    let raw = match option {
71        Some(MachineFingerprint::Static(value)) => value.clone(),
72        Some(MachineFingerprint::Fn(f)) => f().await,
73        None => read_machine_id().await,
74    };
75    hash_machine_value(&raw)
76}
77
78/// Read the OS-native machine GUID and normalise it to a stable lowercase
79/// string. On failure it falls back to a hardware MAC address and finally the
80/// hostname, so this never fails.
81pub(crate) async fn read_machine_id() -> String {
82    match read_native_machine_id().await {
83        Some(id) => id,
84        None => fallback_machine_id(),
85    }
86}
87
88#[cfg(target_os = "macos")]
89async fn read_native_machine_id() -> Option<String> {
90    let output = tokio::time::timeout(
91        std::time::Duration::from_secs(5),
92        tokio::process::Command::new("ioreg")
93            .args(["-rd1", "-c", "IOPlatformExpertDevice"])
94            .kill_on_drop(true)
95            .output(),
96    )
97    .await
98    .ok()?
99    .ok()?;
100    if !output.status.success() {
101        return None;
102    }
103    parse_darwin(&String::from_utf8_lossy(&output.stdout))
104}
105
106#[cfg(target_os = "windows")]
107async fn read_native_machine_id() -> Option<String> {
108    tokio::task::spawn_blocking(|| {
109        use winreg::enums::{HKEY_LOCAL_MACHINE, KEY_READ, KEY_WOW64_64KEY};
110        let value: String = winreg::RegKey::predef(HKEY_LOCAL_MACHINE)
111            .open_subkey_with_flags(
112                r"SOFTWARE\Microsoft\Cryptography",
113                KEY_READ | KEY_WOW64_64KEY,
114            )
115            .ok()?
116            .get_value("MachineGuid")
117            .ok()?;
118        normalize_id(&value)
119    })
120    .await
121    .ok()
122    .flatten()
123}
124
125#[cfg(not(any(target_os = "macos", target_os = "windows")))]
126async fn read_native_machine_id() -> Option<String> {
127    read_linux_machine_id(&["/etc/machine-id", "/var/lib/dbus/machine-id"]).await
128}
129
130/// `IOPlatformUUID` from `ioreg -rd1 -c IOPlatformExpertDevice` output.
131pub(crate) fn parse_darwin(output: &str) -> Option<String> {
132    let (_, after) = output.split_once("IOPlatformUUID")?;
133    let line = after.split('\n').next().unwrap_or_default();
134    let value: String = line
135        .chars()
136        .filter(|c| *c != '=' && *c != '"' && !c.is_whitespace())
137        .collect::<String>()
138        .to_lowercase();
139    (!value.is_empty()).then_some(value)
140}
141
142/// Whitespace stripped and lowercased; `None` when empty. Used for the
143/// Windows `MachineGuid` and Linux `machine-id` values.
144pub(crate) fn normalize_id(raw: &str) -> Option<String> {
145    let value: String = raw
146        .chars()
147        .filter(|c| !c.is_whitespace())
148        .collect::<String>()
149        .to_lowercase();
150    (!value.is_empty()).then_some(value)
151}
152
153/// First non-empty machine id among `paths`.
154#[cfg_attr(any(target_os = "macos", target_os = "windows"), allow(dead_code))]
155pub(crate) async fn read_linux_machine_id(paths: &[&str]) -> Option<String> {
156    for path in paths {
157        if let Ok(content) = tokio::fs::read_to_string(path).await {
158            if let Some(value) = normalize_id(&content) {
159                return Some(value);
160            }
161        }
162    }
163    None
164}
165
166/// First non-internal, non-zero MAC address, else the hostname. Never fails.
167pub(crate) fn fallback_machine_id() -> String {
168    match mac_address::get_mac_address() {
169        Ok(Some(mac)) if mac.bytes() != [0; 6] => mac.to_string().to_lowercase(),
170        _ => super::environment::hostname().to_lowercase(),
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use std::io::Write;
177
178    use super::*;
179
180    #[tokio::test]
181    async fn hashes_a_verbatim_string() {
182        let option = MachineFingerprint::from("my-machine");
183        assert_eq!(
184            resolve_machine_fingerprint(Some(&option)).await,
185            hash_machine_value("my-machine")
186        );
187        let option = MachineFingerprint::from(String::from("my-machine"));
188        assert_eq!(
189            resolve_machine_fingerprint(Some(&option)).await,
190            hash_machine_value("my-machine")
191        );
192    }
193
194    #[tokio::test]
195    async fn awaits_a_sync_function() {
196        let option = MachineFingerprint::from_sync_fn(|| "sync-value".into());
197        assert_eq!(
198            resolve_machine_fingerprint(Some(&option)).await,
199            hash_machine_value("sync-value")
200        );
201    }
202
203    #[tokio::test]
204    async fn awaits_an_async_function() {
205        let option = MachineFingerprint::from_fn(|| async { "async-value".to_owned() });
206        assert_eq!(
207            resolve_machine_fingerprint(Some(&option)).await,
208            hash_machine_value("async-value")
209        );
210        assert_eq!(format!("{option:?}"), "Fn(..)");
211        assert_eq!(
212            format!("{:?}", MachineFingerprint::from("x")),
213            "Static(\"x\")"
214        );
215    }
216
217    #[test]
218    fn parses_the_macos_io_platform_uuid() {
219        assert_eq!(
220            parse_darwin(
221                "+-o Root\n  \"IOPlatformUUID\" = \"ABCDEF12-3456-7890-ABCD-EF1234567890\"\n  \"x\" = 1\n"
222            )
223            .as_deref(),
224            Some("abcdef12-3456-7890-abcd-ef1234567890")
225        );
226    }
227
228    #[test]
229    fn rejects_a_missing_or_empty_macos_uuid() {
230        assert_eq!(parse_darwin("nothing here"), None);
231        assert_eq!(parse_darwin("\"IOPlatformUUID\" = \"\"\n"), None);
232    }
233
234    #[test]
235    fn normalizes_the_windows_machine_guid() {
236        assert_eq!(
237            normalize_id(" 11112222-3333-4444-5555-666677778888\r\n").as_deref(),
238            Some("11112222-3333-4444-5555-666677778888")
239        );
240        assert_eq!(normalize_id(" \r\n"), None);
241    }
242
243    #[tokio::test]
244    async fn reads_the_linux_machine_id_file() {
245        let mut file = tempfile::NamedTempFile::new().unwrap();
246        writeln!(file, "DEADBEEFdeadbeefdeadbeefdeadbeef").unwrap();
247        let path = file.path().to_str().unwrap().to_owned();
248        assert_eq!(
249            read_linux_machine_id(&[&path]).await.as_deref(),
250            Some("deadbeefdeadbeefdeadbeefdeadbeef")
251        );
252    }
253
254    #[tokio::test]
255    async fn tries_the_dbus_path_when_etc_machine_id_is_empty() {
256        let mut empty = tempfile::NamedTempFile::new().unwrap();
257        writeln!(empty, "   ").unwrap();
258        let mut dbus = tempfile::NamedTempFile::new().unwrap();
259        writeln!(dbus, "cafecafecafecafecafecafecafecafe").unwrap();
260        let paths = [
261            empty.path().to_str().unwrap().to_owned(),
262            dbus.path().to_str().unwrap().to_owned(),
263        ];
264        assert_eq!(
265            read_linux_machine_id(&[&paths[0], &paths[1]])
266                .await
267                .as_deref(),
268            Some("cafecafecafecafecafecafecafecafe")
269        );
270    }
271
272    #[tokio::test]
273    async fn returns_none_when_no_linux_machine_id_file_is_readable() {
274        assert_eq!(
275            read_linux_machine_id(&["/nonexistent/machine-id"]).await,
276            None
277        );
278    }
279
280    #[test]
281    fn falls_back_to_a_mac_address_or_the_hostname() {
282        let value = fallback_machine_id();
283        assert!(!value.is_empty());
284        assert_eq!(value, value.to_lowercase());
285        // Fallback is a MAC address or hostname, never the digest form.
286        assert!(!value.ends_with('='));
287    }
288
289    #[tokio::test]
290    async fn reads_a_native_machine_id_without_failing() {
291        let value = read_machine_id().await;
292        assert!(!value.is_empty());
293        assert_eq!(value, value.to_lowercase());
294        // Cached-independent: the same value on every call.
295        assert_eq!(read_machine_id().await, value);
296    }
297
298    #[test]
299    fn golden_machine_hash() {
300        assert_eq!(
301            hash_machine_value("4c4c4544-0042-3510-8051-b4c04f4b4e32"),
302            "WDpaNFX/NZW0yp1UEWsHi36rKjpX4d0zVnasSsrpJOA="
303        );
304    }
305
306    #[test]
307    fn hash_is_a_stable_base64_sha256_digest() {
308        assert_eq!(
309            hash_machine_value("x"),
310            STANDARD.encode(Sha256::digest("x"))
311        );
312        assert_eq!(hash_machine_value("x").len(), 44);
313    }
314}