How is the IPv6 link-local address calculated?
I was told that an IPv6 link-local address — typically starting with fe80::
— is derived from the interface's MAC address. Here are some instruction on how to do the conversion.
But the conversion result doesn't match my actual IPv6 link-local address according to ip a
. My IPv6 link-local addresses and MAC addresses don't seem related at all.
How is the IPv6 link-local address really calculated?
I'm on Arch Linux using NetworkManager 1.42.6.
1 answer
The following users marked this post as Works for me:
User | Comment | Date |
---|---|---|
Matthias Braun | (no comment) | Dec 12, 2023 at 22:57 |
It's NetworkManager that sets the IPv6 link-local address and per default it doesn't use the conversion from MAC to IPv6 address of that converter, called modified EUI-64. As mentioned in the linked Wikipedia article, EUI-64 is deprecated for privacy and security reasons.
Instead of EUI-64, NetworkManager commonly uses the "stable-privacy" address generation mode for IPv6 link-local addresses. The rules for the default value of the address generation mode depend on which component configured the connection. If the connection was created with a tool like nm-connection-editor
, the default is "stable-privacy".
From the documentation:
The value of stable-privacy enables use of cryptographically secure hash of a secret host-specific key along with the connection's stable-id and the network address as specified by RFC7217.
You can check which mode you're using in nm-connection-editor
, going to the "IPv6 Settings" tab of your connection:
You can also use nmcli
:
nmcli -g ipv6.addr-gen-mode connection show YourConnection
If you get the value "default", consult the global NetworkManager config file, commonly at /etc/NetworkManager/NetworkManager.conf
. If there's no value for ipv6.addr-gen-mode
, then "stable privacy" is used.
How does NetworkManager calculate the stable privacy link-local address?
When digging into NetworkManager's source code or debugging NetworkManager, we can see that the function nm_utils_ipv6_addr_set_stable_privacy_with_host_id
calculates the link-local address by hashing this data using SHA-256:
- The network prefix which are the first 8 bytes of the link-local address. For example
fe80000000000000
- The interface name, for example "enp0s25"
- The network ID. See the IDs of your networks with
nmcli -g name,uuid con
- A count starting at zero that's incremented if the generated link-local address turns out to be already in use on the network. See Duplicate Address Detection for details.
- The host ID length.
- The host ID which is either: The contents of
/var/lib/NetworkManager/secret_key
. Or the SHA-256 hash of, essentially,/var/lib/NetworkManager/secret_key
and/etc/machine_id
The first 8 bytes of this hash are then appended to the network prefix which is commonly fe80::
to get the link-local address.
This conforms to the algorithm specified in RFC 7217.
The following shell script attempts to reimplement NetworkManager's calculation for the common case (there are also code paths in NetworkManager for situations where /var/lib/NetworkManager/secret_key
or /etc/machine_id
don't exist on the machine). It assumes that the network prefix for the link-local address is fe80::
.
#!/bin/bash
set -o nounset
# Returns the host ID in plain hexdump style ("ab01cdefa8").
get_host_id_hex(){
local -r secret_key_file="$1";
# File must be readable
if [ -r "$secret_key_file" ]; then
if [ "$(head --bytes=5 "$secret_key_file")" = "nm-v2" ]; then
# Secret key file is version 2
# Adapted from function "_host_id_hash_v2" in "nm-core-utils.c"
(
# Get the file size in bytes
stat -c '%s' "$secret_key_file" | tr -d '\n';
printf ' ';
cat "$secret_key_file";
tr -d '\n' < /etc/machine-id;
) \
| sha256sum \
| awk '{print $1}'
else
# Secret key file is version 1
# xxd produces line breaks after a certain amount of characters → cols 0 removes those
xxd -p -cols 0 "$secret_key_file"
fi
else
>&2 echo "Error: Secret key file \"$secret_key_file\" is not readable. Try again as root.";
fi
}
# First and only argument is a plain hexadecimal string representation of bytes such as "a0eef9" wich is converted to actual bytes.
as_bytes(){
printf '%s' "$1" | xxd -r -p;
}
# Shortens an IPv6 address.
shorten_ip_address() {
expanded_ip="$1"
# Rule 1: Remove leading zeros in each hextet (two-byte group separated by colon)
# "0000:" → "0:"
# "0a00:" → "a00:"
output=$(sed -E 's/(^|:)0{1,3}/\1/g' <<< "$expanded_ip");
# After rule 1, the address is shortened to, e.g., "0:ab:cdef:0:0:a".
# A zero-group of hextets is, for example, ":0:0".
# Replace the largest group of zero hextets with a double colon.
# If two zero-groups are equal in length, replace the first one.
# A single zero-group such as ":0" in "abc:0" is not replaced with "::",
# hence we use "count>=2"
for ((count=8; count>=2; count--)); do
rule_2_output=$(sed -E "s/(^0|:0){$count}:?/::/" <<< "$output");
if [ "$rule_2_output" != "$output" ]; then
output="$rule_2_output"
break;
fi
done
echo "$output"
}
# Takes a plain hex dump ("abcd0189") and produces the number of bytes it represents.
nr_of_bytes_in_hexdump(){
local -r hexdump="$1";
local -r hexdump_nr_of_chars=$(printf '%s' "$hexdump" | wc --chars);
# Two hex digits represent one byte
printf '%s' "$(( "$hexdump_nr_of_chars" / 2 ))";
}
# Converts a decimal number to a 4-byte big-endian integer in hex.
# E.g., Input: 32 → Output: "00000020"
to_4_byte_big_endian(){
# Convert to hex and left-pad with zeros to 8 characters for 4 bytes
printf '%08x' "$1"
}
calculate_address() {
local -r conn_name="$1";
echo "Connection is \"$conn_name\"";
local -r network_id=$(nmcli -g connection.uuid con show "$conn_name");
echo "Network ID is $network_id";
local -r ifname=$(nmcli -g general.ip-iface con show "$conn_name");
echo "Interface name is $ifname";
# First eight bytes of LL address
local -r network_prefix="fe80000000000000";
echo "Assuming network prefix of $network_prefix"
# Duplicate address detection counter. We're not doing DAD and it won't be incremented
local -r dad_counter_hex=$(to_4_byte_big_endian 0);
local -r secret_key_file="/var/lib/NetworkManager/secret_key";
echo "Secret key file is $secret_key_file";
local -r host_id_hex=$(get_host_id_hex "$secret_key_file");
if [ -n "$host_id_hex" ]; then
printf 'Host ID: %s\n' "$host_id_hex";
local -r nr_of_bytes_in_host_id_hex=$(to_4_byte_big_endian $(nr_of_bytes_in_hexdump "$host_id_hex"));
printf 'Nr of bytes in host ID (hex): %s\n' "$nr_of_bytes_in_host_id_hex";
local -r last_eight_bytes_of_address=$(
(
as_bytes "$network_prefix";
printf '%s\0' "$ifname";
printf '%s\0' "$network_id";
as_bytes "$dad_counter_hex";
as_bytes "$nr_of_bytes_in_host_id_hex";
as_bytes "$host_id_hex";
) \
| sha256sum \
| head -c 16 # Get the first 16 characters of the hash which represent 8 bytes
);
# Add colons between hextets (two-byte groups)
local -r expanded_ip_addr=$(sed -E 's/.{4}/\0:/g; s/:$//' <<< "$network_prefix""$last_eight_bytes_of_address");
shorten_ip_address "$expanded_ip_addr"
fi
}
main(){
echo "Calculate the IPv6 stable privacy link-local address, as NetworkManager does it";
if (( $# == 0 ));then
echo "No argument provided → Using first active connection"
local -r conn_name=$(nmcli --mode multiline con show --active | head -n1 | cut -d ':' -f2- | sed -e 's/^[[:space:]]*//')
elif (( $# == 1 )); then
local -r conn_name="$1"
else
>&2 echo "Usage: Call this script without arguments to get the link-local address of the first active NetworkManager connection. Optionally, pass a connection name as the first argument. See all available connections with \"nmcli con\""
exit 1
fi
calculate_address "$conn_name"
}
main "$@"; exit
The script requires root since it reads /var/lib/NetworkManager/secret_key
. To (programmatically) compare the calculated address to the address created by NetworkManager, you can get your current link-local address with
ip -json -6 a show enp0s25 scope link | jq -r '.[0].addr_info[1].local'
You might want to change enp0s25
to the interface name from which you want to query the link-local address.
If you don't use NetworkManager for adding IP addresses to interfaces, see this answer.
1 comment thread