mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 13:12:53 +00:00
215 lines
8.7 KiB
Swift
215 lines
8.7 KiB
Swift
//
|
|
// CredentialAdoptionMarker.swift
|
|
// Yattee
|
|
//
|
|
// Records which legacy Keychain items this device has adopted, and for whom.
|
|
//
|
|
|
|
import CryptoKit
|
|
import Foundation
|
|
|
|
/// Records that a legacy (unscoped) Keychain item was copied into an
|
|
/// identity-scoped key on this device.
|
|
///
|
|
/// The marker is bound to the *secret*, not just the account: it is written
|
|
/// only after the scoped copy reads back, and it carries a fingerprint of the
|
|
/// credential value that was adopted. That binding is what lets an account
|
|
/// deletion clean up the legacy item without ever destroying someone else's
|
|
/// login — see ``CredentialAdoptionMarkerStore/canDeleteLegacyItem(...)``.
|
|
struct CredentialAdoptionMarker: Codable, Hashable, Sendable {
|
|
/// The identity scope that adopted the item. Apple account A's marker must
|
|
/// never authorize anything under account B.
|
|
let identityScope: String
|
|
/// Keychain service the adopted item lives in. The Invidious and Piped
|
|
/// managers keep separate services but share this store, and an instance
|
|
/// UUID can outlive a change of backend — without this, adopting one
|
|
/// backend's credential would suppress the other's offer and point
|
|
/// deletion at the wrong secret.
|
|
let service: String
|
|
let instanceID: UUID
|
|
let accountID: UUID
|
|
/// Salted, truncated digest of the adopted credential — non-secret, and
|
|
/// device-local (the salt never leaves this device).
|
|
let secretFingerprint: String
|
|
let adoptedAt: Date
|
|
}
|
|
|
|
/// Device-local store of adoption markers.
|
|
///
|
|
/// Deliberately never synced: it describes what happened to *this* device's
|
|
/// Keychain, which differs per device during a staggered rollout.
|
|
@MainActor
|
|
final class CredentialAdoptionMarkerStore {
|
|
private static let markersKey = "credentialAdoptionMarkers"
|
|
private static let saltKey = "credentialAdoptionFingerprintSalt"
|
|
|
|
private let defaults: UserDefaults
|
|
|
|
init(defaults: UserDefaults = .standard) {
|
|
self.defaults = defaults
|
|
}
|
|
|
|
// MARK: - Markers
|
|
|
|
func markers() -> [CredentialAdoptionMarker] {
|
|
guard let data = defaults.data(forKey: Self.markersKey),
|
|
let markers = try? JSONDecoder().decode([CredentialAdoptionMarker].self, from: data)
|
|
else { return [] }
|
|
return markers
|
|
}
|
|
|
|
/// Whether this identity has already adopted the legacy item for an instance.
|
|
///
|
|
/// Once true, the app stops adopting and stops reading the legacy key
|
|
/// entirely for that account. Cleared when the user signs in again: the
|
|
/// credential is then one they proved they hold, and nothing about the
|
|
/// adopted copy applies to it any more.
|
|
func hasAdopted(service: String, instanceID: UUID, scope: String) -> Bool {
|
|
marker(service: service, instanceID: instanceID, scope: scope) != nil
|
|
}
|
|
|
|
/// The accounts whose credential on this device came from a legacy item.
|
|
///
|
|
/// Their copies are deliberately device-local, so anything that rewrites
|
|
/// stored credentials to follow the sync setting has to leave them out.
|
|
func adoptedAccountIDs(service: String, scope: String) -> Set<UUID> {
|
|
Set(
|
|
markers()
|
|
.filter { $0.service == service && $0.identityScope == scope }
|
|
.map(\.accountID)
|
|
)
|
|
}
|
|
|
|
/// Whether the marker under `scope` describes exactly this value.
|
|
///
|
|
/// For deciding what a credential found under another scope *is*. A marker
|
|
/// says a secret was adopted rather than signed in for, and the fingerprint
|
|
/// is what ties that claim to one value: a copy that still fingerprints the
|
|
/// same is the adopted one, and a different value is a real login the
|
|
/// marker has nothing to say about.
|
|
func hasAdopted(service: String, instanceID: UUID, scope: String, matching secret: String) -> Bool {
|
|
guard let marker = marker(service: service, instanceID: instanceID, scope: scope) else {
|
|
return false
|
|
}
|
|
return marker.secretFingerprint == fingerprint(of: secret)
|
|
}
|
|
|
|
private func marker(service: String, instanceID: UUID, scope: String) -> CredentialAdoptionMarker? {
|
|
markers().first {
|
|
$0.service == service && $0.instanceID == instanceID && $0.identityScope == scope
|
|
}
|
|
}
|
|
|
|
func record(
|
|
service: String,
|
|
instanceID: UUID,
|
|
accountID: UUID,
|
|
scope: String,
|
|
secret: String
|
|
) {
|
|
var all = markers().filter {
|
|
!($0.service == service && $0.instanceID == instanceID && $0.identityScope == scope)
|
|
}
|
|
all.append(
|
|
CredentialAdoptionMarker(
|
|
identityScope: scope,
|
|
service: service,
|
|
instanceID: instanceID,
|
|
accountID: accountID,
|
|
secretFingerprint: fingerprint(of: secret),
|
|
adoptedAt: Date()
|
|
)
|
|
)
|
|
save(all)
|
|
}
|
|
|
|
/// Retires the marker for one account.
|
|
///
|
|
/// Scoped to `accountID` as well as the instance: an instance can hold
|
|
/// several logins, and only the account that actually adopted the legacy
|
|
/// secret may retire its marker. Matching on the instance alone let a
|
|
/// *sibling* sign-in clear it, which silently withdrew the migrated
|
|
/// account's claim on the unscoped item — after that its deletion could
|
|
/// no longer retire the legacy secret, and it could re-adopt it.
|
|
func removeMarker(service: String, instanceID: UUID, accountID: UUID, scope: String) {
|
|
save(markers().filter {
|
|
!($0.service == service
|
|
&& $0.instanceID == instanceID
|
|
&& $0.accountID == accountID
|
|
&& $0.identityScope == scope)
|
|
})
|
|
}
|
|
|
|
// MARK: - Legacy deletion authority
|
|
|
|
/// Whether deleting an account may also delete the **unscoped** legacy item.
|
|
///
|
|
/// Four conditions, all required:
|
|
///
|
|
/// 1. This device has a marker for the instance — no marker means this
|
|
/// device never adopted anything, so the legacy item is not ours to touch.
|
|
/// Since adoption became automatic this proves only that the device took
|
|
/// the secret, not that the identity signed in now originally owned it.
|
|
/// Deleting anyway is deliberate: the user is removing this login from
|
|
/// this device, which is exactly what the pre-revamp release did to the
|
|
/// same item on logout. Condition 4 is what carries the real weight now.
|
|
/// 2. The marker belongs to the current identity — Apple account A's marker
|
|
/// cannot authorize a deletion while B is signed in.
|
|
/// 3. The marker belongs to the account being deleted. An instance can hold
|
|
/// several accounts, and only the one that actually adopted the legacy
|
|
/// secret may retire it — deleting a *sibling* account must leave it
|
|
/// alone.
|
|
/// 4. The legacy item still holds the exact secret that was adopted — if it
|
|
/// has since been replaced (someone signed in again on an older release,
|
|
/// and that release rewrote the unscoped key), it is a live login of
|
|
/// theirs and must survive.
|
|
///
|
|
/// Deleting a synchronizable item propagates everywhere, so a wrong answer
|
|
/// here logs other devices out.
|
|
func canDeleteLegacyItem(
|
|
service: String,
|
|
instanceID: UUID,
|
|
accountID: UUID,
|
|
scope: String,
|
|
currentLegacyValue: String?
|
|
) -> Bool {
|
|
guard let marker = marker(service: service, instanceID: instanceID, scope: scope) else {
|
|
return false
|
|
}
|
|
|
|
guard marker.accountID == accountID else { return false }
|
|
guard let currentLegacyValue else { return false }
|
|
|
|
return fingerprint(of: currentLegacyValue) == marker.secretFingerprint
|
|
}
|
|
|
|
// MARK: - Fingerprinting
|
|
|
|
/// Salted, truncated digest of a credential.
|
|
///
|
|
/// Salted so the stored marker is not a rainbow-tableable hash of the
|
|
/// secret; truncated because it only ever needs to answer "is this still
|
|
/// the same value". The salt is generated once per device and never synced.
|
|
func fingerprint(of secret: String) -> String {
|
|
var input = Data(salt())
|
|
input.append(Data(secret.utf8))
|
|
return CloudKitIdentity.shortDigest(of: input)
|
|
}
|
|
|
|
private func salt() -> [UInt8] {
|
|
if let existing = defaults.data(forKey: Self.saltKey), existing.count == 32 {
|
|
return [UInt8](existing)
|
|
}
|
|
|
|
var bytes = [UInt8](repeating: 0, count: 32)
|
|
_ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
|
defaults.set(Data(bytes), forKey: Self.saltKey)
|
|
return bytes
|
|
}
|
|
|
|
private func save(_ markers: [CredentialAdoptionMarker]) {
|
|
guard let data = try? JSONEncoder().encode(markers) else { return }
|
|
defaults.set(data, forKey: Self.markersKey)
|
|
}
|
|
}
|