From 0defad71b3e13dff38df00955edbb4e1cce93113 Mon Sep 17 00:00:00 2001 From: Nick Crews Date: Sun, 25 May 2025 16:45:54 -0600 Subject: [PATCH] feat: add plist.py for decrypting the .plist files to FindMyAccessory --- .gitignore | 1 + README.md | 4 +- findmy/__init__.py | 3 +- findmy/__main__.py | 67 ++++++++++++++++++++++++++++++++ findmy/accessory.py | 95 ++++++++++++++++++++++++++++++++++++++++++--- findmy/plist.py | 90 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 252 insertions(+), 8 deletions(-) create mode 100644 findmy/__main__.py create mode 100644 findmy/plist.py diff --git a/.gitignore b/.gitignore index 6abf495..f206d95 100644 --- a/.gitignore +++ b/.gitignore @@ -164,3 +164,4 @@ account.json airtag.plist DO_NOT_COMMIT* .direnv/ +accessories/ diff --git a/README.md b/README.md index dcd893c..e06f85b 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,9 @@ The package can be installed from [PyPi](https://pypi.org/project/findmy/): pip install findmy ``` -For usage examples, see the [examples](examples) directory. Documentation can be found [here](http://docs.mikealmel.ooo/FindMy.py/). +For usage examples, see the [examples](examples) directory. +We are also building out a CLI. Try `python -m findmy` to see the current state of it. +Documentation can be found [here](http://docs.mikealmel.ooo/FindMy.py/). ## Contributing diff --git a/findmy/__init__.py b/findmy/__init__.py index 9cc82e2..4492842 100644 --- a/findmy/__init__.py +++ b/findmy/__init__.py @@ -1,6 +1,6 @@ """A package providing everything you need to work with Apple's FindMy network.""" -from . import errors, keys, reports, scanner +from . import errors, keys, plist, reports, scanner from .accessory import FindMyAccessory from .keys import KeyPair @@ -9,6 +9,7 @@ __all__ = ( "KeyPair", "errors", "keys", + "plist", "reports", "scanner", ) diff --git a/findmy/__main__.py b/findmy/__main__.py new file mode 100644 index 0000000..7ed86d7 --- /dev/null +++ b/findmy/__main__.py @@ -0,0 +1,67 @@ +"""usage: python -m findmy""" # noqa: D400, D415 + +from __future__ import annotations + +import argparse +import logging +from importlib.metadata import version +from pathlib import Path + +from .plist import get_key, list_accessories + + +def main() -> None: # noqa: D103 + parser = argparse.ArgumentParser(prog="findmy", description="FindMy.py CLI tool") + parser.add_argument( + "-v", + "--version", + action="version", + version=version("FindMy"), + ) + parser.add_argument( + "-log-level", + choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], + default="INFO", + help="Set the logging level (default: INFO)", + ) + subparsers = parser.add_subparsers(dest="command", title="commands") + subparsers.required = True + + decrypt_parser = subparsers.add_parser( + "decrypt", + help="Decrypt all the local FindMy accessories to JSON files.", + ) + decrypt_parser.add_argument( + "--out-dir", + type=Path, + default=Path("accessories/"), + help="Output directory for decrypted files (default: accessories/)", + ) + + args = parser.parse_args() + logging.basicConfig(level=args.log_level.upper()) + if args.command == "decrypt": + decrypt_all(args.out_dir) + else: + # This else block should ideally not be reached if subparsers.required is True + # and a default command isn't set, or if a command is always given. + # However, it's good practice for unexpected cases or if the logic changes. + parser.print_help() + parser.exit(1) + + +def decrypt_all(out_dir: str | Path) -> None: + """Decrypt all accessories and save them to the specified directory as JSON files.""" + out_dir = Path(out_dir) + out_dir = out_dir.resolve().absolute() + out_dir.mkdir(parents=True, exist_ok=True) + key = get_key() + accs = list_accessories(key=key) + for acc in accs: + out_path = out_dir / f"{acc.identifier}.json" + acc.to_json(out_path) + print(f"Decrypted accessory: {acc.name} ({acc.identifier})") # noqa: T201 + + +if __name__ == "__main__": + main() diff --git a/findmy/accessory.py b/findmy/accessory.py index f06cd14..d44ef8f 100644 --- a/findmy/accessory.py +++ b/findmy/accessory.py @@ -6,10 +6,12 @@ Accessories could be anything ranging from AirTags to iPhones. from __future__ import annotations +import json import logging import plistlib from abc import ABC, abstractmethod from datetime import datetime, timedelta, timezone +from pathlib import Path from typing import IO, TYPE_CHECKING, overload from typing_extensions import override @@ -18,9 +20,9 @@ from .keys import KeyGenerator, KeyPair, KeyType from .util import crypto if TYPE_CHECKING: - from collections.abc import Generator + from collections.abc import Generator, Mapping -logging.getLogger(__name__) +logger = logging.getLogger(__name__) class RollingKeyPairSource(ABC): @@ -70,6 +72,7 @@ class FindMyAccessory(RollingKeyPairSource): def __init__( # noqa: PLR0913 self, + *, master_key: bytes, skn: bytes, sks: bytes, @@ -90,7 +93,7 @@ class FindMyAccessory(RollingKeyPairSource): self._paired_at: datetime = paired_at if self._paired_at.tzinfo is None: self._paired_at = self._paired_at.astimezone() - logging.warning( + logger.warning( "Pairing datetime is timezone-naive. Assuming system tz: %s.", self._paired_at.tzname(), ) @@ -99,6 +102,21 @@ class FindMyAccessory(RollingKeyPairSource): self._model = model self._identifier = identifier + @property + def master_key(self) -> bytes: + """The private master key.""" + return self._primary_gen.master_key + + @property + def skn(self) -> bytes: + """The SKN for the primary key.""" + return self._primary_gen.initial_sk + + @property + def sks(self) -> bytes: + """The SKS for the secondary key.""" + return self._secondary_gen.initial_sk + @property def paired_at(self) -> datetime: """Date and time at which this accessory was paired with an Apple account.""" @@ -177,9 +195,22 @@ class FindMyAccessory(RollingKeyPairSource): return possible_keys @classmethod - def from_plist(cls, plist: IO[bytes]) -> FindMyAccessory: + def from_plist( + cls, + plist: str | Path | dict | bytes | IO[bytes], + *, + name: str | None = None, + ) -> FindMyAccessory: """Create a FindMyAccessory from a .plist file dumped from the FindMy app.""" - device_data = plistlib.load(plist) + if isinstance(plist, bytes): + # plist is a bytes object + device_data = plistlib.loads(plist) + elif isinstance(plist, (str, Path)): + device_data = plistlib.loads(Path(plist).read_bytes()) + elif isinstance(plist, IO): + device_data = plistlib.load(plist) + else: + device_data = plist # PRIVATE master key. 28 (?) bytes. master_key = device_data["privateKey"]["key"]["data"][-28:] @@ -201,7 +232,44 @@ class FindMyAccessory(RollingKeyPairSource): model = device_data["model"] identifier = device_data["identifier"] - return cls(master_key, skn, sks, paired_at, None, model, identifier) + return cls( + master_key=master_key, + skn=skn, + sks=sks, + paired_at=paired_at, + name=name, + model=model, + identifier=identifier, + ) + + def to_json(self, path: str | Path | None = None) -> dict[str, str | int | None]: + """Convert the accessory to a JSON-serializable dictionary.""" + d = { + "master_key": self._primary_gen.master_key.hex(), + "skn": self.skn.hex(), + "sks": self.sks.hex(), + "paired_at": self._paired_at.isoformat(), + "name": self.name, + "model": self.model, + "identifier": self.identifier, + } + if path is not None: + Path(path).write_text(json.dumps(d, indent=4)) + return d + + @classmethod + def from_json(cls, json_: str | Path | Mapping, /) -> FindMyAccessory: + """Create a FindMyAccessory from a JSON file.""" + data = json.loads(Path(json_).read_text()) if isinstance(json_, (str, Path)) else json_ + return cls( + master_key=bytes.fromhex(data["master_key"]), + skn=bytes.fromhex(data["skn"]), + sks=bytes.fromhex(data["sks"]), + paired_at=datetime.fromisoformat(data["paired_at"]), + name=data["name"], + model=data["model"], + identifier=data["identifier"], + ) class AccessoryKeyGenerator(KeyGenerator[KeyPair]): @@ -236,6 +304,21 @@ class AccessoryKeyGenerator(KeyGenerator[KeyPair]): self._iter_ind = 0 + @property + def master_key(self) -> bytes: + """The private master key.""" + return self._master_key + + @property + def initial_sk(self) -> bytes: + """The initial secret key.""" + return self._initial_sk + + @property + def key_type(self) -> KeyType: + """The type of key this generator produces.""" + return self._key_type + def _get_sk(self, ind: int) -> bytes: if ind < self._cur_sk_ind: # behind us; need to reset :( self._cur_sk = self._initial_sk diff --git a/findmy/plist.py b/findmy/plist.py new file mode 100644 index 0000000..76872a2 --- /dev/null +++ b/findmy/plist.py @@ -0,0 +1,90 @@ +"""Utils for decrypting the encypted .record files into .plist files.""" + +from __future__ import annotations + +import plistlib +import subprocess +from pathlib import Path +from typing import IO + +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + +from .accessory import FindMyAccessory + +# Originally from: +# Author: Shane B. +# in https://github.com/parawanderer/OpenTagViewer/blob/08a59cab551721afb9dc9f829ad31dae8d5bd400/python/airtag_decryptor.py +# which was based on: +# Based on: https://gist.github.com/airy10/5205dc851fbd0715fcd7a5cdde25e7c8 + + +# consider switching to this library https://github.com/microsoft/keyper +# once they publish a version of it that includes my MR with the changes to make it compatible +# with keys that are non-utf-8 encoded (like the BeaconStore one) +# if I contribute this, properly escape the label argument here... +def get_key() -> bytes: + """Get the decryption key for BeaconStore using the system password prompt window.""" + # This thing will pop up 2 Password Input windows... + key_in_hex = subprocess.getoutput("/usr/bin/security find-generic-password -l 'BeaconStore' -w") # noqa: S605 + return bytes.fromhex(key_in_hex) + + +def decrypt_plist(encrypted: str | Path | bytes | IO[bytes], key: bytes) -> dict: + """ + Decrypts the encrypted plist file at `encrypted` using the provided `key`. + + :param encrypted: If bytes or IO, the encrypted plist data. + If str or Path, the path to the encrypted plist file, which is + generally something like `/Users//Library/com.apple.icloud.searchpartyd/OwnedBeacons/.record` + :param key: Raw key to decrypt plist file with. + See: `get_key()` + + :returns: The decoded plist dict + """ # noqa: E501 + if isinstance(encrypted, (str, Path)): + with Path(encrypted).open("rb") as f: + encrypted_bytes = f.read() + elif isinstance(encrypted, bytes): + encrypted_bytes = encrypted + elif isinstance(encrypted, IO): + encrypted_bytes = encrypted.read() + else: + raise TypeError("encrypted must be a str, Path, bytes, or IO[bytes]") # noqa: EM101, TRY003 + + plist = plistlib.loads(encrypted_bytes) + if not isinstance(plist, list) or len(plist) < 3: + raise ValueError(plist, "encrypted plist should be a list of 3 elements") + + nonce, tag, ciphertext = plist[0], plist[1], plist[2] + cipher = Cipher(algorithms.AES(key), modes.GCM(nonce, tag)) + decryptor = cipher.decryptor() + decrypted_plist_bytes = decryptor.update(ciphertext) + decryptor.finalize() + + decrypted_plist = plistlib.loads(decrypted_plist_bytes) + if not isinstance(decrypted_plist, dict): + raise ValueError(decrypted_plist, "decrypted plist should be a dictionary") # noqa: TRY004 + return decrypted_plist + + +def list_accessories( + *, + key: bytes | None = None, + search_path: str | Path | None = None, +) -> list[FindMyAccessory]: + """Get all accesories from the encrypted .plist files dumped from the FindMy app.""" + if search_path is None: + search_path = Path.home() / "Library" / "com.apple.icloud.searchpartyd" + search_path = Path(search_path) + if key is None: + key = get_key() + + accesories = [] + encrypted_plist_paths = search_path.glob("OwnedBeacons/*.record") + for path in encrypted_plist_paths: + plist = decrypt_plist(path, key) + naming_record_path = next((search_path / "BeaconNamingRecord" / path.stem).glob("*.record")) + naming_record_plist = decrypt_plist(naming_record_path, key) + name = naming_record_plist["name"] + accessory = FindMyAccessory.from_plist(plist, name=name) + accesories.append(accessory) + return accesories