mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
Found on the iPad: after signing a second login into an instance, its migrated account could no longer retire the unscoped legacy item when it was deleted, and the instance was left still claiming a pre-revamp login. Storing a credential retires the adoption marker, but it matched on service, instance and identity only. An instance holds several logins, so a sibling sign-in silently withdrew the migrated account's claim on a secret it had nothing to do with. After that the legacy item outlived the account that adopted it, and could be adopted a second time. Erring that way costs nothing — an item that is not deleted is an item nobody is logged out of — but the marker already carries the account it was written for, and deletion authority already checks it. Retiring it now matches on the same thing.
443 lines
19 KiB
Swift
443 lines
19 KiB
Swift
//
|
|
// 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<UUID>] = [:]
|
|
|
|
/// Accounts holding a credential under the current identity.
|
|
var loggedInAccountIDs: Set<UUID> {
|
|
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)
|
|
|
|
// Whatever *this account* adopted is gone, replaced by a login the user
|
|
// just proved they hold. Leaving the marker would keep it out of the
|
|
// resync that follows the iCloud toggle, so the new credential would
|
|
// stay on this device and every other one would show an account it
|
|
// never receives a login for. It would also let a deletion retire an
|
|
// unscoped item this account no longer has anything to do with.
|
|
//
|
|
// A sibling's marker is left strictly alone: signing a second login
|
|
// into the same instance says nothing about the account that adopted
|
|
// that instance's pre-revamp secret.
|
|
markers.removeMarker(
|
|
service: store.service,
|
|
instanceID: account.instanceID,
|
|
accountID: account.id,
|
|
scope: scope
|
|
)
|
|
|
|
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) {
|
|
deleteCredential(forAccountID: account.id)
|
|
HomeInstanceCache.shared.clear(instanceID: account.instanceID, contentType: .feed)
|
|
}
|
|
|
|
/// Rewrites the stored credentials so they carry the current sync setting.
|
|
///
|
|
/// `kSecAttrSynchronizable` is fixed when an item is written, so a login
|
|
/// stored while iCloud sync was off stays local forever. Enabling sync
|
|
/// afterwards uploads the account metadata, and every other device would
|
|
/// then show an account whose credential never arrives. Called when the
|
|
/// user turns sync on.
|
|
func resyncCredentialsWithCurrentSetting() {
|
|
guard let scope = identityScope.current else { return }
|
|
|
|
let syncEnabled = shouldSyncToiCloud
|
|
// An adopted legacy secret is deliberately kept on this device (see
|
|
// `adoptLegacyCredential`). Rewriting it here would undo that: the
|
|
// toggle would push a credential of unknown provenance into whichever
|
|
// iCloud Keychain is current, which is the one thing silent adoption is
|
|
// careful not to do. Nothing is lost — the legacy item it was copied
|
|
// from already syncs, so the user's other devices adopt their own copy.
|
|
let adopted = markers.adoptedAccountIDs(service: store.service, scope: scope)
|
|
|
|
for accountID in loginsByScope[scope] ?? [] where !adopted.contains(accountID) {
|
|
let credentialKey = KeychainCredentialStore.credentialKey(scope: scope, accountID: accountID)
|
|
|
|
// The marker is written *after* the copy it describes, so a launch
|
|
// that ends in between leaves an adopted credential no marker will
|
|
// ever claim — and adoption will not retry, because the key is no
|
|
// longer empty. Recognising the value itself closes that gap: a
|
|
// scoped credential identical to the unscoped item it could only
|
|
// have come from is an adopted copy, marker or not. Migrated
|
|
// accounts reuse their instance's UUID, which is what the legacy
|
|
// key is made of, and they are the only accounts allowed to adopt.
|
|
if let stored = store.value(forKey: credentialKey),
|
|
stored == store.value(forKey: KeychainCredentialStore.legacyKey(instanceID: accountID)) {
|
|
continue
|
|
}
|
|
|
|
rewrite(key: credentialKey, synchronizable: syncEnabled)
|
|
rewrite(
|
|
key: KeychainCredentialStore.usernameKey(scope: scope, accountID: accountID),
|
|
synchronizable: syncEnabled
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Rewrites one item so it carries the wanted sync flag.
|
|
///
|
|
/// Skipped when the flag already matches: the only way to change it is to
|
|
/// delete and re-add, which risks the login for no reason — and deleting a
|
|
/// synchronizable item propagates to every device. If the re-add does fail,
|
|
/// the original is put back the way it was rather than left missing.
|
|
private func rewrite(key: String, synchronizable: Bool) {
|
|
guard let value = store.value(forKey: key) else { return }
|
|
|
|
let current = store.isSynchronizable(key: key)
|
|
guard current != synchronizable else { return }
|
|
|
|
let status = store.set(value, forKey: key, synchronizable: synchronizable)
|
|
guard status != errSecSuccess else { return }
|
|
|
|
let restored = store.set(value, forKey: key, synchronizable: current ?? false)
|
|
LoggingService.shared.error(
|
|
restored == errSecSuccess
|
|
? "Could not change how a \(credentialNoun) syncs — left it as it was"
|
|
: "Could not change how a \(credentialNoun) syncs, and could not restore it",
|
|
category: .keychain,
|
|
details: "status=\(store.describe(status))"
|
|
)
|
|
}
|
|
|
|
func deleteCredential(forAccountID accountID: UUID) {
|
|
guard let scope = currentScope() else { return }
|
|
|
|
store.delete(key: KeychainCredentialStore.credentialKey(scope: scope, accountID: accountID))
|
|
store.delete(key: KeychainCredentialStore.usernameKey(scope: scope, accountID: accountID))
|
|
loginsByScope[scope]?.remove(accountID)
|
|
|
|
LoggingService.shared.info(
|
|
"Deleted \(credentialNoun)",
|
|
category: .keychain,
|
|
details: "accountID=\(accountID)"
|
|
)
|
|
}
|
|
|
|
// 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 }
|
|
// Only the account this instance's pre-revamp login became may inherit
|
|
// its secret. The legacy item is keyed by instance, so any
|
|
// credential-less account on the instance would otherwise match — and a
|
|
// second login the user added themselves would silently come up holding
|
|
// somebody else's session.
|
|
guard account.isMigrated else { return false }
|
|
guard !markers.hasAdopted(service: store.service, 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.
|
|
///
|
|
/// The copy is written **device-local**, whatever the sync setting says.
|
|
/// The pre-revamp release read its unscoped item with
|
|
/// `kSecAttrSynchronizableAny` and no identity in the key, so a credential
|
|
/// retained across an Apple Account change is already served to whoever
|
|
/// holds the device — this migration does not widen that. What it must not
|
|
/// do is re-file that secret as synchronizable under the identity that is
|
|
/// current now, which would upload a login of unproven provenance into that
|
|
/// person's iCloud Keychain and spread it to their other devices. Keeping
|
|
/// the copy local costs the genuine owner nothing: their legacy item syncs,
|
|
/// so every device of theirs finds it and adopts its own copy.
|
|
@discardableResult
|
|
func adoptLegacyCredential(for account: Account) -> Bool {
|
|
guard let scope = currentScope() else { return false }
|
|
guard account.isMigrated else { return false }
|
|
guard !markers.hasAdopted(service: store.service, instanceID: account.instanceID, scope: scope) else { return false }
|
|
guard let legacy = legacyCredential(for: account) else { return false }
|
|
|
|
// Eligibility is re-checked here, not just when the offer was rendered:
|
|
// iCloud Keychain may have delivered the real credential in between, and
|
|
// replacing it with the older legacy secret would be a silent downgrade.
|
|
guard credential(for: account) == nil else {
|
|
LoggingService.shared.info(
|
|
"Skipping legacy \(credentialNoun) adoption — a scoped credential already arrived",
|
|
category: .keychain,
|
|
details: "accountID=\(account.id)"
|
|
)
|
|
return false
|
|
}
|
|
|
|
let key = KeychainCredentialStore.credentialKey(scope: scope, accountID: account.id)
|
|
let status = store.addIfAbsent(legacy, forKey: key, synchronizable: false)
|
|
|
|
// A duplicate means a scoped credential landed between the check above
|
|
// and this write — leave it strictly alone, it is newer than the legacy
|
|
// secret.
|
|
guard status == errSecSuccess else {
|
|
LoggingService.shared.error(
|
|
"Legacy \(credentialNoun) adoption did not write",
|
|
category: .keychain,
|
|
details: "accountID=\(account.id), status=\(store.describe(status))"
|
|
)
|
|
return false
|
|
}
|
|
|
|
// 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.
|
|
let readback = store.value(forKey: key)
|
|
|
|
guard readback == legacy else {
|
|
// A *different* readable value means iCloud Keychain delivered a
|
|
// credential for this key between the add and the read. It is newer
|
|
// than the legacy secret and belongs to a live login, so it is left
|
|
// strictly alone — deleting here would propagate through iCloud
|
|
// Keychain and revoke it on every device.
|
|
if readback != nil {
|
|
LoggingService.shared.info(
|
|
"Legacy \(credentialNoun) adoption superseded by a credential that arrived mid-write",
|
|
category: .keychain,
|
|
details: "accountID=\(account.id)"
|
|
)
|
|
return false
|
|
}
|
|
|
|
// Nothing readable is there. Tempting to roll the write back, but a
|
|
// delete cannot be conditioned on the value that was just read:
|
|
// `SecItemDelete` ignores `kSecValueData` (measured — a delete
|
|
// issued with a deliberately wrong value still removes the item),
|
|
// so the rollback would be a blind delete of whatever occupies the
|
|
// key by then, including a credential that arrived from another
|
|
// device in the gap — and it would propagate. Nothing is deleted;
|
|
// no marker is written, so the account simply stays signed out and
|
|
// an ordinary sign-in (a replacing write) recovers it.
|
|
LoggingService.shared.error(
|
|
"Legacy \(credentialNoun) adoption failed verification — sign-in required",
|
|
category: .keychain,
|
|
details: "accountID=\(account.id)"
|
|
)
|
|
return false
|
|
}
|
|
|
|
markers.record(
|
|
service: store.service,
|
|
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(
|
|
service: store.service,
|
|
instanceID: account.instanceID,
|
|
accountID: account.id,
|
|
scope: scope,
|
|
currentLegacyValue: currentValue
|
|
) else {
|
|
LoggingService.shared.info(
|
|
"Leaving the legacy \(credentialNoun) in place — not this account's to delete",
|
|
category: .keychain,
|
|
details: "instanceID=\(account.instanceID), accountID=\(account.id)"
|
|
)
|
|
return
|
|
}
|
|
|
|
// Re-read immediately before deleting. The Keychain offers no
|
|
// compare-and-delete — `SecItemDelete` ignores `kSecValueData`
|
|
// (measured) — so the fingerprint check above can only ever validate a
|
|
// snapshot. Re-reading here shrinks the window to a single call gap and
|
|
// catches a replacement that landed while the fingerprint was being
|
|
// computed.
|
|
//
|
|
// A residual race remains by construction: if another device running
|
|
// the previous release writes a new legacy value inside that gap, this
|
|
// deletes their fresh login and the deletion propagates. Accepted —
|
|
// closing it is not possible with these APIs, and the alternative
|
|
// (never deleting) leaves a credential the user explicitly removed
|
|
// sitting in their iCloud Keychain indefinitely.
|
|
guard store.value(forKey: legacyKey) == currentValue else {
|
|
LoggingService.shared.info(
|
|
"Legacy \(credentialNoun) changed while being retired — leaving it in place",
|
|
category: .keychain,
|
|
details: "instanceID=\(account.instanceID)"
|
|
)
|
|
return
|
|
}
|
|
|
|
store.delete(key: legacyKey)
|
|
markers.removeMarker(
|
|
service: store.service,
|
|
instanceID: account.instanceID,
|
|
accountID: account.id,
|
|
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))
|
|
}
|
|
}
|