mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
166 lines
6.8 KiB
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)"
|
|
}
|
|
}
|