mirror of
https://github.com/yattee/yattee.git
synced 2026-10-25 22:22:52 +00:00
Fourth review pass, on a hole the previous commit opened. Making a nil ubiquity token mean "unresolved" fixed the shared-local-scope collision, but left a scope resolved from CloudKit alone with nothing local to watch: with iCloud Drive off the token stays nil across a switch from account A to B, so NSUbiquityIdentityDidChange never fires and the scope kept pointing at A - and its retained credentials with it - until relaunch. Two additions. CKAccountChanged is now observed alongside the ubiquity notification, and a tokenless scope is dropped outright when either fires because there is no fingerprint to re-derive it from. Foreground activation also revalidates a tokenless scope against CloudKit, covering a switch whose notification never arrived; it compares before replacing so the common case never blanks out credentials, and an unreachable account leaves the scope standing rather than flapping on every transient failure. A token-derived scope is untouched by all of this - it changes only via the notification, and a test pins that down.
396 lines
17 KiB
Swift
396 lines
17 KiB
Swift
//
|
|
// CredentialIdentityScope.swift
|
|
// Yattee
|
|
//
|
|
// Resolves the Apple-account scope that Keychain credentials are keyed by.
|
|
//
|
|
|
|
import CloudKit
|
|
import Foundation
|
|
|
|
/// Resolves the identity scope that account credentials are namespaced under.
|
|
///
|
|
/// ### Why credentials are scoped at all
|
|
///
|
|
/// Apple documents that Keychain data may remain on device when a user signs
|
|
/// out of their Apple account and chooses to keep it, and Yattee's credential
|
|
/// managers query `kSecAttrSynchronizableAny`. An unscoped key would therefore
|
|
/// hand a credential retained from Apple account A to whoever signs in next —
|
|
/// and for migrated accounts that collision is *guaranteed*, because local
|
|
/// instances survive an Apple-account switch and the migrated account UUID is
|
|
/// deterministically the instance UUID.
|
|
///
|
|
/// Measured on iPadOS 27.0: a synchronizable item retained across a sign-out
|
|
/// keeps `kSecAttrSynchronizable = true` and still matches a strict
|
|
/// `== true` query with **no** Apple account signed in — so the attribute
|
|
/// cannot be used to tell "belongs to the current account" from "left over
|
|
/// from the previous one". Scoping the key is the only reliable defence, and
|
|
/// legacy items are never adopted automatically (see
|
|
/// ``AccountKeychainCredentials/adoptLegacyCredential(for:)``).
|
|
///
|
|
/// ### Why the CloudKit user record, not the ubiquity token
|
|
///
|
|
/// The scope has to be identical on every device signed in to the same Apple
|
|
/// account, or iCloud Keychain sync would stop working — each device would
|
|
/// write under a different key. `FileManager.ubiquityIdentityToken` carries no
|
|
/// such cross-device guarantee, so the CloudKit user record ID is the scope
|
|
/// value; the ubiquity token is used only as a synchronous, offline-capable
|
|
/// signal that the account *changed*.
|
|
@MainActor
|
|
@Observable
|
|
final class CredentialIdentityScope {
|
|
/// Scope used when no Apple account is available — signed out, or a
|
|
/// sideloaded build with no iCloud entitlement.
|
|
static let localScope = CloudKitIdentity.localScope
|
|
|
|
enum State: Equatable {
|
|
/// Signed in, but the CloudKit user record hasn't been fetched yet.
|
|
/// Credential access is refused in this state: writing under a guessed
|
|
/// scope is exactly the mistake scoping exists to prevent.
|
|
case unresolved
|
|
case resolved(String)
|
|
}
|
|
|
|
private(set) var state: State = .unresolved
|
|
|
|
/// Fingerprint of the Apple account the current state belongs to.
|
|
/// A change here means the scope must be re-resolved.
|
|
private(set) var fingerprint: String?
|
|
|
|
private let defaults: UserDefaults
|
|
private let resolveUserRecordID: @Sendable () async -> String?
|
|
private let fingerprintProvider: @Sendable () -> String?
|
|
private let accountAvailabilityProvider: @Sendable () async -> CloudKitIdentity.AccountAvailability
|
|
/// Backoff between attempts. Injectable so tests don't pay real seconds.
|
|
private let retryDelay: @Sendable (Int) async -> Void
|
|
/// Written only on the main actor; read in `deinit`, which runs after the
|
|
/// last reference is gone and so cannot race with it.
|
|
private nonisolated(unsafe) var identityChangeObserver: NSObjectProtocol?
|
|
private nonisolated(unsafe) var cloudKitAccountObserver: NSObjectProtocol?
|
|
|
|
private static let cacheKey = "credentialIdentityScopeCache"
|
|
|
|
/// Attempts made for the Apple account currently being resolved. Reset
|
|
/// whenever the identity changes.
|
|
private var resolutionAttempts = 0
|
|
private var isResolving = false
|
|
/// Set when a resolution is requested while one is already running — the
|
|
/// running one is for a now-stale identity and will bail on the fingerprint
|
|
/// check, so the request has to be replayed rather than dropped.
|
|
private var pendingResolve = false
|
|
/// Whether the current scope was resolved with no ubiquity token behind it.
|
|
///
|
|
/// Such a scope has no fingerprint to compare against, so an Apple-account
|
|
/// switch produces no token change to observe — it has to be revalidated
|
|
/// against CloudKit instead.
|
|
private var resolvedWithoutToken = false
|
|
private static let maxResolutionAttempts = 4
|
|
|
|
/// The resolved scope, or `nil` while unresolved.
|
|
var current: String? {
|
|
if case let .resolved(scope) = state { return scope }
|
|
return nil
|
|
}
|
|
|
|
var isResolved: Bool { current != nil }
|
|
|
|
init(
|
|
defaults: UserDefaults = .standard,
|
|
fingerprintProvider: @escaping @Sendable () -> String? = { CloudKitIdentity.ubiquityFingerprint },
|
|
resolveUserRecordID: @escaping @Sendable () async -> String? = { await CloudKitIdentity.userRecordID() },
|
|
accountAvailabilityProvider: @escaping @Sendable () async -> CloudKitIdentity.AccountAvailability = {
|
|
await CloudKitIdentity.accountAvailability()
|
|
},
|
|
retryDelay: @escaping @Sendable (Int) async -> Void = { attempt in
|
|
try? await Task.sleep(for: .seconds(pow(2.0, Double(attempt))))
|
|
}
|
|
) {
|
|
self.defaults = defaults
|
|
self.fingerprintProvider = fingerprintProvider
|
|
self.resolveUserRecordID = resolveUserRecordID
|
|
self.accountAvailabilityProvider = accountAvailabilityProvider
|
|
self.retryDelay = retryDelay
|
|
adoptCurrentIdentity()
|
|
observeIdentityChanges()
|
|
}
|
|
|
|
deinit {
|
|
if let identityChangeObserver {
|
|
NotificationCenter.default.removeObserver(identityChangeObserver)
|
|
}
|
|
if let cloudKitAccountObserver {
|
|
NotificationCenter.default.removeObserver(cloudKitAccountObserver)
|
|
}
|
|
}
|
|
|
|
/// Re-scopes as soon as the Apple account changes under a running app.
|
|
///
|
|
/// Without this the scope would stay pinned to whichever account was
|
|
/// signed in at launch, and a sign-out or switch mid-session would let
|
|
/// reads and writes reach the previous account's retained credentials —
|
|
/// the exact leak scoping exists to prevent.
|
|
private func observeIdentityChanges() {
|
|
// Two signals, because neither covers the other. The ubiquity token
|
|
// changes only when iCloud Drive is available; with it switched off the
|
|
// token stays nil across a switch and only CloudKit notices.
|
|
identityChangeObserver = NotificationCenter.default.addObserver(
|
|
forName: .NSUbiquityIdentityDidChange,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
MainActor.assumeIsolated { self?.identityDidChange(source: "ubiquity token") }
|
|
}
|
|
|
|
cloudKitAccountObserver = NotificationCenter.default.addObserver(
|
|
forName: .CKAccountChanged,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
MainActor.assumeIsolated { self?.identityDidChange(source: "CloudKit account") }
|
|
}
|
|
}
|
|
|
|
private func identityDidChange(source: String) {
|
|
LoggingService.shared.info(
|
|
"Apple account changed (\(source)) — re-resolving the credential identity scope",
|
|
category: .keychain
|
|
)
|
|
|
|
// A tokenless scope cannot be re-derived by `adoptCurrentIdentity()`
|
|
// (there is no fingerprint to compare), so drop it outright: continuing
|
|
// to serve it would hand the previous account's credentials to the new
|
|
// one.
|
|
if resolvedWithoutToken {
|
|
state = .unresolved
|
|
resolvedWithoutToken = false
|
|
resolutionAttempts = 0
|
|
}
|
|
|
|
adoptCurrentIdentity()
|
|
Task { await resolve() }
|
|
}
|
|
|
|
/// Test seam: a scope pinned up front, never consulting CloudKit.
|
|
/// `nil` pins the unresolved state.
|
|
init(fixedScope: String?, defaults: UserDefaults = .standard) {
|
|
self.defaults = defaults
|
|
fingerprintProvider = { nil }
|
|
resolveUserRecordID = { nil }
|
|
accountAvailabilityProvider = { .indeterminate }
|
|
retryDelay = { _ in }
|
|
fingerprint = nil
|
|
state = fixedScope.map { State.resolved($0) } ?? .unresolved
|
|
}
|
|
|
|
// MARK: - Resolution
|
|
|
|
/// Re-reads the Apple account and restores the cached scope if there is
|
|
/// one. Synchronous and offline-capable, so it is safe at launch.
|
|
///
|
|
/// Call again on `.accountChange`: a switch that happened while the app was
|
|
/// closed shows up here, before any CloudKit request could report it.
|
|
func adoptCurrentIdentity() {
|
|
let current = fingerprintProvider()
|
|
if current != fingerprint {
|
|
resolutionAttempts = 0
|
|
}
|
|
fingerprint = current
|
|
|
|
guard CloudKitAvailability.isAvailable else {
|
|
// No iCloud entitlement: nothing can sync, and there is no ambient
|
|
// identity for a retained credential to leak to.
|
|
resolvedWithoutToken = false
|
|
state = .resolved(Self.localScope)
|
|
return
|
|
}
|
|
|
|
guard let current else {
|
|
// A nil token does NOT establish sign-out — Foundation also returns
|
|
// nil when iCloud Drive is unavailable or switched off, and Yattee
|
|
// declares only the CloudKit service. Scoping to `local` here would
|
|
// collapse two different signed-in Apple accounts into one
|
|
// namespace, which is exactly the collision this class prevents.
|
|
// CloudKit's account status settles it during resolution.
|
|
state = .unresolved
|
|
return
|
|
}
|
|
|
|
if let cached = cachedScope(for: current) {
|
|
resolvedWithoutToken = false
|
|
state = .resolved(cached)
|
|
} else {
|
|
state = .unresolved
|
|
}
|
|
}
|
|
|
|
/// Fetches the CloudKit user record and caches it against the current
|
|
/// Apple account, so every later launch resolves synchronously.
|
|
///
|
|
/// Only the first resolution for a given Apple account needs the network.
|
|
/// While it is outstanding, credential access reports "can't tell yet"
|
|
/// rather than "not signed in" — callers must not treat that as a missing
|
|
/// credential.
|
|
func resolve() async {
|
|
guard !isResolved else { return }
|
|
|
|
// A resolution already running is for whatever identity was current
|
|
// when it started. If that is no longer this one it will bail on its
|
|
// fingerprint check, so the request has to be replayed afterwards
|
|
// rather than dropped — otherwise a switch mid-flight leaves the new
|
|
// identity unresolved until a foreground transition or relaunch.
|
|
guard !isResolving else {
|
|
pendingResolve = true
|
|
return
|
|
}
|
|
|
|
isResolving = true
|
|
defer { isResolving = false }
|
|
|
|
repeat {
|
|
pendingResolve = false
|
|
await performResolution()
|
|
} while pendingResolve && !isResolved
|
|
}
|
|
|
|
private func performResolution() async {
|
|
// Captured before the first await so a switch mid-flight can be
|
|
// detected: a record ID fetched under account A must never be cached
|
|
// for B.
|
|
let requested = fingerprint
|
|
|
|
// Labelled: an unqualified `break` inside the switch below would leave
|
|
// only the switch and fall through to the user-record lookup, which
|
|
// must not run when the account status is undetermined.
|
|
attempts: while resolutionAttempts < Self.maxResolutionAttempts {
|
|
resolutionAttempts += 1
|
|
|
|
// With no ubiquity token, only CloudKit can distinguish "signed
|
|
// out" (where `local` is correct) from "signed in but iCloud Drive
|
|
// is off" (where `local` would be a cross-account collision).
|
|
if requested == nil {
|
|
switch await accountAvailabilityProvider() {
|
|
case .noAccount:
|
|
guard fingerprintProvider() == nil, fingerprint == nil else { return }
|
|
resolvedWithoutToken = true
|
|
state = .resolved(Self.localScope)
|
|
LoggingService.shared.info(
|
|
"No Apple account — credentials scoped locally",
|
|
category: .keychain
|
|
)
|
|
return
|
|
case .available:
|
|
break // fall through to the user-record lookup below
|
|
case .indeterminate:
|
|
guard resolutionAttempts < Self.maxResolutionAttempts else { break attempts }
|
|
await retryDelay(resolutionAttempts)
|
|
guard fingerprint == requested else { return }
|
|
continue attempts
|
|
}
|
|
}
|
|
|
|
if let recordID = await resolveUserRecordID() {
|
|
guard fingerprintProvider() == requested, fingerprint == requested else {
|
|
LoggingService.shared.info(
|
|
"Discarding credential scope resolved for a previous Apple account",
|
|
category: .keychain
|
|
)
|
|
return
|
|
}
|
|
|
|
// Only cacheable against a token. Without one there is no key
|
|
// to cache under that could be trusted to mean this account,
|
|
// so this lookup repeats each launch.
|
|
if let requested {
|
|
cache(recordID, for: requested)
|
|
}
|
|
resolvedWithoutToken = requested == nil
|
|
state = .resolved(recordID)
|
|
|
|
LoggingService.shared.info(
|
|
"Resolved credential identity scope",
|
|
category: .keychain,
|
|
details: "fingerprint=\(requested ?? "none (no ubiquity token)"), attempt=\(resolutionAttempts)"
|
|
)
|
|
return
|
|
}
|
|
|
|
guard resolutionAttempts < Self.maxResolutionAttempts else { break attempts }
|
|
|
|
await retryDelay(resolutionAttempts)
|
|
guard fingerprint == requested else { return }
|
|
}
|
|
|
|
// Every credential read is disabled until this succeeds, so failing
|
|
// quietly would look exactly like "signed out". Foreground activation
|
|
// retries via resolveIfNeeded().
|
|
LoggingService.shared.warning(
|
|
"Could not resolve the credential identity scope — credentials stay unavailable until it does",
|
|
category: .keychain
|
|
)
|
|
}
|
|
|
|
/// Retries a resolution that previously ran out of attempts.
|
|
///
|
|
/// Called when the app returns to the foreground: a device that launched
|
|
/// offline would otherwise keep credentials unavailable for the entire
|
|
/// process lifetime, long after connectivity returned.
|
|
func resolveIfNeeded() async {
|
|
guard !isResolved else {
|
|
await revalidateTokenlessScope()
|
|
return
|
|
}
|
|
resolutionAttempts = 0
|
|
await resolve()
|
|
}
|
|
|
|
/// Confirms that a tokenless scope still names the signed-in account.
|
|
///
|
|
/// With iCloud Drive switched off the ubiquity token stays `nil` across an
|
|
/// Apple-account switch, so nothing local changes and the notification may
|
|
/// never arrive. Without this check the scope would keep pointing at the
|
|
/// previous account until relaunch — and its retained credentials with it.
|
|
///
|
|
/// Revalidation compares before replacing rather than invalidating first,
|
|
/// so the common case (nothing changed) never blanks out credentials.
|
|
private func revalidateTokenlessScope() async {
|
|
guard resolvedWithoutToken, fingerprintProvider() == nil else { return }
|
|
|
|
switch await accountAvailabilityProvider() {
|
|
case .noAccount:
|
|
guard current != Self.localScope else { return }
|
|
LoggingService.shared.info(
|
|
"Apple account signed out — credential scope falling back to local",
|
|
category: .keychain
|
|
)
|
|
resolvedWithoutToken = true
|
|
state = .resolved(Self.localScope)
|
|
|
|
case .available:
|
|
guard let recordID = await resolveUserRecordID(), recordID != current else { return }
|
|
resolvedWithoutToken = true
|
|
LoggingService.shared.info(
|
|
"Apple account changed without a token change — credential scope re-pointed",
|
|
category: .keychain
|
|
)
|
|
state = .resolved(recordID)
|
|
|
|
case .indeterminate:
|
|
// Unreachable proves nothing, and invalidating on every transient
|
|
// network failure would make credentials flap. The scope stands.
|
|
break
|
|
}
|
|
}
|
|
|
|
// MARK: - Cache
|
|
|
|
private func cachedScope(for fingerprint: String) -> String? {
|
|
(defaults.dictionary(forKey: Self.cacheKey) as? [String: String])?[fingerprint]
|
|
}
|
|
|
|
private func cache(_ scope: String, for fingerprint: String) {
|
|
var cache = (defaults.dictionary(forKey: Self.cacheKey) as? [String: String]) ?? [:]
|
|
cache[fingerprint] = scope
|
|
defaults.set(cache, forKey: Self.cacheKey)
|
|
}
|
|
}
|