From 7311801f253a2cd5dc6e0dd394dd51898270e4d8 Mon Sep 17 00:00:00 2001 From: Grega Kespret Date: Thu, 10 Sep 2026 22:28:52 +0200 Subject: [PATCH 1/7] Sign a TLK share's extra record fields when verifying it -[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 Claude-Session: https://claude.ai/code/session_01S8kaPMUXu4LSU1K91q6FDN --- src/icloud/keychain.rs | 198 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 192 insertions(+), 6 deletions(-) diff --git a/src/icloud/keychain.rs b/src/icloud/keychain.rs index e32ebc5..925a789 100644 --- a/src/icloud/keychain.rs +++ b/src/icloud/keychain.rs @@ -271,8 +271,14 @@ pub struct CuttlefishTlkShare { } impl CuttlefishTlkShare { - fn data_for_signing(&self) -> Vec { - [ + /// The record keys -[CKKSTLKShare dataForSigning:] knows about; every other key is signed as an extra. + const KNOWN_KEYS: &'static [&'static str] = &["sender", "receiver", "receiverPublicEncryptionKey", "curve", "epoch", "poisoned", "signature", "version", "parentkeyref", "wrappedkey"]; + + // `fields` is the record this share was decoded from. Apple signs every field it doesn't know + // (bar server_*) after the positional ones, sorted by key, so a newer device can add fields + // without breaking older verifiers. Leaving them out fails every share that carries one. + fn data_for_signing(&self, fields: &[Field]) -> Vec { + let mut data = [ &self.version.to_le_bytes()[..], self.receiver.as_bytes(), self.sender.as_bytes(), @@ -280,8 +286,44 @@ impl CuttlefishTlkShare { &self.curve.to_le_bytes()[..], &self.epoch.to_le_bytes()[..], &self.poisoned.to_le_bytes()[..], - ].concat() + ].concat(); + + let mut extras = BTreeMap::new(); + for field in fields { + let Some(name) = field.identifier.as_ref().and_then(|i| i.name.as_deref()) else { continue }; + if Self::KNOWN_KEYS.contains(&name) || name.starts_with("server_") { continue } + if let Some(bytes) = field.value.as_ref().and_then(tlkshare_extra_signing_bytes) { + extras.insert(name, bytes); + } + } + for bytes in extras.into_values() { + data.extend(bytes); + } + data + } +} + +/// How dataForSigning serializes an extra field; None for the kinds it skips (references, lists, assets, locations). +fn tlkshare_extra_signing_bytes(value: &cloudkit_proto::record::field::Value) -> Option> { + if let Some(s) = &value.string_value { + return Some(s.as_bytes().to_vec()); + } + if let Some(b) = &value.bytes_value { + return Some(b.clone()); + } + if let Some(date) = &value.date_value { + // CloudKit counts from 2001, not 1970; NSISO8601DateFormatter prints whole seconds in UTC + let unix = date.time? + 978307200.0; + return Some(DateTime::from_timestamp(unix.floor() as i64, 0)?.to_rfc3339_opts(chrono::SecondsFormat::Secs, true).into_bytes()); + } + // NSNumber goes in as unsignedLongLongValue: two's complement for integers, truncated for doubles + if let Some(i) = value.signed_value { + return Some(i.to_le_bytes().to_vec()); + } + if let Some(d) = value.double_value { + return Some((d as u64).to_le_bytes().to_vec()); } + None } #[derive(Serialize, Deserialize, Debug)] @@ -1893,13 +1935,19 @@ impl KeychainClient

{ warn!("Missing key!"); continue; }; - let item = CuttlefishTlkShare::from_record(&share_record.inner.as_ref().unwrap().record_field); + let record_fields = &share_record.inner.as_ref().unwrap().record_field; + let item = CuttlefishTlkShare::from_record(record_fields); let Some(sending_peer) = state.state.get(&item.sender) else { warn!("missing sender {} in state! {:?}", item.sender, state.state.keys().collect::>()); continue }; - sending_peer.verify_signature_dig(MessageDigest::sha256(), &item.data_for_signing(), &base64_decode(&item.signature))?; + if let Err(e) = sending_peer.verify_signature_dig(MessageDigest::sha256(), &item.data_for_signing(record_fields), &base64_decode(&item.signature)) { + // Names only: the values are key material. The names show which field a mismatch came from. + let names: Vec<_> = record_fields.iter().filter_map(|f| f.identifier.as_ref()?.name.as_deref()).collect(); + warn!("TLK share for {} failed its signature check; record fields {:?}", share.service(), names); + return Err(e); + } let decoded = KeyedArchive::expand(&base64_decode(&item.wrappedkey))?; @@ -2438,4 +2486,142 @@ impl KeychainClient

{ Ok(dec) } -} \ No newline at end of file +} + +#[cfg(test)] +mod tlkshare_signing_tests { + use super::*; + use cloudkit_proto::record::field::{Identifier as FieldIdentifier, Value as CkValue}; + + // The expected payloads below are built by hand from -[CKKSTLKShare dataForSigning:] + // (apple-oss-distributions/Security, keychain/ckks/CKKSTLKShare.m), not from our code. + + const WRAPPED_TLK: &[u8] = b"wrapped-tlk"; + + fn field(name: &str, value: CkValue) -> Field { + Field { identifier: Some(FieldIdentifier { name: Some(name.to_string()) }), value: Some(value) } + } + + fn string(s: &str) -> CkValue { + CkValue { string_value: Some(s.to_string()), ..Default::default() } + } + + fn bytes(b: &[u8]) -> CkValue { + CkValue { bytes_value: Some(b.to_vec()), ..Default::default() } + } + + fn int(i: i64) -> CkValue { + CkValue { signed_value: Some(i), ..Default::default() } + } + + fn double(d: f64) -> CkValue { + CkValue { double_value: Some(d), ..Default::default() } + } + + fn date(secs_since_2001: f64) -> CkValue { + CkValue { date_value: Some(cloudkit_proto::Date { time: Some(secs_since_2001) }), ..Default::default() } + } + + fn reference() -> CkValue { + CkValue { reference_value: Some(Reference::default()), ..Default::default() } + } + + /// Every field CKKS itself writes on a tlkshare record. + fn share_record(extras: Vec) -> Vec { + let mut fields = vec![ + field("version", int(1)), + field("receiver", string("receiver-peer")), + field("sender", string("sender-peer")), + field("wrappedkey", string(&base64_encode(WRAPPED_TLK))), + field("curve", int(4)), + field("epoch", int(1)), + field("poisoned", int(0)), + field("receiverPublicEncryptionKey", string(&base64_encode(b"receiver-key"))), + field("signature", string(&base64_encode(b"signature"))), + field("parentkeyref", reference()), + ]; + fields.extend(extras); + fields + } + + fn apple_positional_payload() -> Vec { + [ + &1u64.to_le_bytes()[..], + b"receiver-peer", + b"sender-peer", + WRAPPED_TLK, + &4u64.to_le_bytes()[..], + &1u64.to_le_bytes()[..], + &0u64.to_le_bytes()[..], + ].concat() + } + + fn apple_payload_with(extra: &[u8]) -> Vec { + [apple_positional_payload(), extra.to_vec()].concat() + } + + fn signing_payload(fields: &[Field]) -> Vec { + CuttlefishTlkShare::from_record(fields).data_for_signing(fields) + } + + #[test] + fn share_signed_over_an_extra_field_verifies() { + // A share whose record carries one field this code has never heard of, signed the way + // an Apple device signs it. Before the extras were signed, every such share failed + // verification and aborted the whole keychain join. + let key = PKey::from_ec_key(EcKey::generate(&EcGroup::from_curve_name(Nid::SECP384R1).unwrap()).unwrap()).unwrap(); + let fields = share_record(vec![field("someFutureField", bytes(b"proof"))]); + + let mut signer = Signer::new(MessageDigest::sha256(), &key).unwrap(); + signer.update(&apple_payload_with(b"proof")).unwrap(); + let signature = signer.sign_to_vec().unwrap(); + + let mut verifier = Verifier::new(MessageDigest::sha256(), &key).unwrap(); + verifier.update(&signing_payload(&fields)).unwrap(); + assert!(verifier.verify(&signature).unwrap()); + } + + #[test] + fn record_without_extras_signs_only_the_positional_fields() { + // Accounts that already join today have no extras; the known keys, including the + // signature itself and the parent key reference, must stay out of the payload. + assert_eq!(signing_payload(&share_record(vec![])), apple_positional_payload()); + } + + #[test] + fn extras_are_signed_in_key_order_and_server_fields_are_not() { + let fields = share_record(vec![ + field("zeta", string("Z")), + field("server_modified", string("S")), + field("alpha", string("A")), + ]); + assert_eq!(signing_payload(&fields), apple_payload_with(b"AZ")); + } + + #[test] + fn a_date_extra_is_signed_as_iso8601_counted_from_2001() { + // CloudKit dates count seconds from 2001-01-01, and NSISO8601DateFormatter prints whole + // seconds in UTC. Reading the value as Unix time would sign "1970-01-01T00:00:00Z". + assert_eq!(signing_payload(&share_record(vec![field("created", date(0.0))])), apple_payload_with(b"2001-01-01T00:00:00Z")); + assert_eq!(signing_payload(&share_record(vec![field("created", date(1.75))])), apple_payload_with(b"2001-01-01T00:00:01Z")); + } + + #[test] + fn numeric_extras_are_signed_as_eight_little_endian_bytes() { + // NSNumber goes in via unsignedLongLongValue: two's complement for a negative + // integer, truncation towards zero for a double. + assert_eq!(signing_payload(&share_record(vec![field("n", int(7))])), apple_payload_with(&7u64.to_le_bytes())); + assert_eq!(signing_payload(&share_record(vec![field("n", int(-1))])), apple_payload_with(&[0xff; 8])); + assert_eq!(signing_payload(&share_record(vec![field("n", double(2.9))])), apple_payload_with(&2u64.to_le_bytes())); + } + + #[test] + fn extras_apple_does_not_sign_are_skipped() { + let fields = share_record(vec![ + field("aReference", reference()), + field("aList", CkValue { list_values: vec![string("item")], ..Default::default() }), + field("anAsset", CkValue { asset_value: Some(cloudkit_proto::Asset::default()), ..Default::default() }), + ]); + assert_eq!(signing_payload(&fields), apple_positional_payload()); + } +} From 9ef39ea075e0eb55b5bbf775d4e238ca890f42bc Mon Sep 17 00:00:00 2001 From: Grega Kespret Date: Fri, 11 Sep 2026 00:05:09 +0200 Subject: [PATCH 2/7] Match NSNumber semantics for negative doubles --- src/icloud/keychain.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/icloud/keychain.rs b/src/icloud/keychain.rs index 925a789..c582353 100644 --- a/src/icloud/keychain.rs +++ b/src/icloud/keychain.rs @@ -316,12 +316,14 @@ fn tlkshare_extra_signing_bytes(value: &cloudkit_proto::record::field::Value) -> let unix = date.time? + 978307200.0; return Some(DateTime::from_timestamp(unix.floor() as i64, 0)?.to_rfc3339_opts(chrono::SecondsFormat::Secs, true).into_bytes()); } - // NSNumber goes in as unsignedLongLongValue: two's complement for integers, truncated for doubles + // NSNumber goes in as unsignedLongLongValue: two's complement for integers, negative doubles + // saturate to UINT64_MAX, and non-negative doubles truncate towards zero. if let Some(i) = value.signed_value { return Some(i.to_le_bytes().to_vec()); } if let Some(d) = value.double_value { - return Some((d as u64).to_le_bytes().to_vec()); + let unsigned = if d < 0.0 { u64::MAX } else { d as u64 }; + return Some(unsigned.to_le_bytes().to_vec()); } None } @@ -2615,6 +2617,11 @@ mod tlkshare_signing_tests { assert_eq!(signing_payload(&share_record(vec![field("n", double(2.9))])), apple_payload_with(&2u64.to_le_bytes())); } + #[test] + fn a_negative_double_extra_uses_nsnumber_unsigned_saturation() { + assert_eq!(signing_payload(&share_record(vec![field("n", double(-2.9))])), apple_payload_with(&[0xff; 8])); + } + #[test] fn extras_apple_does_not_sign_are_skipped() { let fields = share_record(vec![ From 10fb94a21b19a7dc69096b41ac6621e749bd22f0 Mon Sep 17 00:00:00 2001 From: Grega Kespret Date: Sun, 13 Sep 2026 13:27:17 +0200 Subject: [PATCH 3/7] Match Foundation when signing double and date extras 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 Claude-Session: https://claude.ai/code/session_01U1DuWnYYwPDETmxswCcT8m --- src/icloud/keychain.rs | 66 +++++++++++++++++++++++++++++++++++------- 1 file changed, 55 insertions(+), 11 deletions(-) diff --git a/src/icloud/keychain.rs b/src/icloud/keychain.rs index c582353..590f9e1 100644 --- a/src/icloud/keychain.rs +++ b/src/icloud/keychain.rs @@ -312,22 +312,44 @@ fn tlkshare_extra_signing_bytes(value: &cloudkit_proto::record::field::Value) -> return Some(b.clone()); } if let Some(date) = &value.date_value { - // CloudKit counts from 2001, not 1970; NSISO8601DateFormatter prints whole seconds in UTC - let unix = date.time? + 978307200.0; - return Some(DateTime::from_timestamp(unix.floor() as i64, 0)?.to_rfc3339_opts(chrono::SecondsFormat::Secs, true).into_bytes()); + // CloudKit counts from 2001, not 1970. NSISO8601DateFormatter prints whole seconds in UTC, but + // CFDateFormatter first rounds to the nearest millisecond ((t + 978307200) * 1000 + 0.5), so the + // last half-millisecond of a second prints as the next second. + let millis = ((date.time? + 978307200.0) * 1000.0 + 0.5).floor() as i64; + return Some(DateTime::from_timestamp_millis(millis)?.to_rfc3339_opts(chrono::SecondsFormat::Secs, true).into_bytes()); } - // NSNumber goes in as unsignedLongLongValue: two's complement for integers, negative doubles - // saturate to UINT64_MAX, and non-negative doubles truncate towards zero. + // NSNumber goes in as unsignedLongLongValue: two's complement for an integer. if let Some(i) = value.signed_value { return Some(i.to_le_bytes().to_vec()); } if let Some(d) = value.double_value { - let unsigned = if d < 0.0 { u64::MAX } else { d as u64 }; - return Some(unsigned.to_le_bytes().to_vec()); + return Some(nsnumber_double_unsigned_long_long(d).to_le_bytes().to_vec()); } None } +/// `[[NSNumber numberWithDouble:d] unsignedLongLongValue]`, matched against Foundation on arm64. +/// Foundation keeps a whole double below 2^55 as a tagged integer, which reads back as two's +/// complement. Any other double goes through CFNumber's 128-bit conversion and keeps the low +/// 64 bits; that last cast saturates exactly like `as u64`. Intel Macs return 0x8000000000000000 +/// instead for NaN and for negative fractions above about -1024. +fn nsnumber_double_unsigned_long_long(d: f64) -> u64 { + const TWO_55: f64 = 36028797018963968.0; + const TWO_64: f64 = 18446744073709551616.0; + const TWO_127: f64 = 170141183460469231731687303715884105728.0; + if d.fract() == 0.0 && d.abs() < TWO_55 { + return d as i64 as u64; + } + if d.is_nan() || d < -TWO_127 { + return 0; + } + if d >= TWO_127 { + return u64::MAX; + } + let high = (d / TWO_64).floor(); + (d - high * TWO_64) as u64 +} + #[derive(Serialize, Deserialize, Debug)] pub struct IESCiphertext { #[serde(rename = "SFIESAuthenticationCode")] @@ -1947,7 +1969,7 @@ impl KeychainClient

{ if let Err(e) = sending_peer.verify_signature_dig(MessageDigest::sha256(), &item.data_for_signing(record_fields), &base64_decode(&item.signature)) { // Names only: the values are key material. The names show which field a mismatch came from. let names: Vec<_> = record_fields.iter().filter_map(|f| f.identifier.as_ref()?.name.as_deref()).collect(); - warn!("TLK share for {} failed its signature check; record fields {:?}", share.service(), names); + warn!("TLK share for {} failed its signature check ({e}); record fields {:?}", share.service(), names); return Err(e); } @@ -2608,18 +2630,40 @@ mod tlkshare_signing_tests { assert_eq!(signing_payload(&share_record(vec![field("created", date(1.75))])), apple_payload_with(b"2001-01-01T00:00:01Z")); } + #[test] + fn a_date_extra_rounds_to_the_millisecond_before_dropping_the_fraction() { + // CFDateFormatter rounds to the nearest millisecond before it prints whole seconds. + // Expected strings measured with NSISO8601DateFormatter on macOS. + let signed = |t: f64| signing_payload(&share_record(vec![field("created", date(t))])); + assert_eq!(signed(1.9994), apple_payload_with(b"2001-01-01T00:00:01Z")); + assert_eq!(signed(1.9995), apple_payload_with(b"2001-01-01T00:00:02Z")); + assert_eq!(signed(-0.0001), apple_payload_with(b"2001-01-01T00:00:00Z")); + assert_eq!(signed(-0.5), apple_payload_with(b"2000-12-31T23:59:59Z")); + } + #[test] fn numeric_extras_are_signed_as_eight_little_endian_bytes() { // NSNumber goes in via unsignedLongLongValue: two's complement for a negative - // integer, truncation towards zero for a double. + // integer, truncation towards zero for a small positive double. assert_eq!(signing_payload(&share_record(vec![field("n", int(7))])), apple_payload_with(&7u64.to_le_bytes())); assert_eq!(signing_payload(&share_record(vec![field("n", int(-1))])), apple_payload_with(&[0xff; 8])); assert_eq!(signing_payload(&share_record(vec![field("n", double(2.9))])), apple_payload_with(&2u64.to_le_bytes())); } #[test] - fn a_negative_double_extra_uses_nsnumber_unsigned_saturation() { - assert_eq!(signing_payload(&share_record(vec![field("n", double(-2.9))])), apple_payload_with(&[0xff; 8])); + fn double_extras_match_nsnumber_unsigned_long_long() { + // [[NSNumber numberWithDouble:d] unsignedLongLongValue], measured on an arm64 Mac. A whole + // double below 2^55 reads back as two's complement; anything else keeps the low 64 bits + // of a 128-bit conversion, so negative doubles do not all saturate. + let signed = |d: f64| signing_payload(&share_record(vec![field("n", double(d))])); + assert_eq!(signed(-2.0), apple_payload_with(&0xffff_ffff_ffff_fffeu64.to_le_bytes())); + assert_eq!(signed(-1e5), apple_payload_with(&0xffff_ffff_fffe_7960u64.to_le_bytes())); + assert_eq!(signed(-2.9), apple_payload_with(&[0xff; 8])); + assert_eq!(signed(-1024.5), apple_payload_with(&0xffff_ffff_ffff_f800u64.to_le_bytes())); + assert_eq!(signed(1e20), apple_payload_with(&0x6bc7_5e2d_6310_0000u64.to_le_bytes())); + assert_eq!(signed(18446744073709551616.0), apple_payload_with(&0u64.to_le_bytes())); + assert_eq!(signed(f64::INFINITY), apple_payload_with(&[0xff; 8])); + assert_eq!(signed(f64::NEG_INFINITY), apple_payload_with(&0u64.to_le_bytes())); } #[test] From fe55fa2276b8e847027882494985ea5b36652b42 Mon Sep 17 00:00:00 2001 From: Grega Kespret Date: Sun, 13 Sep 2026 13:31:17 +0200 Subject: [PATCH 4/7] Skip a TLK share that fails verification instead of aborting the fetch 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 Claude-Session: https://claude.ai/code/session_01U1DuWnYYwPDETmxswCcT8m --- src/icloud/keychain.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/icloud/keychain.rs b/src/icloud/keychain.rs index 590f9e1..4331b38 100644 --- a/src/icloud/keychain.rs +++ b/src/icloud/keychain.rs @@ -1952,6 +1952,7 @@ impl KeychainClient

{ }).await?; let mut keys = vec![]; + let mut first_failure = None; let state = self.state.read().await; for share in response.shares { info!("Entering on key {}", share.service()); @@ -1966,11 +1967,14 @@ impl KeychainClient

{ warn!("missing sender {} in state! {:?}", item.sender, state.state.keys().collect::>()); continue }; + // Like CKKS, leave a share we can't verify untrusted and carry on: one bad share + // shouldn't throw away the TLKs for every other zone. if let Err(e) = sending_peer.verify_signature_dig(MessageDigest::sha256(), &item.data_for_signing(record_fields), &base64_decode(&item.signature)) { // Names only: the values are key material. The names show which field a mismatch came from. let names: Vec<_> = record_fields.iter().filter_map(|f| f.identifier.as_ref()?.name.as_deref()).collect(); - warn!("TLK share for {} failed its signature check ({e}); record fields {:?}", share.service(), names); - return Err(e); + warn!("Skipping TLK share for {}: it failed its signature check ({e}); record fields {:?}", share.service(), names); + first_failure.get_or_insert(e); + continue; } @@ -2003,6 +2007,12 @@ impl KeychainClient

{ keys.push(result); } + // With nothing verified, the signature error says more than an empty list would. + if keys.is_empty() { + if let Some(e) = first_failure { + return Err(e); + } + } Ok(keys) } From e237331f90878ad6c47967c4a86df26fd3c09a7b Mon Sep 17 00:00:00 2001 From: Grega Kespret Date: Sun, 13 Sep 2026 13:31:17 +0200 Subject: [PATCH 5/7] Serialize an item's extra AAD fields the way CKKS does 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 Claude-Session: https://claude.ai/code/session_01U1DuWnYYwPDETmxswCcT8m --- src/icloud/keychain.rs | 82 +++++++++++++++++++++++++----------------- 1 file changed, 50 insertions(+), 32 deletions(-) diff --git a/src/icloud/keychain.rs b/src/icloud/keychain.rs index 4331b38..e58b4ec 100644 --- a/src/icloud/keychain.rs +++ b/src/icloud/keychain.rs @@ -131,42 +131,22 @@ impl CuttlefishEncItem { aad.insert("pcspublickey".to_string(), pcspublickey.to_vec()); } + // -[CKKSItem makeAuthenticatedDataDictionaryUpdatingCKKSItemEncVer2:] adds every field it + // doesn't know (bar server_*), serialized the same way a TLK share signs its extras. for field in fields { - let name = field.identifier.as_ref().unwrap().name(); - match name { - "gen" | "pcspublickey" | "UUID" | "data" | "pcsservice" | "pcspublicidentity" | "parentkeyref" | "uploadver" | "wrappedkey" | "encver" => continue, - _name => { - if _name.starts_with("server_") { continue } - let val = field.value.as_ref().unwrap(); - if let Some(string) = &val.string_value { - aad.insert(_name.to_string(), string.as_bytes().to_vec()); - } - if let Some(bytes) = &val.bytes_value { - aad.insert(_name.to_string(), bytes.clone()); - } - if let Some(date) = &val.date_value { - let time = date.time(); - - let secs = time.trunc() as i64; - let nanos = (time.fract() * 1e9) as u32; - - let timestamp = DateTime::from_timestamp(secs, nanos) - .expect("Invalid timestamp"); - aad.insert(_name.to_string(), timestamp.to_rfc3339_opts(chrono::SecondsFormat::Secs, true).into_bytes()); - } - if let Some(i) = &val.signed_value { - aad.insert(_name.to_string(), i.to_le_bytes().to_vec()); - } - if let Some(i) = &val.double_value { - aad.insert(_name.to_string(), (*i as u64).to_le_bytes().to_vec()); - } - } + let Some(name) = field.identifier.as_ref().and_then(|i| i.name.as_deref()) else { continue }; + if Self::AAD_KNOWN_KEYS.contains(&name) || name.starts_with("server_") { continue } + if let Some(bytes) = field.value.as_ref().and_then(ckks_extra_field_bytes) { + aad.insert(name.to_string(), bytes); } } aad } + /// The record keys the v2 AAD builds itself or leaves out; every other key is authenticated as an extra. + const AAD_KNOWN_KEYS: &'static [&'static str] = &["gen", "pcspublickey", "UUID", "data", "pcsservice", "pcspublicidentity", "parentkeyref", "uploadver", "wrappedkey", "encver"]; + fn authenticated_data_v1(&self, uuid: &str) -> BTreeMap> { info!("AAD v1"); BTreeMap::from_iter([ @@ -292,7 +272,7 @@ impl CuttlefishTlkShare { for field in fields { let Some(name) = field.identifier.as_ref().and_then(|i| i.name.as_deref()) else { continue }; if Self::KNOWN_KEYS.contains(&name) || name.starts_with("server_") { continue } - if let Some(bytes) = field.value.as_ref().and_then(tlkshare_extra_signing_bytes) { + if let Some(bytes) = field.value.as_ref().and_then(ckks_extra_field_bytes) { extras.insert(name, bytes); } } @@ -303,8 +283,9 @@ impl CuttlefishTlkShare { } } -/// How dataForSigning serializes an extra field; None for the kinds it skips (references, lists, assets, locations). -fn tlkshare_extra_signing_bytes(value: &cloudkit_proto::record::field::Value) -> Option> { +/// How CKKS serializes a record field it doesn't know, both in -[CKKSTLKShare dataForSigning:] and in +/// an item's v2 authenticated data; None for the kinds it skips (references, lists, assets, locations). +fn ckks_extra_field_bytes(value: &cloudkit_proto::record::field::Value) -> Option> { if let Some(s) = &value.string_value { return Some(s.as_bytes().to_vec()); } @@ -2685,4 +2666,41 @@ mod tlkshare_signing_tests { ]); assert_eq!(signing_payload(&fields), apple_positional_payload()); } + + #[test] + fn an_items_v2_aad_serializes_unknown_fields_like_a_tlk_share() { + // -[CKKSItem makeAuthenticatedDataDictionaryUpdatingCKKSItemEncVer2:] uses the same rules as + // dataForSigning. Reading the date as Unix time or saturating the double would fail decryption. + let item = CuttlefishEncItem { + r#gen: 3, + encver: 2, + parentkeyref: Reference { + record_identifier: Some(cloudkit_proto::RecordIdentifier { + value: Some(cloudkit_proto::Identifier { name: Some("parent-key".to_string()), ..Default::default() }), + ..Default::default() + }), + ..Default::default() + }, + ..Default::default() + }; + let fields = vec![ + field("gen", int(3)), + field("encver", int(2)), + field("uploadver", string("iphone")), + field("parentkeyref", reference()), + field("server_modified", string("S")), + field("aReference", reference()), + field("created", date(0.0)), + field("n", double(-2.0)), + ]; + let expected = BTreeMap::from_iter([ + ("UUID", b"item-uuid".to_vec()), + ("encver", 2u64.to_le_bytes().to_vec()), + ("gen", 3u64.to_le_bytes().to_vec()), + ("wrappedkey", b"parent-key".to_vec()), + ("created", b"2001-01-01T00:00:00Z".to_vec()), + ("n", 0xffff_ffff_ffff_fffeu64.to_le_bytes().to_vec()), + ].map(|(k, v)| (k.to_string(), v))); + assert_eq!(item.authenticated_data_v2("item-uuid", &fields), expected); + } } From e8854853ada7de24955d5e1d8326612fca81f943 Mon Sep 17 00:00:00 2001 From: Grega Kespret Date: Sun, 13 Sep 2026 13:36:53 +0200 Subject: [PATCH 6/7] Fix the test binary's calls to login_apple_delegates and PasswordManager::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 Claude-Session: https://claude.ai/code/session_01U1DuWnYYwPDETmxswCcT8m --- src/test.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/test.rs b/src/test.rs index 6db524d..ce6ba84 100644 --- a/src/test.rs +++ b/src/test.rs @@ -401,10 +401,9 @@ async fn main() { let account = done.lock().await; // account.update_postdata("Testing").await.unwrap(); - let pet = account.get_pet().unwrap(); let spd = account.spd.as_ref().unwrap(); - let delegates = login_apple_delegates(&gsa.user, &pet, spd["adsid"].as_string().unwrap(), None, &mut *anisette_client.lock().await, config.as_ref(), &[LoginDelegate::IDS, LoginDelegate::MobileMe]).await.unwrap(); + let delegates = login_apple_delegates(&*account, None, config.as_ref(), &[LoginDelegate::IDS, LoginDelegate::MobileMe]).await.unwrap(); let user = authenticate_apple(delegates.ids.unwrap(), config.as_ref()).await.unwrap(); let mobileme = delegates.mobileme.unwrap(); @@ -608,7 +607,7 @@ async fn main() { let passwords = PasswordManager::new( keychain.clone(), cloudkit.clone(), client.identity.clone(), connection.clone(), state, Box::new(move |state| { plist::to_file_xml("passwords.plist", state).unwrap(); - })).await; + }), Box::new(|_, _| {})).await; if let Some(mut s) = session { From d142b05bdf01046bf2897ad7bd5eb0caa7ee4bb2 Mon Sep 17 00:00:00 2001 From: Grega Kespret Date: Sun, 13 Sep 2026 13:46:09 +0200 Subject: [PATCH 7/7] Recover a zone whose TLK share was skipped 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 Claude-Session: https://claude.ai/code/session_01U1DuWnYYwPDETmxswCcT8m --- src/icloud/keychain.rs | 37 ++++++++++++++++++++++++++++++------- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/src/icloud/keychain.rs b/src/icloud/keychain.rs index e58b4ec..06928e0 100644 --- a/src/icloud/keychain.rs +++ b/src/icloud/keychain.rs @@ -1031,6 +1031,10 @@ impl KeychainKeyStore { pub fn get_key_id(&self, uuid: &str) -> Option<&CloudKey> { self.0.iter().find(|k| k.uuid == uuid) } + + fn has_zone(&self, zone: &str) -> bool { + self.0.iter().any(|k| k.zone_name == zone) + } } #[derive(Clone, Serialize, Deserialize, Default)] pub struct SavedKeychainZone { @@ -1401,11 +1405,19 @@ impl KeychainClient

{ return Err(PushError::NotInClique) } + // A share we couldn't verify is skipped, so a zone can be left without a key. Ask for the + // shares again until it has one, rather than only when the key store is empty. let state = self.state.read().await; - if state.keystore.0.is_empty() { - let shares = self.fetch_shares_for(state.user_identity.as_ref().unwrap()).await?; + if zones.iter().any(|zone| !state.keystore.has_zone(zone)) { + let had_keys = !state.keystore.0.is_empty(); + let shares = self.fetch_shares_for(state.user_identity.as_ref().unwrap()).await; drop(state); - self.store_keys(&shares).await?; + match shares { + Ok(shares) => self.store_keys(&shares).await?, + // The zones we already hold keys for can still sync. + Err(e) if had_keys => warn!("Couldn't refetch TLK shares for a zone without a key: {e}"), + Err(e) => return Err(e), + } } else { drop(state); } @@ -1428,7 +1440,9 @@ impl KeychainClient

{ for (zone, (_, changes, change)) in zones.iter().zip(item.into_iter()) { let saved_keychain_zone = state.items.entry(zone.to_string()).or_default(); - saved_keychain_zone.change_tag = change.clone().map(|i| i.into()); + // An item whose key we don't have yet must come back on the next sync, so the zone + // keeps its old change tag until every item in it has decrypted. + let mut missing_key = false; for change in changes { let identifier = change.identifier.as_ref().unwrap().value.as_ref().unwrap().name().to_string(); let Some(record) = change.record else { @@ -1438,9 +1452,13 @@ impl KeychainClient

{ }; if record.r#type.as_ref().unwrap().name() == CuttlefishEncItem::record_type() { let item = CuttlefishEncItem::from_record(&record.record_field); - let Ok(mut decoded) = item.decrypt(&identifier, &record, &state.keystore, &cloudkey_access) else { - warn!("Missing decryption key for {}", identifier); - continue; + let mut decoded = match item.decrypt(&identifier, &record, &state.keystore, &cloudkey_access) { + Ok(decoded) => decoded, + Err(e) => { + warn!("Couldn't decrypt {}: {e}", identifier); + missing_key |= matches!(e, PushError::DecryptionKeyNotFound(_)); + continue; + } }; encrypt_entry(&mut decoded, &keychain_access); @@ -1452,6 +1470,11 @@ impl KeychainClient

{ saved_keychain_zone.current_keys.insert(identifier, record); } } + if missing_key { + warn!("Keeping the old change tag for {zone} until its missing keys arrive"); + } else { + saved_keychain_zone.change_tag = change.map(|i| i.into()); + } } (self.update_state)(&state);