Files
yattee/Yattee/Services/Credentials/AccountKeychainCredentials.swift
Arkadiusz Fal abd0f8ddbb Let only the adopting account retire its marker
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.
2026-09-01 23:52:59 +02:00

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))
}
}