// // 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. Ordinary credential reads never /// fall back to it — an implicit read-through would leave no record of what /// was taken, and nothing to bound a later deletion with. static func legacyKey(instanceID: UUID) -> String { instanceID.uuidString } // MARK: - Access /// Reads a value, matching both synchronizable forms — the one that syncs /// first. /// /// Matching both is safe for scoped keys: the scope, not the sync flag, is /// what provides identity isolation. The *order* matters though, because /// `kSecAttrSynchronizable` is part of a generic password's primary key, so /// one key can hold two items at once. Legacy adoption writes a /// device-local copy (`adoptLegacyCredential`), and a credential the user /// signed in for on another device arrives beside it as a separate, /// synchronizable item. A single `SynchronizableAny` match may answer with /// either, and answering with the local copy is how a login already fixed /// elsewhere stays invisible here — for as long as the copy exists. /// /// The synced item is the one somebody proved they hold, so it is asked for /// first, and the local copy only answers when nothing arrived. func value(forKey key: String) -> String? { for synchronizable in [true, false] { var query = itemQuery(key: key, synchronizable: synchronizable) query[kSecReturnData as String] = true query[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 { continue } return value } return nil } /// The attributes identifying one item: class, service, key and the /// synchronizable form. private func itemQuery(key: String, synchronizable: Any) -> [String: Any] { [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: key, kSecAttrSynchronizable as String: synchronizable ] } /// Writes a value **only if the key is currently empty**, returning /// `errSecDuplicateItem` otherwise. /// /// Used by legacy adoption: iCloud Keychain may deliver the real scoped /// credential while the migration is deciding to copy the older one, and a /// replacing write would silently overwrite the newer secret. @discardableResult func addIfAbsent(_ value: String, forKey key: String, synchronizable: Bool) -> OSStatus { var query = itemQuery(key: key, synchronizable: synchronizable) query[kSecValueData as String] = Data(value.utf8) return SecItemAdd(query as CFDictionary, nil) } /// Whether the stored item is marked as syncing through iCloud Keychain. /// /// `nil` when there is no item under this key. The attribute records what /// was requested when the item was written — that is exactly the question /// here, and deliberately not "does this item belong to the current Apple /// account", which it cannot answer (measured). func isSynchronizable(key: String) -> Bool? { for synchronizable in [true, false] { var query = itemQuery(key: key, synchronizable: synchronizable) query[kSecMatchLimit as String] = kSecMatchLimitOne if SecItemCopyMatching(query as CFDictionary, nil) == errSecSuccess { return synchronizable } } return nil } /// Writes a value, replacing any existing item in either synchronizable form. /// /// Deliberately delete-then-add, and **not** `SecItemUpdate` on the item /// already there. Measured on two devices sharing one Apple account: a /// value written this way reached the other device in 15 s, while a value /// changed in place with `SecItemUpdate` had still not arrived 40 minutes /// later. Replacing the item is what iCloud Keychain propagates, and a /// sign-in the user performs on one device is worth nothing to their other /// ones unless it gets there. @discardableResult func set(_ value: String, forKey key: String, synchronizable: Bool) -> OSStatus { delete(key: key) var query = itemQuery(key: key, synchronizable: synchronizable) query[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 { SecItemDelete( itemQuery(key: key, synchronizable: kSecAttrSynchronizableAny) as CFDictionary ) } func describe(_ status: OSStatus) -> String { if status == errSecSuccess { return "success" } return (SecCopyErrorMessageString(status, nil) as String?) ?? "status \(status)" } }