mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
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.
124 lines
5.1 KiB
Swift
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()
|
|
}
|
|
}
|