Archive the identity token by its guaranteed coding contract

Seventh review pass. No correctness findings this round; both are
availability.

The retry budget was only reset inside the tokenless branch of an identity
change, so an account that exhausted its four attempts left the next one
with none - its queued resolve() did no lookup at all and credentials
stayed unavailable until another foreground transition. The budget belongs
to the identity being resolved, so it now resets on every observed change.

ubiquityIdentityToken was archived with requiringSecureCoding: true, but
Foundation guarantees only NSCoding for it. Measured: a class adopting only
NSCoding does throw under secure archiving, and the `try?` there would have
silently produced no fingerprint - losing the scope cache, forcing a
CloudKit lookup every launch and leaving credentials unavailable offline.
This Mac's token happens to support secure coding, so nothing was failing
yet; the archive is only ever hashed, never decoded, so secure coding buys
nothing regardless. Also measured: the non-secure archive is deterministic,
which is what the fingerprint comparison depends on.

Note the digest differs between the two archiving modes, so any scope
cached before this change resolves once more against CloudKit and re-caches.
This commit is contained in:
Arkadiusz Fal
2026-08-24 23:30:24 +02:00
parent 00a298b09f
commit e7476bec9b
3 changed files with 51 additions and 7 deletions

View File

@@ -32,12 +32,22 @@ enum CloudKitIdentity {
/// carries no account information — it only supports equality comparison
/// against a previously persisted fingerprint.
static var ubiquityFingerprint: String? {
guard let token = FileManager.default.ubiquityIdentityToken,
let data = try? NSKeyedArchiver.archivedData(
withRootObject: token,
requiringSecureCoding: true
)
else { return nil }
guard let token = FileManager.default.ubiquityIdentityToken else { return nil }
// The token is only guaranteed to be `NSCoding` — Foundation types it
// as `NSCoding & NSCopying & NSObjectProtocol`, with no
// `NSSecureCoding`. Archiving with `requiringSecureCoding: true` would
// therefore throw on a conforming implementation, and a `try?` there
// would silently yield nil: the scope would lose its cache, forcing a
// CloudKit lookup on every launch and leaving credentials unavailable
// offline. Secure coding buys nothing here regardless — the archive is
// only ever hashed, never decoded.
let archiver = NSKeyedArchiver(requiringSecureCoding: false)
archiver.encode(token, forKey: NSKeyedArchiveRootObjectKey)
archiver.finishEncoding()
let data = archiver.encodedData
guard !data.isEmpty else { return nil }
return shortDigest(of: data)
}

View File

@@ -169,6 +169,12 @@ final class CredentialIdentityScope {
// generation are refused even when the fingerprint looks unchanged.
identityGeneration += 1
// The budget belongs to the identity being resolved, so it resets on
// every change — not only the tokenless branch below. A previous
// identity that exhausted its attempts would otherwise leave the new
// one with none, and its queued resolve() would do no work at all.
resolutionAttempts = 0
// A tokenless scope cannot be re-derived by `adoptCurrentIdentity()`
// (there is no fingerprint to compare), so drop it outright: continuing
// to serve it would hand the previous account's credentials to the new
@@ -176,7 +182,6 @@ final class CredentialIdentityScope {
if resolvedWithoutToken {
state = .unresolved
resolvedWithoutToken = false
resolutionAttempts = 0
}
adoptCurrentIdentity()

View File

@@ -664,6 +664,35 @@ struct CredentialIdentityScopeTests {
#expect(scope.current == "_recordB")
}
@Test("An account change restores the retry budget")
func identityChangeResetsAttemptBudget() async {
let harness = Harness()
harness.fingerprint = nil
harness.availability = .available
harness.tokenlessRecord = "_recordA"
harness.failuresRemaining = 99 // exhaust every attempt for this identity
let scope = CredentialIdentityScope(
defaults: makeDefaults(),
fingerprintProvider: { harness.fingerprint },
resolveUserRecordID: { await harness.resolve() },
accountAvailabilityProvider: { harness.availability },
retryDelay: { _ in }
)
await scope.resolve()
#expect(scope.current == nil)
// A different account signs in. Its lookup must not inherit an
// exhausted budget and give up without trying.
harness.failuresRemaining = 0
harness.tokenlessRecord = "_recordB"
scope.identityDidChange(source: "test")
await scope.resolve()
#expect(scope.current == "_recordB")
}
@Test("An undeterminable account status leaves the scope unresolved")
func indeterminateStatusStaysUnresolved() async {
let harness = Harness()