Skip to content

Add current_keys()/current_mac_addresses() to RollingKeyPairSource - #1

Merged
parawanderer merged 4 commits into
parawanderer:feat/icloud-keychain-exportfrom
ubrt:feature/current-mac-candidates-app
Aug 23, 2026
Merged

parawanderer merged 4 commits into
parawanderer:feat/icloud-keychain-exportfrom
ubrt:feature/current-mac-candidates-app

Conversation

@ubrt

@ubrt ubrt commented Aug 22, 2026

Copy link
Copy Markdown

Same change as the upstream PR at malmeloo#262, for parawanderer/OpenTagViewer#17.

On the target branch, since it isn't main: OpenTagViewer pins parawanderer/FindMy.py@337381d, which is the tip of this branch rather than of main. The app's app/src/main/python/main.py imports MobileMeDelegateError, which exists only here — not in this fork's main, and not in malmeloo/main. So a merge into main alone would produce a commit the app cannot move its pin to; it needs one carrying both this change and the iCloud keychain work.

Happy to retarget if you'd rather take it on main and merge it across yourself — I just couldn't do the pin bump from there.

This should also become unnecessary in time: once the upstream PR lands and you rebase this branch onto a release containing it, the method arrives on its own.

@parawanderer parawanderer left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this — and for working out the base-branch question before opening it; your reasoning there is right, MobileMeDelegateError only exists on this branch so a main merge would give the app nothing to pin to.

I applied it on top of the branch tip (now 4f94015, so it'll want a rebase — no conflicts, it touches files nothing else has). 816 tests pass, ruff and basedpyright clean. The Python 3.10 to_bytes fix is correct and belongs in this PR rather than separately: mac_address is what the new method returns, so without it the feature is simply broken on the oldest supported Python.

Three things, in order of how much they'd change the code.

1. test_a_margin_widens_the_candidate_set_both_ways only tests one way

Every FindMyAccessory test here uses paired_at=now with no alignment update, so alignment_index == 0. Then get_min_index(now - 12h) returns -48, and keys_at returns set() for every negative index — so the widening those tests observe comes entirely from the forward side.

I checked by deleting the backwards half (keys_between(now, now + margin)): all six tests still pass.

That matters because the backwards half is the part your docstring says is the whole point — the accessory advertising a metre from the scanner and absent from its own candidate set. It can only happen when alignment is above zero, which is also the only place it can be tested:

accessory.update_alignment(now, 2880)   # a month old, as a real one would be

behind = {k.adv_key_bytes for k in accessory.keys_at(2879)}
assert behind <= {k.adv_key_bytes for k in accessory.current_keys(now, timedelta(hours=12))}

Passes as written; fails with the backwards half removed.

2. The return type makes the cheap path unreachable

Measured on a 30-day-old accessory aligned at index 2880:

call cost keys
current_keys(now, margin=12h), repeated 42 ms every call 100
margin=1h 4 ms 12
no margin, alignment fresh 0.4 ms 3

The SK ratchet is already cached, which is why a cold call is only ~20% slower than a warm one. What is not cached is derive_ps_key plus KeyPair construction — an EC operation per key, ~0.42 ms, on every call.

update_alignment(now, index) is what collapses that range, and it's already in the API. But current_keys returns set[KeyPair] with no index attached, so a caller that matches an advertisement cannot tell which index matched and cannot feed it back. keys_between yields (ind, key) pairs and the index is dropped one line later.

Since the docstring's use case is a scanning loop, that's the loop's cheap path being unreachable through this method — a caller who wants it has to bypass current_keys and call keys_between directly. An index-preserving variant, or returning the pairs, would fix it.

3. margin defaults to the failure the docstring describes

The docstring argues the margin is what makes an accessory findable, cites is_from's 12-hour window as precedent, and then defaults to timedelta(0). Someone who reads the signature and not the prose gets exactly the bug you're describing.

Worth either defaulting to the 12 hours you cite, or saying plainly in the docstring that the bare call is the unsafe one. Related: the margin covers drift since the last alignment, so it arguably wants to be a function of how stale that alignment is rather than a constant — near zero right after a hit, wider after a day.


Separately, and not for this PR: a memo on _AccessoryKeyGenerator._get_keypair takes the repeated 12-hour call from 42 ms to 6.7 ms, and halves the first one (both generators reach the same indices). That's a memory-for-time trade for @parawanderer to weigh, not something to fold in here.

ubrt added 3 commits August 23, 2026 12:16
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).
`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.
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.
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.
@ubrt
ubrt force-pushed the feature/current-mac-candidates-app branch from 4626612 to 661465a Compare August 23, 2026 10:22

@parawanderer parawanderer left a comment •

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked all three, and the rebase. 817 pass (809 on the branch plus your 8), ruff and basedpyright clean over the library.

The margin test now catches what it is for. Deleting the backwards half fails two tests where it previously failed none. The negative assertion — assert not (behind <= forward_only) — is the part I would not have thought to ask for: it stops the test quietly ceasing to test anything if the fixture's alignment ever drifts back to zero. That is the failure mode the original had, guarded against directly.

The index is right, not just present. I checked the lower-bound claim rather than taking it: of 100 candidates from a 12-hour margin, every reported index genuinely holds the key it is reported for. And update_alignment does ignore an index below the one it holds — fed 2800 against a held 2880, it stayed at 2880 — so the lower bound is safe in the direction it can be wrong.

Re-measured your numbers: 46 ms for the 12-hour margin, 0.5 ms after feeding the index back. The set-like reads survive the dict: in, iteration and len all behave as before on both methods.

On leaving the default at no margin — agreed, and your reasoning is better than my suggestion was. I offered "default to 12 hours, or say plainly the bare call is unsafe" as alternatives. Returning the index changes which of those is right: keeping alignment fresh is now something a caller can actually do, so the wide range is the exception rather than the thing every call should pay for. The docstring saying so, with the two working together, is the correct resolution.

Nothing further from me. (Edited: I first wrote that this was ahead of malmeloo#262 — it is not. You had already pushed the same commit there. The two now differ only in line wrapping applied by pre-commit.ci, so the API shape is the same in both places.)

@parawanderer
parawanderer merged commit 254a762 into parawanderer:feat/icloud-keychain-export Aug 23, 2026
ubrt added a commit to ubrt/OpenTagViewer that referenced this pull request Aug 24, 2026
The BLE work needs RollingKeyPairSource.current_mac_addresses(), contributed
by Ulrich Barrot (@ubrt) and merged as parawanderer/FindMy.py#1. This moves
all four pin sites off the personal fork and onto 254a7624, which carries it.

The same bump also brings the keychain-export work that landed on that branch
meanwhile - including the fix for parawanderer#140, where a recovered peer was addressed
by its escrow label instead of the bottle's id and every share came back
unreadable.

Two things the app was getting wrong against that API:

A bare current_mac_addresses() searches with no margin, so an accessory whose
true index has drifted below where alignment believes it is is simply absent
from its own candidate set - no error, no match, "not found nearby" for a tag
sitting on the desk. FindMy.py's own is_from takes 12 hours; so does this now.

And the call discarded the indices the map exists to provide. A sighting is an
observation of the same kind as a decrypted report, so it now feeds back
through update_alignment and is persisted the same way, which collapses the
next scan from a 12-hour range to three keys.

Co-Authored-By: Ulrich Barrot <12350410+ubrt@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants