Files
yattee/Yattee/Services/Credentials/KeychainCredentialStore.swift

166 lines
6.8 KiB
Swift

//
// 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: `"<scope>|<accountID>"`.
static func credentialKey(scope: String, accountID: UUID) -> String {
"\(scope)|\(accountID.uuidString)"
}
/// Identity-scoped username key: `"<scope>|<accountID>#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)"
}
}