Files
intercept/utils/bluetooth/irk_extractor.py
Smittix d8d08a8b1e feat: Add BT Locate and GPS modes with IRK auto-detection
New modes:
- BT Locate: SAR Bluetooth device location with GPS-tagged signal trail,
  RSSI-based proximity bands, audio alerts, and IRK auto-extraction from
  paired devices (macOS plist / Linux BlueZ)
- GPS: Real-time position tracking with live map, speed, heading, altitude,
  satellite info, and track recording via gpsd

Bug fixes:
- Fix ABBA deadlock between session lock and aggregator lock in BT Locate
- Fix bleak scan lifecycle tracking in BluetoothScanner (is_scanning property
  now cross-checks backend state)
- Fix map tile persistence when switching modes
- Use 15s max_age window for fresh detections in BT Locate poll loop

Documentation:
- Update README, FEATURES.md, USAGE.md, and GitHub Pages with new modes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 21:59:45 +00:00

199 lines
6.1 KiB
Python

"""
IRK Extractor — Extract Identity Resolving Keys from paired Bluetooth devices.
Supports macOS (com.apple.Bluetooth.plist) and Linux (BlueZ info files).
"""
from __future__ import annotations
import logging
import platform
import time
from pathlib import Path
logger = logging.getLogger('intercept.bt.irk_extractor')
# Cache paired IRKs for 30 seconds to avoid repeated disk reads
_cache: list[dict] | None = None
_cache_time: float = 0
_CACHE_TTL = 30.0
def get_paired_irks() -> list[dict]:
"""Return paired Bluetooth devices that have IRKs.
Each entry is a dict with keys:
- name: Device name (str or None)
- address: Bluetooth address (str)
- irk_hex: 32-char hex string of the 16-byte IRK
- address_type: 'random' or 'public' (str or None)
Results are cached for 30 seconds.
"""
global _cache, _cache_time
now = time.monotonic()
if _cache is not None and (now - _cache_time) < _CACHE_TTL:
return _cache
system = platform.system()
try:
if system == 'Darwin':
results = _extract_macos()
elif system == 'Linux':
results = _extract_linux()
else:
logger.debug(f"IRK extraction not supported on {system}")
results = []
except Exception:
logger.exception("Failed to extract paired IRKs")
results = []
_cache = results
_cache_time = now
return results
def _extract_macos() -> list[dict]:
"""Extract IRKs from macOS Bluetooth plist."""
import plistlib
plist_path = Path('/Library/Preferences/com.apple.Bluetooth.plist')
if not plist_path.exists():
logger.debug("macOS Bluetooth plist not found")
return []
with open(plist_path, 'rb') as f:
plist = plistlib.load(f)
devices = []
cache_data = plist.get('CoreBluetoothCache', {})
# CoreBluetoothCache contains BLE device info including IRKs
for device_uuid, device_info in cache_data.items():
if not isinstance(device_info, dict):
continue
irk = device_info.get('IRK')
if irk is None:
continue
# IRK is stored as bytes (16 bytes)
if isinstance(irk, bytes) and len(irk) == 16:
irk_hex = irk.hex()
elif isinstance(irk, str):
irk_hex = irk.replace('-', '').replace(' ', '')
if len(irk_hex) != 32:
continue
else:
continue
name = device_info.get('Name') or device_info.get('DeviceName')
address = device_info.get('DeviceAddress', device_uuid)
addr_type = 'random' if device_info.get('AddressType', 1) == 1 else 'public'
devices.append({
'name': name,
'address': str(address),
'irk_hex': irk_hex,
'address_type': addr_type,
})
# Also check LEPairedDevices / PairedDevices structures
for section_key in ('LEPairedDevices', 'PairedDevices'):
section = plist.get(section_key, {})
if not isinstance(section, dict):
continue
for addr, dev_info in section.items():
if not isinstance(dev_info, dict):
continue
irk = dev_info.get('IRK') or dev_info.get('IdentityResolvingKey')
if irk is None:
continue
if isinstance(irk, bytes) and len(irk) == 16:
irk_hex = irk.hex()
elif isinstance(irk, str):
irk_hex = irk.replace('-', '').replace(' ', '')
if len(irk_hex) != 32:
continue
else:
continue
# Skip if we already have this IRK
if any(d['irk_hex'] == irk_hex for d in devices):
continue
name = dev_info.get('Name') or dev_info.get('DeviceName')
addr_type = 'random' if dev_info.get('AddressType', 1) == 1 else 'public'
devices.append({
'name': name,
'address': str(addr),
'irk_hex': irk_hex,
'address_type': addr_type,
})
logger.info(f"Extracted {len(devices)} IRK(s) from macOS paired devices")
return devices
def _extract_linux() -> list[dict]:
"""Extract IRKs from Linux BlueZ info files.
BlueZ stores paired device info at:
/var/lib/bluetooth/<adapter_mac>/<device_mac>/info
"""
import configparser
bt_root = Path('/var/lib/bluetooth')
if not bt_root.exists():
logger.debug("BlueZ bluetooth directory not found")
return []
devices = []
for adapter_dir in bt_root.iterdir():
if not adapter_dir.is_dir():
continue
for device_dir in adapter_dir.iterdir():
if not device_dir.is_dir():
continue
info_file = device_dir / 'info'
if not info_file.exists():
continue
config = configparser.ConfigParser()
try:
config.read(str(info_file))
except (configparser.Error, OSError):
continue
if not config.has_section('IdentityResolvingKey'):
continue
irk_hex = config.get('IdentityResolvingKey', 'Key', fallback=None)
if not irk_hex:
continue
# BlueZ stores as hex string, may or may not have separators
irk_hex = irk_hex.replace(' ', '').replace('-', '')
if len(irk_hex) != 32:
continue
name = config.get('General', 'Name', fallback=None)
address = device_dir.name # Directory name is the MAC address
addr_type = config.get('General', 'AddressType', fallback=None)
devices.append({
'name': name,
'address': address,
'irk_hex': irk_hex,
'address_type': addr_type,
})
logger.info(f"Extracted {len(devices)} IRK(s) from BlueZ paired devices")
return devices