Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,16 +180,28 @@ client look like several — and by
[findmy-export §5.2](./docs/findmy-export/03-keychain-trust.md), serials are exactly what
distinguishes devices there.

Three things follow:
Four things follow:

- **One source of truth.** A path that needs the identity reads it; it does not compose its own.
`AdiDeviceIdentity.Hardware` is it — Python reads the six values across the bridge rather than
keeping a copy, because there is no single right answer to keep: an install from before the
choice existed must present the Mac its ADI was provisioned with, while a fresh one presents
the iPhone.
- **The parts must agree with each other.** Model, OS version, build, CFNetwork and Darwin
describe one real release ([findmy-export §2.2](./docs/findmy-export/01-authentication.md)).
Claiming a Mac in one string and an iPhone in another is a contradiction Apple's own clients
never produce.
- **The serial is a label, and the only field here the user actually sees.** `0PENTAGVIEWR` is
confirmed accepted and displayed. Without one, Apple omits the row entirely, leaving an entry
with nothing to tell it apart from real hardware.
- **Sending the same value is not the same as sending the same bytes.** Two of these fields are
transformed on the way out, and only by one side. FindMy.py sends `X-Apple-I-MD-LU` as
`base64(uid)` and uppercases `X-Mme-Device-Id`; the Java ADI path sends what it is given. So
handing Python the string Java sends aligns one field and silently leaves the other as two
different values — **alignment that reads as alignment and is not**, which is worse than none,
because nothing ever complains. Java renders the header per hardware profile for exactly this
reason, and `IdentityBridgeTest` pins both directions. Check what actually goes on the wire
before believing two paths agree.

**Changing it later adds an entry rather than renaming one**, and may require signing in again,
so it is not a thing to adjust casually once shipped. Document what the app registers as, so a
Expand Down
79 changes: 69 additions & 10 deletions app/src/main/python/main.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from enum import Enum
from typing import Any, NamedTuple
from typing import Any, NamedTuple, cast
import json
import time
import traceback
Expand All @@ -9,6 +9,7 @@
import NSKeyedUnArchiver

from findmy import FindMyAccessory
from findmy.accessory import FixedRollingKeyPairAccessory
from findmy.reports import (
RemoteAnisetteProvider,
AppleAccount,
Expand Down Expand Up @@ -541,7 +542,65 @@ def _filterReportsByTimeRange(reports, startMs, endMs):
_RANGE_FETCH_RETRY_DELAY_SECONDS = 1


def _isAlignmentWide(accessory: FindMyAccessory, start, end) -> int:
StoredAccessory = FindMyAccessory | FixedRollingKeyPairAccessory
"""
Either kind of tag this app can locate.

A union rather than their shared base, because the base is not enough: `RollingKeyPairSource`
has the key methods but not `to_json`, which comes from `Serializable` further along a separate
line. Naming the two concrete classes says what is actually true - these two, and adding a
third is a deliberate edit here.
"""

ACCESSORY_TYPES: dict[str, type[StoredAccessory]] = {
"accessory": FindMyAccessory,
"custom_rolling_key_accessory": FixedRollingKeyPairAccessory,
}
"""
Which class reads which stored accessory, keyed by FindMy.py's own `type` tag.

**Two sibling classes, not a base and a subclass.** An Apple-paired accessory derives its keys
from a master key, a shared secret and a secondary secret; a self-generated one - OpenHaystack
style - carries a plain list of pre-generated keys and derives nothing. Neither is a special
case of the other, and FindMy.py has no factory that reads both, so the dispatch lives here.

The tag is theirs, written by `to_json` and asserted by each `from_json`, so a mapping handed
to the wrong class fails loudly rather than half-loading.
"""


def accessoryFromJson(accessoryJson: str) -> StoredAccessory:
"""
Rebuild a stored accessory, whichever kind it is.

Everything the fetch path does to an accessory - `keys_between`, `get_min_index`,
`get_max_index`, `update_alignment`, `to_json` - is on `RollingKeyPairSource`, which both
kinds implement. So this is the **only** place the difference matters, and the rest of the
fetch path never learns which it has.

:raises ValueError: if the stored JSON names a type this version cannot read. Deliberately
loud: the alternative is guessing, and guessing wrong means fetching against keys that
belong to a different derivation - which finds nothing, and looks like a tag out of range
rather than like a bug.
"""
mapping: Any = json.loads(accessoryJson)
kind = mapping.get("type") if isinstance(mapping, dict) else None

accessoryType = ACCESSORY_TYPES.get(kind) if isinstance(kind, str) else None
if accessoryType is None:
raise ValueError(
f"stored accessory has type {kind!r}, which this version cannot read - "
f"known types are {sorted(ACCESSORY_TYPES)}"
)

# Cast because the two from_json signatures each want their own TypedDict, and this is
# untyped JSON off disk. Validating it is precisely what from_json does - it asserts the
# type tag and raises on a missing field - so re-describing the shape here would be a
# second implementation of a check the library already owns.
return accessoryType.from_json(cast(Any, mapping))


def _isAlignmentWide(accessory: StoredAccessory, start, end) -> int:
"""Width of the key-index range a history fetch would search, or 0 if unknown."""
try:
return accessory.get_max_index(end) - accessory.get_min_index(start)
Expand All @@ -563,7 +622,7 @@ class AccessoryFetch(NamedTuple):
bounded_to_window: bool


def _fetchReportsForAccessory(account: AppleAccount, accessory: FindMyAccessory, start, end):
def _fetchReportsForAccessory(account: AppleAccount, accessory: StoredAccessory, start, end):
"""
Fetch reports for one accessory, avoiding a full-history key search when possible.

Expand Down Expand Up @@ -613,7 +672,7 @@ def _chunk(items, size):
return [items[i:i + size] for i in range(0, len(items), size)]


def _fetchReportsInRange(account: AppleAccount, accessory: FindMyAccessory, start, end):
def _fetchReportsInRange(account: AppleAccount, accessory: StoredAccessory, start, end):
"""
Fetch the reports an accessory produced inside a specific time window.

Expand Down Expand Up @@ -679,7 +738,7 @@ def _fetchReportsInRange(account: AppleAccount, accessory: FindMyAccessory, star
return reports


def _alignBeforeRangedFetch(account: AppleAccount, accessory: FindMyAccessory, start, end) -> bool:
def _alignBeforeRangedFetch(account: AppleAccount, accessory: StoredAccessory, start, end) -> bool:
"""
Narrow the key index range before generating keys for it.

Expand Down Expand Up @@ -707,7 +766,7 @@ def _alignBeforeRangedFetch(account: AppleAccount, accessory: FindMyAccessory, s
return True


def _keysForRange(accessory: FindMyAccessory, start, end):
def _keysForRange(accessory: StoredAccessory, start, end):
"""The `(index, key)` pairs covering a time window, capped at what one fetch may search."""
try:
indexed_keys = list(accessory.keys_between(start, end))
Expand All @@ -734,7 +793,7 @@ class _ChunkFetchError(Exception):
"""One request of a ranged fetch failed, after its retries."""


def _fetchChunkAndAlign(account: AppleAccount, accessory: FindMyAccessory, chunk, index_by_key):
def _fetchChunkAndAlign(account: AppleAccount, accessory: StoredAccessory, chunk, index_by_key):
"""
Fetch one request's worth of keys and feed what comes back into the accessory's alignment.

Expand Down Expand Up @@ -772,7 +831,7 @@ def _fetchChunkAndAlign(account: AppleAccount, accessory: FindMyAccessory, chunk
return reports


def _updateAlignment(accessory: FindMyAccessory, report, index):
def _updateAlignment(accessory: StoredAccessory, report, index):
if index is None:
return
try:
Expand Down Expand Up @@ -860,7 +919,7 @@ def getLastReports(

print(f"Fetching report for {beaconId} for the last {hoursBack} hours...")

airtag = FindMyAccessory.from_json(json.loads(accessoryJson))
airtag = accessoryFromJson(accessoryJson)
start_dt = datetime.fromtimestamp(start_ms / 1000, tz=timezone.utc)
now_dt = datetime.fromtimestamp(now_ms / 1000, tz=timezone.utc)

Expand Down Expand Up @@ -931,7 +990,7 @@ def getReports(

print(f"Fetching report for {beaconId} in time range {unixStartMs}-{unixEndMs}...")

airtag = FindMyAccessory.from_json(json.loads(accessoryJson))
airtag = accessoryFromJson(accessoryJson)
start_dt = datetime.fromtimestamp(unixStartMs / 1000, tz=timezone.utc)
end_dt = datetime.fromtimestamp(unixEndMs / 1000, tz=timezone.utc)

Expand Down
151 changes: 149 additions & 2 deletions app/src/test/python/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
from pathlib import Path

import json
import re

import pytest

import main
Expand Down Expand Up @@ -66,6 +68,102 @@ def test_convertPlistToJson_produces_restorable_json(plist_path):
assert restored is not None


# --------------------------------------------------------------------------
# accessoryFromJson
#
# Two kinds of tag now reach the fetch path, and they are sibling classes rather than
# a base and a subclass: an Apple-paired accessory derives its keys from a master key
# and two secrets, a self-generated one carries a plain list and derives nothing.
# FindMy.py has no factory that reads both, so the dispatch is ours - which makes
# "does an existing user's stored tag still load" a question worth asking out loud.
# --------------------------------------------------------------------------

_CUSTOM_ACCESSORY = {
"type": "custom_rolling_key_accessory",
# Two 28-byte private keys, which is the size FindMy.py's KeyPair expects.
"private_keys": ["11" * 28, "22" * 28],
"name": "A tag nobody paired",
"identifier": "openhaystack-1",
}


@pytest.mark.skipif(not BEACON_PLISTS, reason="no redacted beacon fixture available")
def test_an_apple_paired_tag_still_loads():
"""
The upgrade case, and the one with something to lose.

Every beacon an existing user has is stored as this. If the dispatch got it wrong they
would all stop locating at once, on a build that installed cleanly.
"""
stored = main.convertPlistToJson(_read_plist_xml(BEACON_PLISTS[0]))

accessory = main.accessoryFromJson(stored)

assert isinstance(accessory, main.FindMyAccessory)
# Round-trips through the same door it came out of, which is what the fetch path does
# after every call to persist the updated alignment.
assert main.accessoryFromJson(json.dumps(accessory.to_json())) is not None


def test_a_self_generated_tag_loads_as_the_other_kind():
accessory = main.accessoryFromJson(json.dumps(_CUSTOM_ACCESSORY))

assert isinstance(accessory, main.FixedRollingKeyPairAccessory)
assert accessory.identifier == "openhaystack-1"
assert accessory.name == "A tag nobody paired"


def test_a_self_generated_tag_survives_the_round_trip_the_fetch_path_makes():
"""
`getLastReports` writes `to_json()` back to the database after every fetch, so a tag that
reads but does not re-read would work once and then be unreadable - a failure that only
appears on the *second* refresh.
"""
once = main.accessoryFromJson(json.dumps(_CUSTOM_ACCESSORY))
twice = main.accessoryFromJson(json.dumps(once.to_json()))

assert twice.identifier == once.identifier
assert list(twice.keys_between(
datetime(2026, 1, 1, tzinfo=timezone.utc),
datetime(2026, 1, 2, tzinfo=timezone.utc),
)) == list(once.keys_between(
datetime(2026, 1, 1, tzinfo=timezone.utc),
datetime(2026, 1, 2, tzinfo=timezone.utc),
))


def test_both_kinds_offer_everything_the_fetch_path_uses():
"""
The reason the rest of the fetch path never learns which kind it has.

If a future library version moved one of these off one of the two classes, the fetch would
fail at runtime for that kind only - and only for whoever owns that kind of tag.
"""
for accessoryType in main.ACCESSORY_TYPES.values():
for method in ("keys_between", "get_min_index", "get_max_index",
"update_alignment", "to_json", "from_json"):
assert hasattr(accessoryType, method), f"{accessoryType.__name__} lost {method}"


def test_an_unknown_type_is_refused_rather_than_guessed_at():
"""
Loud, because the quiet alternative is worse.

Guessing would fetch against keys from a different derivation, which finds nothing and
reads as a tag that is simply out of range rather than as a bug.
"""
with pytest.raises(ValueError) as raised:
main.accessoryFromJson(json.dumps({"type": "something_from_the_future"}))

assert "something_from_the_future" in str(raised.value)


@pytest.mark.parametrize("blob", ['{"no": "type"}', '"a string"', "null", "[]"])
def test_json_that_is_not_an_accessory_is_refused(blob):
with pytest.raises(ValueError):
main.accessoryFromJson(blob)


def test_convertPlistToJson_returns_none_on_garbage():
"""Failure must be None, not an exception - the caller retries later."""
assert main.convertPlistToJson("not a plist at all") is None
Expand Down Expand Up @@ -282,8 +380,10 @@ def get(self, i):


def _runGetLastReports(monkeypatch, fetch_result, hours_back=24):
monkeypatch.setattr(
main.FindMyAccessory, "from_json", staticmethod(lambda _json: _FakeAirtag()))
# The dispatch, not the library class. These tests are about what getLastReports does with
# an accessory, not about reading one - and patching FindMy.py's own from_json used to let
# them feed it a blob with no "type", which the real thing has always rejected.
monkeypatch.setattr(main, "accessoryFromJson", lambda _json: _FakeAirtag())
monkeypatch.setattr(
main, "_fetchReportsForAccessory", lambda *args, **kwargs: fetch_result)

Expand Down Expand Up @@ -502,6 +602,53 @@ def test_a_session_that_still_works_restores_normally():
# Guard against the test environment drifting from what the app ships
# --------------------------------------------------------------------------

# The four files spell the same pin four ways - `...FindMy.py@<sha>`, `?rev=<sha>#<sha>`, and
# TOML's `{ git = "...FindMy.py", rev = "<sha>" }` - so this matches a full sha anywhere on a
# line that names the repository, rather than one exact syntax. Deliberately not "any 40-hex
# string in the file": uv.lock is full of other packages' revisions.
_FINDMY_SHA = re.compile(r"FindMy\.py[^\n]{0,80}?([0-9a-f]{40})")


def test_the_whole_repository_pins_one_findmy():
"""
One repository, one FindMy.py - in the app build, the bridge tests, and the exporter.

**Because `opentagviewer_export` is shared code that runs under both.** Chaquopy packages it
into the APK, where it executes against the pin in `app/build.gradle.kts`, and the desktop
exporter runs the same files from source against `python/uv.lock`. Two commits of one library
behind one package is a bug waiting for the first API difference, and it would break exactly
one of the two consumers.

The exporter tracked the *branch*, so this drifted by construction: every `uv lock` picked up
whatever had been pushed since. A build was still reproducible - the lockfile records what it
resolved - but "which FindMy.py does this repository use" had two answers.

Three files, one sha. Moving it means moving all three.
"""
root = Path(__file__).resolve().parents[3].parent

sources = {
"app/build.gradle.kts": root / "app" / "build.gradle.kts",
"app/src/test/python/requirements.txt": Path(__file__).resolve().parent
/ "requirements.txt",
"python/pyproject.toml": root / "python" / "pyproject.toml",
"python/uv.lock": root / "python" / "uv.lock",
}

found = {}
for name, path in sources.items():
assert path.is_file(), f"{name} is missing - this test no longer checks what it thinks"
shas = set(_FINDMY_SHA.findall(path.read_text(encoding="utf-8")))
assert shas, f"{name} does not pin FindMy.py to a commit"
assert len(shas) == 1, f"{name} names more than one FindMy.py commit: {sorted(shas)}"
found[name] = shas.pop()

assert len(set(found.values())) == 1, (
"the repository pins more than one FindMy.py:\n"
+ "\n".join(f" {name}: {sha}" for name, sha in sorted(found.items()))
)


def test_pinned_versions_match_the_app_build():
"""
These tests only mean anything if they run against the same library the APK ships.
Expand Down
9 changes: 2 additions & 7 deletions app/src/test/python/test_main_ranged_fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -326,13 +326,8 @@ def to_json(self):

@pytest.fixture
def stub_accessory(monkeypatch):
"""from_json needs real accessory data; the tests here only care about failure handling."""
class StubFindMyAccessory:
@staticmethod
def from_json(_data):
return StubAccessory()

monkeypatch.setattr(main, "FindMyAccessory", StubFindMyAccessory)
"""Reading an accessory needs real data; the tests here only care about failure handling."""
monkeypatch.setattr(main, "accessoryFromJson", lambda _json: StubAccessory())


def test_getReports_reports_an_error_when_every_accessory_fails(monkeypatch, stub_accessory):
Expand Down
Loading
Loading