Files
yattee/Yattee/Services/Credentials/KeychainProvenanceProbe.swift
Arkadiusz Fal 9fd0a71035 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.
2026-08-23 21:20:30 +02:00

217 lines
8.5 KiB
Swift

//
// 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