Add identity-scoped, account-keyed credential storage

Yattee 2 keys credentials by instance UUID with no notion of which Apple
account they belong to, and reads them back with SynchronizableAny. Apple
documents that Keychain data may stay on device when a user signs out and
chooses to keep it, so whoever signs in next would be served the previous
user's credential - guaranteed for migrated accounts, since local instances
survive the switch and the migrated account UUID is deterministically the
instance UUID.

Credentials are now keyed "<identityScope>|<accountID>", where the scope is
the CloudKit user record (identical across a user's devices, so iCloud
Keychain sync still works). Usernames get the same scope and live beside the
credential, keeping login identifiers out of synced metadata.

The legacy unscoped item is never adopted automatically. A strict
kSecAttrSynchronizable == true query was the candidate discriminator for
"this secret belongs to the current Apple account", but measured on iPadOS
27.0 a retained item keeps the attribute and still matches that query with
no Apple account signed in at all - the flag records what was once requested,
not live iCloud Keychain membership. Reads therefore never fall back to the
legacy key; adoption is an explicit user action that copies the item,
verifies the scoped read-back, then records a marker bound to the identity
and a salted fingerprint of the secret. The legacy item is retained, because
deleting a synchronizable item propagates and would log out devices still on
the previous release mid-rollout. Deleting it later needs all three of: a
marker on this device, matching identity, and the item still holding the
adopted secret.

The instance-keyed path is untouched, so no shipping call site changes
behavior. BasicAuthCredentialsManager stays instance-keyed - proxy auth
belongs to the instance, not to a user account.

While the scope is unresolved (first launch under a new Apple account,
CloudKit unreachable) reads report "can't tell yet" via isScopeResolved
rather than "signed out", and writes are refused rather than guessing a
scope. Login state is cached per scope so one identity's cache can never
answer for another.

Also adds the debug-only harness the measurement was taken with, under
Advanced > Developer > Experiments.
This commit is contained in:
Arkadiusz Fal
2026-08-23 21:20:30 +02:00
parent 2969b08692
commit 9fd0a71035
15 changed files with 2070 additions and 15 deletions

View File

@@ -48,6 +48,9 @@ final class AppEnvironment {
let invidiousCredentialsManager: InvidiousCredentialsManager
let pipedCredentialsManager: PipedCredentialsManager
let basicAuthCredentialsManager: BasicAuthCredentialsManager
/// Shared by both account credential managers so a single CloudKit lookup
/// resolves the scope for all of them.
let credentialIdentityScope: CredentialIdentityScope
let homeInstanceCache: HomeInstanceCache
let invidiousAPI: InvidiousAPI
let pipedAPI: PipedAPI
@@ -130,13 +133,20 @@ final class AppEnvironment {
}
self.dataManager = dm
// Resolve which Apple account owns this device's credentials before the
// managers that key items by it are built. Cached scopes restore
// synchronously here; a first-ever resolution needs the network and is
// kicked off below.
let identityScope = CredentialIdentityScope()
self.credentialIdentityScope = identityScope
// Initialize Invidious Credentials Manager (needed for SubscriptionService)
let invidiousCreds = InvidiousCredentialsManager()
let invidiousCreds = InvidiousCredentialsManager(identityScope: identityScope)
invidiousCreds.settingsManager = settings
self.invidiousCredentialsManager = invidiousCreds
// Initialize Piped Credentials Manager
let pipedCreds = PipedCredentialsManager()
let pipedCreds = PipedCredentialsManager(identityScope: identityScope)
pipedCreds.settingsManager = settings
self.pipedCredentialsManager = pipedCreds
@@ -337,6 +347,11 @@ final class AppEnvironment {
// existed, so existing setups publish them to iCloud without a re-login
backfillCredentialFlags()
// Resolve the credential identity scope if this Apple account has never
// been seen on this device. Until it lands, account credentials report
// "can't tell yet" rather than "signed out".
Task { await identityScope.resolve() }
// Log device capabilities on startup for debugging
HardwareCapabilities.shared.logCapabilities()

View File

@@ -0,0 +1,74 @@
//
// Account.swift
// Yattee
//
// A single sign-in on a backend instance. One instance may hold several.
//
import Foundation
/// A user's login on an instance.
///
/// Yattee 2 collapsed v1's multi-account model into "one instance = at most one
/// credential" (`Instance.usesAccountLogin` plus a Keychain item keyed by the
/// instance's UUID). `Account` restores multi-login: the instance describes the
/// *server*, the account describes *who is signed in to it*.
///
/// The login identifier (username/email) is deliberately **not** stored here —
/// it lives in the Keychain beside the credential, so synced metadata never
/// carries account identifiers. Only `displayName` is a synced label.
///
/// `type` is not stored either: it is derived by resolving `instanceID` through
/// `InstancesManager`, so an instance that changes type can never disagree with
/// its accounts.
struct Account: Identifiable, Codable, Hashable, Sendable {
let id: UUID
let instanceID: UUID
/// User-set label. The only name that syncs.
var displayName: String?
let dateAdded: Date
/// Bumped on every user edit — supplies the ordering for conflict
/// resolution, which never relies on CloudKit modification dates.
var updatedAt: Date
init(
id: UUID = UUID(),
instanceID: UUID,
displayName: String? = nil,
dateAdded: Date = Date(),
updatedAt: Date = Date()
) {
self.id = id
self.instanceID = instanceID
self.displayName = displayName
self.dateAdded = dateAdded
self.updatedAt = updatedAt
}
/// The account synthesized for an instance that was signed in before
/// multi-account support existed.
///
/// The account's `id` is deliberately the **instance's** UUID: at most one
/// credential ever existed per instance, so reusing the UUID makes the
/// migration deterministic across devices — two devices migrating
/// independently produce the identical record name, which merges per-record
/// instead of duplicating.
static func migrated(from instanceID: UUID, dateAdded: Date = Date()) -> Account {
Account(
id: instanceID,
instanceID: instanceID,
displayName: nil,
dateAdded: dateAdded,
// Epoch-old: any explicit user edit on any device outranks a
// migration write regardless of arrival order.
updatedAt: Date(timeIntervalSince1970: 0)
)
}
/// Whether this account was produced by the legacy migration rather than an
/// explicit sign-in.
var isMigrated: Bool { id == instanceID }
}

View File

@@ -0,0 +1,75 @@
//
// CloudKitIdentity.swift
// Yattee
//
// Resolves the Apple account identity that account-scoped local state is
// namespaced by (Keychain items, the account cache, the sync outbox).
//
import CloudKit
import CryptoKit
import Foundation
/// The identity that account-scoped local state is keyed by.
///
/// Two different values serve two different purposes:
///
/// - `ubiquityFingerprint` is derived from `FileManager.ubiquityIdentityToken`:
/// synchronous and offline-capable, so launch-time gating can decide whether
/// the persisted namespace still belongs to the signed-in account *before*
/// any CloudKit request could complete.
/// - `userRecordID()` is the CloudKit user record name — the durable scope
/// value persisted state is namespaced by. It requires the network, so it is
/// resolved once the account is reachable.
enum CloudKitIdentity {
/// Scope used when no Apple account is available (signed out, or a
/// sideloaded build with no iCloud entitlement).
static let localScope = "local"
/// Short, stable fingerprint of the currently signed-in Apple account.
///
/// `nil` when no Apple account is signed in. The value is opaque and
/// carries no account information — it only supports equality comparison
/// against a previously persisted fingerprint.
static var ubiquityFingerprint: String? {
guard let token = FileManager.default.ubiquityIdentityToken,
let data = try? NSKeyedArchiver.archivedData(
withRootObject: token,
requiringSecureCoding: true
)
else { return nil }
return shortDigest(of: data)
}
/// The CloudKit user record name for the current identity.
///
/// Returns `nil` when CloudKit is unavailable (sideloaded build), when no
/// Apple account is signed in, or when the container cannot be reached —
/// callers must fall back to ``localScope`` rather than treating `nil` as
/// an error state.
static func userRecordID() async -> String? {
guard CloudKitAvailability.isAvailable else { return nil }
do {
let container = CKContainer(identifier: AppIdentifiers.iCloudContainer)
return try await container.userRecordID().recordName
} catch {
LoggingService.shared.debug(
"Could not resolve CloudKit user record ID",
category: .cloudKit,
details: "\(error)"
)
return nil
}
}
/// First 8 bytes of the SHA-256 digest, hex encoded — short enough to read
/// in a log line, wide enough that collisions are not a practical concern.
static func shortDigest(of data: Data) -> String {
SHA256.hash(data: data)
.prefix(8)
.map { String(format: "%02x", $0) }
.joined()
}
}

View File

@@ -0,0 +1,63 @@
//
// AccountCredentialsManager.swift
// Yattee
//
// Account-keyed credential storage — the multi-login replacement for the
// instance-keyed InstanceCredentialsManager.
//
import Foundation
/// Credential storage keyed by `Account`, not by `Instance`.
///
/// Adopted by `InvidiousCredentialsManager` and `PipedCredentialsManager`.
/// `BasicAuthCredentialsManager` deliberately does **not** adopt it and keeps
/// `InstanceCredentialsManager`: HTTP Basic auth is a property of the instance
/// (or the reverse proxy in front of it), not of a user account, and a single
/// proxy credential covers every account on that instance.
@MainActor
protocol AccountCredentialsManager: AnyObject, Observable {
/// Accounts with a stored credential under the **current** identity scope.
var loggedInAccountIDs: Set<UUID> { get }
/// Whether the identity scope has been resolved yet.
///
/// While `false`, every read below reports "no credential" because the app
/// cannot yet tell which scope to look in. Callers must render that as
/// *temporarily unavailable*, never as "signed out" — correcting state on
/// it would discard a perfectly good login.
var isScopeResolved: Bool { get }
func setCredential(_ credential: String, for account: Account)
func credential(for account: Account) -> String?
func deleteCredential(for account: Account)
/// The login identifier the user typed. Kept in the Keychain beside the
/// credential so it never enters synced metadata.
func setUsername(_ username: String, for account: Account)
func username(for account: Account) -> String?
func isLoggedIn(for account: Account) -> Bool
func refreshLoginStatus(for account: Account)
// MARK: - Legacy adoption
/// Whether a pre-revamp credential for this account's instance exists on
/// this device and has not been adopted by the current identity.
///
/// Never acted on automatically: a retained Keychain item carries no proof
/// of which Apple account it belonged to (measured — see
/// ``CredentialIdentityScope``), so adoption is always a deliberate user
/// action.
func hasAdoptableLegacyCredential(for account: Account) -> Bool
/// Copies the legacy item into this account's scoped key after the user
/// asks for it. Returns whether the copy verified.
@discardableResult
func adoptLegacyCredential(for account: Account) -> Bool
/// Deletes the unscoped legacy item **only** if this identity adopted it
/// and it still holds the adopted secret. Called when an account is really
/// deleted, never on mere absence.
func deleteLegacyCredentialIfOwned(for account: Account)
}

View File

@@ -0,0 +1,257 @@
//
// 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)
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) {
guard let scope = currentScope() else { return }
store.delete(key: KeychainCredentialStore.credentialKey(scope: scope, accountID: account.id))
store.delete(key: KeychainCredentialStore.usernameKey(scope: scope, accountID: account.id))
loginsByScope[scope]?.remove(account.id)
LoggingService.shared.info(
"Deleted \(credentialNoun)",
category: .keychain,
details: "accountID=\(account.id)"
)
HomeInstanceCache.shared.clear(instanceID: account.instanceID, contentType: .feed)
}
// 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 }
guard !markers.hasAdopted(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.
@discardableResult
func adoptLegacyCredential(for account: Account) -> Bool {
guard let scope = currentScope() else { return false }
guard let legacy = legacyCredential(for: account) else { return false }
let key = KeychainCredentialStore.credentialKey(scope: scope, accountID: account.id)
let status = store.set(legacy, forKey: key, synchronizable: shouldSyncToiCloud)
// 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.
guard status == errSecSuccess, store.value(forKey: key) == legacy else {
LoggingService.shared.error(
"Legacy \(credentialNoun) adoption failed verification",
category: .keychain,
details: "accountID=\(account.id), status=\(store.describe(status))"
)
store.delete(key: key)
return false
}
markers.record(
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(
instanceID: account.instanceID,
scope: scope,
currentLegacyValue: currentValue
) else {
LoggingService.shared.info(
"Leaving the legacy \(credentialNoun) in place — not this identity's to delete",
category: .keychain,
details: "instanceID=\(account.instanceID)"
)
return
}
store.delete(key: legacyKey)
markers.removeMarker(instanceID: account.instanceID, 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))
}
}

View File

@@ -0,0 +1,147 @@
//
// 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
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(instanceID: UUID, scope: String) -> Bool {
markers().contains { $0.instanceID == instanceID && $0.identityScope == scope }
}
func record(
instanceID: UUID,
accountID: UUID,
scope: String,
secret: String
) {
var all = markers().filter {
!($0.instanceID == instanceID && $0.identityScope == scope)
}
all.append(
CredentialAdoptionMarker(
identityScope: scope,
instanceID: instanceID,
accountID: accountID,
secretFingerprint: fingerprint(of: secret),
adoptedAt: Date()
)
)
save(all)
}
func removeMarker(instanceID: UUID, scope: String) {
save(markers().filter { !($0.instanceID == instanceID && $0.identityScope == scope) })
}
// MARK: - Legacy deletion authority
/// Whether an account deletion may also delete the **unscoped** legacy item.
///
/// Three 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 legacy item still holds the exact secret that was adopted — if it
/// has since been replaced (B signed in again on an older release, and
/// that release rewrote the unscoped key), it is someone else's live
/// login and must survive.
///
/// Deleting a synchronizable item propagates everywhere, so a wrong answer
/// here logs other devices out.
func canDeleteLegacyItem(
instanceID: UUID,
scope: String,
currentLegacyValue: String?
) -> Bool {
guard let marker = markers().first(where: {
$0.instanceID == instanceID && $0.identityScope == scope
}) 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)
}
}

View File

@@ -0,0 +1,169 @@
//
// CredentialIdentityScope.swift
// Yattee
//
// Resolves the Apple-account scope that Keychain credentials are keyed by.
//
import Foundation
/// Resolves the identity scope that account credentials are namespaced under.
///
/// ### Why credentials are scoped at all
///
/// Apple documents that Keychain data may remain on device when a user signs
/// out of their Apple account and chooses to keep it, and Yattee's credential
/// managers query `kSecAttrSynchronizableAny`. An unscoped key would therefore
/// hand a credential retained from Apple account A to whoever signs in next —
/// and for migrated accounts that collision is *guaranteed*, because local
/// instances survive an Apple-account switch and the migrated account UUID is
/// deterministically the instance UUID.
///
/// Measured on iPadOS 27.0: a synchronizable item retained across a sign-out
/// keeps `kSecAttrSynchronizable = true` and still matches a strict
/// `== true` query with **no** Apple account signed in — so the attribute
/// cannot be used to tell "belongs to the current account" from "left over
/// from the previous one". Scoping the key is the only reliable defence, and
/// legacy items are never adopted automatically (see
/// ``AccountKeychainCredentials/adoptLegacyCredential(for:)``).
///
/// ### Why the CloudKit user record, not the ubiquity token
///
/// The scope has to be identical on every device signed in to the same Apple
/// account, or iCloud Keychain sync would stop working — each device would
/// write under a different key. `FileManager.ubiquityIdentityToken` carries no
/// such cross-device guarantee, so the CloudKit user record ID is the scope
/// value; the ubiquity token is used only as a synchronous, offline-capable
/// signal that the account *changed*.
@MainActor
@Observable
final class CredentialIdentityScope {
/// Scope used when no Apple account is available — signed out, or a
/// sideloaded build with no iCloud entitlement.
static let localScope = CloudKitIdentity.localScope
enum State: Equatable {
/// Signed in, but the CloudKit user record hasn't been fetched yet.
/// Credential access is refused in this state: writing under a guessed
/// scope is exactly the mistake scoping exists to prevent.
case unresolved
case resolved(String)
}
private(set) var state: State = .unresolved
/// Fingerprint of the Apple account the current state belongs to.
/// A change here means the scope must be re-resolved.
private(set) var fingerprint: String?
private let defaults: UserDefaults
private let resolveUserRecordID: @Sendable () async -> String?
private static let cacheKey = "credentialIdentityScopeCache"
/// The resolved scope, or `nil` while unresolved.
var current: String? {
if case let .resolved(scope) = state { return scope }
return nil
}
var isResolved: Bool { current != nil }
init(
defaults: UserDefaults = .standard,
resolveUserRecordID: @escaping @Sendable () async -> String? = { await CloudKitIdentity.userRecordID() }
) {
self.defaults = defaults
self.resolveUserRecordID = resolveUserRecordID
adoptCurrentIdentity()
}
/// Test seam: a scope pinned up front, never consulting CloudKit.
/// `nil` pins the unresolved state.
init(fixedScope: String?, defaults: UserDefaults = .standard) {
self.defaults = defaults
resolveUserRecordID = { nil }
fingerprint = nil
state = fixedScope.map { State.resolved($0) } ?? .unresolved
}
// MARK: - Resolution
/// Re-reads the Apple account and restores the cached scope if there is
/// one. Synchronous and offline-capable, so it is safe at launch.
///
/// Call again on `.accountChange`: a switch that happened while the app was
/// closed shows up here, before any CloudKit request could report it.
func adoptCurrentIdentity() {
let current = CloudKitIdentity.ubiquityFingerprint
fingerprint = current
guard CloudKitAvailability.isAvailable else {
// No iCloud entitlement: nothing can sync, and there is no ambient
// identity for a retained credential to leak to.
state = .resolved(Self.localScope)
return
}
guard let current else {
// Signed out. Local scope is correct *and* isolating: items written
// here never collide with a signed-in account's keys.
state = .resolved(Self.localScope)
return
}
if let cached = cachedScope(for: current) {
state = .resolved(cached)
} else {
state = .unresolved
}
}
/// Fetches the CloudKit user record and caches it against the current
/// Apple account, so every later launch resolves synchronously.
///
/// Only the first resolution for a given Apple account needs the network.
/// While it is outstanding, credential access reports "can't tell yet"
/// rather than "not signed in" — callers must not treat that as a missing
/// credential.
func resolve() async {
guard !isResolved else { return }
// Captured before the await so a switch mid-flight can be detected:
// a record ID fetched under account A must never be cached for B.
let requested = fingerprint
guard let recordID = await resolveUserRecordID() else { return }
guard CloudKitIdentity.ubiquityFingerprint == requested, fingerprint == requested else {
LoggingService.shared.info(
"Discarding credential scope resolved for a previous Apple account",
category: .keychain
)
return
}
if let requested {
cache(recordID, for: requested)
}
state = .resolved(recordID)
LoggingService.shared.info(
"Resolved credential identity scope",
category: .keychain,
details: "fingerprint=\(requested ?? "none")"
)
}
// MARK: - Cache
private func cachedScope(for fingerprint: String) -> String? {
(defaults.dictionary(forKey: Self.cacheKey) as? [String: String])?[fingerprint]
}
private func cache(_ scope: String, for fingerprint: String) {
var cache = (defaults.dictionary(forKey: Self.cacheKey) as? [String: String]) ?? [:]
cache[fingerprint] = scope
defaults.set(cache, forKey: Self.cacheKey)
}
}

View File

@@ -12,7 +12,7 @@ import Security
@MainActor
@Observable
final class InvidiousCredentialsManager: InstanceCredentialsManager {
private let keychainServiceName = "com.yattee.invidious"
private static let keychainServiceName = "com.yattee.invidious"
private let thumbnailCacheKey = "com.yattee.invidious.channelThumbnails"
/// Tracks which instances have stored sessions (for reactive UI updates)
@@ -21,15 +21,29 @@ final class InvidiousCredentialsManager: InstanceCredentialsManager {
/// In-memory cache for channel thumbnails (channelID -> URL string)
private var thumbnailCache: [String: String] = [:]
/// Account-keyed, identity-scoped storage — the multi-login path.
///
/// The instance-keyed members below stay for call sites that still think in
/// instances; they read and write the unscoped legacy item unchanged, so an
/// upgrade never silently moves a credential.
let accountCredentials: AccountKeychainCredentials
/// Reference to settings manager for cleanup on logout and iCloud sync decisions
weak var settingsManager: SettingsManager?
weak var settingsManager: SettingsManager? {
didSet { accountCredentials.settingsManager = settingsManager }
}
/// Whether credentials should sync to iCloud Keychain (when iCloud sync is enabled for instances).
private var shouldSyncToiCloud: Bool {
settingsManager?.iCloudSyncEnabled == true && settingsManager?.syncInstances == true
}
init() {
init(identityScope: CredentialIdentityScope? = nil) {
accountCredentials = AccountKeychainCredentials(
service: Self.keychainServiceName,
credentialNoun: "SID",
identityScope: identityScope ?? CredentialIdentityScope()
)
loadThumbnailCache()
}
@@ -98,7 +112,7 @@ final class InvidiousCredentialsManager: InstanceCredentialsManager {
// First, delete any existing item (both synced and non-synced) to avoid duplicates
let deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainServiceName,
kSecAttrService as String: Self.keychainServiceName,
kSecAttrAccount as String: account,
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny
]
@@ -107,7 +121,7 @@ final class InvidiousCredentialsManager: InstanceCredentialsManager {
// Create new item with current sync preference
let addQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainServiceName,
kSecAttrService as String: Self.keychainServiceName,
kSecAttrAccount as String: account,
kSecAttrSynchronizable as String: syncEnabled,
kSecValueData as String: data
@@ -141,7 +155,7 @@ final class InvidiousCredentialsManager: InstanceCredentialsManager {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainServiceName,
kSecAttrService as String: Self.keychainServiceName,
kSecAttrAccount as String: account,
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny,
kSecReturnData as String: true,
@@ -178,7 +192,7 @@ final class InvidiousCredentialsManager: InstanceCredentialsManager {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainServiceName,
kSecAttrService as String: Self.keychainServiceName,
kSecAttrAccount as String: account,
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny
]
@@ -252,3 +266,51 @@ final class InvidiousCredentialsManager: InstanceCredentialsManager {
deleteSID(for: instance)
}
}
// MARK: - AccountCredentialsManager
extension InvidiousCredentialsManager: AccountCredentialsManager {
var loggedInAccountIDs: Set<UUID> { accountCredentials.loggedInAccountIDs }
var isScopeResolved: Bool { accountCredentials.isScopeResolved }
func setCredential(_ credential: String, for account: Account) {
accountCredentials.setCredential(credential, for: account)
}
func credential(for account: Account) -> String? {
accountCredentials.credential(for: account)
}
func deleteCredential(for account: Account) {
accountCredentials.deleteCredential(for: account)
}
func setUsername(_ username: String, for account: Account) {
accountCredentials.setUsername(username, for: account)
}
func username(for account: Account) -> String? {
accountCredentials.username(for: account)
}
func isLoggedIn(for account: Account) -> Bool {
accountCredentials.isLoggedIn(for: account)
}
func refreshLoginStatus(for account: Account) {
accountCredentials.refreshLoginStatus(for: account)
}
func hasAdoptableLegacyCredential(for account: Account) -> Bool {
accountCredentials.hasAdoptableLegacyCredential(for: account)
}
@discardableResult
func adoptLegacyCredential(for account: Account) -> Bool {
accountCredentials.adoptLegacyCredential(for: account)
}
func deleteLegacyCredentialIfOwned(for account: Account) {
accountCredentials.deleteLegacyCredentialIfOwned(for: account)
}
}

View File

@@ -0,0 +1,112 @@
//
// 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, and only when the user explicitly
/// asks for it.
static func legacyKey(instanceID: UUID) -> String {
instanceID.uuidString
}
// MARK: - Access
/// Reads a value, matching both synchronizable forms.
///
/// `SynchronizableAny` is safe for scoped keys — the scope, not the sync
/// flag, is what provides identity isolation.
func value(forKey key: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny,
kSecReturnData as String: true,
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 { return nil }
return value
}
/// Writes a value, replacing any existing item in either synchronizable form.
@discardableResult
func set(_ value: String, forKey key: String, synchronizable: Bool) -> OSStatus {
delete(key: key)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecAttrSynchronizable as String: synchronizable,
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 {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny
]
return SecItemDelete(query as CFDictionary)
}
func describe(_ status: OSStatus) -> String {
if status == errSecSuccess { return "success" }
return (SecCopyErrorMessageString(status, nil) as String?) ?? "status \(status)"
}
}

View File

@@ -0,0 +1,216 @@
//
// KeychainProvenanceProbe.swift
// Yattee
//
// Debug-only diagnostic that settles one open question in the accounts
// revamp: can a Keychain query prove which Apple account a stored credential
// came from?
//
#if DEBUG
import Foundation
import Security
/// Empirical probe for the credential-adoption question in the accounts
/// revamp (design decision D3).
///
/// Yattee 2 stores instance credentials under an *unscoped* key
/// (`kSecAttrAccount = instance.id.uuidString`) and reads them back with
/// `kSecAttrSynchronizableAny`. The revamp re-keys them per Apple account
/// (`"<userRecordID|local>|<accountID>"`) so that a credential retained on
/// device across an Apple-account switch can never be served to the new
/// account. That leaves one thing to decide: on upgrade, may the app adopt the
/// existing unscoped item automatically, or must every user tap an explicit
/// "use saved login from this device" action?
///
/// Adopting automatically is only safe with *provenance of the secret itself*.
/// The candidate discriminator is a **strict `kSecAttrSynchronizable == true`**
/// query: the hypothesis is that an item actively served from the *current*
/// account's iCloud Keychain matches it, while a credential retained after
/// signing out of a *previous* account survives only as a local copy and does
/// not. Nothing in Apple's documentation states this either way, so it has to
/// be measured on a real device across a real account switch.
///
/// This probe writes to its own Keychain service and therefore can never read,
/// modify, or delete a real Yattee credential.
///
/// Everything else mirrors the production managers exactly — same item class,
/// same delete-then-add write, same attribute set — so the measured behaviour
/// transfers to `InvidiousCredentialsManager` / `PipedCredentialsManager`.
enum KeychainProvenanceProbe {
/// Dedicated service name: isolates the probe from `com.yattee.invidious`
/// and `com.yattee.piped`.
static let service = "com.yattee.keychain-provenance-experiment"
/// Stands in for the legacy credential: written with
/// `kSecAttrSynchronizable = true`, exactly like a real logged-in instance
/// while iCloud sync is on.
static let synchronizableAccount = "probe-synchronizable"
/// Control item written with `kSecAttrSynchronizable = false` — never
/// eligible for iCloud Keychain, so it isolates "survived the account
/// switch" from "was synced".
static let localAccount = "probe-local"
// MARK: - Queries
/// The three ways the app can ask for an item, and the question each answers.
enum SyncMatch: String, CaseIterable, Identifiable {
/// The candidate discriminator.
case synchronizableTrue
/// Catches items whose synchronizable flag was cleared on sign-out.
case synchronizableFalse
/// What the production managers use today — matches either form.
case any
var id: String { rawValue }
var queryValue: Any {
switch self {
case .synchronizableTrue: kCFBooleanTrue as Any
case .synchronizableFalse: kCFBooleanFalse as Any
case .any: kSecAttrSynchronizableAny as Any
}
}
var label: String {
switch self {
case .synchronizableTrue: "kSecAttrSynchronizable == true (candidate discriminator)"
case .synchronizableFalse: "kSecAttrSynchronizable == false"
case .any: "kSecAttrSynchronizableAny (what Yattee queries today)"
}
}
}
/// A single matched Keychain item, flattened for reporting.
struct ItemDump: Identifiable, Sendable {
let id = UUID()
let account: String
/// The attribute as stored — not as queried. The interesting column
/// after an account switch.
let synchronizable: Bool?
let accessible: String?
let creationDate: Date?
let modificationDate: Date?
/// Records which identity wrote the item, so a retained item can be
/// attributed to the previous Apple account after a switch.
let payload: String?
}
struct QueryResult: Identifiable, Sendable {
var id: String { match.rawValue }
let match: SyncMatch
let status: OSStatus
let items: [ItemDump]
}
// MARK: - Write
/// Writes a probe item, mirroring the production delete-then-add pattern.
///
/// The delete leg spans both synchronizable forms, exactly like
/// `InvidiousCredentialsManager.setSID(_:for:)` — which means calling this
/// destroys a retained item from a previous account. Only call it when the
/// protocol step says to.
@discardableResult
static func write(account: String, synchronizable: Bool, payload: String) -> OSStatus {
let deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny
]
SecItemDelete(deleteQuery as CFDictionary)
let addQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrSynchronizable as String: synchronizable,
kSecValueData as String: Data(payload.utf8)
]
let status = SecItemAdd(addQuery as CFDictionary, nil)
LoggingService.shared.info(
"Keychain provenance probe written",
category: .keychain,
details: "account=\(account), synchronizable=\(synchronizable), status=\(describe(status))"
)
return status
}
/// Deletes every probe item in both synchronizable forms.
///
/// Deleting a synchronizable item propagates through iCloud Keychain to
/// every device signed in to the same Apple account — intended here, since
/// this is how the experiment is reset between runs.
@discardableResult
static func deleteAll() -> OSStatus {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny
]
let status = SecItemDelete(query as CFDictionary)
LoggingService.shared.info(
"Keychain provenance probes deleted",
category: .keychain,
details: "status=\(describe(status))"
)
return status
}
// MARK: - Read
/// Runs one query and returns every matching item with its stored attributes.
static func query(_ match: SyncMatch) -> QueryResult {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrSynchronizable as String: match.queryValue,
kSecReturnAttributes as String: true,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitAll
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
let attributes = result as? [[String: Any]] ?? []
return QueryResult(
match: match,
status: status,
items: attributes.map(dump).sorted { $0.account < $1.account }
)
}
/// All three queries, in reporting order.
static func queryAll() -> [QueryResult] {
SyncMatch.allCases.map(query)
}
private static func dump(_ attributes: [String: Any]) -> ItemDump {
ItemDump(
account: attributes[kSecAttrAccount as String] as? String ?? "<none>",
synchronizable: (attributes[kSecAttrSynchronizable as String] as? NSNumber)?.boolValue,
accessible: attributes[kSecAttrAccessible as String] as? String,
creationDate: attributes[kSecAttrCreationDate as String] as? Date,
modificationDate: attributes[kSecAttrModificationDate as String] as? Date,
payload: (attributes[kSecValueData as String] as? Data)
.flatMap { String(data: $0, encoding: .utf8) }
)
}
// MARK: - Status
static func describe(_ status: OSStatus) -> String {
if status == errSecSuccess { return "success (0)" }
let message = SecCopyErrorMessageString(status, nil) as String? ?? "unknown"
return "\(message) (\(status))"
}
}
#endif

View File

@@ -0,0 +1,150 @@
//
// KeychainProvenanceReport.swift
// Yattee
//
// Captures and persists the output of KeychainProvenanceProbe so results
// survive the Apple-account sign-out/sign-in steps of the experiment.
//
#if DEBUG
import Foundation
#if canImport(UIKit)
import UIKit
#endif
/// One captured run of the experiment.
///
/// Reports are persisted because the protocol spans an Apple-account sign-out
/// and sign-in: the reading taken *before* the switch is half the evidence, and
/// it must still be readable after it.
struct KeychainProvenanceReport: Codable, Identifiable, Sendable {
let id: UUID
let capturedAt: Date
let label: String
let text: String
init(id: UUID = UUID(), capturedAt: Date = Date(), label: String, text: String) {
self.id = id
self.capturedAt = capturedAt
self.label = label
self.text = text
}
}
/// UserDefaults-backed store for captured reports.
///
/// Deliberately not synced anywhere — the whole point is a per-device record.
enum KeychainProvenanceReportStore {
private static let defaultsKey = "debug.keychainProvenanceReports"
static func load() -> [KeychainProvenanceReport] {
guard let data = UserDefaults.standard.data(forKey: defaultsKey),
let reports = try? JSONDecoder().decode([KeychainProvenanceReport].self, from: data)
else { return [] }
return reports
}
static func append(_ report: KeychainProvenanceReport) -> [KeychainProvenanceReport] {
let reports = load() + [report]
save(reports)
return reports
}
static func clear() {
UserDefaults.standard.removeObject(forKey: defaultsKey)
}
private static func save(_ reports: [KeychainProvenanceReport]) {
guard let data = try? JSONEncoder().encode(reports) else { return }
UserDefaults.standard.set(data, forKey: defaultsKey)
}
}
// MARK: - Report text
enum KeychainProvenanceReportBuilder {
/// Builds the full plain-text report for the current device state.
///
/// - Parameters:
/// - label: free-form run label, e.g. "iPhone 15 — signed in as identity B".
/// - userRecordID: resolved asynchronously by the caller (network), `nil`
/// when unavailable.
static func build(label: String, userRecordID: String?) -> String {
let results = KeychainProvenanceProbe.queryAll()
var lines: [String] = []
lines.append("=== Yattee Keychain provenance experiment ===")
lines.append("Label: \(label.isEmpty ? "<unlabelled>" : label)")
lines.append("Captured: \(iso(Date()))")
lines.append("Platform: \(platformDescription)")
lines.append("")
lines.append("--- Identity ---")
lines.append("CloudKit entitlement: \(CloudKitAvailability.isAvailable ? "available" : "missing (sideload)")")
lines.append("ubiquityIdentityToken: \(CloudKitIdentity.ubiquityFingerprint.map { "present, fp=\($0)" } ?? "none — no Apple account signed in")")
lines.append("CloudKit user record: \(userRecordID ?? "unresolved")")
lines.append("")
lines.append("--- Keychain queries (service \(KeychainProvenanceProbe.service)) ---")
for result in results {
lines.append("")
lines.append("[\(result.match.label)]")
lines.append(" status: \(KeychainProvenanceProbe.describe(result.status)), \(result.items.count) item(s)")
for item in result.items {
lines.append(" • account=\(item.account)")
lines.append(" stored synchronizable: \(item.synchronizable.map(String.init(describing:)) ?? "absent")")
lines.append(" accessible: \(item.accessible ?? "absent")")
lines.append(" created: \(item.creationDate.map(iso) ?? "absent")")
lines.append(" modified: \(item.modificationDate.map(iso) ?? "absent")")
lines.append(" payload: \(item.payload ?? "<not utf8>")")
}
}
lines.append("")
lines.append("--- Discriminator check ---")
lines.append(contentsOf: discriminatorLines(results))
return lines.joined(separator: "\n")
}
/// Compares what the candidate discriminator matched against what Yattee's
/// current `SynchronizableAny` query matches — the difference is exactly
/// what auto-adoption would refuse to adopt.
private static func discriminatorLines(
_ results: [KeychainProvenanceProbe.QueryResult]
) -> [String] {
let strict = Set(results.first { $0.match == .synchronizableTrue }?.items.map(\.account) ?? [])
let any = Set(results.first { $0.match == .any }?.items.map(\.account) ?? [])
var lines: [String] = []
lines.append("matched by strict `== true`: \(format(strict))")
lines.append("matched by `Any` (today): \(format(any))")
lines.append("visible to Yattee but NOT provenance-backed: \(format(any.subtracting(strict)))")
return lines
}
private static func format(_ accounts: Set<String>) -> String {
accounts.isEmpty ? "none" : accounts.sorted().joined(separator: ", ")
}
// MARK: - Environment
private static var platformDescription: String {
let version = ProcessInfo.processInfo.operatingSystemVersionString
#if os(iOS)
return "iOS — \(UIDevice.current.model) — \(version)"
#elseif os(tvOS)
return "tvOS — \(UIDevice.current.model) — \(version)"
#elseif os(macOS)
return "macOS — \(version)"
#else
return version
#endif
}
private static func iso(_ date: Date) -> String {
date.formatted(.iso8601)
}
}
#endif

View File

@@ -12,13 +12,30 @@ import Security
@MainActor
@Observable
final class PipedCredentialsManager: InstanceCredentialsManager {
private let keychainServiceName = "com.yattee.piped"
private static let keychainServiceName = "com.yattee.piped"
/// Tracks which instances have stored tokens (for reactive UI updates)
private(set) var loggedInInstanceIDs: Set<UUID> = []
/// Account-keyed, identity-scoped storage — the multi-login path.
///
/// The instance-keyed members below stay for call sites that still think in
/// instances; they read and write the unscoped legacy item unchanged, so an
/// upgrade never silently moves a credential.
let accountCredentials: AccountKeychainCredentials
/// Reference to settings manager for iCloud sync decisions
weak var settingsManager: SettingsManager?
weak var settingsManager: SettingsManager? {
didSet { accountCredentials.settingsManager = settingsManager }
}
init(identityScope: CredentialIdentityScope? = nil) {
accountCredentials = AccountKeychainCredentials(
service: Self.keychainServiceName,
credentialNoun: "token",
identityScope: identityScope ?? CredentialIdentityScope()
)
}
/// Whether credentials should sync to iCloud Keychain (when iCloud sync is enabled for instances).
private var shouldSyncToiCloud: Bool {
@@ -44,7 +61,7 @@ final class PipedCredentialsManager: InstanceCredentialsManager {
// First, delete any existing item (both synced and non-synced) to avoid duplicates
let deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainServiceName,
kSecAttrService as String: Self.keychainServiceName,
kSecAttrAccount as String: account,
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny
]
@@ -53,7 +70,7 @@ final class PipedCredentialsManager: InstanceCredentialsManager {
// Create new item with current sync preference
let addQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainServiceName,
kSecAttrService as String: Self.keychainServiceName,
kSecAttrAccount as String: account,
kSecAttrSynchronizable as String: syncEnabled,
kSecValueData as String: data
@@ -87,7 +104,7 @@ final class PipedCredentialsManager: InstanceCredentialsManager {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainServiceName,
kSecAttrService as String: Self.keychainServiceName,
kSecAttrAccount as String: account,
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny,
kSecReturnData as String: true,
@@ -124,7 +141,7 @@ final class PipedCredentialsManager: InstanceCredentialsManager {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainServiceName,
kSecAttrService as String: Self.keychainServiceName,
kSecAttrAccount as String: account,
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny
]
@@ -178,3 +195,51 @@ final class PipedCredentialsManager: InstanceCredentialsManager {
}
}
}
// MARK: - AccountCredentialsManager
extension PipedCredentialsManager: AccountCredentialsManager {
var loggedInAccountIDs: Set<UUID> { accountCredentials.loggedInAccountIDs }
var isScopeResolved: Bool { accountCredentials.isScopeResolved }
func setCredential(_ credential: String, for account: Account) {
accountCredentials.setCredential(credential, for: account)
}
func credential(for account: Account) -> String? {
accountCredentials.credential(for: account)
}
func deleteCredential(for account: Account) {
accountCredentials.deleteCredential(for: account)
}
func setUsername(_ username: String, for account: Account) {
accountCredentials.setUsername(username, for: account)
}
func username(for account: Account) -> String? {
accountCredentials.username(for: account)
}
func isLoggedIn(for account: Account) -> Bool {
accountCredentials.isLoggedIn(for: account)
}
func refreshLoginStatus(for account: Account) {
accountCredentials.refreshLoginStatus(for: account)
}
func hasAdoptableLegacyCredential(for account: Account) -> Bool {
accountCredentials.hasAdoptableLegacyCredential(for: account)
}
@discardableResult
func adoptLegacyCredential(for account: Account) -> Bool {
accountCredentials.adoptLegacyCredential(for: account)
}
func deleteLegacyCredentialIfOwned(for account: Account) {
accountCredentials.deleteLegacyCredentialIfOwned(for: account)
}
}

View File

@@ -28,6 +28,9 @@ struct DeveloperSettingsView: View {
debugSection
testingSection
dataSection
#if DEBUG
experimentsSection
#endif
}
.navigationTitle(String(localized: "settings.developer.title"))
#if os(iOS)
@@ -203,6 +206,29 @@ struct DeveloperSettingsView: View {
}
}
#if DEBUG
@ViewBuilder
private var experimentsSection: some View {
SettingsFormSection("Experiments") {
#if os(tvOS)
NavigationLink {
KeychainProvenanceExperimentView()
} label: {
Label {
Text(verbatim: "Keychain Provenance")
} icon: {
Image(systemName: "key.viewfinder")
}
}
#else
SettingsNavigationRow("Keychain Provenance", systemImage: "key.viewfinder") {
KeychainProvenanceExperimentView()
}
#endif
}
}
#endif
// MARK: - Data Actions
private func deduplicateData() {

View File

@@ -0,0 +1,287 @@
//
// KeychainProvenanceExperimentView.swift
// Yattee
//
// Debug-only driver for the Keychain provenance experiment (accounts revamp, D3).
//
#if DEBUG
import SwiftUI
#if canImport(UIKit)
import UIKit
#endif
/// Runs the experiment described in ``KeychainProvenanceProbe``.
///
/// The protocol spans an Apple-account sign-out and sign-in, so the screen is
/// built around capturing readings rather than showing live state: each
/// "Capture report" writes a persisted snapshot that survives the switch, and
/// the whole set is copied out at the end.
struct KeychainProvenanceExperimentView: View {
@State private var label = ""
@State private var userRecordID: String?
@State private var resolvingUserRecordID = true
@State private var reports: [KeychainProvenanceReport] = KeychainProvenanceReportStore.load()
@State private var latestStatus: String?
@State private var showingDeleteConfirmation = false
var body: some View {
SettingsFormContainer {
protocolSection
identitySection
runSection
reportsSection
}
.navigationTitle(Text(verbatim: "Keychain Provenance"))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.task {
userRecordID = await CloudKitIdentity.userRecordID()
resolvingUserRecordID = false
}
.confirmationDialog(
Text(verbatim: "Delete probe items?"),
isPresented: $showingDeleteConfirmation,
titleVisibility: .visible
) {
Button(role: .destructive) {
latestStatus = "Delete: " + KeychainProvenanceProbe.describe(KeychainProvenanceProbe.deleteAll())
} label: {
Text(verbatim: "Delete probe items")
}
Button(String(localized: "common.cancel"), role: .cancel) {}
} message: {
Text(verbatim: "Deletes both probe items on this device. The synchronizable one also disappears from every device signed in to the same Apple account.")
}
}
// MARK: - Sections
@ViewBuilder
private var protocolSection: some View {
SettingsFormSection(
"Protocol",
footer: "Two Apple accounts and a real device are required — iCloud Keychain does not behave faithfully in the Simulator. Do not press Write at steps 3 and 4: writing deletes the retained item the experiment is measuring."
) {
step(1, "Signed in as account A: Write both probes.")
step(2, "Wait ~1 minute for iCloud Keychain to sync, then Capture (label: \"A, before sign-out\").")
step(3, "Sign out of the Apple account in system Settings, choosing to KEEP Keychain data on device. Relaunch Yattee and Capture (label: \"signed out, kept\").")
step(4, "Sign in with account B. Relaunch Yattee and Capture (label: \"B, after switch\"). This is the decisive reading.")
step(5, "Write both probes again under B, then Capture (label: \"B, fresh write\").")
step(6, "Copy all reports and send them back.")
}
}
@ViewBuilder
private var identitySection: some View {
SettingsFormSection(
"Identity",
footer: "The fingerprint is an opaque hash of ubiquityIdentityToken. It must differ between accounts A and B — if it does not, the sign-out did not take effect."
) {
row("CloudKit entitlement", CloudKitAvailability.isAvailable ? "available" : "missing")
row("Apple account", CloudKitIdentity.ubiquityFingerprint.map { "fp=\($0)" } ?? "signed out")
row("User record", resolvingUserRecordID ? "resolving…" : (userRecordID ?? "unresolved"))
}
}
@ViewBuilder
private var runSection: some View {
SettingsFormSection(
"Run",
footer: "The label is stored inside the probe payload, so a report taken after an account switch still shows which account wrote the item it found."
) {
TextField(text: $label) {
Text(verbatim: "Label (e.g. \"A, before sign-out\")")
}
#if os(iOS)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
#endif
actionRow("Write both probes", systemImage: "square.and.pencil") {
writeProbes()
}
actionRow("Capture report", systemImage: "camera.viewfinder") {
capture()
}
if let latestStatus {
Text(verbatim: latestStatus)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
@ViewBuilder
private var reportsSection: some View {
SettingsFormSection("Reports (\(reports.count))") {
if reports.isEmpty {
Text(verbatim: "No reports captured yet.")
.foregroundStyle(.secondary)
} else {
ForEach(reports) { report in
VStack(alignment: .leading, spacing: 4) {
Text(verbatim: report.label.isEmpty ? "<unlabelled>" : report.label)
.font(.headline)
Text(verbatim: report.capturedAt.formatted(date: .abbreviated, time: .standard))
.font(.caption)
.foregroundStyle(.secondary)
Text(verbatim: report.text)
.font(.system(.caption2, design: .monospaced))
.selectableText()
}
.frame(maxWidth: .infinity, alignment: .leading)
}
#if !os(tvOS)
actionRow("Copy all reports", systemImage: "doc.on.doc") {
copyToClipboard(allReportsText)
latestStatus = "Copied \(reports.count) report(s) to the clipboard."
}
#endif
actionRow("Clear reports", systemImage: "trash", role: .destructive) {
KeychainProvenanceReportStore.clear()
reports = []
latestStatus = "Cleared captured reports (probe items untouched)."
}
}
actionRow("Delete probe items", systemImage: "key.slash", role: .destructive) {
showingDeleteConfirmation = true
}
}
}
// MARK: - Actions
private func writeProbes() {
let payload = payloadText()
let synchronizable = KeychainProvenanceProbe.write(
account: KeychainProvenanceProbe.synchronizableAccount,
synchronizable: true,
payload: payload
)
let local = KeychainProvenanceProbe.write(
account: KeychainProvenanceProbe.localAccount,
synchronizable: false,
payload: payload
)
latestStatus = """
Wrote probes — synchronizable: \(KeychainProvenanceProbe.describe(synchronizable)), \
local: \(KeychainProvenanceProbe.describe(local))
"""
}
private func capture() {
let text = KeychainProvenanceReportBuilder.build(label: label, userRecordID: userRecordID)
reports = KeychainProvenanceReportStore.append(
KeychainProvenanceReport(label: label, text: text)
)
// Also logged so the report survives even if the app's storage is
// reset, and so it can be exported from the log viewer on tvOS.
LoggingService.shared.info(
"Keychain provenance report captured",
category: .keychain,
details: text
)
latestStatus = "Captured report \(reports.count)."
}
private func payloadText() -> String {
let fingerprint = CloudKitIdentity.ubiquityFingerprint ?? "none"
let written = Date().formatted(.iso8601)
return "fp=\(fingerprint)|written=\(written)|label=\(label.isEmpty ? "<unlabelled>" : label)"
}
private var allReportsText: String {
reports
.map { "\($0.text)\n" }
.joined(separator: "\n\n========================================\n\n")
}
#if !os(tvOS)
private func copyToClipboard(_ text: String) {
#if os(macOS)
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(text, forType: .string)
#else
UIPasteboard.general.string = text
#endif
}
#endif
// MARK: - Row builders
@ViewBuilder
private func step(_ number: Int, _ text: String) -> some View {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(verbatim: "\(number).")
.font(.footnote.monospacedDigit())
.foregroundStyle(.secondary)
Text(verbatim: text)
.font(.footnote)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
@ViewBuilder
private func row(_ title: String, _ value: String) -> some View {
HStack {
Text(verbatim: title)
Spacer()
Text(verbatim: value)
.font(.system(.footnote, design: .monospaced))
.foregroundStyle(.secondary)
.selectableText()
}
}
@ViewBuilder
private func actionRow(
_ title: String,
systemImage: String,
role: ButtonRole? = nil,
action: @escaping () -> Void
) -> some View {
HStack {
Button(role: role, action: action) {
Label {
Text(verbatim: title)
} icon: {
Image(systemName: systemImage)
}
}
Spacer()
}
}
}
private extension View {
/// tvOS has no text selection — the report is read back from the log
/// viewer there instead.
@ViewBuilder
func selectableText() -> some View {
#if os(tvOS)
self
#else
textSelection(.enabled)
#endif
}
}
// MARK: - Preview
#Preview {
NavigationStack {
KeychainProvenanceExperimentView()
}
}
#endif