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
DO_NOT_COMMIT*
.direnv/
accessories/

View File

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

View File

@@ -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",
)

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

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