From 1bb78d32bf4ea871ccb35396ce8588cbc0b4c337 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Mon, 5 Oct 2026 09:01:59 +0200 Subject: [PATCH] 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. --- .../CloudKit/AccountSyncCoordinator.swift | 82 +---- .../Credentials/KeychainProvenanceProbe.swift | 216 ------------- .../KeychainProvenanceReport.swift | 166 ---------- .../Settings/DeveloperSettingsView.swift | 104 ------ .../KeychainProvenanceExperimentView.swift | 305 ------------------ YatteeTests/AccountSyncTests.swift | 37 --- 6 files changed, 1 insertion(+), 909 deletions(-) delete mode 100644 Yattee/Services/Credentials/KeychainProvenanceProbe.swift delete mode 100644 Yattee/Services/Credentials/KeychainProvenanceReport.swift delete mode 100644 Yattee/Views/Settings/KeychainProvenanceExperimentView.swift diff --git a/Yattee/Services/CloudKit/AccountSyncCoordinator.swift b/Yattee/Services/CloudKit/AccountSyncCoordinator.swift index 3cf7eda1..abc1385b 100644 --- a/Yattee/Services/CloudKit/AccountSyncCoordinator.swift +++ b/Yattee/Services/CloudKit/AccountSyncCoordinator.swift @@ -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 } diff --git a/Yattee/Services/Credentials/KeychainProvenanceProbe.swift b/Yattee/Services/Credentials/KeychainProvenanceProbe.swift deleted file mode 100644 index e9824153..00000000 --- a/Yattee/Services/Credentials/KeychainProvenanceProbe.swift +++ /dev/null @@ -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 -/// (`"|"`) 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 ?? "", - 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 diff --git a/Yattee/Services/Credentials/KeychainProvenanceReport.swift b/Yattee/Services/Credentials/KeychainProvenanceReport.swift deleted file mode 100644 index 0de77023..00000000 --- a/Yattee/Services/Credentials/KeychainProvenanceReport.swift +++ /dev/null @@ -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 ? "" : 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 ?? "")") - } - } - - 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 { - 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 diff --git a/Yattee/Views/Settings/DeveloperSettingsView.swift b/Yattee/Views/Settings/DeveloperSettingsView.swift index 86a89521..8a9dc9f7 100644 --- a/Yattee/Views/Settings/DeveloperSettingsView.swift +++ b/Yattee/Views/Settings/DeveloperSettingsView.swift @@ -28,10 +28,6 @@ struct DeveloperSettingsView: View { debugSection testingSection dataSection - #if DEBUG - experimentsSection - identityScopeSimulatorSection - #endif } .navigationTitle(String(localized: "settings.developer.title")) #if os(iOS) @@ -211,106 +207,6 @@ 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 - } - } - - /// Moves the credential identity scope by hand, the way signing in and out - /// of an Apple account does. - /// - /// A simulator cannot sign into iCloud, so the scope is the only seam the - /// local→identity import can be exercised through: switching here posts the - /// same notification a real resolution posts, and the production observers - /// do the rest — reload the account namespace, sweep the signed-out one - /// into it, refresh statuses. - /// - /// Account sync is suspended for the rest of the launch before the first - /// switch, and stays suspended. The namespaces are invented but the private - /// database is the device's real one, so an activation under a made-up scope - /// would bootstrap it out of the genuine zone and upload the results back - /// stamped with a name no Apple account answers to. The barrier is in the - /// coordinator, not in this caption. - @ViewBuilder - private var identityScopeSimulatorSection: some View { - if let scope = appEnvironment?.credentialIdentityScope { - SettingsFormSection("Identity Scope Simulator") { - HStack { - Label { - Text(verbatim: "Current scope") - } icon: { - Image(systemName: "person.badge.key") - } - Spacer() - Text(verbatim: scope.current ?? "unresolved") - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - } - - identityScopeButton("Switch to local", scope: CredentialIdentityScope.localScope, for: scope) - identityScopeButton("Switch to debug-identity-A", scope: "debug-identity-A", for: scope) - identityScopeButton("Switch to debug-identity-B", scope: "debug-identity-B", for: scope) - } footer: { - Text( - verbatim: """ - Debug only. The scope stays where you put it until the app is relaunched, \ - so it no longer describes the Apple account this device is signed in to. \ - Switching suspends account iCloud sync — no fetching, no uploading — \ - for the rest of the launch, so a made-up identity can never reach the \ - real zone. Relaunch to sync again. - """ - ) - } - } - } - - private func identityScopeButton( - _ title: String, - scope newScope: String, - for identityScope: CredentialIdentityScope - ) -> some View { - HStack { - Button { - // Suspended first, and for good: the scope change is announced - // synchronously, so a coordinator told afterwards could already - // have activated against the real database under a name that - // belongs to nobody. - appEnvironment?.accountSyncCoordinator.enterDebugScopeSimulation() - - // Announcing, so the observers that reload and import run — - // exactly what a resolution does. - identityScope.setFixedScope(newScope) - } label: { - Label { - Text(verbatim: title) - } icon: { - Image(systemName: "arrow.triangle.swap") - } - } - .disabled(identityScope.current == newScope) - Spacer() - } - } - #endif - // MARK: - Data Actions private func deduplicateData() { diff --git a/Yattee/Views/Settings/KeychainProvenanceExperimentView.swift b/Yattee/Views/Settings/KeychainProvenanceExperimentView.swift deleted file mode 100644 index ba223318..00000000 --- a/Yattee/Views/Settings/KeychainProvenanceExperimentView.swift +++ /dev/null @@ -1,305 +0,0 @@ -// -// 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 accountAvailability: CloudKitIdentity.AccountAvailability = .indeterminate - @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 { - accountAvailability = await CloudKitIdentity.accountAvailability() - 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 and must differ between accounts A and B — if it does not, the sign-out did not take effect. An unavailable token does not by itself mean signed out: it also goes missing when iCloud Drive is off, which is why the account status is shown separately." - ) { - row("CloudKit entitlement", CloudKitAvailability.isAvailable ? "available" : "missing") - // A nil token is ambiguous — it also means iCloud Drive is off — - // so the account status is shown beside it rather than inferred. - row("Ubiquity token", CloudKitIdentity.ubiquityFingerprint.map { "fp=\($0)" } ?? "unavailable") - row("Apple account", accountStatusText) - 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 ? "" : 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, - accountAvailability: accountAvailability - ) - 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 var accountStatusText: String { - if resolvingUserRecordID { return "checking…" } - switch accountAvailability { - case .noAccount: return "no account signed in" - case .available: return "signed in" - case .indeterminate: return "could not be determined" - } - } - - private func payloadText() -> String { - let fingerprint = CloudKitIdentity.ubiquityFingerprint ?? "none" - let written = Date().formatted(.iso8601) - return "fp=\(fingerprint)|written=\(written)|label=\(label.isEmpty ? "" : 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 diff --git a/YatteeTests/AccountSyncTests.swift b/YatteeTests/AccountSyncTests.swift index 15e9f7f6..590352a5 100644 --- a/YatteeTests/AccountSyncTests.swift +++ b/YatteeTests/AccountSyncTests.swift @@ -1127,43 +1127,6 @@ struct AccountSyncTests { #expect(didDiscardCachedRecords) } - // MARK: - Simulated identities - - @Test("A simulated identity scope cannot reach the real zone") - func debugScopeSimulationSuspendsSync() async throws { - // Everything a send is normally gated on is satisfied: the category is - // on, the namespace has bootstrapped, the manager is loaded, and the - // outbox is owed a record. - let harness = Self.makeHarness() - let account = harness.accounts.addAccount(instanceID: UUID()) - let recordName = "account-\(account.id.uuidString)" - #expect(harness.accounts.pendingUploads.contains(account.id)) - - harness.coordinator.enterDebugScopeSimulation() - harness.recorder.clear() - - await harness.coordinator.activate() - harness.coordinator.enqueueDirtyRecords() - harness.coordinator.uploadEverything() - - #expect(harness.recorder.changes.isEmpty) - #expect(harness.coordinator.recordToSave(named: recordName) == nil) - - // Records the engine delivers are refused rather than filed under an - // invented name — and the real identity is told to bootstrap again, so - // what the change token skipped is not lost. - harness.coordinator.stageFetched( - records: [harness.mapper.toCKRecord(accountDeletionID: UUID())], - deletions: [] - ) - #expect(harness.store.stagedChanges(in: harness.scope).isEmpty) - #expect(harness.store.hasPendingIdentityRecovery) - - // The debt itself is untouched: it is owed to whatever identity is real - // after the relaunch that ends the simulation. - #expect(harness.accounts.pendingUploads.contains(account.id)) - } - // MARK: - Importing the signed-out namespace /// What the accounts created while signed out owe the server once an Apple