diff --git a/examples/real_airtag.py b/examples/real_airtag.py index c154e81..f4ceb72 100644 --- a/examples/real_airtag.py +++ b/examples/real_airtag.py @@ -5,7 +5,8 @@ Example showing how to retrieve the primary key of your own AirTag, or any other This key can be used to retrieve the device's location for a single day. """ import plistlib -from datetime import datetime +from datetime import datetime, timedelta, timezone +from pathlib import Path from findmy import FindMyAccessory @@ -13,11 +14,11 @@ from findmy import FindMyAccessory # For nearby devices, you can use `device_scanner.py` to find it. PUBLIC_KEY = "" # Path to a .plist dumped from the Find My app. -PLIST_PATH = "airtag.plist" +PLIST_PATH = Path("airtag.plist") # == The variables below are auto-filled from the plist!! == -with open(PLIST_PATH, "rb") as f: +with PLIST_PATH.open("rb") as f: device_data = plistlib.load(f) # PRIVATE master key. 28 (?) bytes. @@ -29,30 +30,34 @@ SKN = device_data["sharedSecret"]["key"]["data"] # "Secondary" shared secret. 32 bytes. SKS = device_data["secondarySharedSecret"]["key"]["data"] -# Lookahead in time slots. Each time slot is 15 minutes. -# Should be AT LEAST the time that has passed since you paired the accessory! -delta = datetime.now() - device_data["pairingDate"] -MAX_LOOKAHEAD = int(delta.total_seconds() / (15 * 60)) + 1 - def main() -> None: - airtag = FindMyAccessory(MASTER_KEY, SKN, SKS) + paired_at = device_data["pairingDate"].replace(tzinfo=timezone.utc) + airtag = FindMyAccessory(MASTER_KEY, SKN, SKS, paired_at) + + now = datetime.now(tz=timezone.utc) + lookup_time = paired_at.replace( + minute=paired_at.minute // 15 * 15, + second=0, + microsecond=0 + ) + timedelta(minutes=15) + + while lookup_time < now: + keys = airtag.keys_at(lookup_time) + for key in keys: + if key.adv_key_b64 != PUBLIC_KEY: + continue - for i in range(MAX_LOOKAHEAD): - prim_key, sec_key = airtag.keys_at(i) - if PUBLIC_KEY in (prim_key.adv_key_b64, sec_key.adv_key_b64): print("KEY FOUND!!") - print(f"This key was found at index {i}." - f" It was likely paired approximately {i * 15} minutes ago.") - print() print("KEEP THE BELOW KEY SECRET! IT CAN BE USED TO RETRIEVE THE DEVICE'S LOCATION!") - if prim_key.adv_key_b64 == PUBLIC_KEY: - print(f"PRIMARY key: {prim_key.private_key_b64}") - else: - print(f"SECONDARY key: {sec_key.private_key_b64}") - break - else: - print("No match found! :(") + print(f" - Key: {key.private_key_b64}") + print(f" - Approx. Time: {lookup_time}") + print(f" - Type: {key.key_type}") + return + + lookup_time += timedelta(minutes=15) + + print("No match found! :(") if __name__ == "__main__": diff --git a/findmy/accessory.py b/findmy/accessory.py index 3e84596..2864243 100644 --- a/findmy/accessory.py +++ b/findmy/accessory.py @@ -5,16 +5,27 @@ Accessories could be anything ranging from AirTags to iPhones. """ from __future__ import annotations +import logging +from datetime import datetime, timedelta from typing import Generator -from .keys import KeyGenerator, KeyPair +from .keys import KeyGenerator, KeyPair, KeyType from .util import crypto +logging.getLogger(__name__) + class FindMyAccessory: """A findable Find My-accessory using official key rollover.""" - def __init__(self, master_key: bytes, skn: bytes, sks: bytes, name: str | None = None) -> None: + def __init__( # noqa: PLR0913 + self, + master_key: bytes, + skn: bytes, + sks: bytes, + paired_at: datetime, + name: str | None = None, + ) -> None: """ Initialize a FindMyAccessory. These values are usually obtained during pairing. @@ -22,23 +33,69 @@ class FindMyAccessory: :param skn: The SKN for the primary key. :param sks: The SKS for the secondary key. """ - self._primary_gen = AccessoryKeyGenerator(master_key, skn) - self._secondary_gen = AccessoryKeyGenerator(master_key, sks) + self._primary_gen = AccessoryKeyGenerator(master_key, skn, KeyType.PRIMARY) + self._secondary_gen = AccessoryKeyGenerator(master_key, sks, KeyType.SECONDARY) + self._paired_at: datetime = paired_at + if self._paired_at.tzinfo is None: + self._paired_at = self._paired_at.astimezone() + logging.warning( + "Pairing datetime is timezone-naive. Assuming system tz: %s.", + self._paired_at.tzname(), + ) self._name = name - def keys_at(self, ind: int) -> tuple[KeyPair, KeyPair]: - """Get the primary and secondary key active at primary key index `ind`.""" - pkey = self._primary_gen[ind] - skey = self._secondary_gen[ind // 96 + 1] + def keys_at(self, ind: int | datetime) -> set[KeyPair]: + """Get the potential primary and secondary keys active at a certain time or index.""" + secondary_offset = 0 - return pkey, skey + if isinstance(ind, datetime): + # number of 15-minute slots since pairing time + ind = ( + int( + (ind - self._paired_at).total_seconds() / (15 * 60), + ) + + 1 + ) + # number of slots until first 4 am + first_rollover = self._paired_at.astimezone().replace( + hour=4, + minute=0, + second=0, + microsecond=0, + ) + if first_rollover < self._paired_at: # we rolled backwards, so increment the day + first_rollover += timedelta(days=1) + secondary_offset = ( + int( + (first_rollover - self._paired_at).total_seconds() / (15 * 60), + ) + + 1 + ) + + possible_keys = set() + # primary key can always be determined + possible_keys.add(self._primary_gen[ind]) + + # when the accessory has been rebooted, it will use the following secondary key + possible_keys.add(self._secondary_gen[ind // 96 + 1]) + + if ind > secondary_offset: + # after the first 4 am after pairing, we need to account for the first day + possible_keys.add(self._secondary_gen[(ind - secondary_offset) // 96 + 2]) + + return possible_keys class AccessoryKeyGenerator(KeyGenerator[KeyPair]): """KeyPair generator. Uses the same algorithm internally as FindMy accessories do.""" - def __init__(self, master_key: bytes, initial_sk: bytes) -> None: + def __init__( + self, + master_key: bytes, + initial_sk: bytes, + key_type: KeyType = KeyType.UNKNOWN, + ) -> None: """ Initialize the key generator. @@ -55,6 +112,7 @@ class AccessoryKeyGenerator(KeyGenerator[KeyPair]): self._master_key = master_key self._initial_sk = initial_sk + self._key_type = key_type self._cur_sk = initial_sk self._cur_sk_ind = 0 @@ -74,7 +132,7 @@ class AccessoryKeyGenerator(KeyGenerator[KeyPair]): def _get_keypair(self, ind: int) -> KeyPair: sk = self._get_sk(ind) privkey = crypto.derive_ps_key(self._master_key, sk) - return KeyPair(privkey) + return KeyPair(privkey, self._key_type) def _generate_keys(self, start: int, stop: int | None) -> Generator[KeyPair, None, None]: ind = start diff --git a/findmy/keys.py b/findmy/keys.py index f90131e..42d5ce5 100644 --- a/findmy/keys.py +++ b/findmy/keys.py @@ -5,6 +5,7 @@ import base64 import hashlib import secrets from abc import ABC, abstractmethod +from enum import Enum from typing import Generator, Generic, TypeVar, overload from cryptography.hazmat.primitives.asymmetric import ec @@ -12,6 +13,14 @@ from cryptography.hazmat.primitives.asymmetric import ec from .util import crypto +class KeyType(Enum): + """Enum of possible key types.""" + + UNKNOWN = 0 + PRIMARY = 1 + SECONDARY = 2 + + class HasPublicKey(ABC): """ ABC for anything that has a public FindMy-key. @@ -40,11 +49,20 @@ class HasPublicKey(ABC): """Return the hashed advertised (public) key as a base64-encoded string.""" return base64.b64encode(self.hashed_adv_key_bytes).decode("ascii") + def __hash__(self) -> int: + return crypto.bytes_to_int(self.adv_key_bytes) + + def __eq__(self, other: HasPublicKey) -> bool: + if not isinstance(other, HasPublicKey): + return NotImplemented + + return self.adv_key_bytes == other.adv_key_bytes + class KeyPair(HasPublicKey): """A private-public keypair for a trackable FindMy accessory.""" - def __init__(self, private_key: bytes) -> None: + def __init__(self, private_key: bytes, key_type: KeyType = KeyType.UNKNOWN) -> None: """Initialize the `KeyPair` with the private key bytes.""" priv_int = crypto.bytes_to_int(private_key) self._priv_key = ec.derive_private_key( @@ -52,6 +70,13 @@ class KeyPair(HasPublicKey): ec.SECP224R1(), ) + self._key_type = key_type + + @property + def key_type(self) -> KeyType: + """Type of this key.""" + return self._key_type + @classmethod def new(cls) -> KeyPair: """Generate a new random `KeyPair`.""" @@ -92,7 +117,7 @@ class KeyPair(HasPublicKey): return self._priv_key.exchange(ec.ECDH(), other_pub_key) def __repr__(self) -> str: - return f'KeyPair(public_key="{self.adv_key_b64}")' + return f'KeyPair(public_key="{self.adv_key_b64}", type={self.key_type})' K = TypeVar("K")