diff --git a/Yattee/Core/AppEnvironment.swift b/Yattee/Core/AppEnvironment.swift index b613c93a..60b1f2e0 100644 --- a/Yattee/Core/AppEnvironment.swift +++ b/Yattee/Core/AppEnvironment.swift @@ -48,6 +48,9 @@ final class AppEnvironment { let invidiousCredentialsManager: InvidiousCredentialsManager let pipedCredentialsManager: PipedCredentialsManager let basicAuthCredentialsManager: BasicAuthCredentialsManager + /// Shared by both account credential managers so a single CloudKit lookup + /// resolves the scope for all of them. + let credentialIdentityScope: CredentialIdentityScope let homeInstanceCache: HomeInstanceCache let invidiousAPI: InvidiousAPI let pipedAPI: PipedAPI @@ -130,13 +133,20 @@ final class AppEnvironment { } self.dataManager = dm + // Resolve which Apple account owns this device's credentials before the + // managers that key items by it are built. Cached scopes restore + // synchronously here; a first-ever resolution needs the network and is + // kicked off below. + let identityScope = CredentialIdentityScope() + self.credentialIdentityScope = identityScope + // Initialize Invidious Credentials Manager (needed for SubscriptionService) - let invidiousCreds = InvidiousCredentialsManager() + let invidiousCreds = InvidiousCredentialsManager(identityScope: identityScope) invidiousCreds.settingsManager = settings self.invidiousCredentialsManager = invidiousCreds // Initialize Piped Credentials Manager - let pipedCreds = PipedCredentialsManager() + let pipedCreds = PipedCredentialsManager(identityScope: identityScope) pipedCreds.settingsManager = settings self.pipedCredentialsManager = pipedCreds @@ -337,6 +347,11 @@ final class AppEnvironment { // existed, so existing setups publish them to iCloud without a re-login backfillCredentialFlags() + // Resolve the credential identity scope if this Apple account has never + // been seen on this device. Until it lands, account credentials report + // "can't tell yet" rather than "signed out". + Task { await identityScope.resolve() } + // Log device capabilities on startup for debugging HardwareCapabilities.shared.logCapabilities() diff --git a/Yattee/Models/Account.swift b/Yattee/Models/Account.swift new file mode 100644 index 00000000..98264126 --- /dev/null +++ b/Yattee/Models/Account.swift @@ -0,0 +1,74 @@ +// +// Account.swift +// Yattee +// +// A single sign-in on a backend instance. One instance may hold several. +// + +import Foundation + +/// A user's login on an instance. +/// +/// Yattee 2 collapsed v1's multi-account model into "one instance = at most one +/// credential" (`Instance.usesAccountLogin` plus a Keychain item keyed by the +/// instance's UUID). `Account` restores multi-login: the instance describes the +/// *server*, the account describes *who is signed in to it*. +/// +/// The login identifier (username/email) is deliberately **not** stored here — +/// it lives in the Keychain beside the credential, so synced metadata never +/// carries account identifiers. Only `displayName` is a synced label. +/// +/// `type` is not stored either: it is derived by resolving `instanceID` through +/// `InstancesManager`, so an instance that changes type can never disagree with +/// its accounts. +struct Account: Identifiable, Codable, Hashable, Sendable { + let id: UUID + let instanceID: UUID + + /// User-set label. The only name that syncs. + var displayName: String? + + let dateAdded: Date + + /// Bumped on every user edit — supplies the ordering for conflict + /// resolution, which never relies on CloudKit modification dates. + var updatedAt: Date + + init( + id: UUID = UUID(), + instanceID: UUID, + displayName: String? = nil, + dateAdded: Date = Date(), + updatedAt: Date = Date() + ) { + self.id = id + self.instanceID = instanceID + self.displayName = displayName + self.dateAdded = dateAdded + self.updatedAt = updatedAt + } + + /// The account synthesized for an instance that was signed in before + /// multi-account support existed. + /// + /// The account's `id` is deliberately the **instance's** UUID: at most one + /// credential ever existed per instance, so reusing the UUID makes the + /// migration deterministic across devices — two devices migrating + /// independently produce the identical record name, which merges per-record + /// instead of duplicating. + static func migrated(from instanceID: UUID, dateAdded: Date = Date()) -> Account { + Account( + id: instanceID, + instanceID: instanceID, + displayName: nil, + dateAdded: dateAdded, + // Epoch-old: any explicit user edit on any device outranks a + // migration write regardless of arrival order. + updatedAt: Date(timeIntervalSince1970: 0) + ) + } + + /// Whether this account was produced by the legacy migration rather than an + /// explicit sign-in. + var isMigrated: Bool { id == instanceID } +} diff --git a/Yattee/Services/CloudKit/CloudKitIdentity.swift b/Yattee/Services/CloudKit/CloudKitIdentity.swift new file mode 100644 index 00000000..6bbb3fbd --- /dev/null +++ b/Yattee/Services/CloudKit/CloudKitIdentity.swift @@ -0,0 +1,75 @@ +// +// 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, + let data = try? NSKeyedArchiver.archivedData( + withRootObject: token, + requiringSecureCoding: true + ) + else { return nil } + + return shortDigest(of: data) + } + + /// 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() + } +} diff --git a/Yattee/Services/Credentials/AccountCredentialsManager.swift b/Yattee/Services/Credentials/AccountCredentialsManager.swift new file mode 100644 index 00000000..226c9d8f --- /dev/null +++ b/Yattee/Services/Credentials/AccountCredentialsManager.swift @@ -0,0 +1,63 @@ +// +// AccountCredentialsManager.swift +// Yattee +// +// Account-keyed credential storage — the multi-login replacement for the +// instance-keyed InstanceCredentialsManager. +// + +import Foundation + +/// Credential storage keyed by `Account`, not by `Instance`. +/// +/// Adopted by `InvidiousCredentialsManager` and `PipedCredentialsManager`. +/// `BasicAuthCredentialsManager` deliberately does **not** adopt it and keeps +/// `InstanceCredentialsManager`: HTTP Basic auth is a property of the instance +/// (or the reverse proxy in front of it), not of a user account, and a single +/// proxy credential covers every account on that instance. +@MainActor +protocol AccountCredentialsManager: AnyObject, Observable { + /// Accounts with a stored credential under the **current** identity scope. + var loggedInAccountIDs: Set { get } + + /// Whether the identity scope has been resolved yet. + /// + /// While `false`, every read below reports "no credential" because the app + /// cannot yet tell which scope to look in. Callers must render that as + /// *temporarily unavailable*, never as "signed out" — correcting state on + /// it would discard a perfectly good login. + var isScopeResolved: Bool { get } + + func setCredential(_ credential: String, for account: Account) + func credential(for account: Account) -> String? + func deleteCredential(for account: Account) + + /// The login identifier the user typed. Kept in the Keychain beside the + /// credential so it never enters synced metadata. + func setUsername(_ username: String, for account: Account) + func username(for account: Account) -> String? + + func isLoggedIn(for account: Account) -> Bool + func refreshLoginStatus(for account: Account) + + // MARK: - Legacy adoption + + /// Whether a pre-revamp credential for this account's instance exists on + /// this device and has not been adopted by the current identity. + /// + /// Never acted on automatically: a retained Keychain item carries no proof + /// of which Apple account it belonged to (measured — see + /// ``CredentialIdentityScope``), so adoption is always a deliberate user + /// action. + func hasAdoptableLegacyCredential(for account: Account) -> Bool + + /// Copies the legacy item into this account's scoped key after the user + /// asks for it. Returns whether the copy verified. + @discardableResult + func adoptLegacyCredential(for account: Account) -> Bool + + /// Deletes the unscoped legacy item **only** if this identity adopted it + /// and it still holds the adopted secret. Called when an account is really + /// deleted, never on mere absence. + func deleteLegacyCredentialIfOwned(for account: Account) +} diff --git a/Yattee/Services/Credentials/AccountKeychainCredentials.swift b/Yattee/Services/Credentials/AccountKeychainCredentials.swift new file mode 100644 index 00000000..6308fb37 --- /dev/null +++ b/Yattee/Services/Credentials/AccountKeychainCredentials.swift @@ -0,0 +1,257 @@ +// +// AccountKeychainCredentials.swift +// Yattee +// +// Shared implementation of account-keyed, identity-scoped credential storage. +// + +import Foundation + +/// The engine behind `AccountCredentialsManager`. +/// +/// Both credential managers own one of these and forward to it, so the scoping +/// rules and the legacy-adoption protocol exist in exactly one place. +@MainActor +@Observable +final class AccountKeychainCredentials { + private let store: KeychainCredentialStore + private let identityScope: CredentialIdentityScope + private let markers: CredentialAdoptionMarkerStore + + /// Name used in log lines ("SID", "token") so messages read naturally for + /// whichever backend owns this instance. + private let credentialNoun: String + + /// Known logins, partitioned by the identity scope they were observed under. + /// + /// Keyed rather than invalidated: a switch of Apple account (or the first + /// resolution completing) must never let one identity's cache answer for + /// another, and clearing a shared cache would mean mutating observable + /// state from inside read methods a view body can call. + private var loginsByScope: [String: Set] = [:] + + /// Accounts holding a credential under the current identity. + var loggedInAccountIDs: Set { + guard let scope = identityScope.current else { return [] } + return loginsByScope[scope] ?? [] + } + + weak var settingsManager: SettingsManager? + + init( + service: String, + credentialNoun: String, + identityScope: CredentialIdentityScope, + markers: CredentialAdoptionMarkerStore? = nil + ) { + store = KeychainCredentialStore(service: service) + self.credentialNoun = credentialNoun + self.identityScope = identityScope + self.markers = markers ?? CredentialAdoptionMarkerStore() + } + + var isScopeResolved: Bool { identityScope.isResolved } + + /// Credentials sync to iCloud Keychain on the same toggle as the instances + /// they belong to. + private var shouldSyncToiCloud: Bool { + settingsManager?.iCloudSyncEnabled == true && settingsManager?.syncInstances == true + } + + private func currentScope() -> String? { + identityScope.current + } + + // MARK: - Credentials + + func setCredential(_ credential: String, for account: Account) { + guard let scope = currentScope() else { + LoggingService.shared.error( + "Refusing to store \(credentialNoun) before the identity scope is resolved", + category: .keychain, + details: "accountID=\(account.id)" + ) + return + } + + let syncEnabled = shouldSyncToiCloud + let status = store.set( + credential, + forKey: KeychainCredentialStore.credentialKey(scope: scope, accountID: account.id), + synchronizable: syncEnabled + ) + + guard status == errSecSuccess else { + LoggingService.shared.error( + "Failed to store \(credentialNoun)", + category: .keychain, + details: "accountID=\(account.id), status=\(store.describe(status))" + ) + return + } + + loginsByScope[scope, default: []].insert(account.id) + + LoggingService.shared.info( + "Stored \(credentialNoun)", + category: .keychain, + details: "accountID=\(account.id), iCloudSync=\(syncEnabled)" + ) + } + + func credential(for account: Account) -> String? { + guard let scope = currentScope() else { return nil } + return store.value( + forKey: KeychainCredentialStore.credentialKey(scope: scope, accountID: account.id) + ) + } + + func deleteCredential(for account: Account) { + guard let scope = currentScope() else { return } + + store.delete(key: KeychainCredentialStore.credentialKey(scope: scope, accountID: account.id)) + store.delete(key: KeychainCredentialStore.usernameKey(scope: scope, accountID: account.id)) + loginsByScope[scope]?.remove(account.id) + + LoggingService.shared.info( + "Deleted \(credentialNoun)", + category: .keychain, + details: "accountID=\(account.id)" + ) + + HomeInstanceCache.shared.clear(instanceID: account.instanceID, contentType: .feed) + } + + // MARK: - Username + + func setUsername(_ username: String, for account: Account) { + guard let scope = currentScope() else { return } + store.set( + username, + forKey: KeychainCredentialStore.usernameKey(scope: scope, accountID: account.id), + synchronizable: shouldSyncToiCloud + ) + } + + func username(for account: Account) -> String? { + guard let scope = currentScope() else { return nil } + return store.value( + forKey: KeychainCredentialStore.usernameKey(scope: scope, accountID: account.id) + ) + } + + // MARK: - Status + + func isLoggedIn(for account: Account) -> Bool { + guard let scope = currentScope() else { return false } + + if loginsByScope[scope]?.contains(account.id) == true { + return true + } + + let hasCredential = credential(for: account) != nil + if hasCredential { + loginsByScope[scope, default: []].insert(account.id) + } + return hasCredential + } + + func refreshLoginStatus(for account: Account) { + guard let scope = currentScope() else { return } + + if credential(for: account) != nil { + loginsByScope[scope, default: []].insert(account.id) + } else { + loginsByScope[scope]?.remove(account.id) + } + } + + // MARK: - Legacy adoption + + func hasAdoptableLegacyCredential(for account: Account) -> Bool { + guard let scope = currentScope() else { return false } + guard !markers.hasAdopted(instanceID: account.instanceID, scope: scope) else { return false } + guard credential(for: account) == nil else { return false } + + return legacyCredential(for: account) != nil + } + + /// Copies the legacy item into this account's scoped key. + /// + /// Copies — never moves. Deleting a synchronizable item propagates to every + /// device signed in to the same Apple account, so removing the legacy item + /// here would log the user out of devices still running the previous + /// release, mid-rollout. It is retained until the account is really + /// deleted; general cleanup waits for a compatibility-breaking release. + @discardableResult + func adoptLegacyCredential(for account: Account) -> Bool { + guard let scope = currentScope() else { return false } + guard let legacy = legacyCredential(for: account) else { return false } + + let key = KeychainCredentialStore.credentialKey(scope: scope, accountID: account.id) + let status = store.set(legacy, forKey: key, synchronizable: shouldSyncToiCloud) + + // The marker is only written after the scoped copy reads back: a marker + // for a credential that never landed would suppress the adoption offer + // and strand the user with no way to sign in short of retyping. + guard status == errSecSuccess, store.value(forKey: key) == legacy else { + LoggingService.shared.error( + "Legacy \(credentialNoun) adoption failed verification", + category: .keychain, + details: "accountID=\(account.id), status=\(store.describe(status))" + ) + store.delete(key: key) + return false + } + + markers.record( + instanceID: account.instanceID, + accountID: account.id, + scope: scope, + secret: legacy + ) + loginsByScope[scope, default: []].insert(account.id) + + LoggingService.shared.info( + "Adopted legacy \(credentialNoun) into the identity-scoped key", + category: .keychain, + details: "accountID=\(account.id), instanceID=\(account.instanceID)" + ) + + return true + } + + func deleteLegacyCredentialIfOwned(for account: Account) { + guard let scope = currentScope() else { return } + + let legacyKey = KeychainCredentialStore.legacyKey(instanceID: account.instanceID) + let currentValue = store.value(forKey: legacyKey) + + guard markers.canDeleteLegacyItem( + instanceID: account.instanceID, + scope: scope, + currentLegacyValue: currentValue + ) else { + LoggingService.shared.info( + "Leaving the legacy \(credentialNoun) in place — not this identity's to delete", + category: .keychain, + details: "instanceID=\(account.instanceID)" + ) + return + } + + store.delete(key: legacyKey) + markers.removeMarker(instanceID: account.instanceID, scope: scope) + + LoggingService.shared.info( + "Deleted the legacy \(credentialNoun) alongside its account", + category: .keychain, + details: "instanceID=\(account.instanceID)" + ) + } + + /// Reads the unscoped, pre-revamp item. Only the adoption paths call this. + private func legacyCredential(for account: Account) -> String? { + store.value(forKey: KeychainCredentialStore.legacyKey(instanceID: account.instanceID)) + } +} diff --git a/Yattee/Services/Credentials/CredentialAdoptionMarker.swift b/Yattee/Services/Credentials/CredentialAdoptionMarker.swift new file mode 100644 index 00000000..b61e4886 --- /dev/null +++ b/Yattee/Services/Credentials/CredentialAdoptionMarker.swift @@ -0,0 +1,147 @@ +// +// CredentialAdoptionMarker.swift +// Yattee +// +// Records which legacy Keychain items this device has adopted, and for whom. +// + +import CryptoKit +import Foundation + +/// Records that a legacy (unscoped) Keychain item was copied into an +/// identity-scoped key on this device. +/// +/// The marker is bound to the *secret*, not just the account: it is written +/// only after the scoped copy reads back, and it carries a fingerprint of the +/// credential value that was adopted. That binding is what lets an account +/// deletion clean up the legacy item without ever destroying someone else's +/// login — see ``CredentialAdoptionMarkerStore/canDeleteLegacyItem(...)``. +struct CredentialAdoptionMarker: Codable, Hashable, Sendable { + /// The identity scope that adopted the item. Apple account A's marker must + /// never authorize anything under account B. + let identityScope: String + let instanceID: UUID + let accountID: UUID + /// Salted, truncated digest of the adopted credential — non-secret, and + /// device-local (the salt never leaves this device). + let secretFingerprint: String + let adoptedAt: Date +} + +/// Device-local store of adoption markers. +/// +/// Deliberately never synced: it describes what happened to *this* device's +/// Keychain, which differs per device during a staggered rollout. +@MainActor +final class CredentialAdoptionMarkerStore { + private static let markersKey = "credentialAdoptionMarkers" + private static let saltKey = "credentialAdoptionFingerprintSalt" + + private let defaults: UserDefaults + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + // MARK: - Markers + + func markers() -> [CredentialAdoptionMarker] { + guard let data = defaults.data(forKey: Self.markersKey), + let markers = try? JSONDecoder().decode([CredentialAdoptionMarker].self, from: data) + else { return [] } + return markers + } + + /// Whether this identity has already adopted the legacy item for an instance. + /// + /// Once true, the app stops offering adoption and stops reading the legacy + /// key entirely for that account. + func hasAdopted(instanceID: UUID, scope: String) -> Bool { + markers().contains { $0.instanceID == instanceID && $0.identityScope == scope } + } + + func record( + instanceID: UUID, + accountID: UUID, + scope: String, + secret: String + ) { + var all = markers().filter { + !($0.instanceID == instanceID && $0.identityScope == scope) + } + all.append( + CredentialAdoptionMarker( + identityScope: scope, + instanceID: instanceID, + accountID: accountID, + secretFingerprint: fingerprint(of: secret), + adoptedAt: Date() + ) + ) + save(all) + } + + func removeMarker(instanceID: UUID, scope: String) { + save(markers().filter { !($0.instanceID == instanceID && $0.identityScope == scope) }) + } + + // MARK: - Legacy deletion authority + + /// Whether an account deletion may also delete the **unscoped** legacy item. + /// + /// Three conditions, all required: + /// + /// 1. This device has a marker for the instance — no marker means this + /// device never adopted anything, so the legacy item is not ours to touch. + /// 2. The marker belongs to the current identity — Apple account A's marker + /// cannot authorize a deletion while B is signed in. + /// 3. The legacy item still holds the exact secret that was adopted — if it + /// has since been replaced (B signed in again on an older release, and + /// that release rewrote the unscoped key), it is someone else's live + /// login and must survive. + /// + /// Deleting a synchronizable item propagates everywhere, so a wrong answer + /// here logs other devices out. + func canDeleteLegacyItem( + instanceID: UUID, + scope: String, + currentLegacyValue: String? + ) -> Bool { + guard let marker = markers().first(where: { + $0.instanceID == instanceID && $0.identityScope == scope + }) else { return false } + + guard let currentLegacyValue else { return false } + + return fingerprint(of: currentLegacyValue) == marker.secretFingerprint + } + + // MARK: - Fingerprinting + + /// Salted, truncated digest of a credential. + /// + /// Salted so the stored marker is not a rainbow-tableable hash of the + /// secret; truncated because it only ever needs to answer "is this still + /// the same value". The salt is generated once per device and never synced. + func fingerprint(of secret: String) -> String { + var input = Data(salt()) + input.append(Data(secret.utf8)) + return CloudKitIdentity.shortDigest(of: input) + } + + private func salt() -> [UInt8] { + if let existing = defaults.data(forKey: Self.saltKey), existing.count == 32 { + return [UInt8](existing) + } + + var bytes = [UInt8](repeating: 0, count: 32) + _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + defaults.set(Data(bytes), forKey: Self.saltKey) + return bytes + } + + private func save(_ markers: [CredentialAdoptionMarker]) { + guard let data = try? JSONEncoder().encode(markers) else { return } + defaults.set(data, forKey: Self.markersKey) + } +} diff --git a/Yattee/Services/Credentials/CredentialIdentityScope.swift b/Yattee/Services/Credentials/CredentialIdentityScope.swift new file mode 100644 index 00000000..0dcc4398 --- /dev/null +++ b/Yattee/Services/Credentials/CredentialIdentityScope.swift @@ -0,0 +1,169 @@ +// +// 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 static let cacheKey = "credentialIdentityScopeCache" + + /// 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, + resolveUserRecordID: @escaping @Sendable () async -> String? = { await CloudKitIdentity.userRecordID() } + ) { + self.defaults = defaults + self.resolveUserRecordID = resolveUserRecordID + adoptCurrentIdentity() + } + + /// Test seam: a scope pinned up front, never consulting CloudKit. + /// `nil` pins the unresolved state. + init(fixedScope: String?, defaults: UserDefaults = .standard) { + self.defaults = defaults + resolveUserRecordID = { nil } + 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 = CloudKitIdentity.ubiquityFingerprint + 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 } + + // Captured before the 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 + + guard let recordID = await resolveUserRecordID() else { return } + + guard CloudKitIdentity.ubiquityFingerprint == 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")" + ) + } + + // 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) + } +} diff --git a/Yattee/Services/Credentials/InvidiousCredentialsManager.swift b/Yattee/Services/Credentials/InvidiousCredentialsManager.swift index b6c5e83f..2ecf8dec 100644 --- a/Yattee/Services/Credentials/InvidiousCredentialsManager.swift +++ b/Yattee/Services/Credentials/InvidiousCredentialsManager.swift @@ -12,7 +12,7 @@ import Security @MainActor @Observable final class InvidiousCredentialsManager: InstanceCredentialsManager { - private let keychainServiceName = "com.yattee.invidious" + private static let keychainServiceName = "com.yattee.invidious" private let thumbnailCacheKey = "com.yattee.invidious.channelThumbnails" /// Tracks which instances have stored sessions (for reactive UI updates) @@ -21,15 +21,29 @@ final class InvidiousCredentialsManager: InstanceCredentialsManager { /// In-memory cache for channel thumbnails (channelID -> URL string) private var thumbnailCache: [String: String] = [:] + /// Account-keyed, identity-scoped storage — the multi-login path. + /// + /// The instance-keyed members below stay for call sites that still think in + /// instances; they read and write the unscoped legacy item unchanged, so an + /// upgrade never silently moves a credential. + let accountCredentials: AccountKeychainCredentials + /// Reference to settings manager for cleanup on logout and iCloud sync decisions - weak var settingsManager: SettingsManager? + weak var settingsManager: SettingsManager? { + didSet { accountCredentials.settingsManager = settingsManager } + } /// Whether credentials should sync to iCloud Keychain (when iCloud sync is enabled for instances). private var shouldSyncToiCloud: Bool { settingsManager?.iCloudSyncEnabled == true && settingsManager?.syncInstances == true } - init() { + init(identityScope: CredentialIdentityScope? = nil) { + accountCredentials = AccountKeychainCredentials( + service: Self.keychainServiceName, + credentialNoun: "SID", + identityScope: identityScope ?? CredentialIdentityScope() + ) loadThumbnailCache() } @@ -98,7 +112,7 @@ final class InvidiousCredentialsManager: InstanceCredentialsManager { // First, delete any existing item (both synced and non-synced) to avoid duplicates let deleteQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: keychainServiceName, + kSecAttrService as String: Self.keychainServiceName, kSecAttrAccount as String: account, kSecAttrSynchronizable as String: kSecAttrSynchronizableAny ] @@ -107,7 +121,7 @@ final class InvidiousCredentialsManager: InstanceCredentialsManager { // Create new item with current sync preference let addQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: keychainServiceName, + kSecAttrService as String: Self.keychainServiceName, kSecAttrAccount as String: account, kSecAttrSynchronizable as String: syncEnabled, kSecValueData as String: data @@ -141,7 +155,7 @@ final class InvidiousCredentialsManager: InstanceCredentialsManager { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: keychainServiceName, + kSecAttrService as String: Self.keychainServiceName, kSecAttrAccount as String: account, kSecAttrSynchronizable as String: kSecAttrSynchronizableAny, kSecReturnData as String: true, @@ -178,7 +192,7 @@ final class InvidiousCredentialsManager: InstanceCredentialsManager { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: keychainServiceName, + kSecAttrService as String: Self.keychainServiceName, kSecAttrAccount as String: account, kSecAttrSynchronizable as String: kSecAttrSynchronizableAny ] @@ -252,3 +266,51 @@ final class InvidiousCredentialsManager: InstanceCredentialsManager { deleteSID(for: instance) } } + +// MARK: - AccountCredentialsManager + +extension InvidiousCredentialsManager: AccountCredentialsManager { + var loggedInAccountIDs: Set { accountCredentials.loggedInAccountIDs } + var isScopeResolved: Bool { accountCredentials.isScopeResolved } + + func setCredential(_ credential: String, for account: Account) { + accountCredentials.setCredential(credential, for: account) + } + + func credential(for account: Account) -> String? { + accountCredentials.credential(for: account) + } + + func deleteCredential(for account: Account) { + accountCredentials.deleteCredential(for: account) + } + + func setUsername(_ username: String, for account: Account) { + accountCredentials.setUsername(username, for: account) + } + + func username(for account: Account) -> String? { + accountCredentials.username(for: account) + } + + func isLoggedIn(for account: Account) -> Bool { + accountCredentials.isLoggedIn(for: account) + } + + func refreshLoginStatus(for account: Account) { + accountCredentials.refreshLoginStatus(for: account) + } + + func hasAdoptableLegacyCredential(for account: Account) -> Bool { + accountCredentials.hasAdoptableLegacyCredential(for: account) + } + + @discardableResult + func adoptLegacyCredential(for account: Account) -> Bool { + accountCredentials.adoptLegacyCredential(for: account) + } + + func deleteLegacyCredentialIfOwned(for account: Account) { + accountCredentials.deleteLegacyCredentialIfOwned(for: account) + } +} diff --git a/Yattee/Services/Credentials/KeychainCredentialStore.swift b/Yattee/Services/Credentials/KeychainCredentialStore.swift new file mode 100644 index 00000000..02d83f52 --- /dev/null +++ b/Yattee/Services/Credentials/KeychainCredentialStore.swift @@ -0,0 +1,112 @@ +// +// KeychainCredentialStore.swift +// Yattee +// +// Keychain read/write shared by the Invidious and Piped credential managers. +// + +import Foundation +import Security + +/// Generic-password Keychain access for one service name. +/// +/// Both credential managers had a byte-for-byte copy of this logic keyed by +/// instance UUID; the account revamp adds identity scoping and username +/// entries on top, so it lives in one place now. +/// +/// **tvOS:** `kSecAttrSynchronizable` is accepted but app Keychain items are +/// never synchronized through iCloud there. Writes still set the attribute (so +/// an item created on Apple TV syncs if Apple ever enables it), but an account +/// synced from another device stays credential-less on tvOS until the user +/// signs in locally — that is permanent, not a pending sync. +struct KeychainCredentialStore: Sendable { + let service: String + + // MARK: - Key construction + + /// Identity-scoped credential key: `"|"`. + static func credentialKey(scope: String, accountID: UUID) -> String { + "\(scope)|\(accountID.uuidString)" + } + + /// Identity-scoped username key: `"|#username"`. + /// + /// Scoped exactly like the credential, so a username retained from one + /// Apple account never surfaces under another. + static func usernameKey(scope: String, accountID: UUID) -> String { + "\(credentialKey(scope: scope, accountID: accountID))#username" + } + + /// The pre-revamp key: a bare instance UUID, with no identity scope. + /// + /// Only the adoption path may read this, and only when the user explicitly + /// asks for it. + static func legacyKey(instanceID: UUID) -> String { + instanceID.uuidString + } + + // MARK: - Access + + /// Reads a value, matching both synchronizable forms. + /// + /// `SynchronizableAny` is safe for scoped keys — the scope, not the sync + /// flag, is what provides identity isolation. + func value(forKey key: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecAttrSynchronizable as String: kSecAttrSynchronizableAny, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess, + let data = result as? Data, + let value = String(data: data, encoding: .utf8) + else { return nil } + + return value + } + + /// Writes a value, replacing any existing item in either synchronizable form. + @discardableResult + func set(_ value: String, forKey key: String, synchronizable: Bool) -> OSStatus { + delete(key: key) + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecAttrSynchronizable as String: synchronizable, + kSecValueData as String: Data(value.utf8) + ] + + return SecItemAdd(query as CFDictionary, nil) + } + + /// Deletes a value in both synchronizable forms. + /// + /// Deleting a synchronizable item propagates to every device signed in to + /// the same Apple account — intended for a real sign-out, which is why the + /// legacy item is never deleted as a side effect of adoption. + @discardableResult + func delete(key: String) -> OSStatus { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecAttrSynchronizable as String: kSecAttrSynchronizableAny + ] + + return SecItemDelete(query as CFDictionary) + } + + func describe(_ status: OSStatus) -> String { + if status == errSecSuccess { return "success" } + return (SecCopyErrorMessageString(status, nil) as String?) ?? "status \(status)" + } +} diff --git a/Yattee/Services/Credentials/KeychainProvenanceProbe.swift b/Yattee/Services/Credentials/KeychainProvenanceProbe.swift new file mode 100644 index 00000000..e9824153 --- /dev/null +++ b/Yattee/Services/Credentials/KeychainProvenanceProbe.swift @@ -0,0 +1,216 @@ +// +// KeychainProvenanceProbe.swift +// Yattee +// +// Debug-only diagnostic that settles one open question in the accounts +// revamp: can a Keychain query prove which Apple account a stored credential +// came from? +// + +#if DEBUG +import Foundation +import Security + +/// Empirical probe for the credential-adoption question in the accounts +/// revamp (design decision D3). +/// +/// Yattee 2 stores instance credentials under an *unscoped* key +/// (`kSecAttrAccount = instance.id.uuidString`) and reads them back with +/// `kSecAttrSynchronizableAny`. The revamp re-keys them per Apple account +/// (`"|"`) so that a credential retained on +/// device across an Apple-account switch can never be served to the new +/// account. That leaves one thing to decide: on upgrade, may the app adopt the +/// existing unscoped item automatically, or must every user tap an explicit +/// "use saved login from this device" action? +/// +/// Adopting automatically is only safe with *provenance of the secret itself*. +/// The candidate discriminator is a **strict `kSecAttrSynchronizable == true`** +/// query: the hypothesis is that an item actively served from the *current* +/// account's iCloud Keychain matches it, while a credential retained after +/// signing out of a *previous* account survives only as a local copy and does +/// not. Nothing in Apple's documentation states this either way, so it has to +/// be measured on a real device across a real account switch. +/// +/// This probe writes to its own Keychain service and therefore can never read, +/// modify, or delete a real Yattee credential. +/// +/// Everything else mirrors the production managers exactly — same item class, +/// same delete-then-add write, same attribute set — so the measured behaviour +/// transfers to `InvidiousCredentialsManager` / `PipedCredentialsManager`. +enum KeychainProvenanceProbe { + /// Dedicated service name: isolates the probe from `com.yattee.invidious` + /// and `com.yattee.piped`. + static let service = "com.yattee.keychain-provenance-experiment" + + /// Stands in for the legacy credential: written with + /// `kSecAttrSynchronizable = true`, exactly like a real logged-in instance + /// while iCloud sync is on. + static let synchronizableAccount = "probe-synchronizable" + + /// Control item written with `kSecAttrSynchronizable = false` — never + /// eligible for iCloud Keychain, so it isolates "survived the account + /// switch" from "was synced". + static let localAccount = "probe-local" + + // MARK: - Queries + + /// The three ways the app can ask for an item, and the question each answers. + enum SyncMatch: String, CaseIterable, Identifiable { + /// The candidate discriminator. + case synchronizableTrue + /// Catches items whose synchronizable flag was cleared on sign-out. + case synchronizableFalse + /// What the production managers use today — matches either form. + case any + + var id: String { rawValue } + + var queryValue: Any { + switch self { + case .synchronizableTrue: kCFBooleanTrue as Any + case .synchronizableFalse: kCFBooleanFalse as Any + case .any: kSecAttrSynchronizableAny as Any + } + } + + var label: String { + switch self { + case .synchronizableTrue: "kSecAttrSynchronizable == true (candidate discriminator)" + case .synchronizableFalse: "kSecAttrSynchronizable == false" + case .any: "kSecAttrSynchronizableAny (what Yattee queries today)" + } + } + } + + /// A single matched Keychain item, flattened for reporting. + struct ItemDump: Identifiable, Sendable { + let id = UUID() + let account: String + /// The attribute as stored — not as queried. The interesting column + /// after an account switch. + let synchronizable: Bool? + let accessible: String? + let creationDate: Date? + let modificationDate: Date? + /// Records which identity wrote the item, so a retained item can be + /// attributed to the previous Apple account after a switch. + let payload: String? + } + + struct QueryResult: Identifiable, Sendable { + var id: String { match.rawValue } + let match: SyncMatch + let status: OSStatus + let items: [ItemDump] + } + + // MARK: - Write + + /// Writes a probe item, mirroring the production delete-then-add pattern. + /// + /// The delete leg spans both synchronizable forms, exactly like + /// `InvidiousCredentialsManager.setSID(_:for:)` — which means calling this + /// destroys a retained item from a previous account. Only call it when the + /// protocol step says to. + @discardableResult + static func write(account: String, synchronizable: Bool, payload: String) -> OSStatus { + let deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecAttrSynchronizable as String: kSecAttrSynchronizableAny + ] + SecItemDelete(deleteQuery as CFDictionary) + + let addQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecAttrSynchronizable as String: synchronizable, + kSecValueData as String: Data(payload.utf8) + ] + + let status = SecItemAdd(addQuery as CFDictionary, nil) + + LoggingService.shared.info( + "Keychain provenance probe written", + category: .keychain, + details: "account=\(account), synchronizable=\(synchronizable), status=\(describe(status))" + ) + + return status + } + + /// Deletes every probe item in both synchronizable forms. + /// + /// Deleting a synchronizable item propagates through iCloud Keychain to + /// every device signed in to the same Apple account — intended here, since + /// this is how the experiment is reset between runs. + @discardableResult + static func deleteAll() -> OSStatus { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrSynchronizable as String: kSecAttrSynchronizableAny + ] + let status = SecItemDelete(query as CFDictionary) + + LoggingService.shared.info( + "Keychain provenance probes deleted", + category: .keychain, + details: "status=\(describe(status))" + ) + + return status + } + + // MARK: - Read + + /// Runs one query and returns every matching item with its stored attributes. + static func query(_ match: SyncMatch) -> QueryResult { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrSynchronizable as String: match.queryValue, + kSecReturnAttributes as String: true, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitAll + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + let attributes = result as? [[String: Any]] ?? [] + + return QueryResult( + match: match, + status: status, + items: attributes.map(dump).sorted { $0.account < $1.account } + ) + } + + /// All three queries, in reporting order. + static func queryAll() -> [QueryResult] { + SyncMatch.allCases.map(query) + } + + private static func dump(_ attributes: [String: Any]) -> ItemDump { + ItemDump( + account: attributes[kSecAttrAccount as String] as? String ?? "", + synchronizable: (attributes[kSecAttrSynchronizable as String] as? NSNumber)?.boolValue, + accessible: attributes[kSecAttrAccessible as String] as? String, + creationDate: attributes[kSecAttrCreationDate as String] as? Date, + modificationDate: attributes[kSecAttrModificationDate as String] as? Date, + payload: (attributes[kSecValueData as String] as? Data) + .flatMap { String(data: $0, encoding: .utf8) } + ) + } + + // MARK: - Status + + static func describe(_ status: OSStatus) -> String { + if status == errSecSuccess { return "success (0)" } + let message = SecCopyErrorMessageString(status, nil) as String? ?? "unknown" + return "\(message) (\(status))" + } +} +#endif diff --git a/Yattee/Services/Credentials/KeychainProvenanceReport.swift b/Yattee/Services/Credentials/KeychainProvenanceReport.swift new file mode 100644 index 00000000..2e777251 --- /dev/null +++ b/Yattee/Services/Credentials/KeychainProvenanceReport.swift @@ -0,0 +1,150 @@ +// +// KeychainProvenanceReport.swift +// Yattee +// +// Captures and persists the output of KeychainProvenanceProbe so results +// survive the Apple-account sign-out/sign-in steps of the experiment. +// + +#if DEBUG +import Foundation + +#if canImport(UIKit) +import UIKit +#endif + +/// One captured run of the experiment. +/// +/// Reports are persisted because the protocol spans an Apple-account sign-out +/// and sign-in: the reading taken *before* the switch is half the evidence, and +/// it must still be readable after it. +struct KeychainProvenanceReport: Codable, Identifiable, Sendable { + let id: UUID + let capturedAt: Date + let label: String + let text: String + + init(id: UUID = UUID(), capturedAt: Date = Date(), label: String, text: String) { + self.id = id + self.capturedAt = capturedAt + self.label = label + self.text = text + } +} + +/// UserDefaults-backed store for captured reports. +/// +/// Deliberately not synced anywhere — the whole point is a per-device record. +enum KeychainProvenanceReportStore { + private static let defaultsKey = "debug.keychainProvenanceReports" + + static func load() -> [KeychainProvenanceReport] { + guard let data = UserDefaults.standard.data(forKey: defaultsKey), + let reports = try? JSONDecoder().decode([KeychainProvenanceReport].self, from: data) + else { return [] } + return reports + } + + static func append(_ report: KeychainProvenanceReport) -> [KeychainProvenanceReport] { + let reports = load() + [report] + save(reports) + return reports + } + + static func clear() { + UserDefaults.standard.removeObject(forKey: defaultsKey) + } + + private static func save(_ reports: [KeychainProvenanceReport]) { + guard let data = try? JSONEncoder().encode(reports) else { return } + UserDefaults.standard.set(data, forKey: defaultsKey) + } +} + +// MARK: - Report text + +enum KeychainProvenanceReportBuilder { + /// Builds the full plain-text report for the current device state. + /// + /// - Parameters: + /// - label: free-form run label, e.g. "iPhone 15 — signed in as identity B". + /// - userRecordID: resolved asynchronously by the caller (network), `nil` + /// when unavailable. + static func build(label: String, userRecordID: String?) -> String { + let results = KeychainProvenanceProbe.queryAll() + + var lines: [String] = [] + lines.append("=== Yattee Keychain provenance experiment ===") + lines.append("Label: \(label.isEmpty ? "" : label)") + lines.append("Captured: \(iso(Date()))") + lines.append("Platform: \(platformDescription)") + lines.append("") + + lines.append("--- Identity ---") + lines.append("CloudKit entitlement: \(CloudKitAvailability.isAvailable ? "available" : "missing (sideload)")") + lines.append("ubiquityIdentityToken: \(CloudKitIdentity.ubiquityFingerprint.map { "present, fp=\($0)" } ?? "none — no Apple account signed in")") + lines.append("CloudKit user record: \(userRecordID ?? "unresolved")") + lines.append("") + + lines.append("--- Keychain queries (service \(KeychainProvenanceProbe.service)) ---") + for result in results { + lines.append("") + lines.append("[\(result.match.label)]") + lines.append(" status: \(KeychainProvenanceProbe.describe(result.status)), \(result.items.count) item(s)") + for item in result.items { + lines.append(" • account=\(item.account)") + lines.append(" stored synchronizable: \(item.synchronizable.map(String.init(describing:)) ?? "absent")") + lines.append(" accessible: \(item.accessible ?? "absent")") + lines.append(" created: \(item.creationDate.map(iso) ?? "absent")") + lines.append(" modified: \(item.modificationDate.map(iso) ?? "absent")") + lines.append(" payload: \(item.payload ?? "")") + } + } + + lines.append("") + lines.append("--- Discriminator check ---") + lines.append(contentsOf: discriminatorLines(results)) + + return lines.joined(separator: "\n") + } + + /// Compares what the candidate discriminator matched against what Yattee's + /// current `SynchronizableAny` query matches — the difference is exactly + /// what auto-adoption would refuse to adopt. + private static func discriminatorLines( + _ results: [KeychainProvenanceProbe.QueryResult] + ) -> [String] { + let strict = Set(results.first { $0.match == .synchronizableTrue }?.items.map(\.account) ?? []) + let any = Set(results.first { $0.match == .any }?.items.map(\.account) ?? []) + + var lines: [String] = [] + lines.append("matched by strict `== true`: \(format(strict))") + lines.append("matched by `Any` (today): \(format(any))") + lines.append("visible to Yattee but NOT provenance-backed: \(format(any.subtracting(strict)))") + return lines + } + + private static func format(_ accounts: Set) -> String { + accounts.isEmpty ? "none" : accounts.sorted().joined(separator: ", ") + } + + // MARK: - Environment + + private static var platformDescription: String { + let version = ProcessInfo.processInfo.operatingSystemVersionString + #if os(iOS) + return "iOS — \(UIDevice.current.model) — \(version)" + #elseif os(tvOS) + return "tvOS — \(UIDevice.current.model) — \(version)" + #elseif os(macOS) + return "macOS — \(version)" + #else + return version + #endif + } + + private static func iso(_ date: Date) -> String { + date.formatted(.iso8601) + } +} +#endif diff --git a/Yattee/Services/Credentials/PipedCredentialsManager.swift b/Yattee/Services/Credentials/PipedCredentialsManager.swift index 86131816..d7d8cf12 100644 --- a/Yattee/Services/Credentials/PipedCredentialsManager.swift +++ b/Yattee/Services/Credentials/PipedCredentialsManager.swift @@ -12,13 +12,30 @@ import Security @MainActor @Observable final class PipedCredentialsManager: InstanceCredentialsManager { - private let keychainServiceName = "com.yattee.piped" + private static let keychainServiceName = "com.yattee.piped" /// Tracks which instances have stored tokens (for reactive UI updates) private(set) var loggedInInstanceIDs: Set = [] + /// Account-keyed, identity-scoped storage — the multi-login path. + /// + /// The instance-keyed members below stay for call sites that still think in + /// instances; they read and write the unscoped legacy item unchanged, so an + /// upgrade never silently moves a credential. + let accountCredentials: AccountKeychainCredentials + /// Reference to settings manager for iCloud sync decisions - weak var settingsManager: SettingsManager? + weak var settingsManager: SettingsManager? { + didSet { accountCredentials.settingsManager = settingsManager } + } + + init(identityScope: CredentialIdentityScope? = nil) { + accountCredentials = AccountKeychainCredentials( + service: Self.keychainServiceName, + credentialNoun: "token", + identityScope: identityScope ?? CredentialIdentityScope() + ) + } /// Whether credentials should sync to iCloud Keychain (when iCloud sync is enabled for instances). private var shouldSyncToiCloud: Bool { @@ -44,7 +61,7 @@ final class PipedCredentialsManager: InstanceCredentialsManager { // First, delete any existing item (both synced and non-synced) to avoid duplicates let deleteQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: keychainServiceName, + kSecAttrService as String: Self.keychainServiceName, kSecAttrAccount as String: account, kSecAttrSynchronizable as String: kSecAttrSynchronizableAny ] @@ -53,7 +70,7 @@ final class PipedCredentialsManager: InstanceCredentialsManager { // Create new item with current sync preference let addQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: keychainServiceName, + kSecAttrService as String: Self.keychainServiceName, kSecAttrAccount as String: account, kSecAttrSynchronizable as String: syncEnabled, kSecValueData as String: data @@ -87,7 +104,7 @@ final class PipedCredentialsManager: InstanceCredentialsManager { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: keychainServiceName, + kSecAttrService as String: Self.keychainServiceName, kSecAttrAccount as String: account, kSecAttrSynchronizable as String: kSecAttrSynchronizableAny, kSecReturnData as String: true, @@ -124,7 +141,7 @@ final class PipedCredentialsManager: InstanceCredentialsManager { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: keychainServiceName, + kSecAttrService as String: Self.keychainServiceName, kSecAttrAccount as String: account, kSecAttrSynchronizable as String: kSecAttrSynchronizableAny ] @@ -178,3 +195,51 @@ final class PipedCredentialsManager: InstanceCredentialsManager { } } } + +// MARK: - AccountCredentialsManager + +extension PipedCredentialsManager: AccountCredentialsManager { + var loggedInAccountIDs: Set { accountCredentials.loggedInAccountIDs } + var isScopeResolved: Bool { accountCredentials.isScopeResolved } + + func setCredential(_ credential: String, for account: Account) { + accountCredentials.setCredential(credential, for: account) + } + + func credential(for account: Account) -> String? { + accountCredentials.credential(for: account) + } + + func deleteCredential(for account: Account) { + accountCredentials.deleteCredential(for: account) + } + + func setUsername(_ username: String, for account: Account) { + accountCredentials.setUsername(username, for: account) + } + + func username(for account: Account) -> String? { + accountCredentials.username(for: account) + } + + func isLoggedIn(for account: Account) -> Bool { + accountCredentials.isLoggedIn(for: account) + } + + func refreshLoginStatus(for account: Account) { + accountCredentials.refreshLoginStatus(for: account) + } + + func hasAdoptableLegacyCredential(for account: Account) -> Bool { + accountCredentials.hasAdoptableLegacyCredential(for: account) + } + + @discardableResult + func adoptLegacyCredential(for account: Account) -> Bool { + accountCredentials.adoptLegacyCredential(for: account) + } + + func deleteLegacyCredentialIfOwned(for account: Account) { + accountCredentials.deleteLegacyCredentialIfOwned(for: account) + } +} diff --git a/Yattee/Views/Settings/DeveloperSettingsView.swift b/Yattee/Views/Settings/DeveloperSettingsView.swift index e3f2fb7f..59a933b7 100644 --- a/Yattee/Views/Settings/DeveloperSettingsView.swift +++ b/Yattee/Views/Settings/DeveloperSettingsView.swift @@ -28,6 +28,9 @@ struct DeveloperSettingsView: View { debugSection testingSection dataSection + #if DEBUG + experimentsSection + #endif } .navigationTitle(String(localized: "settings.developer.title")) #if os(iOS) @@ -203,6 +206,29 @@ struct DeveloperSettingsView: View { } } + #if DEBUG + @ViewBuilder + private var experimentsSection: some View { + SettingsFormSection("Experiments") { + #if os(tvOS) + NavigationLink { + KeychainProvenanceExperimentView() + } label: { + Label { + Text(verbatim: "Keychain Provenance") + } icon: { + Image(systemName: "key.viewfinder") + } + } + #else + SettingsNavigationRow("Keychain Provenance", systemImage: "key.viewfinder") { + KeychainProvenanceExperimentView() + } + #endif + } + } + #endif + // MARK: - Data Actions private func deduplicateData() { diff --git a/Yattee/Views/Settings/KeychainProvenanceExperimentView.swift b/Yattee/Views/Settings/KeychainProvenanceExperimentView.swift new file mode 100644 index 00000000..4154e456 --- /dev/null +++ b/Yattee/Views/Settings/KeychainProvenanceExperimentView.swift @@ -0,0 +1,287 @@ +// +// KeychainProvenanceExperimentView.swift +// Yattee +// +// Debug-only driver for the Keychain provenance experiment (accounts revamp, D3). +// + +#if DEBUG +import SwiftUI + +#if canImport(UIKit) +import UIKit +#endif + +/// Runs the experiment described in ``KeychainProvenanceProbe``. +/// +/// The protocol spans an Apple-account sign-out and sign-in, so the screen is +/// built around capturing readings rather than showing live state: each +/// "Capture report" writes a persisted snapshot that survives the switch, and +/// the whole set is copied out at the end. +struct KeychainProvenanceExperimentView: View { + @State private var label = "" + @State private var userRecordID: String? + @State private var resolvingUserRecordID = true + @State private var reports: [KeychainProvenanceReport] = KeychainProvenanceReportStore.load() + @State private var latestStatus: String? + @State private var showingDeleteConfirmation = false + + var body: some View { + SettingsFormContainer { + protocolSection + identitySection + runSection + reportsSection + } + .navigationTitle(Text(verbatim: "Keychain Provenance")) + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .task { + userRecordID = await CloudKitIdentity.userRecordID() + resolvingUserRecordID = false + } + .confirmationDialog( + Text(verbatim: "Delete probe items?"), + isPresented: $showingDeleteConfirmation, + titleVisibility: .visible + ) { + Button(role: .destructive) { + latestStatus = "Delete: " + KeychainProvenanceProbe.describe(KeychainProvenanceProbe.deleteAll()) + } label: { + Text(verbatim: "Delete probe items") + } + Button(String(localized: "common.cancel"), role: .cancel) {} + } message: { + Text(verbatim: "Deletes both probe items on this device. The synchronizable one also disappears from every device signed in to the same Apple account.") + } + } + + // MARK: - Sections + + @ViewBuilder + private var protocolSection: some View { + SettingsFormSection( + "Protocol", + footer: "Two Apple accounts and a real device are required — iCloud Keychain does not behave faithfully in the Simulator. Do not press Write at steps 3 and 4: writing deletes the retained item the experiment is measuring." + ) { + step(1, "Signed in as account A: Write both probes.") + step(2, "Wait ~1 minute for iCloud Keychain to sync, then Capture (label: \"A, before sign-out\").") + step(3, "Sign out of the Apple account in system Settings, choosing to KEEP Keychain data on device. Relaunch Yattee and Capture (label: \"signed out, kept\").") + step(4, "Sign in with account B. Relaunch Yattee and Capture (label: \"B, after switch\"). This is the decisive reading.") + step(5, "Write both probes again under B, then Capture (label: \"B, fresh write\").") + step(6, "Copy all reports and send them back.") + } + } + + @ViewBuilder + private var identitySection: some View { + SettingsFormSection( + "Identity", + footer: "The fingerprint is an opaque hash of ubiquityIdentityToken. It must differ between accounts A and B — if it does not, the sign-out did not take effect." + ) { + row("CloudKit entitlement", CloudKitAvailability.isAvailable ? "available" : "missing") + row("Apple account", CloudKitIdentity.ubiquityFingerprint.map { "fp=\($0)" } ?? "signed out") + row("User record", resolvingUserRecordID ? "resolving…" : (userRecordID ?? "unresolved")) + } + } + + @ViewBuilder + private var runSection: some View { + SettingsFormSection( + "Run", + footer: "The label is stored inside the probe payload, so a report taken after an account switch still shows which account wrote the item it found." + ) { + TextField(text: $label) { + Text(verbatim: "Label (e.g. \"A, before sign-out\")") + } + #if os(iOS) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + #endif + + actionRow("Write both probes", systemImage: "square.and.pencil") { + writeProbes() + } + + actionRow("Capture report", systemImage: "camera.viewfinder") { + capture() + } + + if let latestStatus { + Text(verbatim: latestStatus) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } + + @ViewBuilder + private var reportsSection: some View { + SettingsFormSection("Reports (\(reports.count))") { + if reports.isEmpty { + Text(verbatim: "No reports captured yet.") + .foregroundStyle(.secondary) + } else { + ForEach(reports) { report in + VStack(alignment: .leading, spacing: 4) { + Text(verbatim: report.label.isEmpty ? "" : report.label) + .font(.headline) + Text(verbatim: report.capturedAt.formatted(date: .abbreviated, time: .standard)) + .font(.caption) + .foregroundStyle(.secondary) + Text(verbatim: report.text) + .font(.system(.caption2, design: .monospaced)) + .selectableText() + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + #if !os(tvOS) + actionRow("Copy all reports", systemImage: "doc.on.doc") { + copyToClipboard(allReportsText) + latestStatus = "Copied \(reports.count) report(s) to the clipboard." + } + #endif + + actionRow("Clear reports", systemImage: "trash", role: .destructive) { + KeychainProvenanceReportStore.clear() + reports = [] + latestStatus = "Cleared captured reports (probe items untouched)." + } + } + + actionRow("Delete probe items", systemImage: "key.slash", role: .destructive) { + showingDeleteConfirmation = true + } + } + } + + // MARK: - Actions + + private func writeProbes() { + let payload = payloadText() + let synchronizable = KeychainProvenanceProbe.write( + account: KeychainProvenanceProbe.synchronizableAccount, + synchronizable: true, + payload: payload + ) + let local = KeychainProvenanceProbe.write( + account: KeychainProvenanceProbe.localAccount, + synchronizable: false, + payload: payload + ) + + latestStatus = """ + Wrote probes — synchronizable: \(KeychainProvenanceProbe.describe(synchronizable)), \ + local: \(KeychainProvenanceProbe.describe(local)) + """ + } + + private func capture() { + let text = KeychainProvenanceReportBuilder.build(label: label, userRecordID: userRecordID) + reports = KeychainProvenanceReportStore.append( + KeychainProvenanceReport(label: label, text: text) + ) + + // Also logged so the report survives even if the app's storage is + // reset, and so it can be exported from the log viewer on tvOS. + LoggingService.shared.info( + "Keychain provenance report captured", + category: .keychain, + details: text + ) + + latestStatus = "Captured report \(reports.count)." + } + + private func payloadText() -> String { + let fingerprint = CloudKitIdentity.ubiquityFingerprint ?? "none" + let written = Date().formatted(.iso8601) + return "fp=\(fingerprint)|written=\(written)|label=\(label.isEmpty ? "" : label)" + } + + private var allReportsText: String { + reports + .map { "\($0.text)\n" } + .joined(separator: "\n\n========================================\n\n") + } + + #if !os(tvOS) + private func copyToClipboard(_ text: String) { + #if os(macOS) + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + #else + UIPasteboard.general.string = text + #endif + } + #endif + + // MARK: - Row builders + + @ViewBuilder + private func step(_ number: Int, _ text: String) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(verbatim: "\(number).") + .font(.footnote.monospacedDigit()) + .foregroundStyle(.secondary) + Text(verbatim: text) + .font(.footnote) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + @ViewBuilder + private func row(_ title: String, _ value: String) -> some View { + HStack { + Text(verbatim: title) + Spacer() + Text(verbatim: value) + .font(.system(.footnote, design: .monospaced)) + .foregroundStyle(.secondary) + .selectableText() + } + } + + @ViewBuilder + private func actionRow( + _ title: String, + systemImage: String, + role: ButtonRole? = nil, + action: @escaping () -> Void + ) -> some View { + HStack { + Button(role: role, action: action) { + Label { + Text(verbatim: title) + } icon: { + Image(systemName: systemImage) + } + } + Spacer() + } + } +} + +private extension View { + /// tvOS has no text selection — the report is read back from the log + /// viewer there instead. + @ViewBuilder + func selectableText() -> some View { + #if os(tvOS) + self + #else + textSelection(.enabled) + #endif + } +} + +// MARK: - Preview + +#Preview { + NavigationStack { + KeychainProvenanceExperimentView() + } +} +#endif diff --git a/YatteeTests/AccountCredentialsTests.swift b/YatteeTests/AccountCredentialsTests.swift new file mode 100644 index 00000000..a4bbd453 --- /dev/null +++ b/YatteeTests/AccountCredentialsTests.swift @@ -0,0 +1,337 @@ +// +// AccountCredentialsTests.swift +// YatteeTests +// +// Identity-scoped, account-keyed credential storage and legacy adoption. +// + +import Testing +import Foundation +@testable import Yattee + +@Suite("Account credential scoping") +@MainActor +struct AccountCredentialScopingTests { + /// A service name unique per test run so parallel runs and leftovers from a + /// crashed run can never collide in the real Keychain. + private func makeService() -> String { + "com.yattee.tests.\(UUID().uuidString)" + } + + private func makeCredentials( + service: String, + scope: String?, + markers: CredentialAdoptionMarkerStore + ) -> AccountKeychainCredentials { + AccountKeychainCredentials( + service: service, + credentialNoun: "credential", + identityScope: CredentialIdentityScope(fixedScope: scope), + markers: markers + ) + } + + private func makeMarkers() -> CredentialAdoptionMarkerStore { + CredentialAdoptionMarkerStore( + defaults: UserDefaults(suiteName: "yattee.tests.\(UUID().uuidString)")! + ) + } + + @Test("Credential key carries the identity scope") + func credentialKeyIsScoped() { + let accountID = UUID() + let key = KeychainCredentialStore.credentialKey(scope: "identity-A", accountID: accountID) + + #expect(key == "identity-A|\(accountID.uuidString)") + #expect( + KeychainCredentialStore.usernameKey(scope: "identity-A", accountID: accountID) + == "identity-A|\(accountID.uuidString)#username" + ) + // The legacy key must stay a bare UUID, or existing items stop resolving. + #expect(KeychainCredentialStore.legacyKey(instanceID: accountID) == accountID.uuidString) + } + + @Test("A credential stored under one Apple account is invisible to another") + func credentialsAreIsolatedAcrossIdentities() { + let service = makeService() + let markers = makeMarkers() + let account = Account(instanceID: UUID()) + + let identityA = makeCredentials(service: service, scope: "identity-A", markers: markers) + identityA.setCredential("secret-a", for: account) + #expect(identityA.credential(for: account) == "secret-a") + + // Same device, same deterministic account UUID, different Apple account. + let identityB = makeCredentials(service: service, scope: "identity-B", markers: markers) + #expect(identityB.credential(for: account) == nil) + #expect(identityB.isLoggedIn(for: account) == false) + + // A's credential is untouched when A comes back. + let identityAAgain = makeCredentials(service: service, scope: "identity-A", markers: markers) + #expect(identityAAgain.credential(for: account) == "secret-a") + + identityAAgain.deleteCredential(for: account) + } + + @Test("Usernames are scoped exactly like credentials") + func usernamesAreScoped() { + let service = makeService() + let markers = makeMarkers() + let account = Account(instanceID: UUID()) + + let identityA = makeCredentials(service: service, scope: "identity-A", markers: markers) + identityA.setUsername("someone@example.com", for: account) + #expect(identityA.username(for: account) == "someone@example.com") + + let identityB = makeCredentials(service: service, scope: "identity-B", markers: markers) + #expect(identityB.username(for: account) == nil) + + identityA.deleteCredential(for: account) + #expect(identityA.username(for: account) == nil) + } + + @Test("An unresolved scope refuses writes and reports nothing signed in") + func unresolvedScopeRefusesAccess() { + let service = makeService() + let account = Account(instanceID: UUID()) + + let credentials = makeCredentials(service: service, scope: nil, markers: makeMarkers()) + #expect(credentials.isScopeResolved == false) + + credentials.setCredential("should-not-persist", for: account) + + // Nothing was written under a guessed scope, so nothing can be read back + // once the real scope arrives. + let resolved = makeCredentials(service: service, scope: "identity-A", markers: makeMarkers()) + #expect(resolved.credential(for: account) == nil) + } + + @Test("Switching identity drops the cached login set") + func identitySwitchClearsLoginCache() { + let service = makeService() + let markers = makeMarkers() + let account = Account(instanceID: UUID()) + let scope = CredentialIdentityScope(fixedScope: "identity-A") + + let credentials = AccountKeychainCredentials( + service: service, + credentialNoun: "credential", + identityScope: scope, + markers: markers + ) + credentials.setCredential("secret-a", for: account) + #expect(credentials.loggedInAccountIDs.contains(account.id)) + + scope.adoptCurrentIdentity() // real device identity — no longer "identity-A" + #expect(credentials.isLoggedIn(for: account) == false) + #expect(!credentials.loggedInAccountIDs.contains(account.id)) + + let cleanup = makeCredentials(service: service, scope: "identity-A", markers: markers) + cleanup.deleteCredential(for: account) + } +} + +@Suite("Legacy credential adoption") +@MainActor +struct LegacyCredentialAdoptionTests { + private func makeService() -> String { + "com.yattee.tests.\(UUID().uuidString)" + } + + private func makeMarkers() -> CredentialAdoptionMarkerStore { + CredentialAdoptionMarkerStore( + defaults: UserDefaults(suiteName: "yattee.tests.\(UUID().uuidString)")! + ) + } + + /// Writes the pre-revamp item: unscoped, keyed by bare instance UUID. + private func seedLegacyItem(service: String, instanceID: UUID, value: String) { + let store = KeychainCredentialStore(service: service) + store.set(value, forKey: KeychainCredentialStore.legacyKey(instanceID: instanceID), synchronizable: false) + } + + private func legacyValue(service: String, instanceID: UUID) -> String? { + KeychainCredentialStore(service: service) + .value(forKey: KeychainCredentialStore.legacyKey(instanceID: instanceID)) + } + + private func makeCredentials( + service: String, + scope: String, + markers: CredentialAdoptionMarkerStore + ) -> AccountKeychainCredentials { + AccountKeychainCredentials( + service: service, + credentialNoun: "credential", + identityScope: CredentialIdentityScope(fixedScope: scope), + markers: markers + ) + } + + @Test("A legacy credential is never adopted automatically") + func legacyCredentialIsNotAutoAdopted() { + let service = makeService() + let instanceID = UUID() + let account = Account.migrated(from: instanceID) + seedLegacyItem(service: service, instanceID: instanceID, value: "legacy-secret") + + let credentials = makeCredentials(service: service, scope: "identity-B", markers: makeMarkers()) + + // Reading never falls back to the unscoped item — that is the whole + // point of scoping, since a retained item proves nothing about who owns it. + #expect(credentials.credential(for: account) == nil) + #expect(credentials.isLoggedIn(for: account) == false) + // But it is offered as an explicit action. + #expect(credentials.hasAdoptableLegacyCredential(for: account) == true) + + KeychainCredentialStore(service: service) + .delete(key: KeychainCredentialStore.legacyKey(instanceID: instanceID)) + } + + @Test("Adoption copies the legacy item and retains it") + func adoptionCopiesAndRetains() { + let service = makeService() + let instanceID = UUID() + let account = Account.migrated(from: instanceID) + seedLegacyItem(service: service, instanceID: instanceID, value: "legacy-secret") + + let credentials = makeCredentials(service: service, scope: "identity-A", markers: makeMarkers()) + #expect(credentials.adoptLegacyCredential(for: account) == true) + + #expect(credentials.credential(for: account) == "legacy-secret") + // Retained: deleting it would propagate through iCloud Keychain and log + // out devices still running the previous release. + #expect(legacyValue(service: service, instanceID: instanceID) == "legacy-secret") + // Adopted once, never offered again for this identity. + #expect(credentials.hasAdoptableLegacyCredential(for: account) == false) + + credentials.deleteCredential(for: account) + KeychainCredentialStore(service: service) + .delete(key: KeychainCredentialStore.legacyKey(instanceID: instanceID)) + } + + @Test("Adoption by one identity does not suppress the offer for another") + func adoptionIsPerIdentity() { + let service = makeService() + let markers = makeMarkers() + let instanceID = UUID() + let account = Account.migrated(from: instanceID) + seedLegacyItem(service: service, instanceID: instanceID, value: "legacy-secret") + + let identityA = makeCredentials(service: service, scope: "identity-A", markers: markers) + #expect(identityA.adoptLegacyCredential(for: account) == true) + + let identityB = makeCredentials(service: service, scope: "identity-B", markers: markers) + #expect(identityB.credential(for: account) == nil) + #expect(identityB.hasAdoptableLegacyCredential(for: account) == true) + + identityA.deleteCredential(for: account) + KeychainCredentialStore(service: service) + .delete(key: KeychainCredentialStore.legacyKey(instanceID: instanceID)) + } + + @Test("Deleting an account removes the legacy item only for the adopting identity") + func legacyDeletionRequiresIdentityMatch() { + let service = makeService() + let markers = makeMarkers() + let instanceID = UUID() + let account = Account.migrated(from: instanceID) + seedLegacyItem(service: service, instanceID: instanceID, value: "legacy-secret") + + let identityA = makeCredentials(service: service, scope: "identity-A", markers: markers) + #expect(identityA.adoptLegacyCredential(for: account) == true) + + // B never adopted anything, so B may not delete the shared legacy item. + let identityB = makeCredentials(service: service, scope: "identity-B", markers: markers) + identityB.deleteLegacyCredentialIfOwned(for: account) + #expect(legacyValue(service: service, instanceID: instanceID) == "legacy-secret") + + // A adopted it and it still holds the adopted secret, so A may. + identityA.deleteLegacyCredentialIfOwned(for: account) + #expect(legacyValue(service: service, instanceID: instanceID) == nil) + + identityA.deleteCredential(for: account) + } + + @Test("A legacy item replaced by another login survives the adopter's deletion") + func legacyDeletionRequiresFingerprintMatch() { + let service = makeService() + let markers = makeMarkers() + let instanceID = UUID() + let account = Account.migrated(from: instanceID) + seedLegacyItem(service: service, instanceID: instanceID, value: "legacy-secret") + + let identityA = makeCredentials(service: service, scope: "identity-A", markers: markers) + #expect(identityA.adoptLegacyCredential(for: account) == true) + + // Someone else signed in on an older release, which rewrote the + // unscoped key. That login is live on their devices. + seedLegacyItem(service: service, instanceID: instanceID, value: "someone-elses-secret") + + identityA.deleteLegacyCredentialIfOwned(for: account) + #expect(legacyValue(service: service, instanceID: instanceID) == "someone-elses-secret") + + identityA.deleteCredential(for: account) + KeychainCredentialStore(service: service) + .delete(key: KeychainCredentialStore.legacyKey(instanceID: instanceID)) + } + + @Test("Adoption is refused while the identity scope is unresolved") + func adoptionRefusedWhenUnresolved() { + let service = makeService() + let instanceID = UUID() + let account = Account.migrated(from: instanceID) + seedLegacyItem(service: service, instanceID: instanceID, value: "legacy-secret") + + let credentials = AccountKeychainCredentials( + service: service, + credentialNoun: "credential", + identityScope: CredentialIdentityScope(fixedScope: nil), + markers: makeMarkers() + ) + + #expect(credentials.hasAdoptableLegacyCredential(for: account) == false) + #expect(credentials.adoptLegacyCredential(for: account) == false) + + KeychainCredentialStore(service: service) + .delete(key: KeychainCredentialStore.legacyKey(instanceID: instanceID)) + } +} + +@Suite("Migrated account identity") +@MainActor +struct MigratedAccountTests { + @Test("A migrated account reuses the instance UUID") + func migratedAccountReusesInstanceID() { + let instanceID = UUID() + let account = Account.migrated(from: instanceID) + + // Determinism across devices: two devices migrating the same instance + // independently produce the identical record name, which merges + // per-record instead of duplicating. + #expect(account.id == instanceID) + #expect(account.instanceID == instanceID) + #expect(account.isMigrated) + #expect(Account.migrated(from: instanceID).id == account.id) + } + + @Test("A migrated account carries an epoch-old updatedAt") + func migratedAccountLosesConflicts() { + let migrated = Account.migrated(from: UUID()) + let explicit = Account(instanceID: UUID()) + + // Any explicit user edit on any device must outrank a migration write, + // regardless of arrival order. + #expect(migrated.updatedAt < explicit.updatedAt) + } + + @Test("A new account gets a fresh UUID") + func newAccountsAreDistinct() { + let instanceID = UUID() + let first = Account(instanceID: instanceID) + let second = Account(instanceID: instanceID) + + #expect(first.id != second.id) + #expect(!first.isMigrated) + } +}