Files
yattee/Yattee/Services/Credentials/CredentialIdentityScope.swift
Arkadiusz Fal f5e88df403 Stop adoption rollback from revoking a synced credential
Two more issues from a second review pass.

Adoption verified its write by reading the key back and deleted it on any
mismatch. But a mismatch can also mean iCloud Keychain delivered a real
credential for that key between the add and the read - and since the delete
spans both synchronizable forms, it would propagate and revoke that
credential on every device. A readable value that is not ours is now left
strictly alone; only an unreadable key (the add reported success but
produced nothing usable) is rolled back so the offer can be retried.

An identity switch arriving while a resolution was in flight was dropped:
the observer's resolve() returned early on the isResolving guard, and the
in-flight one then bailed on its fingerprint check without starting a
lookup for the new identity, leaving credentials disabled until a
foreground transition or relaunch. Requests that arrive mid-resolution are
now replayed once the running one finishes.

Adds a fingerprint provider and an injectable backoff so identity
resolution is testable without a real Apple account or real seconds, plus
tests for retry, foreground recovery, mid-flight switching, signed-out
scoping and the cached-scope fast path.
2026-08-23 23:56:28 +02:00

278 lines
11 KiB
Swift

//
// CredentialIdentityScope.swift
// Yattee
//
// Resolves the Apple-account scope that Keychain credentials are keyed by.
//
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?
/// 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 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
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() },
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.retryDelay = retryDelay
adoptCurrentIdentity()
observeIdentityChanges()
}
deinit {
if let identityChangeObserver {
NotificationCenter.default.removeObserver(identityChangeObserver)
}
}
/// 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() {
identityChangeObserver = NotificationCenter.default.addObserver(
forName: .NSUbiquityIdentityDidChange,
object: nil,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
guard let self else { return }
LoggingService.shared.info(
"Apple account changed — re-resolving the credential identity scope",
category: .keychain
)
self.adoptCurrentIdentity()
Task { await self.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 }
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.
state = .resolved(Self.localScope)
return
}
guard let current else {
// Signed out. Local scope is correct *and* isolating: items written
// here never collide with a signed-in account's keys.
state = .resolved(Self.localScope)
return
}
if let cached = cachedScope(for: current) {
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
while resolutionAttempts < Self.maxResolutionAttempts {
resolutionAttempts += 1
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
}
if let requested {
cache(recordID, for: requested)
}
state = .resolved(recordID)
LoggingService.shared.info(
"Resolved credential identity scope",
category: .keychain,
details: "fingerprint=\(requested ?? "none"), attempt=\(resolutionAttempts)"
)
return
}
guard resolutionAttempts < Self.maxResolutionAttempts else { break }
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 { return }
resolutionAttempts = 0
await resolve()
}
// 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)
}
}