mirror of
https://github.com/smittix/intercept.git
synced 2026-07-06 00:28:12 -07:00
96172ca593
First-time run of ruff-format via pre-commit hook normalises quote style, trailing commas, and whitespace across 188 Python files. No logic changes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
205 lines
6.2 KiB
Python
205 lines
6.2 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
|