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
|
airtag.plist
|
||||||
DO_NOT_COMMIT*
|
DO_NOT_COMMIT*
|
||||||
.direnv/
|
.direnv/
|
||||||
|
accessories/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
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
|
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."""
|
||||||
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.
|
# 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
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