Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Some official/compatible AirTags don't work #90

Open
thisiscam opened this issue Dec 4, 2024 · 50 comments
Open

Some official/compatible AirTags don't work #90

thisiscam opened this issue Dec 4, 2024 · 50 comments

Comments

@thisiscam
Copy link
Contributor

Recently the 5 bytes matching trick discussed in #46 appears to stop working.
I also just tried #50 and seems like I can't find devices that are in nearby state using the example device_scanner.py

@malmeloo
Copy link
Owner

malmeloo commented Dec 4, 2024

Hmm that's strange, I recently used the scanner for another project of mine and could've sworn it received multiple "nearby" reports. That said, I have received a report from someone else saying that the accessory key generation is not working properly, which might be related to your problem. I've been quite busy but will investigate a bit more soon, will keep you posted.

@thisiscam thisiscam changed the title "detect OF devices in Nearby state" doesn't seem to work anymore "detect OF devices in Nearby state" can't search for known key Dec 4, 2024
@thisiscam
Copy link
Contributor Author

Hmm that's strange, I recently used the scanner for another project of mine and could've sworn it received multiple "nearby" reports. That said, I have received a report from someone else saying that the accessory key generation is not working properly, which might be related to your problem. I've been quite busy but will investigate a bit more soon, will keep you posted.

To clarify, I can see there are nearby devices, but I can't match them with my own devices with known private keys.

@malmeloo
Copy link
Owner

malmeloo commented Dec 4, 2024

Ahh alright, thanks for the clarification. I'll look into it!

@malmeloo
Copy link
Owner

malmeloo commented Dec 5, 2024

Could you try whether #91 fixes your issue? You can install it as a drop-in replacement using pip install -U git+https://github.com/malmeloo/FindMy.py@fix/nearby-device-discovery.

@thisiscam
Copy link
Contributor Author

Could you try whether #91 fixes your issue? You can install it as a drop-in replacement using pip install -U git+https://github.com/malmeloo/FindMy.py@fix/nearby-device-discovery.

Unfortunately that didn't fix the problem. I was already doing a timeshift adjustment in my own code. Is it possible that Apple changed their "5 matching bytes" protocol?

@thisiscam
Copy link
Contributor Author

Some possibilities:

  1. The rotating key algorithm was changed
  2. The 5 matching bytes protocol was changed
  3. I'm not using the correct decrypted plist??

Reason 3) is possible is that one of my Mac was updated to macOS 15 recently and I can no longer decrypt the plist on that machine. Ref: MartinPham/FindMySync#38
So I ended up using an older mac that's still on macOS 13 to decrypt the plist on that machine. Interestingly, I found this new decrypted plist file to use the same private key as the one I had been using before, prior to the macOS15 update. So maybe this explains away the possibility of 3) a little bit, but I'm not sure if there are catches.

@malmeloo
Copy link
Owner

malmeloo commented Dec 6, 2024

That's really strange, and I'm honestly not sure what's going wrong. Of course it's possible that Apple changed the spec, but that's assuming that they somehow have the ability to update both airtags and 3rd party tags over the air.

I'm picking up my own 3rd party tag soon to do some experiments on this.

@thisiscam
Copy link
Contributor Author

thisiscam commented Dec 6, 2024

I'm leaning towards 1) at the moment. If I put my phone on flight mode, I get one (new) separated device, which presumably is my airtag. But it also doesn't match with any known public key in the time shift range.

@thisiscam
Copy link
Contributor Author

Ok this should eliminate possibility of 2):

I put my phone flight mode, got one separated device:

SEPARATED Device - DC:B0:6B:C1:E1:20
  Public key:   XLBrweEg0MRQwAt5gk0zk0rRDOQtdW2vr1WCqw==
  Lookup key:   IcctnBNQnY0H3Pqq7JQzGtIQxDNvFAs2yWb2TlH3lkw=
  Status byte:  0
  Hint byte:    0
  Extra data:

I checked that

>>> import base64
>>> s = "XLBrweEg0MRQwAt5gk0zk0rRDOQtdW2vr1WCqw=="
>>> base64.b64decode(s)[1:6].hex()
'b06bc1e120'

So it seems like the 5 bytes trick is still valid!

It then seems likely that they changed the rotating key algorithm. They could be changing only the official airtag's algorithm afaik. I can see an airtag software version number on my phone --- not sure if recently got updated though!

@malmeloo
Copy link
Owner

malmeloo commented Dec 6, 2024

The public key generated by the library is partially derived from those bytes in the mac address, so I'm not surprised it checks out ;-). And that's interesting, though I guess it makes sense that Apple would want to update the firmware on their own tags. Theoretically speaking it would be possible for Apple to use a new, updated algorithm only on their devices / AirTags, since owner devices are aware of the type of accessory that they're tracking.

If possible, could you try to re-pair the tag, then dump the plist again and see if that fixes the issue, at least temporarily?

@thisiscam
Copy link
Contributor Author

thisiscam commented Dec 6, 2024

The public key generated by the library is partially derived from those bytes in the mac address, so I'm not surprised it checks out ;-). And that's interesting, though I guess it makes sense that Apple would want to update the firmware on their own tags. Theoretically speaking it would be possible for Apple to use a new, updated algorithm only on their devices / AirTags, since owner devices are aware of the type of accessory that they're tracking.

If possible, could you try to re-pair the tag, then dump the plist again and see if that fixes the issue, at least temporarily?

Yeah, I re-paired, dumped and decoded the plist on macOS 13. The plist correctly reflects that I have repaired. But that did not fix the issue.

@malmeloo
Copy link
Owner

malmeloo commented Dec 6, 2024

That unfortunate; I'll probably pick up a tag next week and experiment a bit on my own. Will let you know how it goes.

@thisiscam
Copy link
Contributor Author

Sorry for the ping --- is there an update to this? Should I an opensource tag instead?

@malmeloo
Copy link
Owner

malmeloo commented Jan 6, 2025

Depends on what you call an update - I've been able to pair a tag to one of my apple accounts, but for whatever reason I cannot get it to show up on my hackintosh. My best guess is that's because I only booted it up after having logged out of the device I paired it with. I don't exactly have regular access to an iPhone or iPad, let alone one I can use with a temporary account, so it's all taking way longer than necessary. Currently I'm just regularly saving the data this tag is broadcasting, so as soon as that plist is dumped I should know more.

@thisiscam
Copy link
Contributor Author

Thanks for the update! I am fairly certain that the BLE nearby device tracking functionality is not working for official airtag and official clones anymore. In the mean time while you look at this, do you have any recommendation for a open source drop in replacement tag? As in, do you happen to know (to a good degree of certainty) that particular hardware should work with the BLE nearby device tracking?

@malmeloo
Copy link
Owner

malmeloo commented Jan 8, 2025

The BLE device tracking should work with any "static" OpenHaystack tag. There are a bunch of people working on different firmwares for different tags, so it mostly depends on which hardware you can get your hands on. I am personally using these NRF52810 tags with this firmware, but there are likely cheaper options available.

@kimi4422
Copy link

kimi4422 commented Jan 9, 2025

嗯,这很奇怪,我最近将扫描仪用于我的另一个项目,并且可以肯定它收到了多个“附近”报告。话虽如此,我收到了其他人的报告,称附件密钥生成无法正常工作,这可能与您的问题有关。我一直很忙,但很快就会进行更多调查,并随时向您汇报。

Brother, I have some problems that need your help. Please leave your contact information and I can pay you.

@malmeloo
Copy link
Owner

malmeloo commented Jan 9, 2025

嗯,这很奇怪,我最近将扫描仪用于我的另一个项目,并且可以肯定它收到了多个“附近”报告。话虽如此,我收到了其他人的报告,称附件密钥生成无法正常工作,这可能与您的问题有关。我一直很忙,但很快就会进行更多调查,并随时向您汇报。

Brother, I have some problems that need your help. Please leave your contact information and I can pay you.

If it's related to this issue then please leave your question here, that way everyone can benefit from the shared knowledge. If it's something really private you want to share, feel free to use my git email.

@thisiscam
Copy link
Contributor Author

Just an update on my end: I actually tried to pair my official airtag with an iOS 17 device, and got the plist out from a macOS 13 --- it still does not work. At this point, the only possibility is that they changed the key rotation.

@thisiscam
Copy link
Contributor Author

thisiscam commented Jan 10, 2025

Just an update on my end: I actually tried to pair my official airtag with an iOS 17 device, and got the plist out from a macOS 13 --- it still does not work. At this point, the only possibility is that they changed the key rotation.

Actually, I spoke too soon! For the above comment, I was testing iOS 17 + macOS 13 with the latest version of the FindMy.py library, which wasn't working.

However, after switching back to FindMy.py version 0.6 (prior to the nearby scan was implemented) and using my old code from #46, everything is working again! This suggests that an issue in the current FindMy.py scanner code. Upon inspecting the scanner class, I couldn't find the "five consecutive bytes check" in the code. (nvm, found it. Turns out problem was just that the key matching needs a time window)

Since I am able to get everything to work again using iOS 17 + macOS 13 combo, it appears that Apple hasn't changed the AirTag key rotation algorithm itself. But, I am likely using the wrong master key after upgrading to iOS 18 + macOS 15, since the new keychain app does not allow easy extraction of the BeaconStore key to decode the plists, and I had to use macOS 13 to do the plist decoding! Interestingly, if I pair the official AirTag with iOS 18 but extract the plist using macOS 13, I get a plist file that still allows querying the AirTag's location from Apple's server. However, it’s possible that Apple is concealing the true private key with the iOS18 + macOS13 combo.

@malmeloo
Copy link
Owner

That's great news, thanks for investigating! So, just to summarize:

  • Nearby scanning needs a fix in the library
  • iOS 17 + macOS 13 works
  • iOS xx + macOS 15 does not work, because the keys can't be dumped
  • iOS 18 + macOS 13 does not work when scanning, but fetching locations does work

Correct?

@thisiscam
Copy link
Contributor Author

That's great news, thanks for investigating! So, just to summarize:

* Nearby scanning needs a fix in the library

Right. The fix is in #97

* iOS 17 + macOS 13 works

* iOS xx + macOS 15 does not work, because the keys can't be dumped

* iOS 18 + macOS 13 does not work when scanning, but fetching locations does work

Correct?

Correct.

@malmeloo
Copy link
Owner

Cool, thanks for confirming. I'll look at your PR tomorrow when I have my laptop available.

I think it might be time to seriously start looking into how keychain syncing works. That way we should able to obtain the keys directly without having to dump them from a mac.

@thisiscam
Copy link
Contributor Author

Cool, thanks for confirming. I'll look at your PR tomorrow when I have my laptop available.

I think it might be time to seriously start looking into how keychain syncing works. That way we should able to obtain the keys directly without having to dump them from a mac.

Keychain (as in the macOS app) syncing? Or do you simply mean how the airtag communicate with an iOS device to decide on the master private key? I do suspect that we can potentially have an middle man when the airtag pairs for the first time. Not sure how feasible that is though!

@malmeloo
Copy link
Owner

Setting up an AirTag still requires communication with Apple servers for registration, and I'd much prefer trying to reverse engineer a service running on macOS than one that runs exclusively on iOS 😅. I also think that keychain syncing would be more useful in general, because it would allow people to use AirTags without having to reset and pair them through this library. That said, both are interesting to look into.

@thisiscam
Copy link
Contributor Author

Setting up an AirTag still requires communication with Apple servers for registration, and I'd much prefer trying to reverse engineer a service running on macOS than one that runs exclusively on iOS 😅. I also think that keychain syncing would be more useful in general, because it would allow people to use AirTags without having to reset and pair them through this library. That said, both are interesting to look into.

I'm lacking knowledge of what is "keychain syncing"?

@malmeloo
Copy link
Owner

Keychain as in the icloud keychain that stores secrets in your account. Since the AirTag master secrets are stored in the keychain, that should make it possible to pull them directly from an Apple account after logging in, instead of having to dump them from a mac.

@kami83
Copy link

kami83 commented Jan 27, 2025

Hi anything new to this? I am not having access to macOS < 15.0 or iOS<18. Can i do anything?

BR kami

@malmeloo
Copy link
Owner

I am still looking into this, as my hackintosh still refuses to sync for whatever reason. To the best of my knowledge people have not yet been able to dump the master secrets from macOS 15, so if you don't have access to a < 15 machine it's going to be a challenge.

@malmeloo
Copy link
Owner

malmeloo commented Feb 3, 2025

(continuation from #31) @aircable: Do you have the exact pairing date of your tag for me? It should also be in the plist, but it appears to be missing for some reason. Did you edit that part out or is it missing from the original file?

You mentioned that your tag was paired in 2023, but I was able to find the private key belonging to the scan you posted at index ~13291. That's roughly 13291 * 15 minutes = 4.5 months after pairing time, so it can't have been in 2023, unless there's an issue with the library.

If I select September 12th 2024 as pairing date and use the values from the plist you posted, I can pull location reports from Apple that were generated earlier today.

@aircable
Copy link

aircable commented Feb 3, 2025

I am still looking into this, as my hackintosh still refuses to sync for whatever reason. To the best of my knowledge people have not yet been able to dump the master secrets from macOS 15, so if you don't have access to a < 15 machine it's going to be a challenge.

I have, my Macbook Air M1 is Sonoma 14.4. Let me know what to do.

@aircable
Copy link

aircable commented Feb 3, 2025

(continuation from #31) @aircable: Do you have the exact pairing date of your tag for me? It should also be in the plist, but it appears to be missing for some reason. Did you edit that part out or is it missing from the original file?

    <key>pairingDate</key>
    <date>2023-08-31T18:32:21Z</date>

You mentioned that your tag was paired in 2023, but I was able to find the private key belonging to the scan you posted at index ~13291. That's roughly 13291 * 15 minutes = 4.5 months after pairing time, so it can't have been in 2023, unless there's an issue with the library.

How did you do that?

If I select September 12th 2024 as pairing date and use the values from the plist you posted, I can pull location reports from Apple that were generated earlier today.
Like this, from Items.data:
signature

1jBBM7vhRl8/fQtoasef9yAPCS6d0JdFDuTpnTeHk9BWpekPBSi6PkBAUl1lGAQiL6p+
AqIwqbujoCc1Zy8MCQ==

What app do you use to decrypt that?

@aircable
Copy link

aircable commented Feb 3, 2025

I did mention that the tag was off for an extensive amount of time. That's why you could not find it. But tell me, the analyze_key.py file I shared did not go to index 31291?
Look at this, from SecureLocationInfo: That shows we have 5 keys?

<key>ownedDeviceConfigs</key>
	<dict>
		<key>fetchExpirationTime</key>
		<real>31536000</real>
		<key>maxNoOfKeys</key>
		<integer>5</integer>
		<key>ownedDeviceFetchEnabled</key>
		<true/>
		<key>ownedDeviceFetchViaIDS</key>
		<true/>
		<key>ownedDevicePublishEnabled</key>
		<true/>
		<key>ownedDevicePublishToACSN</key>
		<true/>
		<key>ownedDevicePublishToACSNWithVersionCheck</key>
		<true/>
		<key>ownedDeviceStopPublishToACSNIOSVersion</key>
		<string>16.0</string>
		<key>ownedDeviceStopPublishToACSNMacOSVersion</key>
		<string>13.0</string>
		<key>ownedDeviceStopPublishToACSNWatchOSVersion</key>
		<string>9.0</string>
		<key>publishExpirationTime</key>
		<real>15768000</real>
	</dict>

@aircable
Copy link

aircable commented Feb 3, 2025

finding more information, from OwnedDeviceKeyRecord. The deviceIdentifier is the one for that Airtag.
But it is a different key pair from the OwnedBeacon plist file.

	<key>deviceIdentifier</key>
	<string>1E257350-56A1-4347-8953-B37344888AC2</string>
	<key>identifier</key>
	<string>1FFB4B8E-E6B9-4DDB-A06C-45FC2638F2A1</string>
	<key>privateKey</key>
	<dict>
		<key>key</key>
		<dict>
			<key>data</key>
			<data>
			BFrpmM2QZJUEjiGUM52ejt5AshEbHjWmPgzjP47+2p2NpbnaYS20
			7SGDgseqoWzL2DroseLl0r6O4miDNGNAvX54xNP/HNZDrsT/1FCx
			JsFvZOHwYg==
			</data>
		</dict>
	</dict>
	<key>publicKey</key>
	<dict>
		<key>key</key>
		<dict>
			<key>data</key>
			<data>
			BFrpmM2QZJUEjiGUM52ejt5AshEbHjWmPgzjP47+2p2NpbnaYS20
			7SGDgseqoWzL2DroseLl0r6O
			</data>
		</dict>
	</dict>

@malmeloo
Copy link
Owner

malmeloo commented Feb 5, 2025

Sorry, missed your responses.

I have, my Macbook Air M1 is Sonoma 14.4. Let me know what to do.

My plan was to pair a tag and obtain the very first key it broadcasts, then start from there. That should allow me to verify the assumptions about timezones etc. as well, since I'm not 100% sure that's correct yet (even though it shouldn't cause any issues in practice)

Thanks for the pairing date! Do think it'd be possible to connect the tag to an owner device to make it update its internal clock? I'm curious to see if the key rotation algorithm would immediately jump to the "correct" key or not.

How did you do that?
...
What app do you use to decrypt that?

I used the same debugging script you posted but set the pairing date all the way back to 2018 or so. Then filled in the public key (not the lookup key) from your scan and let it run until it found a match. The date of the match is wrong but relative to the pairing date that is set, and since it was ~4.5 months after the pairing date I used, I figured it must've been paired ~4.5 months ago. But I forgot about your tag being turned off for a while, so if it was paired in 2023 that still checks out.

Using the above method to find the "real" (wrong in theory but real according to the accessory's internal clock) pairing date, I plugged it into the real_airtag.py example to obtain the location reports.

I'm not sure what those SecureLocationInfo and OwnedBeacon files are supposed to represent. The private/public key lengths don't match up with the EC keys used in FindMy. However, the following strings look interesting:

<key>ownedDevicePublishToACSN</key>
<true/>
<key>ownedDevicePublishToACSNWithVersionCheck</key>
<true/>
<key>ownedDeviceStopPublishToACSNIOSVersion</key>
<string>16.0</string>
<key>ownedDeviceStopPublishToACSNMacOSVersion</key>
<string>13.0</string>
<key>ownedDeviceStopPublishToACSNWatchOSVersion</key>
<string>9.0</string>

ACSN (anonymous crowd-sourced network) is the server-sided part of FindMy running at Apple. It looks like there are lower OS version bounds for when to enable participation in the network? I don't think it's related to this issue, but interesting nonetheless.

@aircable
Copy link

aircable commented Feb 5, 2025

I used the same debugging script you posted but set the pairing date all the way back to 2018 or so. Then filled in the public key (not the lookup key) from your scan and let it run until it found a match. The date of the match is wrong but relative to the pairing date that is set, and since it was ~4.5 months after the pairing date I used, I figured it must've been paired ~4.5 months ago. But I forgot about your tag being turned off for a while, so if it was paired in 2023 that still checks out.

So, basically you changed the airtag plist file and put some different numbers in and just ran real_airtag.py airtag.plist, right? What did you change in the file, can you please post? I just want to make sure I understand what you did.

@malmeloo
Copy link
Owner

malmeloo commented Feb 6, 2025

I left the plist intact, but manually set the pairingDate (since it was missing). I've filled in your details in the files below.

Run this with the path to the plist to find the private key:
airtag_tester.txt
It finds an approximate time which is roughly 4.5 months after the pairing date I set in the file.

I then played around with the pairing date around september last year until it found the most recent location reports:
real_airtag.txt

@aircable
Copy link

aircable commented Feb 7, 2025

Thank you so much, I was able to follow your steps. Got data.

But not those steps:

I used the same debugging script you posted but set the pairing date all the way back to 2018 or so. Then filled in the public key (not the lookup key) from your scan and let it run until it found a match. The date of the match is wrong but relative to the pairing date that is set, and since it was ~4.5 months after the pairing date I used, I figured it must've been paired ~4.5 months ago. But I forgot about your tag being turned off for a while, so if it was paired in 2023 that still checks out.

Using the airtag_testerl I find the key at

  • Approx. Time: 2023-06-25 12:00:00+00:00
  • Type: KeyType.SECONDARY

That is vs the reported pairing time:
pairingDate
2023-08-31T18:32:21Z
2 months earlier.

Then, as you did, searching for the data using datetime(2024, 9, 11) leads to the most keys found. But no other date determined by the airtag_tester script.

My interpretations maybe is that the possible_keys you generate are just not enough. How many are you actually generating?
I found that querying the server, I find 7 keys and they rotate about once a day.

I also found out that a span of 4 days will give you the maximum number of reports, in out case 2024/9/10-13.
The dates that gives at least one key are 14 days: 24/9/4 and 24/9/18. As far as I remember that is the maximum time the server stores keys anyway. That makes sense then.

Do those numbers ring a bell? Are you creating a 4 day window with the key generator?
My question is, what is the number of days the key rotate, starting from the beginning.
I could not find a repeat. Does that mean there is only one possible pairingDate that is successful?

@aircable
Copy link

aircable commented Feb 7, 2025

I have actually verified that the pairing date for that airtag was indeed 2023/8/31. The plist file is correct.
The difference is 2024-9-11 (real_airtag) vs. 2023-8-31 (plist).
Removing the battery does de-sync the clock then, I guess. So, why does the FindMy app locate it anyway?
Checking way more keys?

@malmeloo
Copy link
Owner

malmeloo commented Feb 7, 2025

Let me try to explain the AirTag's inner workings in a bit more detail. I hope that clarifies things a bit, while at the same time serving as an attempt at more understandable public documentation :-). I'll try to elaborate on (almost) everything start to finish, so it might include some redundant information.

As you are probably aware of by now, the FindMy network works using cryptographic key pairs. The public key is broadcasted by a device, and we use the private key to obtain the device's location. For custom tags, we almost always use static keys, meaning that the key broadcasted by our custom device does not change. This means that the private key is very predictable (it's always the same!), so fetching locations from Apple is very easy. However, AirTags and compatible 3rd-party accessories rotate their keys in an almost unpredictable way in order to increase privacy (+ obfuscation??). While we can use the same method to obtain an AirTag's location as we use for our custom tags, we also need to know which keys the AirTag has broadcasted during the time range that we would like to query. Otherwise, we'd just be throwing a LOT of random keys at Apple's servers in the hope that some of them will work. That's just inefficient and very slow.

AirTags have two distinct key derivation algorithms. The exact cryptographic details of how these work does not matter, but what does matter is that each of these algorithms outputs a single key for a certain value $i$ or $j$ ("index"). One of the algorithms generates a primary key $K_{p,i}$, while the other generates a secondary key $K_{s,j}$. We also assume it to be nontrivial ("impossible") to find these key(s) for a certain index without knowing certain shared secrets, which are stored in the plist file. Since the shared secrets are stored in the plist, obtaining this file would allow us to compute the keys that an AirTag would generate. The only additional thing we need to know is the (approximate) value of $i$ or $j$ so that we do not need to fully exhaust the output range of the key derivators (this is theoretically infinite, since $i$ and $j$ can be any natural number). It is worth noting that, while $i$ and $j$ can be equal, they usually aren't. And even if they were, the resulting primary and secondary keys would be different since the key derivators are seeded with different values.

The main issue is actually finding $i$ and $j$. AirTags use only one of its two key derivators when selecting a new key to broadcast. $i$ and $j$ are selected based on how long ago the tag was paired, while the chosen key derivator depends on the current state of the tag (connected, nearby or separated). The tag is "connected" when actively or very recently in range of its owner device, "nearby" when the tag disconnects from its owner device, and "separated" when it has been disconnected for a longer period of time. The tag is also in "separated" mode after a power cycle.

When the tag has recently actively connected to its owner device, it broadcasts a "lite" version of the regular FindMy broadcast data. This data is not enough for other devices to identify and upload its location, but is sufficient for owner devices to recognize it as one of their own. They do this by only broadcasting part of the primary key. This key changes by incrementing $i$ every 15 minutes and deriving the new key.

When in "nearby" state, the tag also broadcasts its primary key, but in full this time. This "enables" the stranger-finds-my-tag features of FindMy. Again, this key changes every 15 minutes, because $i$ is incremented every 15 minutes. Interesting to note here is that this is why the tag's unwanted tracking detection features are "disabled" when it only recently disconnected from its owner device: the tag essentially changes its identity every 15 minutes, so nearby iPhones do not detect it as the same tag that is following them.

When in "separated" mode, the tag starts broadcasting its secondary key. This key only changes once per day. Note that even while in separated mode, $i$ will keep incrementing by 1 every 15 minutes. When transitioning into separated mode, $j$ is recomputed as $j = i/96 + 1$. When 4 AM hits and the tag is still in separated mode, we increment $j$ by 1. Note that, since we do integer division and there are exactly 96 15-minute "blocks" in a day, $j$ is essentially the same as what $i$ would have been at the "last" 4 AM.

The annoying part is that we are essentially operating in the dark: we do not know which state the tag is in, or what the "last" value of $i$ and $j$ were. Apple has a "strategic" edge over this library because they can track that information and share it among their devices. However, we can still make an educated guess on what the approximate values of $i$ and $j$ are, and add a margin (~24 hours) when calculating the keys so that the probability of "missing" a key is quite low (under normal circumstances, more on that later).

The above means that at any arbitrary point in time after the pairing date, we have two or three potential keys that the tag could be using:

  1. The primary key of the current 15-minute block, if it is in connected or nearby mode;
  2. The secondary key computed from $j = i/96 + 1$, if the accessory has been power cycled or went into separated mode earlier today;
  3. Another secondary key (formula left out for readability), if (2) happened after the very first 4 AM after pairing. This is because, if you paired the tag at 10 PM, power cycled (or lost) it at 11 PM, then went to check its location at 9 AM the following day, $j$ would be computed as 1 since $i &lt; 96$. But this is wrong, because both $i$ and $j$ are initialized as 1, and $j$ must be incremented by 1 at 4 AM, so it should actually be 2 instead. However, if you checked the location at 2 AM, $j$ must be 1 (since the rollover hasn't happened yet), so (2) is not completely redundant.

Note how we can now guess the value of $j$ as long as we have $i$. If we wanted to compute all potential keys for a certain time range, we simply need the range of $i$ plus some margin, calculate all potential keys and throw them in a set. Realistically, the cardinality of this set will be less than $3 \cdot (i_{high} - i_{low})$, because the secondary keys are valid for a full day. Approximately, there will only be a single secondary key in the set for every 96 primary keys.

Now in order to find $i$, the library currently uses the pairing date to calculate what its approximate current value should be. If the tag was paired exactly 24 hours ago, the current value should be somewhere around 96. The margin allows some room for error here. However, evidently, this does not work for all tags, such as yours. Apparently, $i$ does not "jump ahead" to its actual value if its time is resynchronised after extended loss of power. Apple is able to compensate for this since they know what the last value of $i$ was, but the library does not have this knowledge. This is why using the actual pairing date does not work for you: it needs to be adjusted to a later date, because the actual value of $i$ in your AirTag is lower than what the library expects it to be.

The debugging script essentially iterates through the possible values of $i$ starting from a certain datetime until it finds that the supplied public key is contained in the set of potential keys for that index. This works because we know that there must be a value of $i$ for which the AirTag will generate one of the public keys that we observed in the scan. Note how in the airtag_tester script I supplied to you, the pairing date was selected as follows:

paired_at = datetime.now().astimezone(timezone.utc).replace(year=2023)

Right now, this evaluates to february 7th, 2023. However, note that this date could be completely arbitrary for the purpose of this debugging script. All we care about is the offset between this supplied pairing date and the datetime at which the script finds this key among the potential keys at that time. No matter which pairing date you supply, as long as the entered public key is the same, this will always result in roughly the same offset between the supplied pairing date and the final date indicated by the script. Some slight variations are still possible, but this is because it is much more likely for the found key to be a secondary key (which are valid for 24 hours) than for it to be a primary key.

In order to account for these potential offsets, we have a few options:

  1. Require everyone to use an AirTag that has never lost power for an extended period of time before (bad)
  2. Somehow communicate with Apple servers to synchronize the value of $i$ with the one used by Apple (difficult and also bad, since fetching tags would no longer be account-agnostic)
  3. Keep tag state in the library (bad, but could be implemented in an OK way)

I dislike every single one of these solutions since I would very much like to keep the library usable, stateless and account-agnostic, however I do believe (3) could be implemented in an OK way. I'm imagining keeping the setup with the pairing date as it is, but introduce an offset parameter for $i$ to account for periods of power loss. Since we fetch tag locations by sending the individual keys to Apple, we could theoretically look at the times corresponding to the location reports of a single key, and adjust $i$ based on that. So if we believe that a certain key A should be generated for $i = 100$ at 5 PM, but the location reports show that A was seen at 3:30 PM, we know that $i$ should be decremented by 6. However, it would be up the the users of the library to do something with this information and store it for later.

I do realize this is a lot of text, but hopefully this clarifies everything 🙂

@aircable
Copy link

aircable commented Feb 9, 2025

Wow, thank you so much. Your description is incredible. I believe it will be very helpful, for others too.
It is surprising to me that the key rotation for the real airtags create so many keys, nearly infinite since we don't know the range of i or j.
For us, only the separated state is important, since we are looking to track lost airtags. And it could have been powered off for some time.

So, I worked on the airtag_tester a bit and created a program that counts backwards and tries to find the correct key as well as the computed number of days offset, which then leads to the corrected pairing date you can use in your real_airtag script.

And it worked. Output:
KEY FOUND!!
KEEP THE BELOW KEY SECRET! IT CAN BE USED TO RETRIEVE THE DEVICE'S LOCATION!

  • Key: VblRR9fkMzLo0VjJGAct2JSMU4Vk5Jm+wMOdpQ==
  • Approx. Time: 2024-01-29 18:15:00+00:00
  • Type: KeyType.SECONDARY
    Computed Offset: 376 days
    Corrected Pairing Date: 2024-09-10 18:32:21+00:00

Which is exactly the date you would use to find the real Airtags.

Maybe we should now integrate the airtag-decryptor-3.swift, the aitrag_tester_2.py and the real_airtag_2.py scripts into just one "get location from real airtags". And scan_devices.py of course to get the current public key, which airtag_tester_2 uses to start searching.

airtag_tester_2.txt

@malmeloo
Copy link
Owner

Nice! I don't think the scripts would need to be fully integrated though, since "calibration" could be a one-time thing. Once we get at least one location report we can use that to do further offset calibration. I'm thinking of something similar to the following workflow:

  1. Obtain the plist
  2. Use a combination of the scanner script and airtag_tester to "calibrate" and find the initial offset
  3. Automatically calibrate after every fetch of location reports and allow that calibration to be saved somehow

If we make the FindMyAccessory class serializable, the offset could be stored in the JSON representation, and calibration after that can be done without referring to the plist.

@aircable
Copy link

Sounds about right, which means that the library need to track i and j.
Would you change the findmy library or do that somewhere else?

@malmeloo
Copy link
Owner

Probably in the library. We somehow need to keep track of the expected generation time for each key, then if it does not align with the time of the reports for that key we can derive a new offset from the difference in those times. It also doesn't need to be very accurate as long as there's a 24h margin.

@malmeloo malmeloo changed the title "detect OF devices in Nearby state" can't search for known key Some official/compatible AirTags don't work Feb 14, 2025
@alfs
Copy link

alfs commented Feb 22, 2025

Thank you @malmeloo for the very detailed explanation.

I have this exact problem with two dozen tags being paired several months ago. I re-inserted the battery saver tab after pairing. Now when tags are powered and field deployed (separated mode), tag key indexes are off compared to the time-window calculated by FindMy.py.

I presume the tag clocks may also naturally drift due to battery voltage dropping (this would be interesting to investigate by itself, and could be quantified by observing a faraday-caged tag and ble sniffer for some time).
Over time, as batteries expire and gets replaced, tags will then also lose 15 minute time synchronization from pairing date/time.

I therefore think your suggestion (3) with automatic continous calibration towards reported time, and saving of estimated i,j indexes, is a necessity for providing reliable lookups and also has a benefit reducing the report query load.

Key question: would an key-index-centric design make more sense than a time-based design?

Idea: after initial calibration (based on time, opportunistic probing or full probing), each invocation of FindMy.py would
a) save the clock time it was last run
b) Calculate n, m from hours, days of (now() - last_invocation), and request reports for {i .. i+n}, {j .. j+m}. To account for tag clock skew, add a suitable number (10? does not need to be large, over time it will adjust as long as findmy is run periodically vs possible clock skew).
c) update cached i, j values for the latest successfully received reports

If FindMy.py is run at least once per week, it will in the worst case be the same as the current "fetch_last_reports" that queries 7*24 hours + 12+12 hours = 768 id queries only based on time.
However if it's run once per day, it will only send queries for the it's that are actually possible in that time frame, and would not need extra +-12h marigins (which is a magic number I guess, skew can be outside this window).

By searching backwards, and stopping when at least one report has been found, unnecessary queries are avoided (if only the last position is wanted - to get location history, or additional accuracy, more or all reports can of course be fetched. For stationary tags, combining several reports should improve accuracy).

If FindMy.py has not been run for a long time (say months), there can be some opportunistic/sampling discovery, for example instead of 96 reports per day, try 10 reports per day and do a normal re-synchronization after an initial round.

This method requires a local state to be kept and updated, but I think the value outweighs the drawbacks - and the time-based algorithm can still be used of course, if the user chooses. In my use-case with many severely de-synchronized tags, a key index offset is a necessity (edit - after seeing the airtag-tester script, I could also adjust pairing date to accomodate for the time offset... I'll do this for now)

@malmeloo
Copy link
Owner

Yeah I've been thinking about this a bit more recently and I'm increasingly less opposed to tracking internal state for airtags. The library already has an abstract base class for things that need proper state handling, so we could just extend that to the accessory class. Maybe with a warning if it gets garbage collected with unsaved runtime data, to really get the message home.

I think the final implementation will probably look similar to what you're proposing. Keep track of the last fetch time and synchronized i and j, then calculate offsets from there. Your idea about opportunistic discovery is quite interesting, but I think we can actually reduce the number of keys per day to only 1 for this. Since the vast majority of location reports will likely originate from secondary keys, and those only regenerate once per day. However, that does mean that we will still need that 12-24 hour margin, because we cannot recalibrate the indices super accurately using those secondary keys. After all, they're valid for 24 hours instead of 15 minutes.

@alfs
Copy link

alfs commented Feb 23, 2025

I did a PoC (attached) that scans secondary keys for 200 days and saves the last key index for the found report in a cache json file, reused at next run.

It now finds my missing tags (great!), but there is still some problem with reusing indexes (seems keys_between don't produce the same result with a full-range scan [1, 200*24*60/15 ] or a subset scan. I'll look into this a bit more.

edit - keys_between() returns a set, so indexing can't be done this way.

    def keys_between(self, start: int | datetime, end: int | datetime) -> set[KeyPair]:
        """Generate potential key(s) occurring between two indices or timestamps."""
        keys: set[KeyPair] = set()

Either the library can use a sequence, or I can just do the same iteration as the library does ( while start < end: keys.update(self.keys_at(start)) ) and use a list instead.

Anyway, this approach seem promising for very efficient lookup and responses, which enables lookup scaling of many tags (after calibration, 1-2 lookups needed per tag that can be packed in 256 id chunks) vs quering several hundred id's per tag and getting possibly thousand of reports back.

calibrate.txt

@alfs
Copy link

alfs commented Feb 23, 2025

I updated the the indexing code (iterating over keys_at()), and it's now consistent from initial calibration to subsequent runs. Index now matches expected operating time (key index of the last successful report is 1055 => ca 11 days of operation which is according to expectations (paired in december, powered off, and deployed 1,5 week ago.

calibrate.txt

@thisiscam
Copy link
Contributor Author

I did a PoC (attached) that scans secondary keys for 200 days and saves the last key index for the found report in a cache json file, reused at next run.

It now finds my missing tags (great!), but there is still some problem with reusing indexes (seems keys_between don't produce the same result with a full-range scan [1, 2002460/15 ] or a subset scan. I'll look into this a bit more.

edit - keys_between() returns a set, so indexing can't be done this way.

    def keys_between(self, start: int | datetime, end: int | datetime) -> set[KeyPair]:
        """Generate potential key(s) occurring between two indices or timestamps."""
        keys: set[KeyPair] = set()

Either the library can use a sequence, or I can just do the same iteration as the library does ( while start < end: keys.update(self.keys_at(start)) ) and use a list instead.

Anyway, this approach seem promising for very efficient lookup and responses, which enables lookup scaling of many tags (after calibration, 1-2 lookups needed per tag that can be packed in 256 id chunks) vs quering several hundred id's per tag and getting possibly thousand of reports back.

calibrate.txt

You could perhaps use https://pypi.org/project/ordered-set/

@malmeloo
Copy link
Owner

I did a PoC (attached) that scans secondary keys for 200 days and saves the last key index for the found report in a cache json file, reused at next run.

It now finds my missing tags (great!), but there is still some problem with reusing indexes (seems keys_between don't produce the same result with a full-range scan [1, 2002460/15 ] or a subset scan. I'll look into this a bit more.

edit - keys_between() returns a set, so indexing can't be done this way.

    def keys_between(self, start: int | datetime, end: int | datetime) -> set[KeyPair]:
        """Generate potential key(s) occurring between two indices or timestamps."""
        keys: set[KeyPair] = set()

Either the library can use a sequence, or I can just do the same iteration as the library does ( while start < end: keys.update(self.keys_at(start)) ) and use a list instead.

Anyway, this approach seem promising for very efficient lookup and responses, which enables lookup scaling of many tags (after calibration, 1-2 lookups needed per tag that can be packed in 256 id chunks) vs quering several hundred id's per tag and getting possibly thousand of reports back.

calibrate.txt

Nice! The use of sets is indeed a bit of an issue, but once it's implemented in the library that shouldn't be an issue anymore. I'll see if I can find some time this week to create a first version with the fix.

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

No branches or pull requests

6 participants