mirror of
https://github.com/malmeloo/FindMy.py.git
synced 2026-04-17 19:53:53 +02:00
feat: add plist.py for decrypting the .plist files to FindMyAccessory
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -164,3 +164,4 @@ account.json
|
||||
airtag.plist
|
||||
DO_NOT_COMMIT*
|
||||
.direnv/
|
||||
accessories/
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
67
findmy/__main__.py
Normal 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()
|
||||
@@ -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."""
|
||||
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
90
findmy/plist.py
Normal 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
|
||||
Reference in New Issue
Block a user