mirror of
https://github.com/yattee/yattee.git
synced 2026-11-02 01:47:18 +00:00
Settle a secret before handing over the account it belongs to
This commit is contained in:
@@ -244,7 +244,11 @@ final class AccountKeychainCredentials {
|
||||
// username still moves — it is what the sign-in sheet prefills —
|
||||
// and the record may only be pruned once that move is settled too.
|
||||
loginsByScope[sourceScope]?.remove(accountID)
|
||||
return migrateUsername(forAccountID: accountID, from: sourceScope, to: scope)
|
||||
let usernameSettled = migrateUsername(forAccountID: accountID, from: sourceScope, to: scope)
|
||||
// Or a previous attempt moved the secret and died before the marker
|
||||
// that describes where it went.
|
||||
carryAdoptionProvenance(forAccountID: accountID, from: sourceScope, to: scope)
|
||||
return usernameSettled
|
||||
}
|
||||
|
||||
// Decision 5: a credential already stored under the current identity was
|
||||
@@ -256,6 +260,15 @@ final class AccountKeychainCredentials {
|
||||
loginsByScope[scope, default: []].insert(accountID)
|
||||
if sourceCleared { loginsByScope[sourceScope]?.remove(accountID) }
|
||||
|
||||
// "Already occupied" is not always somebody else's sign-in: it is
|
||||
// also what a previous attempt at *this* move looks like when it
|
||||
// died between the verified copy and the marker that records where
|
||||
// the copy went. Left unrepaired, the next resync would read an
|
||||
// unmarked credential as one the user proved they hold and upload
|
||||
// an adopted secret into this identity's iCloud Keychain. The
|
||||
// fingerprint tells the two apart.
|
||||
carryAdoptionProvenance(forAccountID: accountID, from: sourceScope, to: scope)
|
||||
|
||||
guard usernameSettled, sourceCleared else {
|
||||
LoggingService.shared.error(
|
||||
"A redundant \(credentialNoun) could not be cleared out of the namespace being left",
|
||||
@@ -316,28 +329,10 @@ final class AccountKeychainCredentials {
|
||||
let sourceCleared = removed(key: sourceKey)
|
||||
|
||||
// Recorded on the strength of the verified copy, not of the cleanup
|
||||
// that follows it. A retry after a failed deletion takes the
|
||||
// already-has-a-credential path above, which never looks at markers —
|
||||
// so a marker left unwritten here would describe a copy that has moved
|
||||
// and never be corrected.
|
||||
if wasAdopted {
|
||||
// The marker describes where the adopted copy now lives, or the
|
||||
// deletion that retires the unscoped item would no longer recognise
|
||||
// it as this account's to retire.
|
||||
markers.record(
|
||||
service: store.service,
|
||||
instanceID: accountID,
|
||||
accountID: accountID,
|
||||
scope: scope,
|
||||
secret: secret
|
||||
)
|
||||
markers.removeMarker(
|
||||
service: store.service,
|
||||
instanceID: accountID,
|
||||
accountID: accountID,
|
||||
scope: sourceScope
|
||||
)
|
||||
}
|
||||
// that follows it: the marker has to describe where the adopted copy
|
||||
// now lives, or the deletion that retires the unscoped item would no
|
||||
// longer recognise it as this account's to retire.
|
||||
carryAdoptionProvenance(forAccountID: accountID, from: sourceScope, to: scope)
|
||||
|
||||
loginsByScope[scope, default: []].insert(accountID)
|
||||
if sourceCleared { loginsByScope[sourceScope]?.remove(accountID) }
|
||||
@@ -364,6 +359,73 @@ final class AccountKeychainCredentials {
|
||||
return true
|
||||
}
|
||||
|
||||
/// Re-files an adoption marker under the scope its credential now lives in.
|
||||
///
|
||||
/// A marker is the only thing that says a credential was *adopted* — copied
|
||||
/// from the pre-revamp unscoped item rather than signed in for. That claim
|
||||
/// has to follow the secret across a namespace move, because everything
|
||||
/// downstream reads it: `resyncCredentialsWithCurrentSetting` leaves adopted
|
||||
/// copies device-local instead of uploading a secret of unproven provenance
|
||||
/// into an iCloud Keychain, and `canDeleteLegacyItem` needs it to recognise
|
||||
/// the unscoped item as this account's to retire.
|
||||
///
|
||||
/// Called from every path a migration can settle through, not just the one
|
||||
/// that does the copying, because a move is several Keychain calls and any
|
||||
/// of them can be the last one before the process dies. A retry then finds
|
||||
/// the target already holding the secret and would otherwise report success
|
||||
/// while the provenance stayed behind under a namespace nothing reads again.
|
||||
///
|
||||
/// What makes that safe to repair blind is the fingerprint: the marker is
|
||||
/// bound to one value, so a target credential that still fingerprints the
|
||||
/// same is the adopted copy, and a different one is a real sign-in this
|
||||
/// identity made — which the adopted copy was merely redundant beside, and
|
||||
/// which nothing about the adoption applies to.
|
||||
private func carryAdoptionProvenance(forAccountID accountID: UUID, from sourceScope: String, to scope: String) {
|
||||
// Markers are keyed by instance, and only a migrated account — whose ID
|
||||
// *is* its instance's — is ever allowed to adopt, so the account ID is
|
||||
// the right instance to ask about.
|
||||
guard markers.hasAdopted(service: store.service, instanceID: accountID, scope: sourceScope) else { return }
|
||||
|
||||
let targetKey = KeychainCredentialStore.credentialKey(scope: scope, accountID: accountID)
|
||||
|
||||
if let stored = store.value(forKey: targetKey),
|
||||
markers.hasAdopted(
|
||||
service: store.service,
|
||||
instanceID: accountID,
|
||||
scope: sourceScope,
|
||||
matching: stored
|
||||
) {
|
||||
markers.record(
|
||||
service: store.service,
|
||||
instanceID: accountID,
|
||||
accountID: accountID,
|
||||
scope: scope,
|
||||
secret: stored
|
||||
)
|
||||
|
||||
// And the copy itself stays where adoption put it. A repair reached
|
||||
// through a path that never chose the sync flag — or after a resync
|
||||
// that ran while the marker was missing — could be looking at an
|
||||
// item marked synchronizable, which is the one thing an adopted
|
||||
// secret must not be.
|
||||
rewrite(key: targetKey, synchronizable: false)
|
||||
}
|
||||
|
||||
// Retired only once the copy it described is really gone. While the
|
||||
// source item survives — a deletion the Keychain refused — the marker
|
||||
// is still the truth about it, and the sweep is coming back anyway.
|
||||
guard store.value(
|
||||
forKey: KeychainCredentialStore.credentialKey(scope: sourceScope, accountID: accountID)
|
||||
) == nil else { return }
|
||||
|
||||
markers.removeMarker(
|
||||
service: store.service,
|
||||
instanceID: accountID,
|
||||
accountID: accountID,
|
||||
scope: sourceScope
|
||||
)
|
||||
}
|
||||
|
||||
/// Moves the username item, which is a convenience rather than a secret.
|
||||
///
|
||||
/// Counts towards the migration's success all the same. Not because a
|
||||
|
||||
@@ -80,6 +80,20 @@ final class CredentialAdoptionMarkerStore {
|
||||
)
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -82,8 +82,34 @@ final class LocalNamespaceImporter {
|
||||
var pruneable: Set<UUID> = []
|
||||
var refused: Set<UUID> = []
|
||||
|
||||
// The credential is settled before the record is adopted, never the
|
||||
// other way round, and both are needed twice over.
|
||||
//
|
||||
// Adopting announces: `.accountsDidChange` brings the status refresh —
|
||||
// and the silent legacy adoption it can reach — down on the account
|
||||
// synchronously, before this loop gets to its secret. That observer
|
||||
// would find the identity's key empty and fill it with the pre-revamp
|
||||
// unscoped item, an older secret of unproven provenance; the move that
|
||||
// follows would then read its own target as occupied and delete the
|
||||
// valid login as redundant. Nothing is announced here until there is
|
||||
// nothing left to move.
|
||||
//
|
||||
// And a record adopted beside a credential that would not follow leaves
|
||||
// the account owned by two namespaces at once. Signing into a second
|
||||
// identity before the retry claims it there as well, and no later sweep
|
||||
// can take it back out of the first — an account permanently in two
|
||||
// Apple accounts. Settling first means a failure leaves *nothing* under
|
||||
// the identity, so whoever signs in next simply imports it cleanly.
|
||||
//
|
||||
// Residual, and the reason this order rather than a transaction: a
|
||||
// process death between the credential settling and the record being
|
||||
// adopted strands the secret under the identity that was active. The
|
||||
// record is still local, so it lands wherever the user signs in next and
|
||||
// reads as sign-in-required there. Inconvenient, and recoverable by
|
||||
// signing in again — never a silent corruption, which is what the other
|
||||
// order risks.
|
||||
for account in snapshot.accounts {
|
||||
switch accountsManager.adoptImportedAccount(account) {
|
||||
switch accountsManager.importEligibility(for: account) {
|
||||
case .rejectedByTombstone:
|
||||
// Decision 5: the account was deliberately deleted under this
|
||||
// identity, so the local copy is not resurrected — and its
|
||||
@@ -112,13 +138,11 @@ final class LocalNamespaceImporter {
|
||||
)
|
||||
}
|
||||
|
||||
case .imported, .mergedIntoExisting:
|
||||
case .adoptable:
|
||||
// Per-item atomicity: the local record is only dropped once its
|
||||
// credential is verifiably no longer owed by the local scope. A
|
||||
// failure leaves the record in *both* namespaces until the next
|
||||
// sweep — safe, because the collision rule then reads it as an
|
||||
// account the identity already holds and only fills in what is
|
||||
// missing.
|
||||
// credential is verifiably no longer owed by the local scope,
|
||||
// and it is only handed to the identity once the same is true.
|
||||
// A failure leaves both where they are for the next sweep.
|
||||
// Every store is asked even after one fails: they hold separate
|
||||
// Keychain services, and whichever can complete its move should,
|
||||
// rather than waiting on a retry it has nothing to do with.
|
||||
@@ -128,14 +152,30 @@ final class LocalNamespaceImporter {
|
||||
moved = moved && storeMoved
|
||||
}
|
||||
|
||||
if moved {
|
||||
pruneable.insert(account.id)
|
||||
} else {
|
||||
guard moved else {
|
||||
LoggingService.shared.warning(
|
||||
"An imported account's credential could not be moved — leaving it for the next sweep",
|
||||
"An account's credential could not be moved — leaving the record where it is",
|
||||
category: .keychain,
|
||||
details: "accountID=\(account.id)"
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
switch accountsManager.adoptImportedAccount(account) {
|
||||
case .imported, .mergedIntoExisting:
|
||||
pruneable.insert(account.id)
|
||||
|
||||
case .rejectedByTombstone, .deferred:
|
||||
// Neither is reachable: both answers come from the state
|
||||
// this loop read a moment ago, and nothing between the two
|
||||
// calls awaits or gives anything else a turn to change it.
|
||||
// Left local all the same — a next sweep re-reads the fate
|
||||
// and finishes the job whichever way it now reads.
|
||||
LoggingService.shared.warning(
|
||||
"An account's fate changed mid-import — leaving the record where it is",
|
||||
category: .general,
|
||||
details: "accountID=\(account.id)"
|
||||
)
|
||||
}
|
||||
|
||||
case .deferred:
|
||||
|
||||
Reference in New Issue
Block a user