Skip to content

Sign a TLK share's extra record fields when verifying it - #4

Merged
gregakespret merged 7 commits into
findmy-export-supportfrom
fix/tlkshare-signature-extra-fields
Sep 13, 2026
Merged

Sign a TLK share's extra record fields when verifying it#4
gregakespret merged 7 commits into
findmy-export-supportfrom
fix/tlkshare-signature-extra-fields

Conversation

@gregakespret

@gregakespret gregakespret commented Sep 10, 2026

Copy link
Copy Markdown
Owner

fetch_shares_for now verifies a TLK share over the same bytes Apple signs. -[CKKSTLKShare dataForSigning:] (apple-oss-distributions/Security, keychain/ckks/CKKSTLKShare.m) signs the seven positional fields, then every record field it doesn't know, except server_*, sorted by key. data_for_signing takes the share's record fields and appends those extras the way Apple serializes them:

  • strings as UTF-8, bytes raw
  • dates as ISO 8601 whole seconds in UTC, counted from CloudKit's 2001 epoch, after rounding to the nearest millisecond as CFDateFormatter does
  • numbers as eight little-endian bytes of unsignedLongLongValue, including Foundation's handling of negative and very large doubles (checked against Foundation on arm64)
  • references, lists, assets and locations skipped

Without the extras, a share carrying any extra field fails its check, and the ? aborts the keychain join on the first one. A beta account hits this on every attempt, in a different zone each time (Entering on key Health, then Backstop), after its bottle has already decrypted.

A share that fails verification is now skipped, not fatal. CKKS leaves such a share untrusted and carries on, as we already did for a share from an unknown sender. The share is never used. The warn! names the error and the record's field names (never values), so the next mismatch shows which field caused it. If no share verifies at all, the first signature error is returned instead of an empty list. So that a skipped share can't cost a zone its key for good, sync_keychain fetches the shares again whenever a requested zone has no key, and a zone keeps its old change tag while any item in it is waiting on a missing key.

Item decryption uses the same serializer. CuttlefishEncItem::authenticated_data_v2 had its own copy of the extra-field encoding, with the same bugs: dates read as Unix time, negative doubles mapped to 0, and a panic on an out-of-range date. CKKSItem.m uses the same rules as dataForSigning, so both now go through ckks_extra_field_bytes.

Also: src/test.rs builds again. Its calls to login_apple_delegates and PasswordManager::new were out of date.

Tests: nine unit tests in keychain.rs. The expected payloads come from Apple's source or were measured on Foundation, not from this code:

  • a P-384 share signed over an extra field verifies
  • a record without extras signs only the positional fields
  • key order is followed and server_* is left out
  • dates use the 2001 epoch and round to the millisecond first
  • integers and doubles serialize like NSNumber, including negative, huge and infinite doubles
  • the unsupported kinds are skipped
  • an item's v2 AAD serializes extra fields the same way

Run them with cargo test --lib (10 pass). CI runs tests only once #3 lands.

Known gaps: dates before 1582, in year 0 or earlier, or after 9999 still format differently from ICU. It's also unclear how Apple treats an encrypted extra field. Either one only makes a share fail verification; neither can make a forged share pass.

Related: OpenBubbles#34 fixes the same payload and also skips shares that fail.

🤖 Generated with Claude Code

https://claude.ai/code/session_01U1DuWnYYwPDETmxswCcT8m

gregakespret and others added 7 commits September 10, 2026 22:28
-[CKKSTLKShare dataForSigning:] signs the seven positional fields and then
every record field it doesn't know (bar server_*), sorted by key. We signed
only the positional part, so a share carrying any extra field failed its
check, and the ? in fetch_shares_for aborted the keychain join on the first
one. A beta account fails this way on every attempt, in a different zone
each time.

Extras are serialized as Apple does: strings as UTF-8, bytes raw, dates as
ISO 8601 whole seconds UTC counted from CloudKit's 2001 epoch, numbers as
eight little-endian bytes of unsignedLongLongValue. References, lists,
assets and locations are skipped.

A failed check still aborts the join; it now first logs the record's field
names (never values) so the next mismatch can be pinned from the logs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S8kaPMUXu4LSU1K91q6FDN
NSNumber's unsignedLongLongValue only saturates some negative doubles:
a whole double below 2^55 reads back as two's complement, and anything
else keeps the low 64 bits of CFNumber's 128-bit conversion. Dates are
rounded to the nearest millisecond before the fraction is dropped, as
CFDateFormatter does. Both were checked against Foundation on arm64.

The signature-failure warning now includes the error itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U1DuWnYYwPDETmxswCcT8m
One share we couldn't verify threw away every other verified TLK and
failed the keychain join. CKKS leaves such a share untrusted and carries
on, as we already did for a share from an unknown sender. The share is
still never used. If no share verifies at all, the first signature error
is returned rather than an empty list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U1DuWnYYwPDETmxswCcT8m
authenticated_data_v2 had its own copy of the extra-field encoding, with
the bugs the TLK share signature had: dates read as Unix time instead of
counted from 2001, negative doubles mapped to 0, and a panic on an
out-of-range date. CKKSItem.m uses the same rules as dataForSigning, so
both now go through one helper, ckks_extra_field_bytes. An item carrying
a date or double field no longer fails decryption.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U1DuWnYYwPDETmxswCcT8m
…ger::new

login_apple_delegates now takes the AppleAccount, which carries the pet,
spd and anisette client itself. PasswordManager::new gained a
data_updated callback; the test binary doesn't need one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U1DuWnYYwPDETmxswCcT8m
Skipping a share that fails verification could lose a zone's key for
good: sync_keychain only fetched shares while the key store was empty,
and it saved the zone's change tag even when items in it failed to
decrypt, so those items were never downloaded again.

sync_keychain now fetches the shares again whenever a requested zone has
no key. A failed refetch is only a warning if other keys are already
held. A zone keeps its old change tag while any item in it is missing
its key, so the item comes back once the key arrives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U1DuWnYYwPDETmxswCcT8m
@gregakespret
gregakespret merged commit ae2f1e0 into findmy-export-support Sep 13, 2026
8 checks passed
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.

1 participant