From 9fd0a710351bc5c974a0e1ec843da88d1f27600a Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Sun, 23 Aug 2026 21:20:30 +0200 Subject: [PATCH] Add identity-scoped, account-keyed credential storage Yattee 2 keys credentials by instance UUID with no notion of which Apple account they belong to, and reads them back with SynchronizableAny. Apple documents that Keychain data may stay on device when a user signs out and chooses to keep it, so whoever signs in next would be served the previous user's credential - guaranteed for migrated accounts, since local instances survive the switch and the migrated account UUID is deterministically the instance UUID. Credentials are now keyed "|", where the scope is the CloudKit user record (identical across a user's devices, so iCloud Keychain sync still works). Usernames get the same scope and live beside the credential, keeping login identifiers out of synced metadata. The legacy unscoped item is never adopted automatically. A strict kSecAttrSynchronizable == true query was the candidate discriminator for "this secret belongs to the current Apple account", but measured on iPadOS 27.0 a retained item keeps the attribute and still matches that query with no Apple account signed in at all - the flag records what was once requested, not live iCloud Keychain membership. Reads therefore never fall back to the legacy key; adoption is an explicit user action that copies the item, verifies the scoped read-back, then records a marker bound to the identity and a salted fingerprint of the secret. The legacy item is retained, because deleting a synchronizable item propagates and would log out devices still on the previous release mid-rollout. Deleting it later needs all three of: a marker on this device, matching identity, and the item still holding the adopted secret. The instance-keyed path is untouched, so no shipping call site changes behavior. BasicAuthCredentialsManager stays instance-keyed - proxy auth belongs to the instance, not to a user account. While the scope is unresolved (first launch under a new Apple account, CloudKit unreachable) reads report "can't tell yet" via isScopeResolved rather than "signed out", and writes are refused rather than guessing a scope. Login state is cached per scope so one identity's cache can never answer for another. Also adds the debug-only harness the measurement was taken with, under Advanced > Developer > Experiments. --- Yattee/Core/AppEnvironment.swift | 19 +- Yattee/Models/Account.swift | 74 ++++ .../Services/CloudKit/CloudKitIdentity.swift | 75 ++++ .../AccountCredentialsManager.swift | 63 ++++ .../AccountKeychainCredentials.swift | 257 +++++++++++++ .../CredentialAdoptionMarker.swift | 147 ++++++++ .../Credentials/CredentialIdentityScope.swift | 169 +++++++++ .../InvidiousCredentialsManager.swift | 76 +++- .../Credentials/KeychainCredentialStore.swift | 112 ++++++ .../Credentials/KeychainProvenanceProbe.swift | 216 +++++++++++ .../KeychainProvenanceReport.swift | 150 ++++++++ .../Credentials/PipedCredentialsManager.swift | 77 +++- .../Settings/DeveloperSettingsView.swift | 26 ++ .../KeychainProvenanceExperimentView.swift | 287 +++++++++++++++ YatteeTests/AccountCredentialsTests.swift | 337 ++++++++++++++++++ 15 files changed, 2070 insertions(+), 15 deletions(-) create mode 100644 Yattee/Models/Account.swift create mode 100644 Yattee/Services/CloudKit/CloudKitIdentity.swift create mode 100644 Yattee/Services/Credentials/AccountCredentialsManager.swift create mode 100644 Yattee/Services/Credentials/AccountKeychainCredentials.swift create mode 100644 Yattee/Services/Credentials/CredentialAdoptionMarker.swift create mode 100644 Yattee/Services/Credentials/CredentialIdentityScope.swift create mode 100644 Yattee/Services/Credentials/KeychainCredentialStore.swift create mode 100644 Yattee/Services/Credentials/KeychainProvenanceProbe.swift create mode 100644 Yattee/Services/Credentials/KeychainProvenanceReport.swift create mode 100644 Yattee/Views/Settings/KeychainProvenanceExperimentView.swift create mode 100644 YatteeTests/AccountCredentialsTests.swift 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) + } +}