""" 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///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