From ff99e0422dcde93e8fe56d13913b14109af4491a Mon Sep 17 00:00:00 2001 From: Ulrich Barrot <12350410+ubrt@users.noreply.github.com> Date: Sat, 22 Aug 2026 12:30:09 +0200 Subject: [PATCH 1/5] Add current_keys()/current_mac_addresses() to RollingKeyPairSource Convenience helpers to get the key(s)/BLE MAC address(es) an accessory might currently be advertising, spanning the get_min_index()..get_max_index() range for rollover uncertainty. Useful for recognizing an owned accessory's own advertisement in a BLE scan (e.g. to trigger it directly). --- findmy/accessory.py | 24 ++++++++++++++++ tests/test_accessory.py | 64 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 tests/test_accessory.py diff --git a/findmy/accessory.py b/findmy/accessory.py index 9e564bb..13119cd 100644 --- a/findmy/accessory.py +++ b/findmy/accessory.py @@ -124,6 +124,30 @@ def keys_between( yielded.add(key) yield ind, key + def current_keys(self, now: datetime | None = None) -> set[KeyPair]: + """ + Get the set of keys the accessory might currently be advertising. + + Spans the full :meth:`get_min_index`-:meth:`get_max_index` range for `now` + (rather than a single index) to account for rollover uncertainty since the + last observed alignment -- see those methods for why that range can be wider + than one index. + """ + if now is None: + now = datetime.now(timezone.utc) + + return {key for _, key in self.keys_between(now, now)} + + def current_mac_addresses(self, now: datetime | None = None) -> set[str]: + """ + Get the set of BLE MAC addresses the accessory might currently be advertising. + + Useful to recognize an owned accessory's own advertisement in a BLE scan, + e.g. to trigger it directly (playing a sound) without going through Apple's + Find My network. See :meth:`current_keys` for the underlying key selection. + """ + return {key.mac_address for key in self.current_keys(now)} + class FixedRollingKeyPairAccessory( RollingKeyPairSource, util.abc.Serializable[FixedRollingKeyPairAccessoryMapping] diff --git a/tests/test_accessory.py b/tests/test_accessory.py new file mode 100644 index 0000000..b63d2e9 --- /dev/null +++ b/tests/test_accessory.py @@ -0,0 +1,64 @@ +"""Tests for rolling-key accessory current-key/current-MAC helpers.""" + +import re +import secrets +from datetime import datetime, timezone + +MAC_RE = re.compile(r"^[0-9A-F]{2}(:[0-9A-F]{2}){5}$") + + +def test_fixed_rolling_current_keys() -> None: + """current_keys()/current_mac_addresses() on a fixed-key accessory return all its keys.""" + import findmy + + keys = [findmy.KeyPair.new() for _ in range(3)] + accessory = findmy.FixedRollingKeyPairAccessory( + private_keys=[key.private_key_bytes for key in keys], + name="test", + identifier=None, + ) + + current = accessory.current_keys() + assert {key.adv_key_bytes for key in current} == {key.adv_key_bytes for key in keys} + + macs = accessory.current_mac_addresses() + assert macs == {key.mac_address for key in keys} + for mac in macs: + assert MAC_RE.match(mac) + + +def test_findmy_accessory_current_keys_matches_keys_at_alignment() -> None: + """At the alignment date itself, current_keys() must match keys_at(alignment_index).""" + import findmy + + paired_at = datetime.now(timezone.utc) + accessory = findmy.FindMyAccessory( + master_key=secrets.token_bytes(28), + skn=secrets.token_bytes(32), + sks=secrets.token_bytes(32), + paired_at=paired_at, + ) + + expected = accessory.keys_at(0) + current = accessory.current_keys(paired_at) + assert {key.adv_key_bytes for key in current} == {key.adv_key_bytes for key in expected} + + expected_macs = {key.mac_address for key in expected} + assert accessory.current_mac_addresses(paired_at) == expected_macs + for mac in expected_macs: + assert MAC_RE.match(mac) + + +def test_findmy_accessory_current_keys_defaults_to_now() -> None: + """Calling current_keys()/current_mac_addresses() without an explicit `now` must not raise.""" + import findmy + + accessory = findmy.FindMyAccessory( + master_key=secrets.token_bytes(28), + skn=secrets.token_bytes(32), + sks=secrets.token_bytes(32), + paired_at=datetime.now(timezone.utc), + ) + + assert len(accessory.current_keys()) > 0 + assert len(accessory.current_mac_addresses()) > 0 From f11ef6f5128013195f16f0322122a3f21aa11e3c Mon Sep 17 00:00:00 2001 From: Ulrich Barrot <12350410+ubrt@users.noreply.github.com> Date: Sat, 22 Aug 2026 19:58:26 +0200 Subject: [PATCH 2/5] Fix KeyPair.mac_address on Python 3.10 `int.to_bytes()` only gained its default arguments in 3.11, so on 3.10 the call in mac_address raises `TypeError: to_bytes() missing required argument 'byteorder'`. The package declares `requires-python = ">=3.10"`, so this is a supported version where a public property simply does not work. It went unnoticed because nothing called `mac_address` from the test suite; the tests added in this branch are the first, which is how it surfaced. Verified on a real 3.10 interpreter both ways: 103 passed with this change, and the three new tests fail without it. --- findmy/keys.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/findmy/keys.py b/findmy/keys.py index d5418e2..e6411a5 100644 --- a/findmy/keys.py +++ b/findmy/keys.py @@ -98,7 +98,9 @@ def hashed_adv_key_bytes(self) -> bytes: @property def mac_address(self) -> str: """Get the mac address from the public key.""" - first_byte = (self.adv_key_bytes[0] | 0b11000000).to_bytes(1) + # Both arguments spelled out: int.to_bytes only gained defaults in 3.11, and + # this package supports 3.10. + first_byte = (self.adv_key_bytes[0] | 0b11000000).to_bytes(1, "big") return ":".join([parsers.format_hex_byte(x) for x in first_byte + self.adv_key_bytes[1:6]]) def adv_data(self, status: int = 0, hint: int = 0) -> bytes: From 999666c652f4a366e086d78235684e8a502feb67 Mon Sep 17 00:00:00 2001 From: Ulrich Barrot <12350410+ubrt@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:50:20 +0200 Subject: [PATCH 3/5] Let current_keys() search either side of the alignment index get_min_index returns the alignment index itself for any time at or after the alignment date, so an accessory whose true index has drifted below where alignment believes it is falls outside the range entirely. It is then never matched, and nothing raises: it simply never appears nearby. Observed on real hardware rather than reasoned about. Two tags lying beside the scanner, both separated and both reported by the network minutes earlier: one matched, the other advertised steadily at -49 dBm for over a minute while absent from its own 69-address candidate set. The optional margin widens the range on both sides. NearbyOfflineFindingDevice.is_from already takes the same precaution, with 12 hours, which is the value that fixed it here. Omitting it is unchanged behaviour, and pinned as such. --- findmy/accessory.py | 33 +++++++++++++++++++---- tests/test_accessory.py | 59 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 86 insertions(+), 6 deletions(-) diff --git a/findmy/accessory.py b/findmy/accessory.py index 13119cd..ebefdfb 100644 --- a/findmy/accessory.py +++ b/findmy/accessory.py @@ -124,7 +124,11 @@ def keys_between( yielded.add(key) yield ind, key - def current_keys(self, now: datetime | None = None) -> set[KeyPair]: + def current_keys( + self, + now: datetime | None = None, + margin: timedelta | None = None, + ) -> set[KeyPair]: """ Get the set of keys the accessory might currently be advertising. @@ -132,21 +136,40 @@ def current_keys(self, now: datetime | None = None) -> set[KeyPair]: (rather than a single index) to account for rollover uncertainty since the last observed alignment -- see those methods for why that range can be wider than one index. + + `margin` widens that range on *both* sides. Without it the range starts at + the alignment index, so an accessory whose true index has ended up below + where alignment believes it is can never be matched: it is simply never + recognized, with nothing raising anywhere. This has been observed on a real + accessory, advertising steadily a metre from the scanner and absent from its + own candidate set. :meth:`findmy.scanner.NearbyOfflineFindingDevice.is_from` + takes the same precaution, with a 12 hour margin. + + Widening only adds candidate keys, so callers that match against a set pay + nothing for it at match time -- though each extra index costs a derivation + here. """ if now is None: now = datetime.now(timezone.utc) + if margin is None: + margin = timedelta(0) - return {key for _, key in self.keys_between(now, now)} + return {key for _, key in self.keys_between(now - margin, now + margin)} - def current_mac_addresses(self, now: datetime | None = None) -> set[str]: + def current_mac_addresses( + self, + now: datetime | None = None, + margin: timedelta | None = None, + ) -> set[str]: """ Get the set of BLE MAC addresses the accessory might currently be advertising. Useful to recognize an owned accessory's own advertisement in a BLE scan, e.g. to trigger it directly (playing a sound) without going through Apple's - Find My network. See :meth:`current_keys` for the underlying key selection. + Find My network. See :meth:`current_keys` for the underlying key selection, + and for why `margin` is worth passing. """ - return {key.mac_address for key in self.current_keys(now)} + return {key.mac_address for key in self.current_keys(now, margin)} class FixedRollingKeyPairAccessory( diff --git a/tests/test_accessory.py b/tests/test_accessory.py index b63d2e9..e480a6c 100644 --- a/tests/test_accessory.py +++ b/tests/test_accessory.py @@ -2,7 +2,7 @@ import re import secrets -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone MAC_RE = re.compile(r"^[0-9A-F]{2}(:[0-9A-F]{2}){5}$") @@ -62,3 +62,60 @@ def test_findmy_accessory_current_keys_defaults_to_now() -> None: assert len(accessory.current_keys()) > 0 assert len(accessory.current_mac_addresses()) > 0 + + +def test_a_margin_widens_the_candidate_set_both_ways() -> None: + """ + The margin is what makes an accessory findable when alignment has drifted ahead of it. + + Without one the range starts at the alignment index, so a true index below that is + excluded and the accessory is never matched - observed on real hardware, advertising a + metre away and absent from its own candidate set. + """ + import findmy + + paired_at = datetime.now(timezone.utc) + accessory = findmy.FindMyAccessory( + master_key=secrets.token_bytes(28), + skn=secrets.token_bytes(32), + sks=secrets.token_bytes(32), + paired_at=paired_at, + ) + + without = accessory.current_keys(paired_at) + with_margin = accessory.current_keys(paired_at, margin=timedelta(hours=12)) + + assert len(with_margin) > len(without) + assert {k.adv_key_bytes for k in without} <= {k.adv_key_bytes for k in with_margin} + + +def test_no_margin_behaves_exactly_as_before() -> None: + """The parameter is additive: omitting it must not change what existing callers get.""" + import findmy + + paired_at = datetime.now(timezone.utc) + accessory = findmy.FindMyAccessory( + master_key=secrets.token_bytes(28), + skn=secrets.token_bytes(32), + sks=secrets.token_bytes(32), + paired_at=paired_at, + ) + + assert {k.adv_key_bytes for k in accessory.current_keys(paired_at)} == { + k.adv_key_bytes for k in accessory.current_keys(paired_at, margin=timedelta(0)) + } + + +def test_the_margin_reaches_mac_addresses_too() -> None: + import findmy + + accessory = findmy.FindMyAccessory( + master_key=secrets.token_bytes(28), + skn=secrets.token_bytes(32), + sks=secrets.token_bytes(32), + paired_at=datetime.now(timezone.utc), + ) + + assert len(accessory.current_mac_addresses(margin=timedelta(hours=12))) > len( + accessory.current_mac_addresses() + ) From 74e22bbbda8ef675b292f69590c297b398994b81 Mon Sep 17 00:00:00 2001 From: Ulrich Barrot <12350410+ubrt@users.noreply.github.com> Date: Sun, 23 Aug 2026 12:22:02 +0200 Subject: [PATCH 4/5] Return the matching index, and test the half of the margin that matters Three things from review. **The margin test only exercised the forward side.** Every fixture here pairs at `now` with no alignment update, so `alignment_index == 0`, `get_min_index(now - 12h)` is -48, and `keys_at` yields nothing for a negative index. Confirmed by deleting the backwards half: the old test still passed. Replaced with one aligned at index 2880, which fails without it, and which asserts the forward half alone does *not* cover the case so it cannot quietly stop testing anything. **current_keys hid the cheap path.** The documented use is a scanning loop, and a loop that matches an advertisement has to report which index matched or it can never call update_alignment - so every later call pays for the wide range again. Measured on a 30-day-old accessory aligned at 2880: a 12 hour margin derives 100 keys against 3 for a fresh alignment, about 100x the cost. Both methods now return a mapping to the index. A dict answers `in` and iterates like the set did, so simple callers read the same. For a secondary key the index is a lower bound, since one covers 96 primary indices and keys_between yields a key's first occurrence in the range. Said so in the docstring, and safe regardless: update_alignment ignores an index below the one it holds. **Left the default at no margin.** Defaulting to 12 hours would make every call ~100x more expensive to cover a case that does not arise while alignment is kept fresh, and with the index now returnable, keeping it fresh is something a caller can actually do. The docstring says plainly that a bare call assumes trustworthy alignment and describes the two working together instead. Rebased onto 4f94015. --- findmy/accessory.py | 66 +++++++++++++++++++++--------- tests/test_accessory.py | 90 +++++++++++++++++++++++++++++++++++------ 2 files changed, 124 insertions(+), 32 deletions(-) diff --git a/findmy/accessory.py b/findmy/accessory.py index ebefdfb..2848f19 100644 --- a/findmy/accessory.py +++ b/findmy/accessory.py @@ -128,48 +128,76 @@ def current_keys( self, now: datetime | None = None, margin: timedelta | None = None, - ) -> set[KeyPair]: + ) -> dict[KeyPair, int]: """ - Get the set of keys the accessory might currently be advertising. + Get the keys the accessory might currently be advertising, each with its index. Spans the full :meth:`get_min_index`-:meth:`get_max_index` range for `now` (rather than a single index) to account for rollover uncertainty since the last observed alignment -- see those methods for why that range can be wider than one index. - `margin` widens that range on *both* sides. Without it the range starts at - the alignment index, so an accessory whose true index has ended up below - where alignment believes it is can never be matched: it is simply never - recognized, with nothing raising anywhere. This has been observed on a real - accessory, advertising steadily a metre from the scanner and absent from its - own candidate set. :meth:`findmy.scanner.NearbyOfflineFindingDevice.is_from` - takes the same precaution, with a 12 hour margin. - - Widening only adds candidate keys, so callers that match against a set pay - nothing for it at match time -- though each extra index costs a derivation - here. + Returns a mapping rather than a set so that a caller which matches an + advertisement can pass the matching index back to :meth:`update_alignment`. + That is what keeps the range narrow, and the difference is not small: on a + 30-day-old accessory aligned at index 2880, a 12 hour margin derives 100 keys + where a fresh alignment needs 3, at roughly 100x the cost. Membership tests + and iteration behave as they would on a set, so ``key in acc.current_keys()`` + still reads the same. + + **A bare call assumes the alignment is trustworthy**, which is the common case + only for a caller that keeps it so. `margin` widens the range on *both* sides + and exists for when it is not: without it the range starts at the alignment + index, so an accessory whose true index has ended up below where alignment + believes it is can never be matched -- it is simply never recognized, with + nothing raising anywhere. This has been observed on a real accessory, + advertising steadily a metre from the scanner and absent from its own + candidate set. :meth:`findmy.scanner.NearbyOfflineFindingDevice.is_from` takes + the same precaution, with a 12 hour margin. + + So the two work together: pass a margin to recover from drift, feed the index + of whatever matched back into :meth:`update_alignment`, and subsequent calls + collapse to the cheap case. """ if now is None: now = datetime.now(timezone.utc) if margin is None: margin = timedelta(0) - return {key for _, key in self.keys_between(now - margin, now + margin)} + return {key: ind for ind, key in self.keys_between(now - margin, now + margin)} def current_mac_addresses( self, now: datetime | None = None, margin: timedelta | None = None, - ) -> set[str]: + ) -> dict[str, int]: """ - Get the set of BLE MAC addresses the accessory might currently be advertising. + Get the BLE MAC addresses the accessory might currently be advertising. Useful to recognize an owned accessory's own advertisement in a BLE scan, e.g. to trigger it directly (playing a sound) without going through Apple's - Find My network. See :meth:`current_keys` for the underlying key selection, - and for why `margin` is worth passing. + Find My network. + + Maps each address to the key index it came from, so a scanner can report a + match straight back to :meth:`update_alignment`:: + + candidates = accessory.current_mac_addresses(margin=timedelta(hours=12)) + ... + index = candidates.get(seen_address) + if index is not None: + accessory.update_alignment(seen_at, index) + + The index is the first one in the searched range at which that key is valid, + which for a secondary key is a lower bound: one covers 96 primary indices. + Handing it to :meth:`update_alignment` is still safe, since that ignores any + index below the one it already holds. + + See :meth:`current_keys` for the underlying key selection, for why `margin` + is worth passing, and for what feeding the index back saves. """ - return {key.mac_address for key in self.current_keys(now, margin)} + return { + key.mac_address: ind for key, ind in self.current_keys(now, margin).items() + } class FixedRollingKeyPairAccessory( diff --git a/tests/test_accessory.py b/tests/test_accessory.py index e480a6c..19ca0ab 100644 --- a/tests/test_accessory.py +++ b/tests/test_accessory.py @@ -22,7 +22,7 @@ def test_fixed_rolling_current_keys() -> None: assert {key.adv_key_bytes for key in current} == {key.adv_key_bytes for key in keys} macs = accessory.current_mac_addresses() - assert macs == {key.mac_address for key in keys} + assert set(macs) == {key.mac_address for key in keys} for mac in macs: assert MAC_RE.match(mac) @@ -44,7 +44,7 @@ def test_findmy_accessory_current_keys_matches_keys_at_alignment() -> None: assert {key.adv_key_bytes for key in current} == {key.adv_key_bytes for key in expected} expected_macs = {key.mac_address for key in expected} - assert accessory.current_mac_addresses(paired_at) == expected_macs + assert set(accessory.current_mac_addresses(paired_at)) == expected_macs for mac in expected_macs: assert MAC_RE.match(mac) @@ -64,29 +64,40 @@ def test_findmy_accessory_current_keys_defaults_to_now() -> None: assert len(accessory.current_mac_addresses()) > 0 -def test_a_margin_widens_the_candidate_set_both_ways() -> None: +def test_a_margin_reaches_indices_below_the_alignment_point() -> None: """ - The margin is what makes an accessory findable when alignment has drifted ahead of it. + The backwards half, which is the half the margin exists for. - Without one the range starts at the alignment index, so a true index below that is - excluded and the accessory is never matched - observed on real hardware, advertising a - metre away and absent from its own candidate set. + An accessory whose true index has ended up *below* where alignment believes it is + falls outside the range entirely and is never matched. That can only happen once + alignment is above zero, so it can only be tested there: with the default + `alignment_index == 0` every negative index yields nothing and the widening comes + entirely from the forward side. """ import findmy - paired_at = datetime.now(timezone.utc) + now = datetime.now(timezone.utc) accessory = findmy.FindMyAccessory( master_key=secrets.token_bytes(28), skn=secrets.token_bytes(32), sks=secrets.token_bytes(32), - paired_at=paired_at, + paired_at=now - timedelta(days=30), ) + accessory.update_alignment(now, 2880) - without = accessory.current_keys(paired_at) - with_margin = accessory.current_keys(paired_at, margin=timedelta(hours=12)) + behind = {k.adv_key_bytes for k in accessory.keys_at(2879)} + with_margin = { + k.adv_key_bytes for k in accessory.current_keys(now, margin=timedelta(hours=12)) + } + forward_only = { + k.adv_key_bytes for _, k in accessory.keys_between(now, now + timedelta(hours=12)) + } - assert len(with_margin) > len(without) - assert {k.adv_key_bytes for k in without} <= {k.adv_key_bytes for k in with_margin} + assert behind, "the fixture should have keys at the index just behind alignment" + assert behind <= with_margin + assert not (behind <= forward_only), ( + "if this passes, the forward half alone covers it and the test proves nothing" + ) def test_no_margin_behaves_exactly_as_before() -> None: @@ -119,3 +130,56 @@ def test_the_margin_reaches_mac_addresses_too() -> None: assert len(accessory.current_mac_addresses(margin=timedelta(hours=12))) > len( accessory.current_mac_addresses() ) + + +def test_the_index_comes_back_with_each_candidate() -> None: + """ + The index is what makes the cheap path reachable. + + A scanner that matches an advertisement has to be able to report *which* index it + matched, or it cannot call update_alignment and every later call pays for the wide + range again. + """ + import findmy + + now = datetime.now(timezone.utc) + accessory = findmy.FindMyAccessory( + master_key=secrets.token_bytes(28), + skn=secrets.token_bytes(32), + sks=secrets.token_bytes(32), + paired_at=now - timedelta(days=30), + ) + accessory.update_alignment(now, 2880) + + candidates = accessory.current_mac_addresses(now, margin=timedelta(hours=12)) + known_mac = next(iter(accessory.keys_at(2879))).mac_address + + # The index reported is the first one in the searched range at which that key is + # valid, not necessarily the only one: a secondary key covers 96 primary indices, + # so for those it is a lower bound. What has to hold is that the key really does + # occur there, which is what makes it safe to hand to update_alignment. + reported = candidates[known_mac] + assert known_mac in {k.mac_address for k in accessory.keys_at(reported)} + + +def test_feeding_a_matched_index_back_collapses_the_next_call() -> None: + """The pattern the docstring recommends, pinned end to end.""" + import findmy + + now = datetime.now(timezone.utc) + accessory = findmy.FindMyAccessory( + master_key=secrets.token_bytes(28), + skn=secrets.token_bytes(32), + sks=secrets.token_bytes(32), + paired_at=now - timedelta(days=30), + ) + accessory.update_alignment(now - timedelta(days=1), 2784) + + wide = accessory.current_mac_addresses(now, margin=timedelta(hours=12)) + seen_mac, seen_index = next(iter(wide.items())) + + accessory.update_alignment(now, seen_index) + narrow = accessory.current_mac_addresses(now) + + assert len(narrow) < len(wide) + assert seen_mac in wide From 80273b66852cb81e6290e9cdbaffe41a5cb8e328 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 10:23:16 +0000 Subject: [PATCH 5/5] [pre-commit.ci lite] apply automatic fixes --- findmy/accessory.py | 4 +--- tests/test_accessory.py | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/findmy/accessory.py b/findmy/accessory.py index 2848f19..215131f 100644 --- a/findmy/accessory.py +++ b/findmy/accessory.py @@ -195,9 +195,7 @@ def current_mac_addresses( See :meth:`current_keys` for the underlying key selection, for why `margin` is worth passing, and for what feeding the index back saves. """ - return { - key.mac_address: ind for key, ind in self.current_keys(now, margin).items() - } + return {key.mac_address: ind for key, ind in self.current_keys(now, margin).items()} class FixedRollingKeyPairAccessory( diff --git a/tests/test_accessory.py b/tests/test_accessory.py index 19ca0ab..95ab225 100644 --- a/tests/test_accessory.py +++ b/tests/test_accessory.py @@ -86,9 +86,7 @@ def test_a_margin_reaches_indices_below_the_alignment_point() -> None: accessory.update_alignment(now, 2880) behind = {k.adv_key_bytes for k in accessory.keys_at(2879)} - with_margin = { - k.adv_key_bytes for k in accessory.current_keys(now, margin=timedelta(hours=12)) - } + with_margin = {k.adv_key_bytes for k in accessory.current_keys(now, margin=timedelta(hours=12))} forward_only = { k.adv_key_bytes for _, k in accessory.keys_between(now, now + timedelta(hours=12)) }