Files
yattee/Yattee/Services/Credentials/CredentialAdoptionMarker.swift
Arkadiusz Fal 313403cc1e Stop a missing ubiquity token from meaning "signed out"
Third review pass.

adoptCurrentIdentity() treated a nil ubiquityIdentityToken as sign-out and
scoped credentials to "local". But Foundation also returns nil when iCloud
Drive is unavailable or switched off, and Yattee declares only the CloudKit
service with no ubiquity container, so the token tracks iCloud Drive state
rather than the account. In that configuration two different signed-in
Apple accounts both resolved to "local" and shared one credential
namespace - since Keychain items survive an account switch, account B could
then read account A's retained credential. That is the precise failure this
whole change exists to prevent.

A nil token now means unresolved, and CloudKit's account status settles it
during resolution: noAccount scopes to local, available proceeds to the
user-record lookup, anything else stays unresolved and retries. A scope
resolved without a token is not cached, because there is no key that could
be trusted to mean this account.

Adoption markers are now namespaced by Keychain service. Both credential
managers share one marker store and an instance UUID can outlive a change
of backend, so adopting an Invidious credential could suppress the Piped
offer for the same instance and point deletion at the wrong secret.

Writing the account-status branch also surfaced a Swift trap: an
unqualified `break` inside the switch left only the switch and fell through
to the user-record lookup, resolving a scope on an undetermined account
status. The loop is labelled now, and the test that caught it is included.
2026-08-23 23:56:31 +02:00

171 lines
6.4 KiB
Swift

//
// CredentialAdoptionMarker.swift
// Yattee
//
// Records which legacy Keychain items this device has adopted, and for whom.
//
import CryptoKit
import Foundation
/// Records that a legacy (unscoped) Keychain item was copied into an
/// identity-scoped key on this device.
///
/// The marker is bound to the *secret*, not just the account: it is written
/// only after the scoped copy reads back, and it carries a fingerprint of the
/// credential value that was adopted. That binding is what lets an account
/// deletion clean up the legacy item without ever destroying someone else's
/// login — see ``CredentialAdoptionMarkerStore/canDeleteLegacyItem(...)``.
struct CredentialAdoptionMarker: Codable, Hashable, Sendable {
/// The identity scope that adopted the item. Apple account A's marker must
/// never authorize anything under account B.
let identityScope: String
/// Keychain service the adopted item lives in. The Invidious and Piped
/// managers keep separate services but share this store, and an instance
/// UUID can outlive a change of backend — without this, adopting one
/// backend's credential would suppress the other's offer and point
/// deletion at the wrong secret.
let service: String
let instanceID: UUID
let accountID: UUID
/// Salted, truncated digest of the adopted credential — non-secret, and
/// device-local (the salt never leaves this device).
let secretFingerprint: String
let adoptedAt: Date
}
/// Device-local store of adoption markers.
///
/// Deliberately never synced: it describes what happened to *this* device's
/// Keychain, which differs per device during a staggered rollout.
@MainActor
final class CredentialAdoptionMarkerStore {
private static let markersKey = "credentialAdoptionMarkers"
private static let saltKey = "credentialAdoptionFingerprintSalt"
private let defaults: UserDefaults
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
}
// MARK: - Markers
func markers() -> [CredentialAdoptionMarker] {
guard let data = defaults.data(forKey: Self.markersKey),
let markers = try? JSONDecoder().decode([CredentialAdoptionMarker].self, from: data)
else { return [] }
return markers
}
/// Whether this identity has already adopted the legacy item for an instance.
///
/// Once true, the app stops offering adoption and stops reading the legacy
/// key entirely for that account.
func hasAdopted(service: String, instanceID: UUID, scope: String) -> Bool {
marker(service: service, instanceID: instanceID, scope: scope) != nil
}
private func marker(service: String, instanceID: UUID, scope: String) -> CredentialAdoptionMarker? {
markers().first {
$0.service == service && $0.instanceID == instanceID && $0.identityScope == scope
}
}
func record(
service: String,
instanceID: UUID,
accountID: UUID,
scope: String,
secret: String
) {
var all = markers().filter {
!($0.service == service && $0.instanceID == instanceID && $0.identityScope == scope)
}
all.append(
CredentialAdoptionMarker(
identityScope: scope,
service: service,
instanceID: instanceID,
accountID: accountID,
secretFingerprint: fingerprint(of: secret),
adoptedAt: Date()
)
)
save(all)
}
func removeMarker(service: String, instanceID: UUID, scope: String) {
save(markers().filter {
!($0.service == service && $0.instanceID == instanceID && $0.identityScope == scope)
})
}
// MARK: - Legacy deletion authority
/// Whether deleting an account may also delete the **unscoped** legacy item.
///
/// Four conditions, all required:
///
/// 1. This device has a marker for the instance — no marker means this
/// device never adopted anything, so the legacy item is not ours to touch.
/// 2. The marker belongs to the current identity — Apple account A's marker
/// cannot authorize a deletion while B is signed in.
/// 3. The marker belongs to the account being deleted. An instance can hold
/// several accounts, and only the one that actually adopted the legacy
/// secret may retire it — deleting a *sibling* account must leave it
/// alone.
/// 4. The legacy item still holds the exact secret that was adopted — if it
/// has since been replaced (someone signed in again on an older release,
/// and that release rewrote the unscoped key), it is a live login of
/// theirs and must survive.
///
/// Deleting a synchronizable item propagates everywhere, so a wrong answer
/// here logs other devices out.
func canDeleteLegacyItem(
service: String,
instanceID: UUID,
accountID: UUID,
scope: String,
currentLegacyValue: String?
) -> Bool {
guard let marker = marker(service: service, instanceID: instanceID, scope: scope) else {
return false
}
guard marker.accountID == accountID else { return false }
guard let currentLegacyValue else { return false }
return fingerprint(of: currentLegacyValue) == marker.secretFingerprint
}
// MARK: - Fingerprinting
/// Salted, truncated digest of a credential.
///
/// Salted so the stored marker is not a rainbow-tableable hash of the
/// secret; truncated because it only ever needs to answer "is this still
/// the same value". The salt is generated once per device and never synced.
func fingerprint(of secret: String) -> String {
var input = Data(salt())
input.append(Data(secret.utf8))
return CloudKitIdentity.shortDigest(of: input)
}
private func salt() -> [UInt8] {
if let existing = defaults.data(forKey: Self.saltKey), existing.count == 32 {
return [UInt8](existing)
}
var bytes = [UInt8](repeating: 0, count: 32)
_ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
defaults.set(Data(bytes), forKey: Self.saltKey)
return bytes
}
private func save(_ markers: [CredentialAdoptionMarker]) {
guard let data = try? JSONEncoder().encode(markers) else { return }
defaults.set(data, forKey: Self.markersKey)
}
}