Files
yattee/Yattee/Services/CloudKit/CloudKitIdentity.swift
Arkadiusz Fal e7476bec9b Archive the identity token by its guaranteed coding contract
Seventh review pass. No correctness findings this round; both are
availability.

The retry budget was only reset inside the tokenless branch of an identity
change, so an account that exhausted its four attempts left the next one
with none - its queued resolve() did no lookup at all and credentials
stayed unavailable until another foreground transition. The budget belongs
to the identity being resolved, so it now resets on every observed change.

ubiquityIdentityToken was archived with requiringSecureCoding: true, but
Foundation guarantees only NSCoding for it. Measured: a class adopting only
NSCoding does throw under secure archiving, and the `try?` there would have
silently produced no fingerprint - losing the scope cache, forcing a
CloudKit lookup every launch and leaving credentials unavailable offline.
This Mac's token happens to support secure coding, so nothing was failing
yet; the archive is only ever hashed, never decoded, so secure coding buys
nothing regardless. Also measured: the non-secure archive is deterministic,
which is what the fingerprint comparison depends on.

Note the digest differs between the two archiving modes, so any scope
cached before this change resolves once more against CloudKit and re-caches.
2026-08-24 23:30:24 +02:00

124 lines
5.1 KiB
Swift

//
// CloudKitIdentity.swift
// Yattee
//
// Resolves the Apple account identity that account-scoped local state is
// namespaced by (Keychain items, the account cache, the sync outbox).
//
import CloudKit
import CryptoKit
import Foundation
/// The identity that account-scoped local state is keyed by.
///
/// Two different values serve two different purposes:
///
/// - `ubiquityFingerprint` is derived from `FileManager.ubiquityIdentityToken`:
/// synchronous and offline-capable, so launch-time gating can decide whether
/// the persisted namespace still belongs to the signed-in account *before*
/// any CloudKit request could complete.
/// - `userRecordID()` is the CloudKit user record name — the durable scope
/// value persisted state is namespaced by. It requires the network, so it is
/// resolved once the account is reachable.
enum CloudKitIdentity {
/// Scope used when no Apple account is available (signed out, or a
/// sideloaded build with no iCloud entitlement).
static let localScope = "local"
/// Short, stable fingerprint of the currently signed-in Apple account.
///
/// `nil` when no Apple account is signed in. The value is opaque and
/// carries no account information — it only supports equality comparison
/// against a previously persisted fingerprint.
static var ubiquityFingerprint: String? {
guard let token = FileManager.default.ubiquityIdentityToken else { return nil }
// The token is only guaranteed to be `NSCoding` — Foundation types it
// as `NSCoding & NSCopying & NSObjectProtocol`, with no
// `NSSecureCoding`. Archiving with `requiringSecureCoding: true` would
// therefore throw on a conforming implementation, and a `try?` there
// would silently yield nil: the scope would lose its cache, forcing a
// CloudKit lookup on every launch and leaving credentials unavailable
// offline. Secure coding buys nothing here regardless — the archive is
// only ever hashed, never decoded.
let archiver = NSKeyedArchiver(requiringSecureCoding: false)
archiver.encode(token, forKey: NSKeyedArchiveRootObjectKey)
archiver.finishEncoding()
let data = archiver.encodedData
guard !data.isEmpty else { return nil }
return shortDigest(of: data)
}
/// Whether an Apple account is signed in, as distinct from unreachable.
///
/// `ubiquityIdentityToken` alone cannot answer this: Foundation returns
/// `nil` both when the user is signed out *and* when iCloud Drive is
/// unavailable or switched off, and Yattee declares only the CloudKit
/// service (no ubiquity container), so the token's availability tracks
/// system iCloud Drive state rather than the account. Treating a nil token
/// as "signed out" would collapse two different Apple accounts into the
/// same `local` credential scope.
enum AccountAvailability {
/// No Apple account — the `local` scope is genuinely correct.
case noAccount
/// Signed in; the user record can be fetched.
case available
/// Unreachable, restricted, or undetermined. Never assume either way.
case indeterminate
}
static func accountAvailability() async -> AccountAvailability {
guard CloudKitAvailability.isAvailable else { return .noAccount }
do {
let container = CKContainer(identifier: AppIdentifiers.iCloudContainer)
switch try await container.accountStatus() {
case .noAccount: return .noAccount
case .available: return .available
default: return .indeterminate
}
} catch {
LoggingService.shared.debug(
"Could not determine CloudKit account status",
category: .cloudKit,
details: "\(error)"
)
return .indeterminate
}
}
/// The CloudKit user record name for the current identity.
///
/// Returns `nil` when CloudKit is unavailable (sideloaded build), when no
/// Apple account is signed in, or when the container cannot be reached —
/// callers must fall back to ``localScope`` rather than treating `nil` as
/// an error state.
static func userRecordID() async -> String? {
guard CloudKitAvailability.isAvailable else { return nil }
do {
let container = CKContainer(identifier: AppIdentifiers.iCloudContainer)
return try await container.userRecordID().recordName
} catch {
LoggingService.shared.debug(
"Could not resolve CloudKit user record ID",
category: .cloudKit,
details: "\(error)"
)
return nil
}
}
/// First 8 bytes of the SHA-256 digest, hex encoded — short enough to read
/// in a log line, wide enough that collisions are not a practical concern.
static func shortDigest(of data: Data) -> String {
SHA256.hash(data: data)
.prefix(8)
.map { String(format: "%02x", $0) }
.joined()
}
}