From 73469e937147f5f9ac792c7a39bec81950647eb1 Mon Sep 17 00:00:00 2001 From: Mike A Date: Sat, 17 Feb 2024 17:42:23 +0100 Subject: [PATCH 01/34] Fix SMS 2FA not working --- findmy/reports/account.py | 61 +++++++++---------- poetry.lock | 120 ++++++++++++++------------------------ pyproject.toml | 3 +- 3 files changed, 74 insertions(+), 110 deletions(-) diff --git a/findmy/reports/account.py b/findmy/reports/account.py index 45048a1..8e9b7b8 100644 --- a/findmy/reports/account.py +++ b/findmy/reports/account.py @@ -5,7 +5,6 @@ import asyncio import base64 import hashlib import hmac -import json import logging import plistlib import uuid @@ -21,9 +20,9 @@ from typing import ( Sequence, TypedDict, TypeVar, + cast, ) -import bs4 import srp._pysrp as srp from cryptography.hazmat.primitives import hashes, padding from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes @@ -55,11 +54,14 @@ logging.getLogger(__name__) srp.rfc5054_enable() srp.no_username_in_x() +_PhoneNumber = TypedDict("_PhoneNumber", {"phoneNumber": str, "2fa": bool}) + class _AccountInfo(TypedDict): account_name: str first_name: str last_name: str + phone_numbers: list[_PhoneNumber] _P = ParamSpec("_P") @@ -118,17 +120,6 @@ def _decrypt_cbc(session_key: bytes, data: bytes) -> bytes: return padder.update(data) + padder.finalize() -def _extract_phone_numbers(html: str) -> list[dict]: - soup = bs4.BeautifulSoup(html, features="html.parser") - data_elem = soup.find("script", {"class": "boot_args"}) - if not data_elem: - msg = "Could not find HTML element containing phone numbers" - raise RuntimeError(msg) - - data = json.loads(data_elem.text) - return data.get("direct", {}).get("phoneNumberVerification", {}).get("trustedPhoneNumbers", []) - - class BaseAppleAccount(Closable, ABC): """Base class for an Apple account.""" @@ -409,20 +400,17 @@ class AsyncAppleAccount(BaseAppleAccount): """See `BaseAppleAccount.get_2fa_methods`.""" methods: list[AsyncSecondFactorMethod] = [] - # sms - auth_page = await self._sms_2fa_request("GET", "https://gsa.apple.com/auth") - try: - phone_numbers = _extract_phone_numbers(auth_page) - methods.extend( - AsyncSmsSecondFactor( - self, - number.get("id") or -1, - number.get("numberWithDialCode") or "-", - ) - for number in phone_numbers - ) - except RuntimeError: - logging.warning("Unable to extract phone numbers from login page") + # TODO(malmeloo): ID may be incorrect! Need to fetch from somewhere! + # https://github.com/malmeloo/FindMy.py/issues/8 + i = 1 + for num in (self._account_info or {}).get("phone_numbers", []): + if num.get("type") != "2fa": + continue + + factor = AsyncSmsSecondFactor(self, i, num.get("phoneNumber")) + methods.append(factor) + + i += 1 return methods @@ -575,11 +563,15 @@ class AsyncAppleAccount(BaseAppleAccount): spd = decode_plist(spd) logging.debug("Received account information") - self._account_info = { - "account_name": spd.get("acname"), - "first_name": spd.get("fn"), - "last_name": spd.get("ln"), - } + self._account_info = cast( + _AccountInfo, + { + "account_name": spd.get("acname"), + "first_name": spd.get("fn"), + "last_name": spd.get("ln"), + "phoneNumbers": [], + }, + ) # TODO(malmeloo): support trusted device auth (need account to test) # https://github.com/malmeloo/FindMy.py/issues/1 @@ -587,6 +579,11 @@ class AsyncAppleAccount(BaseAppleAccount): if au in ("secondaryAuth",): logging.info("Detected 2FA requirement: %s", au) + self._account_info["phone_numbers"] = spd.get("additionalInfo", {}).get( + "phoneNumbers", + [], + ) + return self._set_login_state( LoginState.REQUIRE_2FA, {"adsid": spd["adsid"], "idms_token": spd["GsIdmsToken"]}, diff --git a/poetry.lock b/poetry.lock index c78d880..ffa49d9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -190,27 +190,6 @@ files = [ [package.extras] dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"] -[[package]] -name = "beautifulsoup4" -version = "4.12.3" -description = "Screen-scraping library" -optional = false -python-versions = ">=3.6.0" -files = [ - {file = "beautifulsoup4-4.12.3-py3-none-any.whl", hash = "sha256:b80878c9f40111313e55da8ba20bdba06d8fa3969fc68304167741bbf9e082ed"}, - {file = "beautifulsoup4-4.12.3.tar.gz", hash = "sha256:74e3d1928edc070d21748185c46e3fb33490f22f52a3addee9aee0f4f7781051"}, -] - -[package.dependencies] -soupsieve = ">1.2" - -[package.extras] -cchardet = ["cchardet"] -chardet = ["chardet"] -charset-normalizer = ["charset-normalizer"] -html5lib = ["html5lib"] -lxml = ["lxml"] - [[package]] name = "bleak" version = "0.21.1" @@ -456,43 +435,43 @@ files = [ [[package]] name = "cryptography" -version = "42.0.2" +version = "42.0.3" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = ">=3.7" files = [ - {file = "cryptography-42.0.2-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:701171f825dcab90969596ce2af253143b93b08f1a716d4b2a9d2db5084ef7be"}, - {file = "cryptography-42.0.2-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:61321672b3ac7aade25c40449ccedbc6db72c7f5f0fdf34def5e2f8b51ca530d"}, - {file = "cryptography-42.0.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea2c3ffb662fec8bbbfce5602e2c159ff097a4631d96235fcf0fb00e59e3ece4"}, - {file = "cryptography-42.0.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b15c678f27d66d247132cbf13df2f75255627bcc9b6a570f7d2fd08e8c081d2"}, - {file = "cryptography-42.0.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8e88bb9eafbf6a4014d55fb222e7360eef53e613215085e65a13290577394529"}, - {file = "cryptography-42.0.2-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a047682d324ba56e61b7ea7c7299d51e61fd3bca7dad2ccc39b72bd0118d60a1"}, - {file = "cryptography-42.0.2-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:36d4b7c4be6411f58f60d9ce555a73df8406d484ba12a63549c88bd64f7967f1"}, - {file = "cryptography-42.0.2-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:a00aee5d1b6c20620161984f8ab2ab69134466c51f58c052c11b076715e72929"}, - {file = "cryptography-42.0.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b97fe7d7991c25e6a31e5d5e795986b18fbbb3107b873d5f3ae6dc9a103278e9"}, - {file = "cryptography-42.0.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5fa82a26f92871eca593b53359c12ad7949772462f887c35edaf36f87953c0e2"}, - {file = "cryptography-42.0.2-cp37-abi3-win32.whl", hash = "sha256:4b063d3413f853e056161eb0c7724822a9740ad3caa24b8424d776cebf98e7ee"}, - {file = "cryptography-42.0.2-cp37-abi3-win_amd64.whl", hash = "sha256:841ec8af7a8491ac76ec5a9522226e287187a3107e12b7d686ad354bb78facee"}, - {file = "cryptography-42.0.2-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:55d1580e2d7e17f45d19d3b12098e352f3a37fe86d380bf45846ef257054b242"}, - {file = "cryptography-42.0.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28cb2c41f131a5758d6ba6a0504150d644054fd9f3203a1e8e8d7ac3aea7f73a"}, - {file = "cryptography-42.0.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b9097a208875fc7bbeb1286d0125d90bdfed961f61f214d3f5be62cd4ed8a446"}, - {file = "cryptography-42.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:44c95c0e96b3cb628e8452ec060413a49002a247b2b9938989e23a2c8291fc90"}, - {file = "cryptography-42.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2f9f14185962e6a04ab32d1abe34eae8a9001569ee4edb64d2304bf0d65c53f3"}, - {file = "cryptography-42.0.2-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:09a77e5b2e8ca732a19a90c5bca2d124621a1edb5438c5daa2d2738bfeb02589"}, - {file = "cryptography-42.0.2-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:ad28cff53f60d99a928dfcf1e861e0b2ceb2bc1f08a074fdd601b314e1cc9e0a"}, - {file = "cryptography-42.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:130c0f77022b2b9c99d8cebcdd834d81705f61c68e91ddd614ce74c657f8b3ea"}, - {file = "cryptography-42.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:fa3dec4ba8fb6e662770b74f62f1a0c7d4e37e25b58b2bf2c1be4c95372b4a33"}, - {file = "cryptography-42.0.2-cp39-abi3-win32.whl", hash = "sha256:3dbd37e14ce795b4af61b89b037d4bc157f2cb23e676fa16932185a04dfbf635"}, - {file = "cryptography-42.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:8a06641fb07d4e8f6c7dda4fc3f8871d327803ab6542e33831c7ccfdcb4d0ad6"}, - {file = "cryptography-42.0.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:087887e55e0b9c8724cf05361357875adb5c20dec27e5816b653492980d20380"}, - {file = "cryptography-42.0.2-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a7ef8dd0bf2e1d0a27042b231a3baac6883cdd5557036f5e8df7139255feaac6"}, - {file = "cryptography-42.0.2-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:4383b47f45b14459cab66048d384614019965ba6c1a1a141f11b5a551cace1b2"}, - {file = "cryptography-42.0.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:fbeb725c9dc799a574518109336acccaf1303c30d45c075c665c0793c2f79a7f"}, - {file = "cryptography-42.0.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:320948ab49883557a256eab46149df79435a22d2fefd6a66fe6946f1b9d9d008"}, - {file = "cryptography-42.0.2-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5ef9bc3d046ce83c4bbf4c25e1e0547b9c441c01d30922d812e887dc5f125c12"}, - {file = "cryptography-42.0.2-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:52ed9ebf8ac602385126c9a2fe951db36f2cb0c2538d22971487f89d0de4065a"}, - {file = "cryptography-42.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:141e2aa5ba100d3788c0ad7919b288f89d1fe015878b9659b307c9ef867d3a65"}, - {file = "cryptography-42.0.2.tar.gz", hash = "sha256:e0ec52ba3c7f1b7d813cd52649a5b3ef1fc0d433219dc8c93827c57eab6cf888"}, + {file = "cryptography-42.0.3-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:de5086cd475d67113ccb6f9fae6d8fe3ac54a4f9238fd08bfdb07b03d791ff0a"}, + {file = "cryptography-42.0.3-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:935cca25d35dda9e7bd46a24831dfd255307c55a07ff38fd1a92119cffc34857"}, + {file = "cryptography-42.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20100c22b298c9eaebe4f0b9032ea97186ac2555f426c3e70670f2517989543b"}, + {file = "cryptography-42.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2eb6368d5327d6455f20327fb6159b97538820355ec00f8cc9464d617caecead"}, + {file = "cryptography-42.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:39d5c93e95bcbc4c06313fc6a500cee414ee39b616b55320c1904760ad686938"}, + {file = "cryptography-42.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3d96ea47ce6d0055d5b97e761d37b4e84195485cb5a38401be341fabf23bc32a"}, + {file = "cryptography-42.0.3-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:d1998e545081da0ab276bcb4b33cce85f775adb86a516e8f55b3dac87f469548"}, + {file = "cryptography-42.0.3-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:93fbee08c48e63d5d1b39ab56fd3fdd02e6c2431c3da0f4edaf54954744c718f"}, + {file = "cryptography-42.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:90147dad8c22d64b2ff7331f8d4cddfdc3ee93e4879796f837bdbb2a0b141e0c"}, + {file = "cryptography-42.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4dcab7c25e48fc09a73c3e463d09ac902a932a0f8d0c568238b3696d06bf377b"}, + {file = "cryptography-42.0.3-cp37-abi3-win32.whl", hash = "sha256:1e935c2900fb53d31f491c0de04f41110351377be19d83d908c1fd502ae8daa5"}, + {file = "cryptography-42.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:762f3771ae40e111d78d77cbe9c1035e886ac04a234d3ee0856bf4ecb3749d54"}, + {file = "cryptography-42.0.3-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0d3ec384058b642f7fb7e7bff9664030011ed1af8f852540c76a1317a9dd0d20"}, + {file = "cryptography-42.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35772a6cffd1f59b85cb670f12faba05513446f80352fe811689b4e439b5d89e"}, + {file = "cryptography-42.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:04859aa7f12c2b5f7e22d25198ddd537391f1695df7057c8700f71f26f47a129"}, + {file = "cryptography-42.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c3d1f5a1d403a8e640fa0887e9f7087331abb3f33b0f2207d2cc7f213e4a864c"}, + {file = "cryptography-42.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:df34312149b495d9d03492ce97471234fd9037aa5ba217c2a6ea890e9166f151"}, + {file = "cryptography-42.0.3-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:de4ae486041878dc46e571a4c70ba337ed5233a1344c14a0790c4c4be4bbb8b4"}, + {file = "cryptography-42.0.3-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:0fab2a5c479b360e5e0ea9f654bcebb535e3aa1e493a715b13244f4e07ea8eec"}, + {file = "cryptography-42.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25b09b73db78facdfd7dd0fa77a3f19e94896197c86e9f6dc16bce7b37a96504"}, + {file = "cryptography-42.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d5cf11bc7f0b71fb71af26af396c83dfd3f6eed56d4b6ef95d57867bf1e4ba65"}, + {file = "cryptography-42.0.3-cp39-abi3-win32.whl", hash = "sha256:0fea01527d4fb22ffe38cd98951c9044400f6eff4788cf52ae116e27d30a1ba3"}, + {file = "cryptography-42.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:2619487f37da18d6826e27854a7f9d4d013c51eafb066c80d09c63cf24505306"}, + {file = "cryptography-42.0.3-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ead69ba488f806fe1b1b4050febafdbf206b81fa476126f3e16110c818bac396"}, + {file = "cryptography-42.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:20180da1b508f4aefc101cebc14c57043a02b355d1a652b6e8e537967f1e1b46"}, + {file = "cryptography-42.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:5fbf0f3f0fac7c089308bd771d2c6c7b7d53ae909dce1db52d8e921f6c19bb3a"}, + {file = "cryptography-42.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c23f03cfd7d9826cdcbad7850de67e18b4654179e01fe9bc623d37c2638eb4ef"}, + {file = "cryptography-42.0.3-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db0480ffbfb1193ac4e1e88239f31314fe4c6cdcf9c0b8712b55414afbf80db4"}, + {file = "cryptography-42.0.3-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:6c25e1e9c2ce682d01fc5e2dde6598f7313027343bd14f4049b82ad0402e52cd"}, + {file = "cryptography-42.0.3-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:9541c69c62d7446539f2c1c06d7046aef822940d248fa4b8962ff0302862cc1f"}, + {file = "cryptography-42.0.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:1b797099d221df7cce5ff2a1d272761d1554ddf9a987d3e11f6459b38cd300fd"}, + {file = "cryptography-42.0.3.tar.gz", hash = "sha256:069d2ce9be5526a44093a0991c450fe9906cdf069e0e7cd67d9dee49a62b9ebe"}, ] [package.dependencies] @@ -677,13 +656,13 @@ files = [ [[package]] name = "identify" -version = "2.5.33" +version = "2.5.34" description = "File identification library for Python" optional = false python-versions = ">=3.8" files = [ - {file = "identify-2.5.33-py2.py3-none-any.whl", hash = "sha256:d40ce5fcd762817627670da8a7d8d8e65f24342d14539c59488dc603bf662e34"}, - {file = "identify-2.5.33.tar.gz", hash = "sha256:161558f9fe4559e1557e1bff323e8631f6a0e4837f7497767c1782832f16b62d"}, + {file = "identify-2.5.34-py2.py3-none-any.whl", hash = "sha256:a4316013779e433d08b96e5eabb7f641e6c7942e4ab5d4c509ebd2e7a8994aed"}, + {file = "identify-2.5.34.tar.gz", hash = "sha256:ee17bc9d499899bc9eaec1ac7bf2dc9eedd480db9d88b96d123d3b64a9d34f5d"}, ] [package.extras] @@ -957,13 +936,13 @@ test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest- [[package]] name = "pre-commit" -version = "3.6.0" +version = "3.6.1" description = "A framework for managing and maintaining multi-language pre-commit hooks." optional = false python-versions = ">=3.9" files = [ - {file = "pre_commit-3.6.0-py2.py3-none-any.whl", hash = "sha256:c255039ef399049a5544b6ce13d135caba8f2c28c3b4033277a788f434308376"}, - {file = "pre_commit-3.6.0.tar.gz", hash = "sha256:d30bad9abf165f7785c15a21a1f46da7d0677cb00ee7ff4c579fd38922efe15d"}, + {file = "pre_commit-3.6.1-py2.py3-none-any.whl", hash = "sha256:9fe989afcf095d2c4796ce7c553cf28d4d4a9b9346de3cda079bcf40748454a4"}, + {file = "pre_commit-3.6.1.tar.gz", hash = "sha256:c90961d8aa706f75d60935aba09469a6b0bcb8345f127c3fbee4bdc5f114cf4b"}, ] [package.dependencies] @@ -1174,18 +1153,18 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "setuptools" -version = "69.0.3" +version = "69.1.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "setuptools-69.0.3-py3-none-any.whl", hash = "sha256:385eb4edd9c9d5c17540511303e39a147ce2fc04bc55289c322b9e5904fe2c05"}, - {file = "setuptools-69.0.3.tar.gz", hash = "sha256:be1af57fc409f93647f2e8e4573a142ed38724b8cdd389706a867bb4efcf1e78"}, + {file = "setuptools-69.1.0-py3-none-any.whl", hash = "sha256:c054629b81b946d63a9c6e732bc8b2513a7c3ea645f11d0139a2191d735c60c6"}, + {file = "setuptools-69.1.0.tar.gz", hash = "sha256:850894c4195f09c4ed30dba56213bf7c3f21d86ed6bdaafb5df5972593bfc401"}, ] [package.extras] docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.1)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] [[package]] @@ -1210,17 +1189,6 @@ files = [ {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, ] -[[package]] -name = "soupsieve" -version = "2.5" -description = "A modern CSS selector implementation for Beautiful Soup." -optional = false -python-versions = ">=3.8" -files = [ - {file = "soupsieve-2.5-py3-none-any.whl", hash = "sha256:eaa337ff55a1579b6549dc679565eac1e3d000563bcb1c8ab0d0fefbc0c2cdc7"}, - {file = "soupsieve-2.5.tar.gz", hash = "sha256:5663d5a7b3bfaeee0bc4372e7fc48f9cff4940b3eec54a6451cc5299f1097690"}, -] - [[package]] name = "sphinx" version = "7.2.6" @@ -1778,4 +1746,4 @@ scan = ["bleak"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<3.13" -content-hash = "696a56ccbba231e3ec702aaee911977819b996d21074b37807c42a45d107c7ab" +content-hash = "b5fc6932f5ef4606b7117906804439785eb86cccabc82dcc828843c76a76ea4d" diff --git a/pyproject.toml b/pyproject.toml index 02c3e8e..15c4586 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,6 @@ packages = [{ include = "findmy" }] python = ">=3.9,<3.13" srp = "^1.0.20" cryptography = "^42.0.2" -beautifulsoup4 = "^4.12.2" aiohttp = "^3.9.1" bleak = "^0.21.1" @@ -42,7 +41,7 @@ select = [ ignore = [ "ANN101", # annotations on `self` "ANN102", # annotations on `cls` - "FIX002", # resolving TODOs + "FIX001", "FIX002", # resolving TODOs "D203", # one blank line before class docstring "D212", # multi-line docstring start at first line From 8dfe59522b83161b44e417b51dac243fae03c9c4 Mon Sep 17 00:00:00 2001 From: Mike A Date: Sat, 17 Feb 2024 20:50:14 +0100 Subject: [PATCH 02/34] Add trusted device 2FA support --- findmy/reports/__init__.py | 3 +- findmy/reports/account.py | 112 ++++++++++++++++++++++++++++++------ findmy/reports/twofactor.py | 40 ++++++++++--- 3 files changed, 130 insertions(+), 25 deletions(-) diff --git a/findmy/reports/__init__.py b/findmy/reports/__init__.py index b358a97..fde5512 100644 --- a/findmy/reports/__init__.py +++ b/findmy/reports/__init__.py @@ -2,7 +2,7 @@ from .account import AppleAccount, AsyncAppleAccount from .anisette import RemoteAnisetteProvider from .state import LoginState -from .twofactor import SmsSecondFactorMethod +from .twofactor import SmsSecondFactorMethod, TrustedDeviceSecondFactorMethod __all__ = ( "AppleAccount", @@ -10,4 +10,5 @@ __all__ = ( "LoginState", "RemoteAnisetteProvider", "SmsSecondFactorMethod", + "TrustedDeviceSecondFactorMethod", ) diff --git a/findmy/reports/account.py b/findmy/reports/account.py index 8e9b7b8..d47ece4 100644 --- a/findmy/reports/account.py +++ b/findmy/reports/account.py @@ -38,9 +38,11 @@ from .state import LoginState from .twofactor import ( AsyncSecondFactorMethod, AsyncSmsSecondFactor, + AsyncTrustedDeviceSecondFactor, BaseSecondFactorMethod, SyncSecondFactorMethod, SyncSmsSecondFactor, + SyncTrustedDeviceSecondFactor, ) if TYPE_CHECKING: @@ -54,13 +56,17 @@ logging.getLogger(__name__) srp.rfc5054_enable() srp.no_username_in_x() -_PhoneNumber = TypedDict("_PhoneNumber", {"phoneNumber": str, "2fa": bool}) + +class _PhoneNumber(TypedDict): + phoneNumber: str + type: str class _AccountInfo(TypedDict): account_name: str first_name: str last_name: str + trusted_device_2fa: bool phone_numbers: list[_PhoneNumber] @@ -214,6 +220,24 @@ class BaseAppleAccount(Closable, ABC): """ raise NotImplementedError + @abstractmethod + def td_2fa_request(self) -> MaybeCoro[None]: + """ + Request a 2FA code to be sent to a trusted device. + + Consider using `BaseSecondFactorMethod.request` instead. + """ + raise NotImplementedError + + @abstractmethod + def td_2fa_submit(self, code: str) -> MaybeCoro[LoginState]: + """ + Submit a 2FA code that was sent to a trusted device. + + Consider using `BaseSecondFactorMethod.submit` instead. + """ + raise NotImplementedError + @abstractmethod def fetch_reports( self, @@ -400,10 +424,16 @@ class AsyncAppleAccount(BaseAppleAccount): """See `BaseAppleAccount.get_2fa_methods`.""" methods: list[AsyncSecondFactorMethod] = [] + if self._account_info is None: + return [] + + if self._account_info["trusted_device_2fa"]: + methods.append(AsyncTrustedDeviceSecondFactor(self)) + # TODO(malmeloo): ID may be incorrect! Need to fetch from somewhere! # https://github.com/malmeloo/FindMy.py/issues/8 i = 1 - for num in (self._account_info or {}).get("phone_numbers", []): + for num in self._account_info.get("phone_numbers", []): if num.get("type") != "2fa": continue @@ -451,6 +481,35 @@ class AsyncAppleAccount(BaseAppleAccount): # AUTHENTICATED -> LOGGED_IN return await self._login_mobileme() + @require_login_state(LoginState.REQUIRE_2FA) + @override + async def td_2fa_request(self) -> None: + """See `BaseAppleAccount.td_2fa_request`.""" + await self._sms_2fa_request( + "GET", + "https://gsa.apple.com/auth/verify/trusteddevice", + ) + + @require_login_state(LoginState.REQUIRE_2FA) + @override + async def td_2fa_submit(self, code: str) -> LoginState: + """See `BaseAppleAccount.td_2fa_submit`.""" + headers = {"security-code": code} + await self._sms_2fa_request( + "GET", + "https://gsa.apple.com/grandslam/GsService2/validate", + headers=headers, + ) + + # REQUIRE_2FA -> AUTHENTICATED + new_state = await self._gsa_authenticate() + if new_state != LoginState.AUTHENTICATED: + msg = f"Unexpected state after submitting 2FA: {new_state}" + raise UnhandledProtocolError(msg) + + # AUTHENTICATED -> LOGGED_IN + return await self._login_mobileme() + @require_login_state(LoginState.LOGGED_IN) async def fetch_raw_reports(self, start: int, end: int, ids: list[str]) -> dict[str, Any]: """Make a request for location reports, returning raw data.""" @@ -569,19 +628,20 @@ class AsyncAppleAccount(BaseAppleAccount): "account_name": spd.get("acname"), "first_name": spd.get("fn"), "last_name": spd.get("ln"), + "trusted_device_2fa": False, "phoneNumbers": [], }, ) - # TODO(malmeloo): support trusted device auth (need account to test) - # https://github.com/malmeloo/FindMy.py/issues/1 au = r["Status"].get("au") - if au in ("secondaryAuth",): + if au in ("secondaryAuth", "trustedDeviceSecondaryAuth"): logging.info("Detected 2FA requirement: %s", au) - self._account_info["phone_numbers"] = spd.get("additionalInfo", {}).get( - "phoneNumbers", - [], + self._account_info.update( + { + "trusted_device_2fa": au == "trustedDeviceSecondaryAuth", + "phone_numbers": spd.get("additionalInfo", {}).get("phoneNumbers", []), + }, ) return self._set_login_state( @@ -645,20 +705,24 @@ class AsyncAppleAccount(BaseAppleAccount): method: str, url: str, data: dict[str, Any] | None = None, + headers: dict[str, Any] | None = None, ) -> str: adsid = self._login_state_data["adsid"] idms_token = self._login_state_data["idms_token"] identity_token = base64.b64encode((adsid + ":" + idms_token).encode()).decode() - headers = { - "User-Agent": "Xcode", - "Accept-Language": "en-us", - "X-Apple-Identity-Token": identity_token, - "X-Apple-App-Info": "com.apple.gs.xcode.auth", - "X-Xcode-Version": "11.2 (11B41)", - "X-Mme-Client-Info": " " - " ", - } + headers = headers or {} + headers.update( + { + "User-Agent": "Xcode", + "Accept-Language": "en-us", + "X-Apple-Identity-Token": identity_token, + "X-Apple-App-Info": "com.apple.gs.xcode.auth", + "X-Xcode-Version": "11.2 (11B41)", + "X-Mme-Client-Info": " " + " ", + }, + ) headers.update(await self.get_anisette_headers()) r = await self._http.request( @@ -794,6 +858,8 @@ class AppleAccount(BaseAppleAccount): for m in methods: if isinstance(m, AsyncSmsSecondFactor): res.append(SyncSmsSecondFactor(self, m.phone_number_id, m.phone_number)) + elif isinstance(m, AsyncTrustedDeviceSecondFactor): + res.append(SyncTrustedDeviceSecondFactor(self)) else: msg = ( f"Failed to cast 2FA object to sync alternative: {m}." @@ -815,6 +881,18 @@ class AppleAccount(BaseAppleAccount): coro = self._asyncacc.sms_2fa_submit(phone_number_id, code) return self._evt_loop.run_until_complete(coro) + @override + def td_2fa_request(self) -> None: + """See `AsyncAppleAccount.td_2fa_request`.""" + coro = self._asyncacc.td_2fa_request() + return self._evt_loop.run_until_complete(coro) + + @override + def td_2fa_submit(self, code: str) -> LoginState: + """See `AsyncAppleAccount.td_2fa_submit`.""" + coro = self._asyncacc.td_2fa_submit(code) + return self._evt_loop.run_until_complete(coro) + @override def fetch_reports( self, diff --git a/findmy/reports/twofactor.py b/findmy/reports/twofactor.py index 005beef..307a3be 100644 --- a/findmy/reports/twofactor.py +++ b/findmy/reports/twofactor.py @@ -122,8 +122,12 @@ class SmsSecondFactorMethod(BaseSecondFactorMethod, ABC): raise NotImplementedError +class TrustedDeviceSecondFactorMethod(BaseSecondFactorMethod, ABC): + """Base class for trusted device-based two-factor authentication.""" + + class AsyncSmsSecondFactor(AsyncSecondFactorMethod, SmsSecondFactorMethod): - """An async implementation of a second-factor method.""" + """An async implementation of `SmsSecondFactorMethod`.""" def __init__( self, @@ -164,16 +168,12 @@ class AsyncSmsSecondFactor(AsyncSecondFactorMethod, SmsSecondFactorMethod): @override async def submit(self, code: str) -> LoginState: - """See `BaseSecondFactorMethod.submit`.""" + """Submit the 2FA code as received over SMS.""" return await self.account.sms_2fa_submit(self._phone_number_id, code) class SyncSmsSecondFactor(SyncSecondFactorMethod, SmsSecondFactorMethod): - """ - A sync implementation of `BaseSecondFactorMethod`. - - Uses `AsyncSmsSecondFactor` internally. - """ + """A sync implementation of `SmsSecondFactorMethod`.""" def __init__( self, @@ -208,3 +208,29 @@ class SyncSmsSecondFactor(SyncSecondFactorMethod, SmsSecondFactorMethod): def submit(self, code: str) -> LoginState: """See `AsyncSmsSecondFactor.submit`.""" return self.account.sms_2fa_submit(self._phone_number_id, code) + + +class AsyncTrustedDeviceSecondFactor(AsyncSecondFactorMethod, TrustedDeviceSecondFactorMethod): + """An async implementation of `TrustedDeviceSecondFactorMethod`.""" + + @override + async def request(self) -> None: + return await self.account.td_2fa_request() + + @override + async def submit(self, code: str) -> LoginState: + return await self.account.td_2fa_submit(code) + + +class SyncTrustedDeviceSecondFactor(SyncSecondFactorMethod, TrustedDeviceSecondFactorMethod): + """A sync implementation of `TrustedDeviceSecondFactorMethod`.""" + + @override + def request(self) -> None: + """See `AsyncTrustedDeviceSecondFactor.request`.""" + return self.account.td_2fa_request() + + @override + def submit(self, code: str) -> LoginState: + """See `AsyncTrustedDeviceSecondFactor.submit`.""" + return self.account.td_2fa_submit(code) From a2672c3cc9717c676f22c575a8cf334e2499ff20 Mon Sep 17 00:00:00 2001 From: Mike A Date: Sat, 17 Feb 2024 20:51:40 +0100 Subject: [PATCH 03/34] Update examples --- examples/fetch_reports.py | 21 +++++++++++++-------- examples/fetch_reports_async.py | 16 ++++++++++------ 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/examples/fetch_reports.py b/examples/fetch_reports.py index 82521df..03fdd57 100644 --- a/examples/fetch_reports.py +++ b/examples/fetch_reports.py @@ -8,6 +8,7 @@ from findmy.reports import ( LoginState, RemoteAnisetteProvider, SmsSecondFactorMethod, + TrustedDeviceSecondFactorMethod, ) # URL to (public or local) anisette server @@ -34,14 +35,17 @@ def login(account: AppleAccount) -> None: methods = account.get_2fa_methods() # Print the (masked) phone numbers - for method in methods: - if isinstance(method, SmsSecondFactorMethod): - print(method.phone_number) + for i, method in enumerate(methods): + if isinstance(method, TrustedDeviceSecondFactorMethod): + print(f"{i} - Trusted Device") + elif isinstance(method, SmsSecondFactorMethod): + print(f"{i} - SMS ({method.phone_number})") - # Just take the first one to keep things simple - method = methods[0] + ind = int(input("Method? > ")) + + method = methods[ind] method.request() - code = input("Code: ") + code = input("Code? > ") # This automatically finishes the post-2FA login flow method.submit(code) @@ -64,8 +68,9 @@ def fetch_reports(lookup_key: KeyPair) -> None: print(f"Logged in as: {acc.account_name} ({acc.first_name} {acc.last_name})") # It's that simple! - reports = acc.fetch_last_reports([lookup_key]) - print(reports) + reports = acc.fetch_last_reports([lookup_key])[lookup_key] + for report in sorted(reports): + print(report) if __name__ == "__main__": diff --git a/examples/fetch_reports_async.py b/examples/fetch_reports_async.py index ce24723..3c4f8bc 100644 --- a/examples/fetch_reports_async.py +++ b/examples/fetch_reports_async.py @@ -9,6 +9,7 @@ from findmy.reports import ( LoginState, RemoteAnisetteProvider, SmsSecondFactorMethod, + TrustedDeviceSecondFactorMethod, ) # URL to (public or local) anisette server @@ -35,14 +36,17 @@ async def login(account: AsyncAppleAccount) -> None: methods = await account.get_2fa_methods() # Print the (masked) phone numbers - for method in methods: - if isinstance(method, SmsSecondFactorMethod): - print(method.phone_number) + for i, method in enumerate(methods): + if isinstance(method, TrustedDeviceSecondFactorMethod): + print(f"{i} - Trusted Device") + elif isinstance(method, SmsSecondFactorMethod): + print(f"{i} - SMS ({method.phone_number})") - # Just take the first one to keep things simple - method = methods[0] + ind = int(input("Method? > ")) + + method = methods[ind] await method.request() - code = input("Code: ") + code = input("Code? > ") # This automatically finishes the post-2FA login flow await method.submit(code) From d4d7198ab6320c01f28efcca45a8fe46dd6f2bd8 Mon Sep 17 00:00:00 2001 From: Mike A Date: Tue, 20 Feb 2024 14:27:24 +0100 Subject: [PATCH 04/34] Add correct headers to trusted device request --- findmy/reports/account.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/findmy/reports/account.py b/findmy/reports/account.py index d47ece4..d7bc2d0 100644 --- a/findmy/reports/account.py +++ b/findmy/reports/account.py @@ -485,16 +485,25 @@ class AsyncAppleAccount(BaseAppleAccount): @override async def td_2fa_request(self) -> None: """See `BaseAppleAccount.td_2fa_request`.""" + headers = { + "Content-Type": "text/x-xml-plist", + "Accept": "text/x-xml-plist", + } await self._sms_2fa_request( "GET", "https://gsa.apple.com/auth/verify/trusteddevice", + headers=headers, ) @require_login_state(LoginState.REQUIRE_2FA) @override async def td_2fa_submit(self, code: str) -> LoginState: """See `BaseAppleAccount.td_2fa_submit`.""" - headers = {"security-code": code} + headers = { + "security-code": code, + "Content-Type": "text/x-xml-plist", + "Accept": "text/x-xml-plist", + } await self._sms_2fa_request( "GET", "https://gsa.apple.com/grandslam/GsService2/validate", From ae68ac2d70e50cfb34bf130c5669eb1408e5feb3 Mon Sep 17 00:00:00 2001 From: Mike A Date: Fri, 23 Feb 2024 14:53:03 +0100 Subject: [PATCH 05/34] fix(2fa): Fix 403 for trusted device --- findmy/reports/account.py | 2 +- findmy/util/http.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/findmy/reports/account.py b/findmy/reports/account.py index d7bc2d0..e23a1b6 100644 --- a/findmy/reports/account.py +++ b/findmy/reports/account.py @@ -737,7 +737,7 @@ class AsyncAppleAccount(BaseAppleAccount): r = await self._http.request( method, url, - json=data or {}, + json=data, headers=headers, ) if not r.ok: diff --git a/findmy/util/http.py b/findmy/util/http.py index fb87325..0c941e7 100644 --- a/findmy/util/http.py +++ b/findmy/util/http.py @@ -15,7 +15,7 @@ logging.getLogger(__name__) class _HttpRequestOptions(TypedDict, total=False): - json: dict[str, Any] + json: dict[str, Any] | None headers: dict[str, str] auth: tuple[str, str] | BasicAuth data: bytes From ccfbd093c7a8c092436cd51f2777e28e09215aff Mon Sep 17 00:00:00 2001 From: Mike A Date: Fri, 23 Feb 2024 15:11:24 +0100 Subject: [PATCH 06/34] Revert "Fix SMS 2FA not working" This reverts commit 73469e93 --- findmy/reports/account.py | 46 +++++++++------ poetry.lock | 120 ++++++++++++++++++++++++-------------- pyproject.toml | 3 +- 3 files changed, 105 insertions(+), 64 deletions(-) diff --git a/findmy/reports/account.py b/findmy/reports/account.py index e23a1b6..bf6ae08 100644 --- a/findmy/reports/account.py +++ b/findmy/reports/account.py @@ -5,6 +5,7 @@ import asyncio import base64 import hashlib import hmac +import json import logging import plistlib import uuid @@ -23,6 +24,7 @@ from typing import ( cast, ) +import bs4 import srp._pysrp as srp from cryptography.hazmat.primitives import hashes, padding from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes @@ -57,17 +59,11 @@ srp.rfc5054_enable() srp.no_username_in_x() -class _PhoneNumber(TypedDict): - phoneNumber: str - type: str - - class _AccountInfo(TypedDict): account_name: str first_name: str last_name: str trusted_device_2fa: bool - phone_numbers: list[_PhoneNumber] _P = ParamSpec("_P") @@ -126,6 +122,17 @@ def _decrypt_cbc(session_key: bytes, data: bytes) -> bytes: return padder.update(data) + padder.finalize() +def _extract_phone_numbers(html: str) -> list[dict]: + soup = bs4.BeautifulSoup(html, features="html.parser") + data_elem = soup.find("script", {"class": "boot_args"}) + if not data_elem: + msg = "Could not find HTML element containing phone numbers" + raise RuntimeError(msg) + + data = json.loads(data_elem.text) + return data.get("direct", {}).get("phoneNumberVerification", {}).get("trustedPhoneNumbers", []) + + class BaseAppleAccount(Closable, ABC): """Base class for an Apple account.""" @@ -430,17 +437,20 @@ class AsyncAppleAccount(BaseAppleAccount): if self._account_info["trusted_device_2fa"]: methods.append(AsyncTrustedDeviceSecondFactor(self)) - # TODO(malmeloo): ID may be incorrect! Need to fetch from somewhere! - # https://github.com/malmeloo/FindMy.py/issues/8 - i = 1 - for num in self._account_info.get("phone_numbers", []): - if num.get("type") != "2fa": - continue - - factor = AsyncSmsSecondFactor(self, i, num.get("phoneNumber")) - methods.append(factor) - - i += 1 + # sms + auth_page = await self._sms_2fa_request("GET", "https://gsa.apple.com/auth") + try: + phone_numbers = _extract_phone_numbers(auth_page) + methods.extend( + AsyncSmsSecondFactor( + self, + number.get("id") or -1, + number.get("numberWithDialCode") or "-", + ) + for number in phone_numbers + ) + except RuntimeError: + logging.warning("Unable to extract phone numbers from login page") return methods @@ -638,7 +648,6 @@ class AsyncAppleAccount(BaseAppleAccount): "first_name": spd.get("fn"), "last_name": spd.get("ln"), "trusted_device_2fa": False, - "phoneNumbers": [], }, ) @@ -649,7 +658,6 @@ class AsyncAppleAccount(BaseAppleAccount): self._account_info.update( { "trusted_device_2fa": au == "trustedDeviceSecondaryAuth", - "phone_numbers": spd.get("additionalInfo", {}).get("phoneNumbers", []), }, ) diff --git a/poetry.lock b/poetry.lock index ffa49d9..c78d880 100644 --- a/poetry.lock +++ b/poetry.lock @@ -190,6 +190,27 @@ files = [ [package.extras] dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"] +[[package]] +name = "beautifulsoup4" +version = "4.12.3" +description = "Screen-scraping library" +optional = false +python-versions = ">=3.6.0" +files = [ + {file = "beautifulsoup4-4.12.3-py3-none-any.whl", hash = "sha256:b80878c9f40111313e55da8ba20bdba06d8fa3969fc68304167741bbf9e082ed"}, + {file = "beautifulsoup4-4.12.3.tar.gz", hash = "sha256:74e3d1928edc070d21748185c46e3fb33490f22f52a3addee9aee0f4f7781051"}, +] + +[package.dependencies] +soupsieve = ">1.2" + +[package.extras] +cchardet = ["cchardet"] +chardet = ["chardet"] +charset-normalizer = ["charset-normalizer"] +html5lib = ["html5lib"] +lxml = ["lxml"] + [[package]] name = "bleak" version = "0.21.1" @@ -435,43 +456,43 @@ files = [ [[package]] name = "cryptography" -version = "42.0.3" +version = "42.0.2" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = ">=3.7" files = [ - {file = "cryptography-42.0.3-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:de5086cd475d67113ccb6f9fae6d8fe3ac54a4f9238fd08bfdb07b03d791ff0a"}, - {file = "cryptography-42.0.3-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:935cca25d35dda9e7bd46a24831dfd255307c55a07ff38fd1a92119cffc34857"}, - {file = "cryptography-42.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20100c22b298c9eaebe4f0b9032ea97186ac2555f426c3e70670f2517989543b"}, - {file = "cryptography-42.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2eb6368d5327d6455f20327fb6159b97538820355ec00f8cc9464d617caecead"}, - {file = "cryptography-42.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:39d5c93e95bcbc4c06313fc6a500cee414ee39b616b55320c1904760ad686938"}, - {file = "cryptography-42.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3d96ea47ce6d0055d5b97e761d37b4e84195485cb5a38401be341fabf23bc32a"}, - {file = "cryptography-42.0.3-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:d1998e545081da0ab276bcb4b33cce85f775adb86a516e8f55b3dac87f469548"}, - {file = "cryptography-42.0.3-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:93fbee08c48e63d5d1b39ab56fd3fdd02e6c2431c3da0f4edaf54954744c718f"}, - {file = "cryptography-42.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:90147dad8c22d64b2ff7331f8d4cddfdc3ee93e4879796f837bdbb2a0b141e0c"}, - {file = "cryptography-42.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4dcab7c25e48fc09a73c3e463d09ac902a932a0f8d0c568238b3696d06bf377b"}, - {file = "cryptography-42.0.3-cp37-abi3-win32.whl", hash = "sha256:1e935c2900fb53d31f491c0de04f41110351377be19d83d908c1fd502ae8daa5"}, - {file = "cryptography-42.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:762f3771ae40e111d78d77cbe9c1035e886ac04a234d3ee0856bf4ecb3749d54"}, - {file = "cryptography-42.0.3-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0d3ec384058b642f7fb7e7bff9664030011ed1af8f852540c76a1317a9dd0d20"}, - {file = "cryptography-42.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35772a6cffd1f59b85cb670f12faba05513446f80352fe811689b4e439b5d89e"}, - {file = "cryptography-42.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:04859aa7f12c2b5f7e22d25198ddd537391f1695df7057c8700f71f26f47a129"}, - {file = "cryptography-42.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c3d1f5a1d403a8e640fa0887e9f7087331abb3f33b0f2207d2cc7f213e4a864c"}, - {file = "cryptography-42.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:df34312149b495d9d03492ce97471234fd9037aa5ba217c2a6ea890e9166f151"}, - {file = "cryptography-42.0.3-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:de4ae486041878dc46e571a4c70ba337ed5233a1344c14a0790c4c4be4bbb8b4"}, - {file = "cryptography-42.0.3-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:0fab2a5c479b360e5e0ea9f654bcebb535e3aa1e493a715b13244f4e07ea8eec"}, - {file = "cryptography-42.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25b09b73db78facdfd7dd0fa77a3f19e94896197c86e9f6dc16bce7b37a96504"}, - {file = "cryptography-42.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d5cf11bc7f0b71fb71af26af396c83dfd3f6eed56d4b6ef95d57867bf1e4ba65"}, - {file = "cryptography-42.0.3-cp39-abi3-win32.whl", hash = "sha256:0fea01527d4fb22ffe38cd98951c9044400f6eff4788cf52ae116e27d30a1ba3"}, - {file = "cryptography-42.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:2619487f37da18d6826e27854a7f9d4d013c51eafb066c80d09c63cf24505306"}, - {file = "cryptography-42.0.3-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ead69ba488f806fe1b1b4050febafdbf206b81fa476126f3e16110c818bac396"}, - {file = "cryptography-42.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:20180da1b508f4aefc101cebc14c57043a02b355d1a652b6e8e537967f1e1b46"}, - {file = "cryptography-42.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:5fbf0f3f0fac7c089308bd771d2c6c7b7d53ae909dce1db52d8e921f6c19bb3a"}, - {file = "cryptography-42.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c23f03cfd7d9826cdcbad7850de67e18b4654179e01fe9bc623d37c2638eb4ef"}, - {file = "cryptography-42.0.3-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db0480ffbfb1193ac4e1e88239f31314fe4c6cdcf9c0b8712b55414afbf80db4"}, - {file = "cryptography-42.0.3-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:6c25e1e9c2ce682d01fc5e2dde6598f7313027343bd14f4049b82ad0402e52cd"}, - {file = "cryptography-42.0.3-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:9541c69c62d7446539f2c1c06d7046aef822940d248fa4b8962ff0302862cc1f"}, - {file = "cryptography-42.0.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:1b797099d221df7cce5ff2a1d272761d1554ddf9a987d3e11f6459b38cd300fd"}, - {file = "cryptography-42.0.3.tar.gz", hash = "sha256:069d2ce9be5526a44093a0991c450fe9906cdf069e0e7cd67d9dee49a62b9ebe"}, + {file = "cryptography-42.0.2-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:701171f825dcab90969596ce2af253143b93b08f1a716d4b2a9d2db5084ef7be"}, + {file = "cryptography-42.0.2-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:61321672b3ac7aade25c40449ccedbc6db72c7f5f0fdf34def5e2f8b51ca530d"}, + {file = "cryptography-42.0.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea2c3ffb662fec8bbbfce5602e2c159ff097a4631d96235fcf0fb00e59e3ece4"}, + {file = "cryptography-42.0.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b15c678f27d66d247132cbf13df2f75255627bcc9b6a570f7d2fd08e8c081d2"}, + {file = "cryptography-42.0.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8e88bb9eafbf6a4014d55fb222e7360eef53e613215085e65a13290577394529"}, + {file = "cryptography-42.0.2-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a047682d324ba56e61b7ea7c7299d51e61fd3bca7dad2ccc39b72bd0118d60a1"}, + {file = "cryptography-42.0.2-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:36d4b7c4be6411f58f60d9ce555a73df8406d484ba12a63549c88bd64f7967f1"}, + {file = "cryptography-42.0.2-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:a00aee5d1b6c20620161984f8ab2ab69134466c51f58c052c11b076715e72929"}, + {file = "cryptography-42.0.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b97fe7d7991c25e6a31e5d5e795986b18fbbb3107b873d5f3ae6dc9a103278e9"}, + {file = "cryptography-42.0.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5fa82a26f92871eca593b53359c12ad7949772462f887c35edaf36f87953c0e2"}, + {file = "cryptography-42.0.2-cp37-abi3-win32.whl", hash = "sha256:4b063d3413f853e056161eb0c7724822a9740ad3caa24b8424d776cebf98e7ee"}, + {file = "cryptography-42.0.2-cp37-abi3-win_amd64.whl", hash = "sha256:841ec8af7a8491ac76ec5a9522226e287187a3107e12b7d686ad354bb78facee"}, + {file = "cryptography-42.0.2-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:55d1580e2d7e17f45d19d3b12098e352f3a37fe86d380bf45846ef257054b242"}, + {file = "cryptography-42.0.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28cb2c41f131a5758d6ba6a0504150d644054fd9f3203a1e8e8d7ac3aea7f73a"}, + {file = "cryptography-42.0.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b9097a208875fc7bbeb1286d0125d90bdfed961f61f214d3f5be62cd4ed8a446"}, + {file = "cryptography-42.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:44c95c0e96b3cb628e8452ec060413a49002a247b2b9938989e23a2c8291fc90"}, + {file = "cryptography-42.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2f9f14185962e6a04ab32d1abe34eae8a9001569ee4edb64d2304bf0d65c53f3"}, + {file = "cryptography-42.0.2-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:09a77e5b2e8ca732a19a90c5bca2d124621a1edb5438c5daa2d2738bfeb02589"}, + {file = "cryptography-42.0.2-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:ad28cff53f60d99a928dfcf1e861e0b2ceb2bc1f08a074fdd601b314e1cc9e0a"}, + {file = "cryptography-42.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:130c0f77022b2b9c99d8cebcdd834d81705f61c68e91ddd614ce74c657f8b3ea"}, + {file = "cryptography-42.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:fa3dec4ba8fb6e662770b74f62f1a0c7d4e37e25b58b2bf2c1be4c95372b4a33"}, + {file = "cryptography-42.0.2-cp39-abi3-win32.whl", hash = "sha256:3dbd37e14ce795b4af61b89b037d4bc157f2cb23e676fa16932185a04dfbf635"}, + {file = "cryptography-42.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:8a06641fb07d4e8f6c7dda4fc3f8871d327803ab6542e33831c7ccfdcb4d0ad6"}, + {file = "cryptography-42.0.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:087887e55e0b9c8724cf05361357875adb5c20dec27e5816b653492980d20380"}, + {file = "cryptography-42.0.2-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a7ef8dd0bf2e1d0a27042b231a3baac6883cdd5557036f5e8df7139255feaac6"}, + {file = "cryptography-42.0.2-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:4383b47f45b14459cab66048d384614019965ba6c1a1a141f11b5a551cace1b2"}, + {file = "cryptography-42.0.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:fbeb725c9dc799a574518109336acccaf1303c30d45c075c665c0793c2f79a7f"}, + {file = "cryptography-42.0.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:320948ab49883557a256eab46149df79435a22d2fefd6a66fe6946f1b9d9d008"}, + {file = "cryptography-42.0.2-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5ef9bc3d046ce83c4bbf4c25e1e0547b9c441c01d30922d812e887dc5f125c12"}, + {file = "cryptography-42.0.2-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:52ed9ebf8ac602385126c9a2fe951db36f2cb0c2538d22971487f89d0de4065a"}, + {file = "cryptography-42.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:141e2aa5ba100d3788c0ad7919b288f89d1fe015878b9659b307c9ef867d3a65"}, + {file = "cryptography-42.0.2.tar.gz", hash = "sha256:e0ec52ba3c7f1b7d813cd52649a5b3ef1fc0d433219dc8c93827c57eab6cf888"}, ] [package.dependencies] @@ -656,13 +677,13 @@ files = [ [[package]] name = "identify" -version = "2.5.34" +version = "2.5.33" description = "File identification library for Python" optional = false python-versions = ">=3.8" files = [ - {file = "identify-2.5.34-py2.py3-none-any.whl", hash = "sha256:a4316013779e433d08b96e5eabb7f641e6c7942e4ab5d4c509ebd2e7a8994aed"}, - {file = "identify-2.5.34.tar.gz", hash = "sha256:ee17bc9d499899bc9eaec1ac7bf2dc9eedd480db9d88b96d123d3b64a9d34f5d"}, + {file = "identify-2.5.33-py2.py3-none-any.whl", hash = "sha256:d40ce5fcd762817627670da8a7d8d8e65f24342d14539c59488dc603bf662e34"}, + {file = "identify-2.5.33.tar.gz", hash = "sha256:161558f9fe4559e1557e1bff323e8631f6a0e4837f7497767c1782832f16b62d"}, ] [package.extras] @@ -936,13 +957,13 @@ test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest- [[package]] name = "pre-commit" -version = "3.6.1" +version = "3.6.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." optional = false python-versions = ">=3.9" files = [ - {file = "pre_commit-3.6.1-py2.py3-none-any.whl", hash = "sha256:9fe989afcf095d2c4796ce7c553cf28d4d4a9b9346de3cda079bcf40748454a4"}, - {file = "pre_commit-3.6.1.tar.gz", hash = "sha256:c90961d8aa706f75d60935aba09469a6b0bcb8345f127c3fbee4bdc5f114cf4b"}, + {file = "pre_commit-3.6.0-py2.py3-none-any.whl", hash = "sha256:c255039ef399049a5544b6ce13d135caba8f2c28c3b4033277a788f434308376"}, + {file = "pre_commit-3.6.0.tar.gz", hash = "sha256:d30bad9abf165f7785c15a21a1f46da7d0677cb00ee7ff4c579fd38922efe15d"}, ] [package.dependencies] @@ -1153,18 +1174,18 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "setuptools" -version = "69.1.0" +version = "69.0.3" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "setuptools-69.1.0-py3-none-any.whl", hash = "sha256:c054629b81b946d63a9c6e732bc8b2513a7c3ea645f11d0139a2191d735c60c6"}, - {file = "setuptools-69.1.0.tar.gz", hash = "sha256:850894c4195f09c4ed30dba56213bf7c3f21d86ed6bdaafb5df5972593bfc401"}, + {file = "setuptools-69.0.3-py3-none-any.whl", hash = "sha256:385eb4edd9c9d5c17540511303e39a147ce2fc04bc55289c322b9e5904fe2c05"}, + {file = "setuptools-69.0.3.tar.gz", hash = "sha256:be1af57fc409f93647f2e8e4573a142ed38724b8cdd389706a867bb4efcf1e78"}, ] [package.extras] docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.1)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] [[package]] @@ -1189,6 +1210,17 @@ files = [ {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, ] +[[package]] +name = "soupsieve" +version = "2.5" +description = "A modern CSS selector implementation for Beautiful Soup." +optional = false +python-versions = ">=3.8" +files = [ + {file = "soupsieve-2.5-py3-none-any.whl", hash = "sha256:eaa337ff55a1579b6549dc679565eac1e3d000563bcb1c8ab0d0fefbc0c2cdc7"}, + {file = "soupsieve-2.5.tar.gz", hash = "sha256:5663d5a7b3bfaeee0bc4372e7fc48f9cff4940b3eec54a6451cc5299f1097690"}, +] + [[package]] name = "sphinx" version = "7.2.6" @@ -1746,4 +1778,4 @@ scan = ["bleak"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<3.13" -content-hash = "b5fc6932f5ef4606b7117906804439785eb86cccabc82dcc828843c76a76ea4d" +content-hash = "696a56ccbba231e3ec702aaee911977819b996d21074b37807c42a45d107c7ab" diff --git a/pyproject.toml b/pyproject.toml index 15c4586..02c3e8e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ packages = [{ include = "findmy" }] python = ">=3.9,<3.13" srp = "^1.0.20" cryptography = "^42.0.2" +beautifulsoup4 = "^4.12.2" aiohttp = "^3.9.1" bleak = "^0.21.1" @@ -41,7 +42,7 @@ select = [ ignore = [ "ANN101", # annotations on `self` "ANN102", # annotations on `cls` - "FIX001", "FIX002", # resolving TODOs + "FIX002", # resolving TODOs "D203", # one blank line before class docstring "D212", # multi-line docstring start at first line From 8725c20ed084dba71e88da26ed7dbc0397aa82f4 Mon Sep 17 00:00:00 2001 From: Jan Hajek Date: Mon, 26 Feb 2024 08:55:19 +0100 Subject: [PATCH 07/34] Use Bluetooth MAC address instead of UUID on MacOS --- findmy/scanner/scanner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/findmy/scanner/scanner.py b/findmy/scanner/scanner.py index 0371644..408896c 100644 --- a/findmy/scanner/scanner.py +++ b/findmy/scanner/scanner.py @@ -131,7 +131,7 @@ class OfflineFindingScanner: You most likely do not want to use this yourself; check out `OfflineFindingScanner.create` instead. """ - self._scanner: BleakScanner = BleakScanner(self._scan_callback) + self._scanner: BleakScanner = BleakScanner(self._scan_callback, cb=dict(use_bdaddr=True)) self._loop = loop self._device_fut: asyncio.Future[tuple[BLEDevice, AdvertisementData]] = loop.create_future() From 9ccc22b1e7e8257581824b8591b2cbbafa9a8f7d Mon Sep 17 00:00:00 2001 From: Mike Almeloo Date: Thu, 29 Feb 2024 17:15:09 +0100 Subject: [PATCH 08/34] Create FUNDING.yml --- .github/FUNDING.yml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..b2408f4 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,4 @@ +# These are supported funding model platforms + +github: + - malmeloo From 77f24ed3c9dd0aee9b1e3d006188f6f93eb11e21 Mon Sep 17 00:00:00 2001 From: Mike Almeloo Date: Thu, 29 Feb 2024 17:15:54 +0100 Subject: [PATCH 09/34] Update FUNDING.yml --- .github/FUNDING.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index b2408f4..d90f350 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,4 +1 @@ -# These are supported funding model platforms - -github: - - malmeloo +github: [malmeloo] From 31b4e3825a219e50198ebeb2c2a7153397b510b0 Mon Sep 17 00:00:00 2001 From: Mike Almeloo Date: Thu, 29 Feb 2024 17:25:56 +0100 Subject: [PATCH 10/34] Add derivative projects to README.md --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index b7c07af..947901e 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,13 @@ pre-commit install After following the above steps, your code will be linted and formatted automatically before committing it. +## Derivative projects + +There are several other cool projects based on this library! Some of them have been listed below, make sure to check them out as well. + +* [OfflineFindRecovery](https://github.com/hajekj/OfflineFindRecovery) - Set of scripts to be able to precisely locate your lost MacBook via Apple's Offline Find through Bluetooth Low Energy. +* [SwiftFindMy](https://github.com/airy10/SwiftFindMy) - Swift port of FindMy.py + ## Credits While I designed the library, the vast majority of actual functionality From fba76913dcd9fc5a96206cfa210e91c166c4d3fa Mon Sep 17 00:00:00 2001 From: Mike Almeloo Date: Thu, 29 Feb 2024 18:00:57 +0100 Subject: [PATCH 11/34] Create draft release on publish --- .github/workflows/publish.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7ff53da..d793c28 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -32,3 +32,9 @@ jobs: run: | poetry config pypi-token.pypi ${{ secrets.PYPI_API_TOKEN }} poetry publish + + - name: Create release + uses: softprops/action-gh-release@v1 + with: + draft: true + files: dist/* From 73f4a049d4435b74da2fbb69b2207111d0469d5a Mon Sep 17 00:00:00 2001 From: Mike A Date: Thu, 29 Feb 2024 18:07:04 +0100 Subject: [PATCH 12/34] chore(deps): Update dependencies --- poetry.lock | 120 ++++++++++++++++++++++++------------------------- pyproject.toml | 2 +- 2 files changed, 61 insertions(+), 61 deletions(-) diff --git a/poetry.lock b/poetry.lock index c78d880..205f5a7 100644 --- a/poetry.lock +++ b/poetry.lock @@ -134,13 +134,13 @@ files = [ [[package]] name = "astroid" -version = "3.0.3" +version = "3.1.0" description = "An abstract syntax tree for Python with inference support." optional = false python-versions = ">=3.8.0" files = [ - {file = "astroid-3.0.3-py3-none-any.whl", hash = "sha256:92fcf218b89f449cdf9f7b39a269f8d5d617b27be68434912e11e79203963a17"}, - {file = "astroid-3.0.3.tar.gz", hash = "sha256:4148645659b08b70d72460ed1921158027a9e53ae8b7234149b1400eddacbb93"}, + {file = "astroid-3.1.0-py3-none-any.whl", hash = "sha256:951798f922990137ac090c53af473db7ab4e70c770e6d7fae0cec59f74411819"}, + {file = "astroid-3.1.0.tar.gz", hash = "sha256:ac248253bfa4bd924a0de213707e7ebeeb3138abeb48d798784ead1e56d419d4"}, ] [package.dependencies] @@ -456,43 +456,43 @@ files = [ [[package]] name = "cryptography" -version = "42.0.2" +version = "42.0.5" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = ">=3.7" files = [ - {file = "cryptography-42.0.2-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:701171f825dcab90969596ce2af253143b93b08f1a716d4b2a9d2db5084ef7be"}, - {file = "cryptography-42.0.2-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:61321672b3ac7aade25c40449ccedbc6db72c7f5f0fdf34def5e2f8b51ca530d"}, - {file = "cryptography-42.0.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea2c3ffb662fec8bbbfce5602e2c159ff097a4631d96235fcf0fb00e59e3ece4"}, - {file = "cryptography-42.0.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b15c678f27d66d247132cbf13df2f75255627bcc9b6a570f7d2fd08e8c081d2"}, - {file = "cryptography-42.0.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8e88bb9eafbf6a4014d55fb222e7360eef53e613215085e65a13290577394529"}, - {file = "cryptography-42.0.2-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a047682d324ba56e61b7ea7c7299d51e61fd3bca7dad2ccc39b72bd0118d60a1"}, - {file = "cryptography-42.0.2-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:36d4b7c4be6411f58f60d9ce555a73df8406d484ba12a63549c88bd64f7967f1"}, - {file = "cryptography-42.0.2-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:a00aee5d1b6c20620161984f8ab2ab69134466c51f58c052c11b076715e72929"}, - {file = "cryptography-42.0.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b97fe7d7991c25e6a31e5d5e795986b18fbbb3107b873d5f3ae6dc9a103278e9"}, - {file = "cryptography-42.0.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5fa82a26f92871eca593b53359c12ad7949772462f887c35edaf36f87953c0e2"}, - {file = "cryptography-42.0.2-cp37-abi3-win32.whl", hash = "sha256:4b063d3413f853e056161eb0c7724822a9740ad3caa24b8424d776cebf98e7ee"}, - {file = "cryptography-42.0.2-cp37-abi3-win_amd64.whl", hash = "sha256:841ec8af7a8491ac76ec5a9522226e287187a3107e12b7d686ad354bb78facee"}, - {file = "cryptography-42.0.2-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:55d1580e2d7e17f45d19d3b12098e352f3a37fe86d380bf45846ef257054b242"}, - {file = "cryptography-42.0.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28cb2c41f131a5758d6ba6a0504150d644054fd9f3203a1e8e8d7ac3aea7f73a"}, - {file = "cryptography-42.0.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b9097a208875fc7bbeb1286d0125d90bdfed961f61f214d3f5be62cd4ed8a446"}, - {file = "cryptography-42.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:44c95c0e96b3cb628e8452ec060413a49002a247b2b9938989e23a2c8291fc90"}, - {file = "cryptography-42.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2f9f14185962e6a04ab32d1abe34eae8a9001569ee4edb64d2304bf0d65c53f3"}, - {file = "cryptography-42.0.2-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:09a77e5b2e8ca732a19a90c5bca2d124621a1edb5438c5daa2d2738bfeb02589"}, - {file = "cryptography-42.0.2-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:ad28cff53f60d99a928dfcf1e861e0b2ceb2bc1f08a074fdd601b314e1cc9e0a"}, - {file = "cryptography-42.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:130c0f77022b2b9c99d8cebcdd834d81705f61c68e91ddd614ce74c657f8b3ea"}, - {file = "cryptography-42.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:fa3dec4ba8fb6e662770b74f62f1a0c7d4e37e25b58b2bf2c1be4c95372b4a33"}, - {file = "cryptography-42.0.2-cp39-abi3-win32.whl", hash = "sha256:3dbd37e14ce795b4af61b89b037d4bc157f2cb23e676fa16932185a04dfbf635"}, - {file = "cryptography-42.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:8a06641fb07d4e8f6c7dda4fc3f8871d327803ab6542e33831c7ccfdcb4d0ad6"}, - {file = "cryptography-42.0.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:087887e55e0b9c8724cf05361357875adb5c20dec27e5816b653492980d20380"}, - {file = "cryptography-42.0.2-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a7ef8dd0bf2e1d0a27042b231a3baac6883cdd5557036f5e8df7139255feaac6"}, - {file = "cryptography-42.0.2-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:4383b47f45b14459cab66048d384614019965ba6c1a1a141f11b5a551cace1b2"}, - {file = "cryptography-42.0.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:fbeb725c9dc799a574518109336acccaf1303c30d45c075c665c0793c2f79a7f"}, - {file = "cryptography-42.0.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:320948ab49883557a256eab46149df79435a22d2fefd6a66fe6946f1b9d9d008"}, - {file = "cryptography-42.0.2-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5ef9bc3d046ce83c4bbf4c25e1e0547b9c441c01d30922d812e887dc5f125c12"}, - {file = "cryptography-42.0.2-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:52ed9ebf8ac602385126c9a2fe951db36f2cb0c2538d22971487f89d0de4065a"}, - {file = "cryptography-42.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:141e2aa5ba100d3788c0ad7919b288f89d1fe015878b9659b307c9ef867d3a65"}, - {file = "cryptography-42.0.2.tar.gz", hash = "sha256:e0ec52ba3c7f1b7d813cd52649a5b3ef1fc0d433219dc8c93827c57eab6cf888"}, + {file = "cryptography-42.0.5-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:a30596bae9403a342c978fb47d9b0ee277699fa53bbafad14706af51fe543d16"}, + {file = "cryptography-42.0.5-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:b7ffe927ee6531c78f81aa17e684e2ff617daeba7f189f911065b2ea2d526dec"}, + {file = "cryptography-42.0.5-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2424ff4c4ac7f6b8177b53c17ed5d8fa74ae5955656867f5a8affaca36a27abb"}, + {file = "cryptography-42.0.5-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:329906dcc7b20ff3cad13c069a78124ed8247adcac44b10bea1130e36caae0b4"}, + {file = "cryptography-42.0.5-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:b03c2ae5d2f0fc05f9a2c0c997e1bc18c8229f392234e8a0194f202169ccd278"}, + {file = "cryptography-42.0.5-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8837fe1d6ac4a8052a9a8ddab256bc006242696f03368a4009be7ee3075cdb7"}, + {file = "cryptography-42.0.5-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:0270572b8bd2c833c3981724b8ee9747b3ec96f699a9665470018594301439ee"}, + {file = "cryptography-42.0.5-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:b8cac287fafc4ad485b8a9b67d0ee80c66bf3574f655d3b97ef2e1082360faf1"}, + {file = "cryptography-42.0.5-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:16a48c23a62a2f4a285699dba2e4ff2d1cff3115b9df052cdd976a18856d8e3d"}, + {file = "cryptography-42.0.5-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2bce03af1ce5a5567ab89bd90d11e7bbdff56b8af3acbbec1faded8f44cb06da"}, + {file = "cryptography-42.0.5-cp37-abi3-win32.whl", hash = "sha256:b6cd2203306b63e41acdf39aa93b86fb566049aeb6dc489b70e34bcd07adca74"}, + {file = "cryptography-42.0.5-cp37-abi3-win_amd64.whl", hash = "sha256:98d8dc6d012b82287f2c3d26ce1d2dd130ec200c8679b6213b3c73c08b2b7940"}, + {file = "cryptography-42.0.5-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:5e6275c09d2badf57aea3afa80d975444f4be8d3bc58f7f80d2a484c6f9485c8"}, + {file = "cryptography-42.0.5-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4985a790f921508f36f81831817cbc03b102d643b5fcb81cd33df3fa291a1a1"}, + {file = "cryptography-42.0.5-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7cde5f38e614f55e28d831754e8a3bacf9ace5d1566235e39d91b35502d6936e"}, + {file = "cryptography-42.0.5-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7367d7b2eca6513681127ebad53b2582911d1736dc2ffc19f2c3ae49997496bc"}, + {file = "cryptography-42.0.5-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cd2030f6650c089aeb304cf093f3244d34745ce0cfcc39f20c6fbfe030102e2a"}, + {file = "cryptography-42.0.5-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a2913c5375154b6ef2e91c10b5720ea6e21007412f6437504ffea2109b5a33d7"}, + {file = "cryptography-42.0.5-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:c41fb5e6a5fe9ebcd58ca3abfeb51dffb5d83d6775405305bfa8715b76521922"}, + {file = "cryptography-42.0.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3eaafe47ec0d0ffcc9349e1708be2aaea4c6dd4978d76bf6eb0cb2c13636c6fc"}, + {file = "cryptography-42.0.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1b95b98b0d2af784078fa69f637135e3c317091b615cd0905f8b8a087e86fa30"}, + {file = "cryptography-42.0.5-cp39-abi3-win32.whl", hash = "sha256:1f71c10d1e88467126f0efd484bd44bca5e14c664ec2ede64c32f20875c0d413"}, + {file = "cryptography-42.0.5-cp39-abi3-win_amd64.whl", hash = "sha256:a011a644f6d7d03736214d38832e030d8268bcff4a41f728e6030325fea3e400"}, + {file = "cryptography-42.0.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:9481ffe3cf013b71b2428b905c4f7a9a4f76ec03065b05ff499bb5682a8d9ad8"}, + {file = "cryptography-42.0.5-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:ba334e6e4b1d92442b75ddacc615c5476d4ad55cc29b15d590cc6b86efa487e2"}, + {file = "cryptography-42.0.5-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:ba3e4a42397c25b7ff88cdec6e2a16c2be18720f317506ee25210f6d31925f9c"}, + {file = "cryptography-42.0.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:111a0d8553afcf8eb02a4fea6ca4f59d48ddb34497aa8706a6cf536f1a5ec576"}, + {file = "cryptography-42.0.5-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:cd65d75953847815962c84a4654a84850b2bb4aed3f26fadcc1c13892e1e29f6"}, + {file = "cryptography-42.0.5-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:e807b3188f9eb0eaa7bbb579b462c5ace579f1cedb28107ce8b48a9f7ad3679e"}, + {file = "cryptography-42.0.5-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f12764b8fffc7a123f641d7d049d382b73f96a34117e0b637b80643169cec8ac"}, + {file = "cryptography-42.0.5-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:37dd623507659e08be98eec89323469e8c7b4c1407c85112634ae3dbdb926fdd"}, + {file = "cryptography-42.0.5.tar.gz", hash = "sha256:6fe07eec95dfd477eb9530aef5bead34fec819b3aaf6c5bd6d20565da607bfe1"}, ] [package.dependencies] @@ -677,13 +677,13 @@ files = [ [[package]] name = "identify" -version = "2.5.33" +version = "2.5.35" description = "File identification library for Python" optional = false python-versions = ">=3.8" files = [ - {file = "identify-2.5.33-py2.py3-none-any.whl", hash = "sha256:d40ce5fcd762817627670da8a7d8d8e65f24342d14539c59488dc603bf662e34"}, - {file = "identify-2.5.33.tar.gz", hash = "sha256:161558f9fe4559e1557e1bff323e8631f6a0e4837f7497767c1782832f16b62d"}, + {file = "identify-2.5.35-py2.py3-none-any.whl", hash = "sha256:c4de0081837b211594f8e877a6b4fad7ca32bbfc1a9307fdd61c28bfe923f13e"}, + {file = "identify-2.5.35.tar.gz", hash = "sha256:10a7ca245cfcd756a554a7288159f72ff105ad233c7c4b9c6f0f4d108f5f6791"}, ] [package.extras] @@ -957,13 +957,13 @@ test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest- [[package]] name = "pre-commit" -version = "3.6.0" +version = "3.6.2" description = "A framework for managing and maintaining multi-language pre-commit hooks." optional = false python-versions = ">=3.9" files = [ - {file = "pre_commit-3.6.0-py2.py3-none-any.whl", hash = "sha256:c255039ef399049a5544b6ce13d135caba8f2c28c3b4033277a788f434308376"}, - {file = "pre_commit-3.6.0.tar.gz", hash = "sha256:d30bad9abf165f7785c15a21a1f46da7d0677cb00ee7ff4c579fd38922efe15d"}, + {file = "pre_commit-3.6.2-py2.py3-none-any.whl", hash = "sha256:ba637c2d7a670c10daedc059f5c49b5bd0aadbccfcd7ec15592cf9665117532c"}, + {file = "pre_commit-3.6.2.tar.gz", hash = "sha256:c3ef34f463045c88658c5b99f38c1e297abdcc0ff13f98d3370055fbbfabc67e"}, ] [package.dependencies] @@ -1075,13 +1075,13 @@ pyobjc-core = ">=9.2" [[package]] name = "pyright" -version = "1.1.350" +version = "1.1.352" description = "Command line wrapper for pyright" optional = false python-versions = ">=3.7" files = [ - {file = "pyright-1.1.350-py3-none-any.whl", hash = "sha256:f1dde6bcefd3c90aedbe9dd1c573e4c1ddbca8c74bf4fa664dd3b1a599ac9a66"}, - {file = "pyright-1.1.350.tar.gz", hash = "sha256:a8ba676de3a3737ea4d8590604da548d4498cc5ee9ee00b1a403c6db987916c6"}, + {file = "pyright-1.1.352-py3-none-any.whl", hash = "sha256:0040cf173c6a60704e553bfd129dfe54de59cc76d0b2b80f77cfab4f50701d64"}, + {file = "pyright-1.1.352.tar.gz", hash = "sha256:a621c0dfbcf1291b3610641a07380fefaa1d0e182890a1b2a7f13b446e8109a9"}, ] [package.dependencies] @@ -1174,19 +1174,19 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "setuptools" -version = "69.0.3" +version = "69.1.1" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "setuptools-69.0.3-py3-none-any.whl", hash = "sha256:385eb4edd9c9d5c17540511303e39a147ce2fc04bc55289c322b9e5904fe2c05"}, - {file = "setuptools-69.0.3.tar.gz", hash = "sha256:be1af57fc409f93647f2e8e4573a142ed38724b8cdd389706a867bb4efcf1e78"}, + {file = "setuptools-69.1.1-py3-none-any.whl", hash = "sha256:02fa291a0471b3a18b2b2481ed902af520c69e8ae0919c13da936542754b4c56"}, + {file = "setuptools-69.1.1.tar.gz", hash = "sha256:5c0806c7d9af348e6dd3777b4f4dbb42c7ad85b190104837488eab9a7c945cf8"}, ] [package.extras] docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] -testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.1)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pip (>=19.1)", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] [[package]] name = "six" @@ -1390,24 +1390,24 @@ six = "*" [[package]] name = "typing-extensions" -version = "4.9.0" +version = "4.10.0" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" files = [ - {file = "typing_extensions-4.9.0-py3-none-any.whl", hash = "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd"}, - {file = "typing_extensions-4.9.0.tar.gz", hash = "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783"}, + {file = "typing_extensions-4.10.0-py3-none-any.whl", hash = "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475"}, + {file = "typing_extensions-4.10.0.tar.gz", hash = "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb"}, ] [[package]] name = "urllib3" -version = "2.2.0" +version = "2.2.1" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.8" files = [ - {file = "urllib3-2.2.0-py3-none-any.whl", hash = "sha256:ce3711610ddce217e6d113a2732fafad960a03fd0318c91faa79481e35c11224"}, - {file = "urllib3-2.2.0.tar.gz", hash = "sha256:051d961ad0c62a94e50ecf1af379c3aba230c66c710493493560c0c223c49f20"}, + {file = "urllib3-2.2.1-py3-none-any.whl", hash = "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d"}, + {file = "urllib3-2.2.1.tar.gz", hash = "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19"}, ] [package.extras] @@ -1418,13 +1418,13 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "virtualenv" -version = "20.25.0" +version = "20.25.1" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.7" files = [ - {file = "virtualenv-20.25.0-py3-none-any.whl", hash = "sha256:4238949c5ffe6876362d9c0180fc6c3a824a7b12b80604eeb8085f2ed7460de3"}, - {file = "virtualenv-20.25.0.tar.gz", hash = "sha256:bf51c0d9c7dd63ea8e44086fa1e4fb1093a31e963b86959257378aef020e1f1b"}, + {file = "virtualenv-20.25.1-py3-none-any.whl", hash = "sha256:961c026ac520bac5f69acb8ea063e8a4f071bcc9457b9c1f28f6b085c511583a"}, + {file = "virtualenv-20.25.1.tar.gz", hash = "sha256:e08e13ecdca7a0bd53798f356d5831434afa5b07b93f0abdf0797b7a06ffe197"}, ] [package.dependencies] @@ -1778,4 +1778,4 @@ scan = ["bleak"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<3.13" -content-hash = "696a56ccbba231e3ec702aaee911977819b996d21074b37807c42a45d107c7ab" +content-hash = "0c628a22aa23af1e1bba0d769f6116d2547468345beafe549e6fb6e5aba60414" diff --git a/pyproject.toml b/pyproject.toml index 02c3e8e..f8ff347 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ packages = [{ include = "findmy" }] [tool.poetry.dependencies] python = ">=3.9,<3.13" srp = "^1.0.20" -cryptography = "^42.0.2" +cryptography = "^42.0.5" beautifulsoup4 = "^4.12.2" aiohttp = "^3.9.1" bleak = "^0.21.1" From 70e168833931aa518d040da60bdd024f7788f564 Mon Sep 17 00:00:00 2001 From: Mike A Date: Thu, 29 Feb 2024 18:10:53 +0100 Subject: [PATCH 13/34] Bump version: `0.4.0` -> `0.5.0` --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f8ff347..c241125 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "FindMy" -version = "0.4.0" +version = "0.5.0" description = "Everything you need to work with Apple's Find My network!" authors = ["Mike Almeloo "] readme = "README.md" From 2e256d09897deae0aa94daf56e742848a04fa0b1 Mon Sep 17 00:00:00 2001 From: Mike A Date: Thu, 29 Feb 2024 18:20:23 +0100 Subject: [PATCH 14/34] fix(ci): Do not filter tags on branches --- .github/workflows/docs.yml | 1 - .github/workflows/publish.yml | 1 - 2 files changed, 2 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index f8a3c72..345446b 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -9,7 +9,6 @@ on: jobs: deploy: runs-on: ubuntu-latest - if: github.ref == 'refs/heads/main' permissions: pages: write diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d793c28..85ef4d1 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -10,7 +10,6 @@ on: jobs: deploy: runs-on: ubuntu-latest - if: github.ref == 'refs/heads/main' steps: - uses: actions/checkout@v4 From 30d310b9f62c8a0d1710809bed0254438c152e16 Mon Sep 17 00:00:00 2001 From: Mike Almeloo Date: Thu, 29 Feb 2024 18:25:05 +0100 Subject: [PATCH 15/34] fix(ci): Allow creation of releases --- .github/workflows/publish.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 85ef4d1..b1d2c14 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,5 +1,7 @@ name: Upload Python Package +permissions: + contents: write on: workflow_dispatch: From 8da681c9ca4daa2abde686b7408bae4bcc74a84a Mon Sep 17 00:00:00 2001 From: Mike Almeloo Date: Thu, 29 Feb 2024 18:52:38 +0100 Subject: [PATCH 16/34] chore(docs): Update current feature list --- README.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 947901e..4e1f49c 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ application wishing to integrate with the Find My network. - [x] Fetch location reports - [x] Apple acount sign-in - [x] SMS 2FA support + - [x] Trusted Device 2FA support - [x] Scan for nearby FindMy-devices - [x] Decode their info, such as public keys and status bytes - [x] Import or create your own accessory keys @@ -34,8 +35,6 @@ application wishing to integrate with the Find My network. ### Roadmap -- [ ] Trusted device 2FA - - Work has been done, but needs testing (I don't own any Apple devices) - [ ] Local anisette generation (without server) - Can be done using [pyprovision](https://github.com/Dadoum/pyprovision/), however I want to wait until Python wheels are available. @@ -48,7 +47,7 @@ The package can be installed from [PyPi](https://pypi.org/project/findmy/): pip install findmy ``` -For usage examples, see the [examples](examples) directory. Documentation coming soon™. +For usage examples, see the [examples](examples) directory. Documentation can be found [here](http://docs.mikealmel.ooo/FindMy.py/). ## Contributing From b97b094ccae0ca0c48a3900d1320a724d988d441 Mon Sep 17 00:00:00 2001 From: Mike A Date: Tue, 5 Mar 2024 23:55:40 +0100 Subject: [PATCH 17/34] chore(ani): Refactoring to improve readability --- findmy/reports/account.py | 154 +++++++++++++-------------- findmy/reports/anisette.py | 211 ++++++++++++++++++++++++++++++------- findmy/util/crypto.py | 37 ++++++- pyproject.toml | 1 + 4 files changed, 276 insertions(+), 127 deletions(-) diff --git a/findmy/reports/account.py b/findmy/reports/account.py index bf6ae08..1efb967 100644 --- a/findmy/reports/account.py +++ b/findmy/reports/account.py @@ -3,8 +3,6 @@ from __future__ import annotations import asyncio import base64 -import hashlib -import hmac import json import logging import plistlib @@ -26,12 +24,10 @@ from typing import ( import bs4 import srp._pysrp as srp -from cryptography.hazmat.primitives import hashes, padding -from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes -from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC from typing_extensions import override from findmy.errors import InvalidCredentialsError, InvalidStateError, UnhandledProtocolError +from findmy.util import crypto from findmy.util.closable import Closable from findmy.util.http import HttpSession, decode_plist @@ -96,32 +92,6 @@ def require_login_state(*states: LoginState) -> Callable[[_F], _F]: return decorator -def _encrypt_password(password: str, salt: bytes, iterations: int) -> bytes: - p = hashlib.sha256(password.encode("utf-8")).digest() - kdf = PBKDF2HMAC( - algorithm=hashes.SHA256(), - length=32, - salt=salt, - iterations=iterations, - ) - return kdf.derive(p) - - -def _decrypt_cbc(session_key: bytes, data: bytes) -> bytes: - extra_data_key = hmac.new(session_key, b"extra data key:", hashlib.sha256).digest() - extra_data_iv = hmac.new(session_key, b"extra data iv:", hashlib.sha256).digest() - # Get only the first 16 bytes of the iv - extra_data_iv = extra_data_iv[:16] - - # Decrypt with AES CBC - cipher = Cipher(algorithms.AES(extra_data_key), modes.CBC(extra_data_iv)) - decryptor = cipher.decryptor() - data = decryptor.update(data) + decryptor.finalize() - # Remove PKCS#7 padding - padder = padding.PKCS7(128).unpadder() - return padder.update(data) + padder.finalize() - - def _extract_phone_numbers(html: str) -> list[dict]: soup = bs4.BeautifulSoup(html, features="html.parser") data_elem = soup.find("script", {"class": "boot_args"}) @@ -273,7 +243,11 @@ class BaseAppleAccount(Closable, ABC): raise NotImplementedError @abstractmethod - def get_anisette_headers(self, serial: str = "0") -> MaybeCoro[dict[str, str]]: + def get_anisette_headers( + self, + with_client_info: bool = False, + serial: str = "0", + ) -> MaybeCoro[dict[str, str]]: """ Retrieve a complete dictionary of Anisette headers. @@ -285,6 +259,20 @@ class BaseAppleAccount(Closable, ABC): class AsyncAppleAccount(BaseAppleAccount): """An async implementation of `BaseAppleAccount`.""" + # auth endpoints + _ENDPOINT_GSA = "https://gsa.apple.com/grandslam/GsService2" + _ENDPOINT_LOGIN_MOBILEME = "https://setup.icloud.com/setup/iosbuddy/loginDelegates" + + # 2fa auth endpoints + _ENDPOINT_2FA_METHODS = "https://gsa.apple.com/auth" + _ENDPOINT_2FA_SMS_REQUEST = "https://gsa.apple.com/auth/verify/phone" + _ENDPOINT_2FA_SMS_SUBMIT = "https://gsa.apple.com/auth/verify/phone/securitycode" + _ENDPOINT_2FA_TD_REQUEST = "https://gsa.apple.com/auth/verify/trusteddevice" + _ENDPOINT_2FA_TD_SUBMIT = "https://gsa.apple.com/grandslam/GsService2/validate" + + # reports endpoints + _ENDPOINT_REPORTS_FETCH = "https://gateway.icloud.com/acsnservice/fetch" + def __init__( self, anisette: BaseAnisetteProvider, @@ -438,7 +426,7 @@ class AsyncAppleAccount(BaseAppleAccount): methods.append(AsyncTrustedDeviceSecondFactor(self)) # sms - auth_page = await self._sms_2fa_request("GET", "https://gsa.apple.com/auth") + auth_page = await self._sms_2fa_request("GET", self._ENDPOINT_2FA_METHODS) try: phone_numbers = _extract_phone_numbers(auth_page) methods.extend( @@ -462,7 +450,7 @@ class AsyncAppleAccount(BaseAppleAccount): await self._sms_2fa_request( "PUT", - "https://gsa.apple.com/auth/verify/phone", + self._ENDPOINT_2FA_SMS_REQUEST, data, ) @@ -478,7 +466,7 @@ class AsyncAppleAccount(BaseAppleAccount): await self._sms_2fa_request( "POST", - "https://gsa.apple.com/auth/verify/phone/securitycode", + self._ENDPOINT_2FA_SMS_SUBMIT, data, ) @@ -501,7 +489,7 @@ class AsyncAppleAccount(BaseAppleAccount): } await self._sms_2fa_request( "GET", - "https://gsa.apple.com/auth/verify/trusteddevice", + self._ENDPOINT_2FA_TD_REQUEST, headers=headers, ) @@ -516,7 +504,7 @@ class AsyncAppleAccount(BaseAppleAccount): } await self._sms_2fa_request( "GET", - "https://gsa.apple.com/grandslam/GsService2/validate", + self._ENDPOINT_2FA_TD_SUBMIT, headers=headers, ) @@ -537,8 +525,9 @@ class AsyncAppleAccount(BaseAppleAccount): self._login_state_data["mobileme_data"]["tokens"]["searchPartyToken"], ) data = {"search": [{"startDate": start, "endDate": end, "ids": ids}]} + r = await self._http.post( - "https://gateway.icloud.com/acsnservice/fetch", + self._ENDPOINT_REPORTS_FETCH, auth=auth, headers=await self.get_anisette_headers(), json=data, @@ -616,7 +605,7 @@ class AsyncAppleAccount(BaseAppleAccount): logging.debug("Attempting password challenge") - usr.p = _encrypt_password(self._password, r["s"], r["i"]) + usr.p = crypto.encrypt_password(self._password, r["s"], r["i"]) m1 = usr.process_challenge(r["s"], r["B"]) if m1 is None: msg = "Failed to process challenge" @@ -637,8 +626,12 @@ class AsyncAppleAccount(BaseAppleAccount): logging.debug("Decrypting SPD data in response") - spd = _decrypt_cbc(usr.get_session_key() or b"", r["spd"]) - spd = decode_plist(spd) + spd = decode_plist( + crypto.decrypt_spd_aes_cbc( + usr.get_session_key() or b"", + r["spd"], + ), + ) logging.debug("Received account information") self._account_info = cast( @@ -655,27 +648,23 @@ class AsyncAppleAccount(BaseAppleAccount): if au in ("secondaryAuth", "trustedDeviceSecondaryAuth"): logging.info("Detected 2FA requirement: %s", au) - self._account_info.update( - { - "trusted_device_2fa": au == "trustedDeviceSecondaryAuth", - }, - ) + self._account_info["trusted_device_2fa"] = au == "trustedDeviceSecondaryAuth" return self._set_login_state( LoginState.REQUIRE_2FA, {"adsid": spd["adsid"], "idms_token": spd["GsIdmsToken"]}, ) - if au is not None: - msg = f"Unknown auth value: {au}" - raise UnhandledProtocolError(msg) + if au is None: + logging.info("GSA authentication successful") - logging.info("GSA authentication successful") + idms_pet = spd.get("t", {}).get("com.apple.gs.idms.pet", {}).get("token", "") + return self._set_login_state( + LoginState.AUTHENTICATED, + {"idms_pet": idms_pet, "adsid": spd["adsid"]}, + ) - idms_pet = spd.get("t", {}).get("com.apple.gs.idms.pet", {}).get("token", "") - return self._set_login_state( - LoginState.AUTHENTICATED, - {"idms_pet": idms_pet, "adsid": spd["adsid"]}, - ) + msg = f"Unknown auth value: {au}" + raise UnhandledProtocolError(msg) @require_login_state(LoginState.AUTHENTICATED) async def _login_mobileme(self) -> LoginState: @@ -698,7 +687,7 @@ class AsyncAppleAccount(BaseAppleAccount): headers.update(await self.get_anisette_headers()) resp = await self._http.post( - "https://setup.icloud.com/setup/iosbuddy/loginDelegates", + self._ENDPOINT_LOGIN_MOBILEME, auth=(self._username or "", self._login_state_data["idms_pet"]), data=data, headers=headers, @@ -734,13 +723,9 @@ class AsyncAppleAccount(BaseAppleAccount): "User-Agent": "Xcode", "Accept-Language": "en-us", "X-Apple-Identity-Token": identity_token, - "X-Apple-App-Info": "com.apple.gs.xcode.auth", - "X-Xcode-Version": "11.2 (11B41)", - "X-Mme-Client-Info": " " - " ", }, ) - headers.update(await self.get_anisette_headers()) + headers.update(await self.get_anisette_headers(with_client_info=True)) r = await self._http.request( method, @@ -754,34 +739,29 @@ class AsyncAppleAccount(BaseAppleAccount): return r.text() - async def _gsa_request(self, params: dict[str, Any]) -> dict[Any, Any]: - request_data = { - "cpd": { - "bootstrap": True, - "icscrec": True, - "pbe": False, - "prkgen": True, - "svct": "iCloud", - }, - } - request_data["cpd"].update(await self.get_anisette_headers()) - request_data.update(params) - + async def _gsa_request(self, parameters: dict[str, Any]) -> dict[str, Any]: body = { - "Header": {"Version": "1.0.1"}, - "Request": request_data, + "Header": { + "Version": "1.0.1", + }, + "Request": { + "cpd": await self._anisette.get_cpd( + self._uid, + self._devid, + ), + **parameters, + }, } headers = { "Content-Type": "text/x-xml-plist", "Accept": "*/*", "User-Agent": "akd/1.0 CFNetwork/978.0.7 Darwin/18.7.0", - "X-MMe-Client-Info": " " - "", + "X-MMe-Client-Info": self._anisette.client, } resp = await self._http.post( - "https://gsa.apple.com/grandslam/GsService2", + self._ENDPOINT_GSA, headers=headers, data=plistlib.dumps(body), ) @@ -791,9 +771,13 @@ class AsyncAppleAccount(BaseAppleAccount): return resp.plist()["Response"] @override - async def get_anisette_headers(self, serial: str = "0") -> dict[str, str]: + async def get_anisette_headers( + self, + with_client_info: bool = False, + serial: str = "0", + ) -> dict[str, str]: """See `BaseAppleAccount.get_anisette_headers`.""" - return await self._anisette.get_headers(self._uid, self._devid, serial) + return await self._anisette.get_headers(self._uid, self._devid, serial, with_client_info) class AppleAccount(BaseAppleAccount): @@ -932,7 +916,11 @@ class AppleAccount(BaseAppleAccount): return self._evt_loop.run_until_complete(coro) @override - def get_anisette_headers(self, serial: str = "0") -> dict[str, str]: + def get_anisette_headers( + self, + with_client_info: bool = False, + serial: str = "0", + ) -> dict[str, str]: """See `AsyncAppleAccount.get_anisette_headers`.""" - coro = self._asyncacc.get_anisette_headers(serial) + coro = self._asyncacc.get_anisette_headers(with_client_info, serial) return self._evt_loop.run_until_complete(coro) diff --git a/findmy/reports/anisette.py b/findmy/reports/anisette.py index bdf4b2e..3bfe4e9 100644 --- a/findmy/reports/anisette.py +++ b/findmy/reports/anisette.py @@ -13,48 +13,148 @@ from findmy.util.closable import Closable from findmy.util.http import HttpSession -def _gen_meta_headers( - user_id: str, - device_id: str, - serial: str = "0", -) -> dict[str, str]: - now = datetime.now(tz=timezone.utc) - locale_str = locale.getdefaultlocale()[0] or "en_US" - - return { - "X-Apple-I-Client-Time": now.replace(microsecond=0).isoformat() + "Z", - "X-Apple-I-TimeZone": str(now.astimezone().tzinfo), - "loc": locale_str, - "X-Apple-Locale": locale_str, - "X-Apple-I-MD-RINFO": "17106176", - "X-Apple-I-MD-LU": base64.b64encode(str(user_id).upper().encode()).decode(), - "X-Mme-Device-Id": str(device_id).upper(), - "X-Apple-I-SRL-NO": serial, - } - - class BaseAnisetteProvider(Closable, ABC): - """Abstract base class for Anisette providers.""" + """ + Abstract base class for Anisette providers. + Generously derived from https://github.com/nythepegasus/grandslam/blob/main/src/grandslam/gsa.py#L41. + """ + + @property @abstractmethod - async def _get_base_headers(self) -> dict[str, str]: + def otp(self) -> str: + """ + A seemingly random base64 string containing 28 bytes. + + TODO: Figure out how to generate this. + """ raise NotImplementedError + @property + @abstractmethod + def machine(self) -> str: + """ + A base64 encoded string of 60 'random' bytes. + + We're not sure how this is generated, we have to rely on the server. + TODO: Figure out how to generate this. + """ + raise NotImplementedError + + @property + def timestamp(self) -> str: + """Current timestamp in ISO 8601 format.""" + return datetime.now(tz=timezone.utc).replace(microsecond=0).isoformat() + "Z" + + @property + def timezone(self) -> str: + """Abbreviation of the timezone of the device.""" + return str(datetime.now().astimezone().tzinfo) + + @property + def locale(self) -> str: + """Locale of the device (e.g. en_US).""" + return locale.getdefaultlocale()[0] or "en_US" + + @property + def router(self) -> str: + """ + A number, either 17106176 or 50660608. + + It doesn't seem to matter which one we use. + - 17106176 is used by Sideloadly and Provision (android) based servers. + - 50660608 is used by Windows iCloud based servers. + """ + return "17106176" + + @property + def client(self) -> str: + """ + Client string. + + The format is as follows: + <%MODEL%> <%OS%;%MAJOR%.%MINOR%(%SPMAJOR%,%SPMINOR%);%BUILD%> + <%AUTHKIT_BUNDLE_ID%/%AUTHKIT_VERSION% (%APP_BUNDLE_ID%/%APP_VERSION%)> + + Where: + MODEL: The model of the device (e.g. MacBookPro15,1 or 'PC' + OS: The OS of the device (e.g. Mac OS X or Windows) + MAJOR: The major version of the OS (e.g. 10) + MINOR: The minor version of the OS (e.g. 15) + SPMAJOR: The major version of the service pack (e.g. 0) (Windows only) + SPMINOR: The minor version of the service pack (e.g. 0) (Windows only) + BUILD: The build number of the OS (e.g. 19C57) + AUTHKIT_BUNDLE_ID: The bundle ID of the AuthKit framework (e.g. com.apple.AuthKit) + AUTHKIT_VERSION: The version of the AuthKit framework (e.g. 1) + APP_BUNDLE_ID: The bundle ID of the app (e.g. com.apple.dt.Xcode) + APP_VERSION: The version of the app (e.g. 3594.4.19) + """ + return ( + " " + "" + ) + async def get_headers( self, user_id: str, device_id: str, serial: str = "0", + with_client_info: bool = False, ) -> dict[str, str]: """ - Retrieve a complete dictionary of Anisette headers. + Generate a complete dictionary of Anisette headers. Consider using `BaseAppleAccount.get_anisette_headers` instead. """ - base_headers = await self._get_base_headers() - base_headers.update(_gen_meta_headers(user_id, device_id, serial)) + headers = { + # Current Time + "X-Apple-I-Client-Time": self.timestamp, + "X-Apple-I-TimeZone": self.timezone, + # Locale + "loc": self.locale, + "X-Apple-Locale": self.locale, + # 'One Time Password' + "X-Apple-I-MD": self.otp, + # 'Local User ID' + "X-Apple-I-MD-LU": base64.b64encode(str(user_id).encode()).decode(), + # 'Machine ID' + "X-Apple-I-MD-M": self.machine, + # 'Routing Info', some implementations convert this to an integer + "X-Apple-I-MD-RINFO": self.router, + # 'Device Unique Identifier' + "X-Mme-Device-Id": str(device_id).upper(), + # 'Device Serial Number' + "X-Apple-I-SRL-NO": serial, + } - return base_headers + if with_client_info: + headers["X-Mme-Client-Info"] = self.client + headers["X-Apple-App-Info"] = "com.apple.gs.xcode.auth" + headers["X-Xcode-Version"] = "11.2 (11B41)" + + return headers + + async def get_cpd( + self, + user_id: str, + device_id: str, + serial: str = "0", + ) -> dict[str, str]: + """ + Generate a complete dictionary of CPD data. + + Intended for internal use. + """ + cpd = { + "bootstrap": True, + "icscrec": True, + "pbe": False, + "prkgen": True, + "svct": "iCloud", + } + cpd.update(await self.get_headers(user_id, device_id, serial)) + + return cpd class RemoteAnisetteProvider(BaseAnisetteProvider): @@ -68,17 +168,42 @@ class RemoteAnisetteProvider(BaseAnisetteProvider): self._http = HttpSession() - logging.info("Using remote anisette server: %s", self._server_url) + self._anisette_data: dict[str, str] | None = None + + @property + @override + def otp(self) -> str: + """See `BaseAnisetteProvider.otp`_.""" + otp = (self._anisette_data or {}).get("X-Apple-I-MD") + if otp is None: + logging.warning("X-Apple-I-MD header not found! Returning fallback...") + return otp or "" + + @property + @override + def machine(self) -> str: + """See `BaseAnisetteProvider.machine`_.""" + machine = (self._anisette_data or {}).get("X-Apple-I-MD-M") + if machine is None: + logging.warning("X-Apple-I-MD-M header not found! Returning fallback...") + return machine or "" @override - async def _get_base_headers(self) -> dict[str, str]: - r = await self._http.get(self._server_url) - headers = r.json() + async def get_headers( + self, + user_id: str, + device_id: str, + serial: str = "0", + with_client_info: bool = False, + ) -> dict[str, str]: + """See `BaseAnisetteProvider.get_headers`_.""" + if self._anisette_data is None: + logging.info("Fetching anisette data from %s", self._server_url) - return { - "X-Apple-I-MD": headers["X-Apple-I-MD"], - "X-Apple-I-MD-M": headers["X-Apple-I-MD-M"], - } + r = await self._http.get(self._server_url) + self._anisette_data = r.json() + + return await super().get_headers(user_id, device_id, serial, with_client_info) @override async def close(self) -> None: @@ -91,14 +216,18 @@ class RemoteAnisetteProvider(BaseAnisetteProvider): class LocalAnisetteProvider(BaseAnisetteProvider): """Anisette provider. Generates headers without a remote server using pyprovision.""" - def __init__(self) -> None: - """Initialize the provider.""" - super().__init__() - + @property @override - async def _get_base_headers(self) -> dict[str, str]: - return NotImplemented + def otp(self) -> str: + """See `BaseAnisetteProvider.otp`_.""" + raise NotImplementedError + + @property + @override + def machine(self) -> str: + """See `BaseAnisetteProvider.machine`_.""" + raise NotImplementedError @override async def close(self) -> None: - """See `AnisetteProvider.close`.""" + """See `BaseAnisetteProvider.close`_.""" diff --git a/findmy/util/crypto.py b/findmy/util/crypto.py index e14c206..810e92b 100644 --- a/findmy/util/crypto.py +++ b/findmy/util/crypto.py @@ -1,13 +1,44 @@ """Pure-python NIST P-224 Elliptic Curve cryptography. Used for some Apple algorithms.""" -from cryptography.hazmat.primitives import hashes +import hashlib +import hmac + +from cryptography.hazmat.primitives import hashes, padding +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC from cryptography.hazmat.primitives.kdf.x963kdf import X963KDF -ECPoint = tuple[float, float] - P224_N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFF16A2E0B8F03E13DD29455C5C2A3D +def encrypt_password(password: str, salt: bytes, iterations: int) -> bytes: + """Encrypt password using PBKDF2-HMAC.""" + p = hashlib.sha256(password.encode("utf-8")).digest() + kdf = PBKDF2HMAC( + algorithm=hashes.SHA256(), + length=32, + salt=salt, + iterations=iterations, + ) + return kdf.derive(p) + + +def decrypt_spd_aes_cbc(session_key: bytes, data: bytes) -> bytes: + """Decrypt SPD data using SRP session key.""" + extra_data_key = hmac.new(session_key, b"extra data key:", hashlib.sha256).digest() + extra_data_iv = hmac.new(session_key, b"extra data iv:", hashlib.sha256).digest() + # Get only the first 16 bytes of the iv + extra_data_iv = extra_data_iv[:16] + + # Decrypt with AES CBC + cipher = Cipher(algorithms.AES(extra_data_key), modes.CBC(extra_data_iv)) + decryptor = cipher.decryptor() + data = decryptor.update(data) + decryptor.finalize() + # Remove PKCS#7 padding + padder = padding.PKCS7(128).unpadder() + return padder.update(data) + padder.finalize() + + def x963_kdf(value: bytes, si: bytes, length: int) -> bytes: """Single pass of X9.63 KDF with SHA1.""" return X963KDF( diff --git a/pyproject.toml b/pyproject.toml index c241125..d0dfd7d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,7 @@ ignore = [ "D105", # docstrings in magic methods "PLR2004", # "magic" values >.> + "FBT", # boolean "traps" ] line-length = 100 From 9143f0f41ac7e1850b2f9a82e46a89cf73873f03 Mon Sep 17 00:00:00 2001 From: Mike A Date: Tue, 5 Mar 2024 23:56:59 +0100 Subject: [PATCH 18/34] chore(reports): Remove dead code --- findmy/reports/reports.py | 22 ++-------------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/findmy/reports/reports.py b/findmy/reports/reports.py index b9792f1..f8bd602 100644 --- a/findmy/reports/reports.py +++ b/findmy/reports/reports.py @@ -5,28 +5,18 @@ import base64 import hashlib import struct from datetime import datetime, timezone -from typing import TYPE_CHECKING, Sequence, TypedDict, overload +from typing import TYPE_CHECKING, Sequence, overload from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes -from typing_extensions import Unpack, override +from typing_extensions import override from findmy.keys import KeyPair -from findmy.util.http import HttpSession if TYPE_CHECKING: from .account import AsyncAppleAccount -_session = HttpSession() - - -class _FetcherConfig(TypedDict): - user_id: str - device_id: str - dsid: str - search_party_token: str - def _decrypt_payload(payload: bytes, key: KeyPair) -> bytes: eph_key = ec.EllipticCurvePublicKey.from_encoded_point( @@ -181,14 +171,6 @@ class LocationReportsFetcher: """ self._account: AsyncAppleAccount = account - self._http: HttpSession = HttpSession() - - self._config: _FetcherConfig | None = None - - def apply_config(self, **conf: Unpack[_FetcherConfig]) -> None: - """Configure internal variables necessary to make reports fetching calls.""" - self._config = conf - @overload async def fetch_reports( self, From 94d9add122e074e28f9332e20acd47cda13a2083 Mon Sep 17 00:00:00 2001 From: Mike A Date: Wed, 6 Mar 2024 00:01:45 +0100 Subject: [PATCH 19/34] fix(ci): Make pre-commit happy --- findmy/scanner/scanner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/findmy/scanner/scanner.py b/findmy/scanner/scanner.py index 408896c..aeca3cc 100644 --- a/findmy/scanner/scanner.py +++ b/findmy/scanner/scanner.py @@ -131,7 +131,7 @@ class OfflineFindingScanner: You most likely do not want to use this yourself; check out `OfflineFindingScanner.create` instead. """ - self._scanner: BleakScanner = BleakScanner(self._scan_callback, cb=dict(use_bdaddr=True)) + self._scanner: BleakScanner = BleakScanner(self._scan_callback, cb={"use_bdaddr": True}) self._loop = loop self._device_fut: asyncio.Future[tuple[BLEDevice, AdvertisementData]] = loop.create_future() From 7537fc4b5dec16a2e8b85ccc83d58f27423a3266 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 5 Mar 2024 23:13:28 +0000 Subject: [PATCH 20/34] Add renovate.json --- renovate.json | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 renovate.json diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..5db72dd --- /dev/null +++ b/renovate.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended" + ] +} From f8f82f4d63b5e3bd1928c253e96e019f1cfd8640 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 8 Mar 2024 10:20:02 +0000 Subject: [PATCH 21/34] chore(deps): update dependency pyright to v1.1.353 --- poetry.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index 205f5a7..ee391dd 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1075,13 +1075,13 @@ pyobjc-core = ">=9.2" [[package]] name = "pyright" -version = "1.1.352" +version = "1.1.353" description = "Command line wrapper for pyright" optional = false python-versions = ">=3.7" files = [ - {file = "pyright-1.1.352-py3-none-any.whl", hash = "sha256:0040cf173c6a60704e553bfd129dfe54de59cc76d0b2b80f77cfab4f50701d64"}, - {file = "pyright-1.1.352.tar.gz", hash = "sha256:a621c0dfbcf1291b3610641a07380fefaa1d0e182890a1b2a7f13b446e8109a9"}, + {file = "pyright-1.1.353-py3-none-any.whl", hash = "sha256:8d7e6719d0be4fd9f4a37f010237c6a74d91ec1e7c81de634c2f3f9965f8ab43"}, + {file = "pyright-1.1.353.tar.gz", hash = "sha256:24343bbc2a4f997563f966b6244a2e863473f1d85af6d24abcb366fcbb4abca9"}, ] [package.dependencies] From d3e627459f1cf51a5e5d88a57fe83cf7d97fa6c8 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 8 Mar 2024 21:45:01 +0000 Subject: [PATCH 22/34] chore(deps): update softprops/action-gh-release action to v2 --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b1d2c14..91fc823 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -35,7 +35,7 @@ jobs: poetry publish - name: Create release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 with: draft: true files: dist/* From 20eaee5e1b8a1bfa7428452daef9122ff3de5b9a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 13 Mar 2024 18:16:50 +0000 Subject: [PATCH 23/34] chore(deps): update dependency pyright to v1.1.354 --- poetry.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index ee391dd..b8c0c19 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1075,13 +1075,13 @@ pyobjc-core = ">=9.2" [[package]] name = "pyright" -version = "1.1.353" +version = "1.1.354" description = "Command line wrapper for pyright" optional = false python-versions = ">=3.7" files = [ - {file = "pyright-1.1.353-py3-none-any.whl", hash = "sha256:8d7e6719d0be4fd9f4a37f010237c6a74d91ec1e7c81de634c2f3f9965f8ab43"}, - {file = "pyright-1.1.353.tar.gz", hash = "sha256:24343bbc2a4f997563f966b6244a2e863473f1d85af6d24abcb366fcbb4abca9"}, + {file = "pyright-1.1.354-py3-none-any.whl", hash = "sha256:f28d61ae8ae035fc52ded1070e8d9e786051a26a4127bbd7a4ba0399b81b37b5"}, + {file = "pyright-1.1.354.tar.gz", hash = "sha256:b1070dc774ff2e79eb0523fe87f4ba9a90550de7e4b030a2bc9e031864029a1f"}, ] [package.dependencies] From 3b060772e53fc572de2ff6031b78e16ff292dfc9 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 20 Mar 2024 11:35:11 +0000 Subject: [PATCH 24/34] chore(deps): update dependency pyright to v1.1.355 --- poetry.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index b8c0c19..fe38a45 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1075,13 +1075,13 @@ pyobjc-core = ">=9.2" [[package]] name = "pyright" -version = "1.1.354" +version = "1.1.355" description = "Command line wrapper for pyright" optional = false python-versions = ">=3.7" files = [ - {file = "pyright-1.1.354-py3-none-any.whl", hash = "sha256:f28d61ae8ae035fc52ded1070e8d9e786051a26a4127bbd7a4ba0399b81b37b5"}, - {file = "pyright-1.1.354.tar.gz", hash = "sha256:b1070dc774ff2e79eb0523fe87f4ba9a90550de7e4b030a2bc9e031864029a1f"}, + {file = "pyright-1.1.355-py3-none-any.whl", hash = "sha256:bf30b6728fd68ae7d09c98292b67152858dd89738569836896df786e52b5fe48"}, + {file = "pyright-1.1.355.tar.gz", hash = "sha256:dca4104cd53d6484e6b1b50b7a239ad2d16d2ffd20030bcf3111b56f44c263bf"}, ] [package.dependencies] From 842e8d5674d6c7a366feb64ce5c4c2e8dcbf2eaf Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 24 Mar 2024 18:30:15 +0000 Subject: [PATCH 25/34] chore(deps): update dependency pre-commit to v3.7.0 --- poetry.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index b8c0c19..e3e2605 100644 --- a/poetry.lock +++ b/poetry.lock @@ -957,13 +957,13 @@ test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest- [[package]] name = "pre-commit" -version = "3.6.2" +version = "3.7.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." optional = false python-versions = ">=3.9" files = [ - {file = "pre_commit-3.6.2-py2.py3-none-any.whl", hash = "sha256:ba637c2d7a670c10daedc059f5c49b5bd0aadbccfcd7ec15592cf9665117532c"}, - {file = "pre_commit-3.6.2.tar.gz", hash = "sha256:c3ef34f463045c88658c5b99f38c1e297abdcc0ff13f98d3370055fbbfabc67e"}, + {file = "pre_commit-3.7.0-py2.py3-none-any.whl", hash = "sha256:5eae9e10c2b5ac51577c3452ec0a490455c45a0533f7960f993a0d01e59decab"}, + {file = "pre_commit-3.7.0.tar.gz", hash = "sha256:e209d61b8acdcf742404408531f0c37d49d2c734fd7cff2d6076083d191cb060"}, ] [package.dependencies] From 3812df93b494d072b5639d6c1e384aa0d6c86dcb Mon Sep 17 00:00:00 2001 From: Mike Almeloo Date: Sun, 24 Mar 2024 23:45:26 +0100 Subject: [PATCH 26/34] Make renovate run once a month --- renovate.json => .github/renovate.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) rename renovate.json => .github/renovate.json (64%) diff --git a/renovate.json b/.github/renovate.json similarity index 64% rename from renovate.json rename to .github/renovate.json index 5db72dd..6a279c2 100644 --- a/renovate.json +++ b/.github/renovate.json @@ -1,6 +1,7 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": [ - "config:recommended" + "config:recommended", + "schedule:monthly" ] } From c873e80459f45ed922b53e98cd97a6ec4fd4394b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 1 Apr 2024 02:10:02 +0000 Subject: [PATCH 27/34] chore(deps): update actions/configure-pages action to v5 --- .github/workflows/docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 345446b..1f2e5fc 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -34,7 +34,7 @@ jobs: poetry run make html - name: Setup Pages - uses: actions/configure-pages@v4 + uses: actions/configure-pages@v5 - name: Upload Pages artifact uses: actions/upload-pages-artifact@v3 From 030a7df1837b3502b9cc1ba7f825b8ad20e7fe39 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 10 Apr 2024 14:54:52 +0000 Subject: [PATCH 28/34] chore(deps): update dependency pyright to v1.1.358 --- poetry.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index fe4043d..690a3c5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1075,13 +1075,13 @@ pyobjc-core = ">=9.2" [[package]] name = "pyright" -version = "1.1.355" +version = "1.1.358" description = "Command line wrapper for pyright" optional = false python-versions = ">=3.7" files = [ - {file = "pyright-1.1.355-py3-none-any.whl", hash = "sha256:bf30b6728fd68ae7d09c98292b67152858dd89738569836896df786e52b5fe48"}, - {file = "pyright-1.1.355.tar.gz", hash = "sha256:dca4104cd53d6484e6b1b50b7a239ad2d16d2ffd20030bcf3111b56f44c263bf"}, + {file = "pyright-1.1.358-py3-none-any.whl", hash = "sha256:0995b6a95eb11bd26f093cd5dee3d5e7258441b1b94d4a171b5dc5b79a1d4f4e"}, + {file = "pyright-1.1.358.tar.gz", hash = "sha256:185524a8d52f6f14bbd3b290b92ad905f25b964dddc9e7148aad760bd35c9f60"}, ] [package.dependencies] From 05ef09798a1841bb3c11022dce4246285231936a Mon Sep 17 00:00:00 2001 From: Mike A Date: Tue, 23 Apr 2024 20:09:44 +0200 Subject: [PATCH 29/34] scanner: Fix Windows compatibility --- findmy/scanner/scanner.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/findmy/scanner/scanner.py b/findmy/scanner/scanner.py index aeca3cc..dab1efa 100644 --- a/findmy/scanner/scanner.py +++ b/findmy/scanner/scanner.py @@ -173,7 +173,12 @@ class OfflineFindingScanner: if not apple_data: return None - additional_data = device.details.get("props", {}) + try: + additional_data = device.details.get("props", {}) + except AttributeError: + # Likely Windows host, where details is a '_RawAdvData' object. + # See: https://github.com/malmeloo/FindMy.py/issues/24 + additional_data = {} return OfflineFindingDevice.from_payload(device.address, apple_data, additional_data) async def scan_for( From 3cc97863e24ee5feaaef1a22b2568abe3771033a Mon Sep 17 00:00:00 2001 From: Mike A Date: Tue, 23 Apr 2024 20:43:43 +0200 Subject: [PATCH 30/34] reports: convert timestamps to local timezone --- findmy/reports/reports.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/findmy/reports/reports.py b/findmy/reports/reports.py index f8bd602..42d604a 100644 --- a/findmy/reports/reports.py +++ b/findmy/reports/reports.py @@ -121,7 +121,7 @@ class LocationReport: Requires a `KeyPair` to decrypt the report's payload. """ timestamp_int = int.from_bytes(payload[0:4], "big") + (60 * 60 * 24 * 11323) - timestamp = datetime.fromtimestamp(timestamp_int, tz=timezone.utc) + timestamp = datetime.fromtimestamp(timestamp_int, tz=timezone.utc).astimezone() data = _decrypt_payload(payload, key) latitude = struct.unpack(">i", data[0:4])[0] / 10000000 @@ -232,7 +232,7 @@ class LocationReportsFetcher: date_published = datetime.fromtimestamp( report.get("datePublished", 0) / 1000, tz=timezone.utc, - ) + ).astimezone() description = report.get("description", "") payload = base64.b64decode(report["payload"]) From 4e213e5a4806b8792ebe5cc3bf056e83c70a3c39 Mon Sep 17 00:00:00 2001 From: Mike A Date: Tue, 23 Apr 2024 21:36:46 +0200 Subject: [PATCH 31/34] reports: update real_airtag example --- examples/_login.py | 101 ++++++++++++++++++++++++++++++++ examples/fetch_reports.py | 54 ++--------------- examples/fetch_reports_async.py | 52 ++-------------- examples/real_airtag.py | 75 +++++++++++++++--------- findmy/reports/__init__.py | 3 +- 5 files changed, 160 insertions(+), 125 deletions(-) create mode 100644 examples/_login.py diff --git a/examples/_login.py b/examples/_login.py new file mode 100644 index 0000000..eb821e0 --- /dev/null +++ b/examples/_login.py @@ -0,0 +1,101 @@ +import json +from pathlib import Path + +from findmy.reports import ( + AppleAccount, + AsyncAppleAccount, + BaseAnisetteProvider, + LoginState, + SmsSecondFactorMethod, + TrustedDeviceSecondFactorMethod, +) + +ACCOUNT_STORE = "account.json" + + +def _login_sync(account: AppleAccount) -> None: + email = input("email? > ") + password = input("passwd? > ") + + state = account.login(email, password) + + if state == LoginState.REQUIRE_2FA: # Account requires 2FA + # This only supports SMS methods for now + methods = account.get_2fa_methods() + + # Print the (masked) phone numbers + for i, method in enumerate(methods): + if isinstance(method, TrustedDeviceSecondFactorMethod): + print(f"{i} - Trusted Device") + elif isinstance(method, SmsSecondFactorMethod): + print(f"{i} - SMS ({method.phone_number})") + + ind = int(input("Method? > ")) + + method = methods[ind] + method.request() + code = input("Code? > ") + + # This automatically finishes the post-2FA login flow + method.submit(code) + + +async def _login_async(account: AsyncAppleAccount) -> None: + email = input("email? > ") + password = input("passwd? > ") + + state = await account.login(email, password) + + if state == LoginState.REQUIRE_2FA: # Account requires 2FA + # This only supports SMS methods for now + methods = await account.get_2fa_methods() + + # Print the (masked) phone numbers + for i, method in enumerate(methods): + if isinstance(method, TrustedDeviceSecondFactorMethod): + print(f"{i} - Trusted Device") + elif isinstance(method, SmsSecondFactorMethod): + print(f"{i} - SMS ({method.phone_number})") + + ind = int(input("Method? > ")) + + method = methods[ind] + await method.request() + code = input("Code? > ") + + # This automatically finishes the post-2FA login flow + await method.submit(code) + + +def get_account_sync(anisette: BaseAnisetteProvider) -> AppleAccount: + """Tries to restore a saved Apple account, or prompts the user for login otherwise. (sync)""" + acc = AppleAccount(anisette) + + # Save / restore account logic + acc_store = Path("account.json") + try: + with acc_store.open() as f: + acc.restore(json.load(f)) + except FileNotFoundError: + _login_sync(acc) + with acc_store.open("w+") as f: + json.dump(acc.export(), f) + + return acc + + +async def get_account_async(anisette: BaseAnisetteProvider) -> AsyncAppleAccount: + """Tries to restore a saved Apple account, or prompts the user for login otherwise. (async)""" + acc = AsyncAppleAccount(anisette) + + # Save / restore account logic + acc_store = Path("account.json") + try: + with acc_store.open() as f: + acc.restore(json.load(f)) + except FileNotFoundError: + await _login_async(acc) + with acc_store.open("w+") as f: + json.dump(acc.export(), f) + + return acc diff --git a/examples/fetch_reports.py b/examples/fetch_reports.py index 03fdd57..903b1a4 100644 --- a/examples/fetch_reports.py +++ b/examples/fetch_reports.py @@ -1,25 +1,15 @@ -import json import logging -from pathlib import Path + +from _login import get_account_sync from findmy import KeyPair -from findmy.reports import ( - AppleAccount, - LoginState, - RemoteAnisetteProvider, - SmsSecondFactorMethod, - TrustedDeviceSecondFactorMethod, -) +from findmy.reports import RemoteAnisetteProvider # URL to (public or local) anisette server ANISETTE_SERVER = "http://localhost:6969" -# Apple account details -ACCOUNT_EMAIL = "test@test.com" -ACCOUNT_PASS = "" - # Private base64-encoded key to look up -KEY_PRIV = "" +KEY_PRIV = "Vq/RNibhblTitwb7hjPkZZj6gyJcAJSVMQ6Shg==" # Optional, to verify that advertisement key derivation works for your key KEY_ADV = "" @@ -27,43 +17,9 @@ KEY_ADV = "" logging.basicConfig(level=logging.DEBUG) -def login(account: AppleAccount) -> None: - state = account.login(ACCOUNT_EMAIL, ACCOUNT_PASS) - - if state == LoginState.REQUIRE_2FA: # Account requires 2FA - # This only supports SMS methods for now - methods = account.get_2fa_methods() - - # Print the (masked) phone numbers - for i, method in enumerate(methods): - if isinstance(method, TrustedDeviceSecondFactorMethod): - print(f"{i} - Trusted Device") - elif isinstance(method, SmsSecondFactorMethod): - print(f"{i} - SMS ({method.phone_number})") - - ind = int(input("Method? > ")) - - method = methods[ind] - method.request() - code = input("Code? > ") - - # This automatically finishes the post-2FA login flow - method.submit(code) - - def fetch_reports(lookup_key: KeyPair) -> None: anisette = RemoteAnisetteProvider(ANISETTE_SERVER) - acc = AppleAccount(anisette) - - # Save / restore account logic - acc_store = Path("account.json") - try: - with acc_store.open() as f: - acc.restore(json.load(f)) - except FileNotFoundError: - login(acc) - with acc_store.open("w+") as f: - json.dump(acc.export(), f) + acc = get_account_sync(anisette) print(f"Logged in as: {acc.account_name} ({acc.first_name} {acc.last_name})") diff --git a/examples/fetch_reports_async.py b/examples/fetch_reports_async.py index 3c4f8bc..754a05f 100644 --- a/examples/fetch_reports_async.py +++ b/examples/fetch_reports_async.py @@ -1,24 +1,14 @@ import asyncio -import json import logging -from pathlib import Path + +from _login import get_account_async from findmy import KeyPair -from findmy.reports import ( - AsyncAppleAccount, - LoginState, - RemoteAnisetteProvider, - SmsSecondFactorMethod, - TrustedDeviceSecondFactorMethod, -) +from findmy.reports import RemoteAnisetteProvider # URL to (public or local) anisette server ANISETTE_SERVER = "http://localhost:6969" -# Apple account details -ACCOUNT_EMAIL = "test@test.com" -ACCOUNT_PASS = "" - # Private base64-encoded key to look up KEY_PRIV = "" @@ -28,44 +18,12 @@ KEY_ADV = "" logging.basicConfig(level=logging.DEBUG) -async def login(account: AsyncAppleAccount) -> None: - state = await account.login(ACCOUNT_EMAIL, ACCOUNT_PASS) - - if state == LoginState.REQUIRE_2FA: # Account requires 2FA - # This only supports SMS methods for now - methods = await account.get_2fa_methods() - - # Print the (masked) phone numbers - for i, method in enumerate(methods): - if isinstance(method, TrustedDeviceSecondFactorMethod): - print(f"{i} - Trusted Device") - elif isinstance(method, SmsSecondFactorMethod): - print(f"{i} - SMS ({method.phone_number})") - - ind = int(input("Method? > ")) - - method = methods[ind] - await method.request() - code = input("Code? > ") - - # This automatically finishes the post-2FA login flow - await method.submit(code) - - async def fetch_reports(lookup_key: KeyPair) -> None: anisette = RemoteAnisetteProvider(ANISETTE_SERVER) - acc = AsyncAppleAccount(anisette) + + acc = await get_account_async(anisette) try: - acc_store = Path("account.json") - try: - with acc_store.open() as f: - acc.restore(json.load(f)) - except FileNotFoundError: - await login(acc) - with acc_store.open("w+") as f: - json.dump(acc.export(), f) - print(f"Logged in as: {acc.account_name} ({acc.first_name} {acc.last_name})") # It's that simple! diff --git a/examples/real_airtag.py b/examples/real_airtag.py index db03962..e669050 100644 --- a/examples/real_airtag.py +++ b/examples/real_airtag.py @@ -1,17 +1,20 @@ """ -Example showing how to retrieve the primary key of your own AirTag, or any other FindMy-accessory. +Example showing how to fetch locations of an AirTag, or any other FindMy accessory. +""" +from __future__ import annotations -This key can be used to retrieve the device's location for a single day. -""" import plistlib from datetime import datetime, timedelta, timezone from pathlib import Path -from findmy import FindMyAccessory +from _login import get_account_sync + +from findmy import FindMyAccessory, KeyPair +from findmy.reports import RemoteAnisetteProvider + +# URL to (public or local) anisette server +ANISETTE_SERVER = "http://localhost:6969" -# PUBLIC key that the accessory is broadcasting or has previously broadcast. -# 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 = Path("airtag.plist") @@ -29,34 +32,50 @@ SKN = device_data["sharedSecret"]["key"]["data"] # "Secondary" shared secret. 32 bytes. SKS = device_data["secondarySharedSecret"]["key"]["data"] +# "Paired at" timestamp (UTC) +PAIRED_AT = device_data["pairingDate"].replace(tzinfo=timezone.utc) + + +def _gen_keys(airtag: FindMyAccessory, _from: datetime, to: datetime) -> set[KeyPair]: + keys = set() + while _from < to: + keys.update(airtag.keys_at(_from)) + + _from += timedelta(minutes=15) + + return keys + def main() -> None: - paired_at = device_data["pairingDate"].replace(tzinfo=timezone.utc) - airtag = FindMyAccessory(MASTER_KEY, SKN, SKS, paired_at) + # Step 0: create an accessory key generator + 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) + # Step 1: Generate the accessory's private keys, + # starting from 7 days ago until now (12 hour margin) + fetch_to = datetime.now(tz=timezone.utc).astimezone() + timedelta(hours=12) + fetch_from = fetch_to - timedelta(days=8) - while lookup_time < now: - keys = airtag.keys_at(lookup_time) - for key in keys: - if key.adv_key_b64 != PUBLIC_KEY: - continue + print(f"Generating keys from {fetch_from} to {fetch_to} ...") + lookup_keys = _gen_keys(airtag, fetch_from, fetch_to) - print("KEY FOUND!!") - print("KEEP THE BELOW KEY SECRET! IT CAN BE USED TO RETRIEVE THE DEVICE'S LOCATION!") - print(f" - Key: {key.private_key_b64}") - print(f" - Approx. Time: {lookup_time}") - print(f" - Type: {key.key_type}") - return + print(f"Generated {len(lookup_keys)} keys") - lookup_time += timedelta(minutes=15) + # Step 2: log into an Apple account + print("Logging into account") + anisette = RemoteAnisetteProvider(ANISETTE_SERVER) + acc = get_account_sync(anisette) - print("No match found! :(") + # step 3: fetch reports! + print("Fetching reports") + reports = acc.fetch_reports(list(lookup_keys), fetch_from, fetch_to) + + # step 4: print 'em + # reports are in {key: [report]} format, but we only really care about the reports + print() + print("Location reports:") + reports = sorted([r for rs in reports.values() for r in rs]) + for report in reports: + print(f" - {report}") if __name__ == "__main__": diff --git a/findmy/reports/__init__.py b/findmy/reports/__init__.py index fde5512..a5c3851 100644 --- a/findmy/reports/__init__.py +++ b/findmy/reports/__init__.py @@ -1,6 +1,6 @@ """Code related to fetching location reports.""" from .account import AppleAccount, AsyncAppleAccount -from .anisette import RemoteAnisetteProvider +from .anisette import BaseAnisetteProvider, RemoteAnisetteProvider from .state import LoginState from .twofactor import SmsSecondFactorMethod, TrustedDeviceSecondFactorMethod @@ -8,6 +8,7 @@ __all__ = ( "AppleAccount", "AsyncAppleAccount", "LoginState", + "BaseAnisetteProvider", "RemoteAnisetteProvider", "SmsSecondFactorMethod", "TrustedDeviceSecondFactorMethod", From e8c1541f50ca614fbf1f5b172d7b268e89efc3f9 Mon Sep 17 00:00:00 2001 From: Mike A Date: Tue, 23 Apr 2024 21:43:15 +0200 Subject: [PATCH 32/34] reports: fetch max. 256 reports at once --- findmy/reports/reports.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/findmy/reports/reports.py b/findmy/reports/reports.py index 42d604a..a8a83b9 100644 --- a/findmy/reports/reports.py +++ b/findmy/reports/reports.py @@ -3,6 +3,7 @@ from __future__ import annotations import base64 import hashlib +import logging import struct from datetime import datetime, timezone from typing import TYPE_CHECKING, Sequence, overload @@ -17,6 +18,8 @@ from findmy.keys import KeyPair if TYPE_CHECKING: from .account import AsyncAppleAccount +logging.getLogger(__name__) + def _decrypt_payload(payload: bytes, key: KeyPair) -> bytes: eph_key = ec.EllipticCurvePublicKey.from_encoded_point( @@ -207,8 +210,12 @@ class LocationReportsFetcher: if isinstance(device, KeyPair): return await self._fetch_reports(date_from, date_to, [device]) - # sequence of KeyPairs - reports = await self._fetch_reports(date_from, date_to, device) + # sequence of KeyPairs (fetch 256 max at a time) + reports: list[LocationReport] = [] + for key_offset in range(0, len(device), 256): + chunk = device[key_offset : key_offset + 256] + reports.extend(await self._fetch_reports(date_from, date_to, chunk)) + res: dict[KeyPair, list[LocationReport]] = {key: [] for key in device} for report in reports: res[report.key].append(report) @@ -220,6 +227,8 @@ class LocationReportsFetcher: date_to: datetime, keys: Sequence[KeyPair], ) -> list[LocationReport]: + logging.debug("Fetching reports for %s keys", len(keys)) + start_date = int(date_from.timestamp() * 1000) end_date = int(date_to.timestamp() * 1000) ids = [key.hashed_adv_key_b64 for key in keys] From 5f4a02307a6058eacbb859229e847f93adb912e4 Mon Sep 17 00:00:00 2001 From: Mike A Date: Wed, 24 Apr 2024 11:31:03 +0200 Subject: [PATCH 33/34] examples: remove hardcoded private key It's a long-expired rolling key so good luck using it :-) --- examples/fetch_reports.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/fetch_reports.py b/examples/fetch_reports.py index 903b1a4..321a775 100644 --- a/examples/fetch_reports.py +++ b/examples/fetch_reports.py @@ -9,7 +9,7 @@ from findmy.reports import RemoteAnisetteProvider ANISETTE_SERVER = "http://localhost:6969" # Private base64-encoded key to look up -KEY_PRIV = "Vq/RNibhblTitwb7hjPkZZj6gyJcAJSVMQ6Shg==" +KEY_PRIV = "" # Optional, to verify that advertisement key derivation works for your key KEY_ADV = "" From 0e0cce3657359f0cf9622c9d7ce6305bd44a782b Mon Sep 17 00:00:00 2001 From: Mike A Date: Wed, 24 Apr 2024 11:33:38 +0200 Subject: [PATCH 34/34] repo: Update .gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 4f3a295..a9c36a3 100644 --- a/.gitignore +++ b/.gitignore @@ -161,3 +161,5 @@ cython_debug/ .idea/ account.json +airtag.plist +DO_NOT_COMMIT*