Summary
RecoveredPeer.peer_id returns the escrow record's label suffix, but Cuttlefish addresses peers by the SHA256:-prefixed hash carried in the sealed bottle. On my account those are different strings, so FetchRecoverableTlkShares is asked for shares belonging to a peer that does not exist. Apple answers with the view key sets and zero shares, and the export dies at zone_keys().
The failure reads as an account/permissions problem — "carries no readable share record" for every view — and is not one. Every share is present and readable once the right peer id is used.
Fixing it needs the id corrected in both places it is used, not just the fetch. See "Two uses" below.
Environment
- Exporter 1.3.0,
--source icloud
- FindMy.py fork,
feat/icloud-keychain-export @ 337381de (current branch tip)
- Linux, Python 3.13, local anisette
- Account: personal, 6 escrow records / 5 recoverable, trust circle of 5 peers. Reproduced identically on a second, unrelated account.
Symptom
INFO findmy.keychain.escrow: Account holds 6 escrow record(s), 5 of them recoverable
INFO findmy.keychain.recovery: Recovered 764 bytes of sealed material from com.apple.icdp.record.<REDACTED>
INFO findmy.keychain.peers: Trust circle holds 5 peer(s)
INFO findmy.keychain.shares: Peer <REDACTED> is entitled to 22 share(s)
INFO findmy.keychain.shares: Entry for DevicePairing carries no readable share record. ...
WARN findmy.keychain.shares: Entry for ProtectedCloudStorage carries no readable share record, and that view is one this needs ...
WARN findmy.keychain.shares: Entry for Manatee carries no readable share record, and that view is one this needs ...
INFO findmy.keychain.shares: Read 0 share(s) across views:
INFO findmy.keychain.session: 0 elliptic-curve key(s) across Manatee, ProtectedCloudStorage
findmy.keychain.session.KeychainSessionError: No keychain keys are held, so nothing can be decrypted.
Escrow recovery itself succeeds — the passcode is correct and the bottle opens. It fails one step later.
Why it is not "this peer has no shares"
Each of the 22 entries carries ~1.4 KB, so I dumped one and decoded it:
RecoverableTlkShare
1: service .......... "Groups" <- parses fine
2: ViewKeySet ....... 1409 B
1: {2: 397B} tlk <- matches the .proto exactly
2: {2: 497B} classA
3: {2: 497B} classB
3: share ............ ABSENT
describe_wire at depth 0 for all 22: 1:bytes 6B, 2:bytes 1409B — field 3 is never present. So the schema is right and the data is real; Apple is returning view keys and declining to return shares, because the peer named in the request is not one it knows.
Root cause
RecoveredPeer.peer_id (findmy/keychain/session.py):
@property
def peer_id(self) -> str:
return self.record.peer_id # com.apple.icdp.record.<suffix>, prefix stripped
EscrowRecord.peer_id's own docstring says the value is "a SHA256:-prefixed base64 digest — the same value a peer carries as its hash". On my account it is not: it is a 26-character string with no SHA256: prefix, and it is not in the peer directory. The real id is on the sealed bottle, which recover() already has in scope and already uses for the sponsor lookup:
sponsor = directory.get(sealed.peer_id) or directory.get(inner.peer_id)
Proof
Instrumented key_shares to fetch with each candidate id in the same run:
| id source |
in peer directory |
readable shares |
record.peer_id (label suffix) |
no |
0 / 22 |
sealed.peer_id (SHA256:…) |
yes |
21 / 22, Manatee included |
Two uses — fixing only the fetch is not enough
With just the fetch corrected, all 21 shares come back and then every one is rejected:
Shares for <label-suffix-id>: 0/21 unwrapped
because key_shares also passes expected_receiver=peer.peer_id to unwrap_share. Correcting the property fixes both call sites at once.
Fix
Stash the bottle's id at recovery time and prefer it:
# in recover(), replacing the bare `return RecoveredPeer(...)`
recovered = RecoveredPeer(record=record, fields=fields, salt=salt, keys=keys,
bottle=open_bottle(sealed, keys, sponsor=sponsor))
real_id = sealed.peer_id or inner.peer_id
if real_id and real_id != record.peer_id:
logger.info("Addressing the recovered peer as %s (the escrow label says %s)",
real_id, record.peer_id)
object.__setattr__(recovered, "_cuttlefish_peer_id", real_id)
return recovered
# and on RecoveredPeer
@property
def peer_id(self) -> str:
return getattr(self, "_cuttlefish_peer_id", None) or self.record.peer_id
Falls back to the previous behaviour when the two agree, so it cannot regress accounts where the label suffix already is the peer hash. (A cleaner version would make it a real dataclass field — I kept the diff minimal to show the point.)
After the fix
INFO findmy.keychain.session: Addressing the recovered peer as SHA256:<REDACTED> (the escrow label says <REDACTED>)
INFO findmy.keychain.session: Shares for SHA256:<REDACTED>: 21/21 unwrapped
INFO findmy.keychain.session: The Manatee view holds 106 elliptic-curve key(s)
INFO findmy.cloudkit.pcs: The zone yields 3 key(s) for its records
Exporting 19
A full export, and on the second account an inventory that matches its known tag count exactly (32 of 32, third-party "Works with Find My" trackers included).
Possibly unrelated, seen on one account
Protection structure's signature did not verify under the master EC key.
The key id matched, so this is more likely a misreading of the signed data's
layout than a wrong key.
Logged once on the larger account; every beacon still decrypted (33 master + 33 naming + 32 alignment). Mentioning it only in case it is the same class of layout issue. Happy to open it separately with a wire dump if useful.
Thanks for building this route — being able to read the inventory without a Mac is the thing that made it worth chasing. Glad to test a patch on both accounts.
Summary
RecoveredPeer.peer_idreturns the escrow record's label suffix, but Cuttlefish addresses peers by theSHA256:-prefixed hash carried in the sealed bottle. On my account those are different strings, soFetchRecoverableTlkSharesis asked for shares belonging to a peer that does not exist. Apple answers with the view key sets and zero shares, and the export dies atzone_keys().The failure reads as an account/permissions problem — "carries no readable share record" for every view — and is not one. Every share is present and readable once the right peer id is used.
Fixing it needs the id corrected in both places it is used, not just the fetch. See "Two uses" below.
Environment
--source icloudfeat/icloud-keychain-export@337381de(current branch tip)Symptom
Escrow recovery itself succeeds — the passcode is correct and the bottle opens. It fails one step later.
Why it is not "this peer has no shares"
Each of the 22 entries carries ~1.4 KB, so I dumped one and decoded it:
describe_wireat depth 0 for all 22:1:bytes 6B, 2:bytes 1409B— field 3 is never present. So the schema is right and the data is real; Apple is returning view keys and declining to return shares, because the peer named in the request is not one it knows.Root cause
RecoveredPeer.peer_id(findmy/keychain/session.py):EscrowRecord.peer_id's own docstring says the value is "aSHA256:-prefixed base64 digest — the same value a peer carries as itshash". On my account it is not: it is a 26-character string with noSHA256:prefix, and it is not in the peer directory. The real id is on the sealed bottle, whichrecover()already has in scope and already uses for the sponsor lookup:Proof
Instrumented
key_sharesto fetch with each candidate id in the same run:record.peer_id(label suffix)sealed.peer_id(SHA256:…)Two uses — fixing only the fetch is not enough
With just the fetch corrected, all 21 shares come back and then every one is rejected:
because
key_sharesalso passesexpected_receiver=peer.peer_idtounwrap_share. Correcting the property fixes both call sites at once.Fix
Stash the bottle's id at recovery time and prefer it:
Falls back to the previous behaviour when the two agree, so it cannot regress accounts where the label suffix already is the peer hash. (A cleaner version would make it a real dataclass field — I kept the diff minimal to show the point.)
After the fix
A full export, and on the second account an inventory that matches its known tag count exactly (32 of 32, third-party "Works with Find My" trackers included).
Possibly unrelated, seen on one account
Logged once on the larger account; every beacon still decrypted (33 master + 33 naming + 32 alignment). Mentioning it only in case it is the same class of layout issue. Happy to open it separately with a wire dump if useful.
Thanks for building this route — being able to read the inventory without a Mac is the thing that made it worth chasing. Glad to test a patch on both accounts.