sync_bluetooth.sh
1#!/bin/sh
2#
3# This script updates Bluetooth configuration to use the same device (mouse/keyboard) on both Windows and Linux.
4# It is based on these recommendations: https://wiki.archlinux.org/title/Bluetooth.
5# It works specifically for Logitech Pebble mouse on Linux Mint and it may or may not work for you.
6# It should be relatively easy to add other devices though.
7#
8# Usage:
9#
10# > ./sync_bluetooth.sh [media_path]
11#
12# Script tries to find Windows registry in mounted volumes (/media by default).
13# It finds matching configurations in /var/lib/bluetooth (MAC address may be slightly different
14# so it ignores the last byte), updates the configuration according to this table and restarts
15# the bluetooth service. Previous configuration is saved to info.backup.
16# To try it before using, use DRY_RUN environment variable:
17#
18# > DRY_RUN=1 ./sync_bluetooth.sh [media_path]
19#
20
21MEDIA_PATH=${1:-/media}
22KEYS_PATH=ControlSet001\\Services\\BTHPORT\\Parameters\\Keys
23BT_CONFIG_PATH=/var/lib/bluetooth
24
25# =============== Utils ===============
26
27find_sys32() {
28 while [ "$#" -gt 0 ]; do
29 current="$1"
30 shift
31
32 win_dir="$current/Windows"
33 sys32_dir="$win_dir/System32"
34
35 if [ -d "$sys32_dir" ]; then
36 echo "$sys32_dir"
37 exit 0
38 fi
39
40 if [ -r "$current" ] && [ -x "$current" ]; then
41 for sub in "$current"/*; do
42 if [ -d "$sub" ] && [ -x "$sub" ]; then
43 # Append to the end of the queue
44 set -- "$@" "$sub"
45 fi
46 done
47 fi
48 done
49 exit 1
50}
51
52exec_reg_command() {
53 REG_PATH=$1
54 CMD=$2
55
56 chntpw -e "$REG_PATH" << EOF
57$CMD
58q
59EOF
60}
61
62list_reg_keys() {
63 REG_PATH=$1
64 KEY_PATH=$2
65
66 OUTPUT=$(exec_reg_command "$REG_PATH" "ls $KEY_PATH")
67
68 echo "$OUTPUT" | awk '
69 BEGIN { in_keys = 0 }
70 / key name/ { in_keys = 1; next }
71 in_keys {
72 if ($0 ~ /^([[:space:]]*|[[:space:]]*size[[:space:]].*)$/) exit
73 if ($0 ~ /<.*>/) {
74 gsub(/[<>[:space:]]/, "", $0)
75 print $0
76 }
77 }
78'
79}
80
81read_reg_value() {
82 REG_PATH=$1
83 KEY_PATH=$2
84
85 OUTPUT=$(exec_reg_command "$REG_PATH" "hex $KEY_PATH")
86
87 echo "$OUTPUT" | awk '
88 /^(> )?Value .* \[0x[0-9a-fA-F]+\]$/ {
89 match($0, /\[0x[0-9a-fA-F]+\]/)
90 hexlen = substr($0, RSTART+3, RLENGTH-4)
91 bytelen = strtonum("0x" hexlen)
92 getline
93 # Extract hex bytes from column 2 onward
94 n = split($0, fields)
95 hex = ""
96 count = 0
97 for (i = 2; i <= n && count < bytelen; i++) {
98 if (fields[i] ~ /^[0-9A-Fa-f]{2}$/) {
99 hex = hex fields[i]
100 count++
101 }
102 }
103 print hex
104 exit
105 }
106'
107}
108
109mac_to_hex() {
110 echo "$1" | tr '[:upper:]' '[:lower:]' | tr -d ':'
111}
112
113hex_to_mac() {
114 echo "$1" | sed 's/../&:/g;s/:$//' | tr '[:lower:]' '[:upper:]'
115}
116
117find_matching_keys() {
118 KEYS=$1
119 BT_DEVICES=$2
120 for KEY in $KEYS; do
121 KEY_PREFIX=$(echo "$KEY" | cut -c1-10)
122 for BT in $BT_DEVICES; do
123 BT_CLEAN=$(mac_to_hex "$BT" | cut -c1-10)
124 if [ "$KEY_PREFIX" = "$BT_CLEAN" ]; then
125 echo "${KEY}_${BT}"
126 fi
127 done
128 done
129}
130
131reverse_hex_bytes() {
132 while read -r HEX; do
133 echo "$HEX" | sed 's/../& /g' | awk '{
134 for (i=NF; i>=1; i--) printf "%s", $i;
135 print "";
136 }'
137 done
138}
139
140reverse_mac_bytes() {
141 while read -r MAC; do
142 echo "$MAC" | awk -F: '{
143 for (i=NF; i>=1; i--) {
144 printf "%s", toupper($i);
145 if (i > 1) printf ":";
146 }
147 print "";
148 }'
149 done
150}
151
152hex_to_dec() {
153 while read -r HEX; do
154 awk "BEGIN { printf \"%0.f\n\", 0x$HEX }"
155 done
156}
157
158dec_to_hex() {
159 while read -r DEC; do
160 printf "%x\n" "$DEC"
161 done
162}
163
164read_from_config() {
165 FILE="$1"
166 SECTION="$2"
167 key="$3"
168
169 sudo awk -v target_section="$SECTION" -v target_key="$key" '
170 BEGIN { in_section=0 }
171 {
172 if ($0 == target_section) {
173 in_section=1
174 next
175 }
176 if (in_section) {
177 if ($0 ~ /^\[.*\]$/) {
178 in_section=0
179 next
180 }
181 if ($0 ~ "^" target_key "=") {
182 split($0, parts, "=")
183 print parts[2]
184 exit
185 }
186 }
187 }
188 ' "$FILE"
189}
190
191write_to_config() {
192 FILE="$1"
193 SECTION="$2"
194 KEY="$3"
195 VALUE="$4"
196
197 # Use a temporary file for safe in-place editing
198 TMP_FILE=$(mktemp) || exit 1
199
200 TEXT=$(sudo awk -v target_section="$SECTION" -v target_key="$KEY" -v new_value="$VALUE" '
201 BEGIN {
202 in_target_section = 0
203 found_section = 0
204 key_found = 0
205 key_added = 0
206 }
207
208 # Match section headers
209 /^\[.*\]$/ {
210 # If we were in target section and key wasnt found/added, add it now
211 if (in_target_section && !key_found && !key_added) {
212 print target_key "=" new_value
213 key_added = 1
214 }
215
216 # Check if this is our target section
217 if ($0 == target_section) {
218 in_target_section = 1
219 found_section = 1
220 } else {
221 in_target_section = 0
222 }
223 print $0
224 next
225 }
226
227 # Process lines within sections
228 {
229 if (in_target_section) {
230 # Check if this is an empty line and we need to add the key
231 if ($0 == "" && !key_found && !key_added) {
232 print target_key "=" new_value
233 key_added = 1
234 print $0
235 }
236 # Check if line contains a key=value pair
237 else if (match($0, /^[^=]+=/)) {
238 # Extract key (everything before first =)
239 split($0, parts, "=")
240 line_key = parts[1]
241
242 if (line_key == target_key) {
243 # Replace the value
244 print target_key "=" new_value
245 key_found = 1
246 key_added = 1
247 } else {
248 print $0
249 }
250 } else {
251 print $0
252 }
253 } else {
254 print $0
255 }
256 }
257
258 END {
259 # If we found the section but never added the key, add it at the end
260 if (found_section && !key_added) {
261 print target_key "=" new_value
262 }
263
264 # Set exit code based on whether section was found
265 if (!found_section) {
266 exit 1
267 }
268 }
269 ' "$FILE")
270
271 echo "$TEXT" > "$TMP_FILE"
272 echo "$SECTION: setting $KEY=$VALUE"
273
274 if [ -z "$DRY_RUN" ]; then
275 sudo mv "$TMP_FILE" "$FILE"
276 sudo chown root:root "$FILE"
277 else
278 rm "$TMP_FILE"
279 fi
280}
281
282select_device_key() {
283 DEVICE_KEYS=$1
284 BT_HOST_CONFIG_PATH=$2
285 echo "Matching devices:"
286 echo "================="
287
288 # Convert space-separated string to indexed list
289 i=1
290 for KEY in $DEVICE_KEYS; do
291 HEX=$(echo "$KEY" | cut -d "_" -f 1)
292 MAC=$(echo "$KEY" | cut -d "_" -f 2)
293 DEVICE_NAME=$(read_from_config "$BT_HOST_CONFIG_PATH/$MAC/info" "[General]" "Name")
294 echo "$i) $DEVICE_NAME ($HEX => $MAC)"
295 i=$((i + 1))
296 done
297
298 if [ $((i - 1)) -gt 1 ]; then
299 printf "Please select a device (1-%d) [1]: " $((i - 1))
300 else
301 printf "Please select a device [1]: "
302 fi
303 read -r choice
304
305 if [ -z "$choice" ]; then
306 choice="1"
307 fi
308
309 # Validate input
310 if ! echo "$choice" | grep -q '^[0-9]\+$'; then
311 echo "Error: Please enter a valid number."
312 return 1
313 fi
314
315 if [ "$choice" -lt 1 ] || [ "$choice" -ge "$i" ]; then
316 echo "Error: Please enter a number between 1 and $((i - 1))."
317 return 1
318 fi
319
320 # Get the selected key
321 j=1
322 for KEY in $DEVICE_KEYS; do
323 if [ "$j" -eq "$choice" ]; then
324 SELECTED_KEY="$KEY"
325 break
326 fi
327 j=$((j + 1))
328 done
329
330 echo "You selected: $(echo "$SELECTED_KEY" | cut -d " " -f 1)"
331 return 0
332}
333
334save_backup() {
335 FILE=$1
336 if [ -z "$DRY_RUN" ]; then
337 sudo cp "$FILE" "$FILE.backup"
338 echo "Backup saved to $FILE.backup"
339 fi
340}
341
342# =============== Script starts here ===============
343
344WINDOWS_SYS32_PATH=$(find_sys32 "$MEDIA_PATH")
345
346if [ -z "$WINDOWS_SYS32_PATH" ]; then
347 echo "No Windows registry directories found in $MEDIA_PATH"
348 echo "Check that Windows volume is mounted"
349 exit 1
350fi
351
352SYSTEM_PATH="$WINDOWS_SYS32_PATH/config/SYSTEM"
353
354if ! command -v chntpw > /dev/null; then
355 apt install chntpw
356fi
357
358echo "Accessing $SYSTEM_PATH..."
359echo ""
360
361KEYS=$(list_reg_keys "$SYSTEM_PATH" "$KEYS_PATH")
362
363if ! sudo -n true 2>/dev/null; then
364 echo "Note: We need elevated privileges to read Bluetooth data from /var/lib/bluetooth."
365fi
366
367HOST_MACS=$(sudo ls "$BT_CONFIG_PATH")
368
369MATCHING_PAIRS=$(find_matching_keys "$KEYS" "$HOST_MACS")
370
371if [ -z "$MATCHING_PAIRS" ]; then
372 echo "No host matches found :("
373 exit 1
374fi
375
376HOST_HEX=$(echo "$MATCHING_PAIRS" | head -1 | cut -d "_" -f 1)
377HOST_MAC=$(echo "$MATCHING_PAIRS" | head -1 | cut -d "_" -f 2)
378
379echo "Found matching host MAC: $HOST_MAC ($HOST_HEX)"
380
381BT_DEVICE_KEYS=$(list_reg_keys "$SYSTEM_PATH" "$KEYS_PATH\\$HOST_HEX")
382
383BT_HOST_CONFIG_PATH="$BT_CONFIG_PATH/$HOST_MAC"
384
385BT_DEVICES=$(sudo ls "$BT_HOST_CONFIG_PATH")
386
387MATCHING_PAIRS=$(find_matching_keys "$BT_DEVICE_KEYS" "$BT_DEVICES")
388
389if [ -z "$MATCHING_PAIRS" ]; then
390 echo "No device matches found :("
391 exit 1
392fi
393
394if ! select_device_key "$MATCHING_PAIRS" "$BT_HOST_CONFIG_PATH"; then
395 echo "No suitable device were selected, quitting..."
396 exit 0
397fi
398
399BT_DEVICE_HEX=$(echo "$SELECTED_KEY" | cut -d "_" -f 1)
400OLD_BT_DEVICE_MAC=$(echo "$SELECTED_KEY" | cut -d "_" -f 2)
401
402DEVICE_NAME=$(read_from_config "$BT_HOST_CONFIG_PATH/$OLD_BT_DEVICE_MAC/info" "[General]" "Name")
403echo "Found matching device: $BT_DEVICE_HEX => $OLD_BT_DEVICE_MAC ($DEVICE_NAME)"
404
405BT_DEVICE_MAC=$(hex_to_mac "$BT_DEVICE_HEX")
406
407BT_DEVICE_CONFIG_PATH="$BT_HOST_CONFIG_PATH/$BT_DEVICE_MAC"
408OLD_BT_DEVICE_CONFIG_PATH="$BT_HOST_CONFIG_PATH/$OLD_BT_DEVICE_MAC"
409
410if ! [ "$OLD_BT_DEVICE_MAC" = "$BT_DEVICE_MAC" ]; then
411 if [ -z "$DRY_RUN" ]; then
412 sudo cp -r "$OLD_BT_DEVICE_CONFIG_PATH" "$BT_DEVICE_CONFIG_PATH"
413 else
414 BT_DEVICE_CONFIG_PATH=$OLD_BT_DEVICE_CONFIG_PATH
415 fi
416fi
417
418INFO_FILE="$BT_DEVICE_CONFIG_PATH/info"
419
420DEVICE_REG_PATH="$KEYS_PATH\\$HOST_HEX\\$BT_DEVICE_HEX"
421
422ERAND=$(read_reg_value "$SYSTEM_PATH" "$DEVICE_REG_PATH\\ERand")
423LTK=$(read_reg_value "$SYSTEM_PATH" "$DEVICE_REG_PATH\\LTK")
424EDIV=$(read_reg_value "$SYSTEM_PATH" "$DEVICE_REG_PATH\\EDIV")
425IRK=$(read_reg_value "$SYSTEM_PATH" "$DEVICE_REG_PATH\\IRK")
426
427case "$DEVICE_NAME" in
428 # ===========================================================================
429 # Add your device here
430 # See https://wiki.archlinux.org/title/Bluetooth#Preparing_Bluetooth_5.1_Keys
431 # for instructions for your device
432 # ===========================================================================
433
434 "Logitech Pebble")
435
436 save_backup "$INFO_FILE"
437 write_to_config "$INFO_FILE" "[IdentityResolvingKey]" "Key" "$(echo "$IRK" | reverse_hex_bytes)"
438 write_to_config "$INFO_FILE" "[LongTermKey]" "Key" "$LTK"
439 write_to_config "$INFO_FILE" "[LongTermKey]" "EDiv" "$(echo "$EDIV" | reverse_hex_bytes | hex_to_dec)"
440 write_to_config "$INFO_FILE" "[LongTermKey]" "Rand" "$(echo "$ERAND" | reverse_hex_bytes | hex_to_dec)"
441
442 ;;
443
444 *)
445 echo "Unknown device: $DEVICE_NAME"
446 exit 1
447 ;;
448esac
449
450if [ -z "$DRY_RUN" ]; then
451 echo "Restarting bluetooth service..."
452 sudo systemctl restart bluetooth
453
454 if ! [ "$OLD_BT_DEVICE_CONFIG_PATH" = "$BT_DEVICE_CONFIG_PATH" ]; then
455 echo "Device configuration was moved from $OLD_BT_DEVICE_CONFIG_PATH to $BT_DEVICE_CONFIG_PATH."
456 printf "Do you want to remove old configuration? [Y/n]"
457 read -r input
458 if [ -z "$input" ] || [ "$input" = "y" ] || [ "$input" = "Y" ]; then
459 echo "Removing old configuration files..."
460 sudo rm -rf "$OLD_BT_DEVICE_CONFIG_PATH"
461 fi
462 fi
463fi