feat: add plist.py for decrypting the .plist files to FindMyAccessory

This commit is contained in:
Nick Crews
2025-05-25 16:45:54 -06:00
parent 7f6ee0ec51
commit 0defad71b3
6 changed files with 252 additions and 8 deletions

1
.gitignore vendored
View File

@@ -164,3 +164,4 @@ account.json
airtag.plist airtag.plist
DO_NOT_COMMIT* DO_NOT_COMMIT*
.direnv/ .direnv/
accessories/

View File

@@ -54,7 +54,9 @@ The package can be installed from [PyPi](https://pypi.org/project/findmy/):
pip install 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 ## Contributing

View File

@@ -1,6 +1,6 @@
"""A package providing everything you need to work with Apple's FindMy network.""" """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 .accessory import FindMyAccessory
from .keys import KeyPair from .keys import KeyPair
@@ -9,6 +9,7 @@ __all__ = (
"KeyPair", "KeyPair",
"errors", "errors",
"keys", "keys",
"plist",
"reports", "reports",
"scanner", "scanner",
) )

67
findmy/__main__.py Normal file
View File

@@ -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()

View File

@@ -6,10 +6,12 @@ Accessories could be anything ranging from AirTags to iPhones.
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import plistlib import plistlib
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import IO, TYPE_CHECKING, overload from typing import IO, TYPE_CHECKING, overload
from typing_extensions import override from typing_extensions import override
@@ -18,9 +20,9 @@ from .keys import KeyGenerator, KeyPair, KeyType
from .util import crypto from .util import crypto
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Generator from collections.abc import Generator, Mapping
logging.getLogger(__name__) logger = logging.getLogger(__name__)
class RollingKeyPairSource(ABC): class RollingKeyPairSource(ABC):
@@ -70,6 +72,7 @@ class FindMyAccessory(RollingKeyPairSource):
def __init__( # noqa: PLR0913 def __init__( # noqa: PLR0913
self, self,
*,
master_key: bytes, master_key: bytes,
skn: bytes, skn: bytes,
sks: bytes, sks: bytes,
@@ -90,7 +93,7 @@ class FindMyAccessory(RollingKeyPairSource):
self._paired_at: datetime = paired_at self._paired_at: datetime = paired_at
if self._paired_at.tzinfo is None: if self._paired_at.tzinfo is None:
self._paired_at = self._paired_at.astimezone() self._paired_at = self._paired_at.astimezone()
logging.warning( logger.warning(
"Pairing datetime is timezone-naive. Assuming system tz: %s.", "Pairing datetime is timezone-naive. Assuming system tz: %s.",
self._paired_at.tzname(), self._paired_at.tzname(),
) )
@@ -99,6 +102,21 @@ class FindMyAccessory(RollingKeyPairSource):
self._model = model self._model = model
self._identifier = identifier 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 @property
def paired_at(self) -> datetime: def paired_at(self) -> datetime:
"""Date and time at which this accessory was paired with an Apple account.""" """Date and time at which this accessory was paired with an Apple account."""
@@ -177,9 +195,22 @@ class FindMyAccessory(RollingKeyPairSource):
return possible_keys return possible_keys
@classmethod @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.""" """Create a FindMyAccessory from a .plist file dumped from the FindMy app."""
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) device_data = plistlib.load(plist)
else:
device_data = plist
# PRIVATE master key. 28 (?) bytes. # PRIVATE master key. 28 (?) bytes.
master_key = device_data["privateKey"]["key"]["data"][-28:] master_key = device_data["privateKey"]["key"]["data"][-28:]
@@ -201,7 +232,44 @@ class FindMyAccessory(RollingKeyPairSource):
model = device_data["model"] model = device_data["model"]
identifier = device_data["identifier"] 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]): class AccessoryKeyGenerator(KeyGenerator[KeyPair]):
@@ -236,6 +304,21 @@ class AccessoryKeyGenerator(KeyGenerator[KeyPair]):
self._iter_ind = 0 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: def _get_sk(self, ind: int) -> bytes:
if ind < self._cur_sk_ind: # behind us; need to reset :( if ind < self._cur_sk_ind: # behind us; need to reset :(
self._cur_sk = self._initial_sk self._cur_sk = self._initial_sk

90
findmy/plist.py Normal file
View File

@@ -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. <shane@wander.dev>
# 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/<username>/Library/com.apple.icloud.searchpartyd/OwnedBeacons/<UUID>.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