Historically, there are multiple ways of permanently identifying a user on Linux. Probably the most stable and easy way is querying the Machine ID. In Portable 20, we have gone though many ways to limit tracking via said method to a great extent.

What Machine ID really is

To quote freedesktop.org:

The machine ID is usually generated from a random source during system installation or first boot and stays constant for all subsequent boots. Optionally, for stateless systems, it is generated during runtime during early boot if necessary.

Something IS not looking good: the ID is generated during installation on normal distributions and it seemly does not reset. Adding more insult to injury, the documentation explicitly states that:

This ID uniquely identifies the host.

As this is generally considered unique (they use UUID v4), this drastically lowers the effort to track a user across different applications and even persists through data wipes.

Ways to get it

Filesystem

If you read through the linked document, it should be clear that /etc/machine-id holds the filesystem copy written by systemd-machine-id-commit.service. But /var/lib/dbus/machine-id also holds a copy of D-Bus Machine ID. These are the two primary sources of Machine ID fingerprinting for the filesystem side. Fortunately, Portable presents an empty /var for the sandboxed application, so there is one less exposure point for us to worry about.

Inter-process communication

You might be amazed that D-Bus inter process communication is another source to identify a Machine ID. But the D-Bus specification defines the org.freedesktop.DBus.Peer interface. It implements a method called GetMachineId which is expected to yield either /var/lib/dbus/machine-id or /etc/machine-id. The issue is that many D-Bus services are required for applications to function correctly, such as the Desktop Portal. Furthermore, granting a basic SEE policy or TALK policy implicitly allows calling GetXXX methods to obtain peer information. This is problematic for a number of cases: we did use the SEE policy for A11y bus to work, and several places like the Document Portal has TALK. Moreover, it is insufficient to restrict object path like what we did before. Because Peer interface is located in literally every accessible object path, it requires filtering on the interface or even method level.

A convenient script can be used to test this issue:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#!/usr/bin/bash

# Query org.freedesktop.DBus to get all registered bus names
names=$(dbus-send \
--session \
--print-reply \
--dest=org.freedesktop.DBus \
/org/freedesktop/DBus \
org.freedesktop.DBus.ListNames 2>/dev/null | \
awk -F'"' '/string/ {print $2}')

printf "\n%-45s %s\n" "SERVICE NAME" "MACHINE ID"
printf "%-45s %s\n" "---------------------------------------------" "--------------------------------"

# Loop through each name and attempt to call Peer.GetMachineId
for name in $names; do
machine_id=$(dbus-send \
--session \
--print-reply \
--reply-timeout=1000 \
--dest="$name" \
/ \
org.freedesktop.DBus.Peer.GetMachineId 2>/dev/null | \
awk -F'"' '/string/ {print $2}')

if [[ -n "$machine_id" ]]; then
printf "%-45s %s\n" "$name" "$machine_id"
else
printf "%-45s %s\n" "$name" "[N/A or Timeout]"
fi
done

Should you run this script on host or in legacy Portable, lots of unique names would return a specific uniquely identifiable Machine ID.

How do we dodge from this

First of all, when we did a full rewrite of Portable this summer, Rust traits played an important role in D-Bus rule generation. The BusAccessLevel dictates a single rule in XDG D-Bus Proxy, defined as follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
The BusAccessLevel enum is used to define a rule on which sandboxed application is allowed
to communicate with outside applications.

It is only for one bus name, thus the final rule would be vector of them.
*/
#[derive(Debug)]
pub enum BusAccessLevel {
/**
Allow the sandboxed app to take ownership of said bus name.
Very dangerous as it allows app to impersonate other services.
*/
OwnName {
bus_name: BusName,
},

/**
Allow a sandboxed process to call certain methods on certain object paths
*/
Call {
bus_name: BusName,
/**
"method" may be quite misleading, but it actually maps to

            interface name [.] method name
            
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

A .* suffix may be allowed.
*/
method: String,

/**
possible with a `/ *` suffix
*/
object_path: String,
},

/**
Allows a sandboxed process to receive broadcasts from outside
*/
GetBroadcast {
bus_name: BusName,
/**
"method" may be quite misleading, but it actually maps to

interface name [.] method name
1
2
3
4
5
6
7
8
9
10
11

A .* suffix may be allowed.
*/
method: String,

/**
possible with a `/ *` suffix
*/
object_path: String,
},
}

These rules can then be translated into xdg-dbus-proxy arguments via the ToCmdline trait we mentioned in the last article. It is quite obvious that we stripped SEE and TALK policy from the list because that would definitely cause a machine ID leak. But this is not enough, as improperly written Call rules could still leak Machine ID if they don’t filter interface properly. As such, we started a snooping campaign – that is, searching for every piece of IPC documentation that is involved with the sandbox, and sniffing D-Bus traffic in the same time. As of today, the vast majority of D-Bus rules are fully hardened to prevent such leaks. In fact, if you are using GNOME with iBus, it should now be impossible to obtain Machine ID from D-Bus IPC.

But wait, what about the physical file on disk? Some applications won’t even function without it! Which is why I’m sliding in the system subsystem (no pun intended :p). You see, we already have some interesting modules under the subsystem, specifically nsswitch for a transiently generated /etc/nsswitch.conf file to mitigate socket exposure, passwd for hiding non-related users in /etc/passwd. It would not be inappropriate to create another module, machine_id.rs into this subsystem, whose primary job is to asynchronously bind and generate the per-app Machine ID if missing.

Let’s head back to the documentation. It states that “starting with systemd v30, newly generated machine IDs do qualify as Variant 1 Version 4 UUIDs, as per RFC 4122”. Thus we just need the uuid crate with the relevant feature v4 enabled, and plug it in:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
/**
Generates or retrieves the machine-id from a file

The default location to store which file is at XDG_CONFIG_HOME/portable/sandbox_id/machine-id
*/
pub async fn bind(
config: std::sync::Arc<crate::config::Config>,
xdg: std::sync::Arc<crate::xdg::XdgDirs>,
)
-> Result<crate::bind::types::BindRules, super::SystemBindError> {
let path = {
let mut path = xdg.config_home.to_path_buf();
path.push("portable");
path.push(&config.metadata.sandbox_id);
path.push("machine-id");
path
};

exist_or_generate_id(&path)
.await
?;

use crate::bind::types::BindRule;

Ok(
vec![
BindRule::Path {
source: path,
dest: "/etc/machine-id".into(),
class: crate::bind::types::BindType::ReadOnly,
}
]
)

}

/**
This function accepts a give file path, and verifies that the file actually is present.

If it does not exist,
it is expected to generate machine-id then write to that specific file for future use.
*/
async fn exist_or_generate_id(path: &std::path::PathBuf) -> Result<(), super::SystemBindError> {
if tokio::fs::try_exists(&path).await.map_err(super::SystemBindError::IOError)? {
Ok(())
} else {
let uuid = generate_id();

let mut file = {
let parent = match path.parent() {
Some(v) => v,
None => {
return Err(
super::SystemBindError::NoneParentForMachineID,
);
}
};

tokio::fs::create_dir_all(&parent)
.await
.map_err(super::SystemBindError::IOError)
?;

tokio::fs::OpenOptions::new()
.read(false)
.write(true)
.create_new(true)
.mode(0o700)
.open(&path)
.await
.map_err(super::SystemBindError::IOError)
?
};

use tokio::io::AsyncWriteExt;

file
.write(
uuid
.as_bytes()
)
.await
.map_err(super::SystemBindError::IOError)
?;
Ok(())
}
}

/**
Generates a version 4 UUID to be used as machine-id
*/
fn generate_id() -> String {
let uuid = uuid::Uuid::new_v4();

uuid.
simple()
.encode_lower(&mut uuid::Uuid::encode_buffer())
.to_string()
}

You’ll notice that this UUID is stored persistently, because some applications may intentionally forget login credentials if the Machine ID is changed. But one can easily reset the per-app Machine ID by simply deleting $XDG_CONFIG_HOME/portable/$sandbox_id/machine-id, or write another desired value. This raises the effort to track users across applications via Machine ID. Ultimately pulling our goals to be a private sandbox closer.