Remove keychain provenance and identity scope simulator debug tools

Both were DEBUG-only Developer settings entries used while building
the accounts revamp and are no longer needed. The simulator's
account-sync suspension barrier in AccountSyncCoordinator existed only
to keep invented scopes away from the real zone, so it goes with it.
This commit is contained in:
Arkadiusz Fal
2026-10-05 09:01:59 +02:00
parent cf90eb4d4e
commit 1bb78d32bf
6 changed files with 1 additions and 909 deletions

View File

@@ -95,19 +95,6 @@ final class AccountSyncCoordinator {
private var lastResolvedNamespace: String?
/// Set once the DEBUG identity-scope simulator has invented a scope, and
/// never cleared: from that moment this process is describing an Apple
/// account it is not signed in to.
///
/// Only the *namespaces* are invented — the database, the zone and the
/// records in it stay real. Without this, activating under `debug-identity-A`
/// would bootstrap that namespace out of the genuine private zone and then
/// upload the results back into it stamped with a name no identity will ever
/// answer to, which is real data lost inside a real zone. Compiled in every
/// configuration so the guards below read the same everywhere; only DEBUG
/// has any way to set it.
private var debugSimulationActive = false
private var isActivating = false
private var activationRequested = false
@@ -282,31 +269,6 @@ final class AccountSyncCoordinator {
database = nil
}
#if DEBUG
/// Suspends account sync for the rest of this process, for the identity
/// scope simulator in Developer settings.
///
/// Called *before* the first invented scope is announced. The simulator's
/// whole point is to drive the local→identity import without an Apple
/// account to sign into, and the namespaces it invents are only safe as long
/// as nothing carries them to the server: the private database underneath is
/// the real one, so a bootstrap under a fake namespace reads genuine records
/// and the upload that follows writes them back stamped with a name the real
/// identity will never recognise.
///
/// Never lifted. The simulated scope itself lasts until the app is
/// relaunched, and a coordinator that resumed while the scope was still
/// invented would be exactly the thing this prevents.
func enterDebugScopeSimulation() {
guard !debugSimulationActive else { return }
debugSimulationActive = true
LoggingService.shared.logCloudKit(
"Account sync is suspended for this launch: the identity scope is being simulated"
)
}
#endif
// MARK: - Fetching
/// Notes which identity the fetch now starting belongs to.
@@ -331,23 +293,6 @@ final class AccountSyncCoordinator {
guard !accountRecords.isEmpty || !accountDeletions.isEmpty else { return }
// Refused rather than staged, and treated exactly like records that
// arrived before an identity could be attributed to them. Staging is
// namespaced, so these genuine records would be filed under an invented
// name — where applying them is the pollution this is preventing, and
// not applying them is a durable copy of real data nothing will read
// again. The recovery flag is what makes them recoverable: the engine's
// token has moved past them, and the real identity re-bootstraps its
// zone from a nil token on the next launch, which is when the scope is
// real again.
guard !debugSimulationActive else {
store.hasPendingIdentityRecovery = true
LoggingService.shared.logCloudKit(
"Dropping \(accountRecords.count + accountDeletions.count) account records fetched under a simulated identity"
)
return
}
// The identity the *fetch* began under, not whatever is current now.
let owner = fetchIdentity ?? (namespace, identityGeneration)
@@ -417,15 +362,6 @@ final class AccountSyncCoordinator {
}
private func performActivation() async {
// Before anything is read or written, and on the same terms as the
// category being off: staged records are kept, nothing is applied, and
// nothing is enqueued. A simulated scope names an Apple account this
// device is not signed in to, while the database it would activate
// against is that device's real one.
guard !debugSimulationActive else {
LoggingService.shared.logCloudKit("Account sync is suspended: the identity scope is simulated")
return
}
guard let namespace else { return }
guard isCategoryEnabled() else {
LoggingService.shared.logCloudKit("Account sync category is off — incoming records stay deferred")
@@ -628,10 +564,6 @@ final class AccountSyncCoordinator {
/// Runs outside the engine's own token: the engine's state and every other
/// category's pending changes are untouched.
private func runBootstrapIfNeeded(in namespace: String) async {
// Unreachable through `performActivation`, which already refuses. Kept
// because this is the one place that reads the real zone from a nil
// token, and it must never do that on behalf of an invented namespace.
guard !debugSimulationActive else { return }
guard !store.isBootstrapCompleted(in: namespace) else { return }
guard let database else { return }
@@ -721,8 +653,6 @@ final class AccountSyncCoordinator {
/// Safe to call repeatedly: the engine collapses duplicate pending changes,
/// and each entry is cleared only by a server confirmation.
func enqueueDirtyRecords() {
// Nothing an invented namespace owes is owed to anybody's real zone.
guard !debugSimulationActive else { return }
guard let enqueueChanges, let namespace else { return }
guard isCategoryEnabled(), store.isBootstrapCompleted(in: namespace) else { return }
guard isManagerLoaded(for: namespace) else { return }
@@ -764,10 +694,6 @@ final class AccountSyncCoordinator {
/// as done.
@discardableResult
func uploadEverything() -> Bool {
// A simulated identity has no zone to fill, and marking its accounts
// dirty would only leave an outbox nothing can ever settle.
guard !debugSimulationActive else { return false }
// Marking everything dirty commits the *in-memory* list to this
// identity's zone, so it must not run against a snapshot loaded under
// another one.
@@ -792,9 +718,7 @@ final class AccountSyncCoordinator {
/// must come back to it otherwise.
@discardableResult
func discardWhatTheZoneHeld() -> Bool {
// A simulated identity's zone is nobody's, and the outbox it would
// empty belongs to the identity actually signed in.
guard !debugSimulationActive, let namespace else { return false }
guard let namespace else { return false }
// Fenced before anything is destroyed, the way `uploadEverything()` is:
// during the reload gap the accounts in memory are still the previous
@@ -850,10 +774,6 @@ final class AccountSyncCoordinator {
/// upload ordering. Holding is safe because the outbox is durable: the
/// change is registered again as soon as what it waits on is confirmed.
func recordToSave(named recordName: String) -> CKRecord? {
// The last gate before a record reaches the server, and the one that
// would stamp it with a simulated identity. Withdrawn instead: the
// outbox is durable, and this launch has nowhere legitimate to send it.
guard !debugSimulationActive else { return nil }
guard let namespace, isCategoryEnabled(), store.isBootstrapCompleted(in: namespace) else { return nil }
guard isManagerLoaded(for: namespace) else { return nil }

View File

@@ -1,216 +0,0 @@
//
// 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

@@ -1,166 +0,0 @@
//
// 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?,
accountAvailability: CloudKitIdentity.AccountAvailability
) -> 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)")")
// A nil token does NOT mean signed out — Foundation also returns nil
// when iCloud Drive is off, so the report must not assert either way.
// CloudKit's account status is what disambiguates.
lines.append("ubiquityIdentityToken: \(CloudKitIdentity.ubiquityFingerprint.map { "present, fp=\($0)" } ?? "unavailable (signed out, or iCloud Drive off)")")
lines.append("CloudKit account: \(describe(accountAvailability))")
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: ", ")
}
private static func describe(_ availability: CloudKitIdentity.AccountAvailability) -> String {
switch availability {
case .noAccount: "no account signed in"
case .available: "signed in"
case .indeterminate: "could not be determined"
}
}
// 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