commit 3af6dc76e6e28c64f20ca62236af95f467e80878
parent 40592e1aca08b08191ed151b330300133d68271f
Author: Michael Camilleri <[email protected]>
Date: Fri, 5 Jun 2026 22:20:25 +0900
Adopt a sibling's push secret before minting a competing one
The account push secret (and the account push address before it)
converges across an account's own devices purely through the inbound
fetch path: a fetched decision-account-pushSecret fires
onAccountPushSecret, which caches the value and re-derives every
per-game push address from it. But the first reconcilePushRegistration
at startup ran before that path had a chance to deliver anything.
syncEngine.start() only constructs the engines — it does not fetch — so
the reconcile fired against empty UserDefaults, minted a competing
secret, and enqueued divergent HMAC-derived addresses for every Player
record.
On a freshly-updated device whose sibling had already published the
secret, that meant briefly clobbering the converged per-game addresses
with a set derived from the loser's own secret, then re-correcting them
once a later fetch finally delivered the winner. The scheme self-heals,
but the window left some games temporarily unreachable.
Minting only happens when nothing is cached for the current account, so
the clobber is bounded to a device's first run on this build (and
reinstall / account switch) — not every launch. Gate a single
account-zone-covering fetch on exactly that condition: when no
secret/address is cached, fetch first so the inbound path can adopt a
sibling's published value before reconcile would mint. A database-level
fetch is used (not zone-scoped) so the account zone is discovered even
on a device that has never seen it. On every later launch the values are
cached, the condition is false, and startup is unchanged — no recurring
fetch. If the fetch throws, syncMonitor.run swallows it and reconcile
still runs (no worse than before). This runs once per launch at most,
guarded by syncStartTask.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
1 file changed, 28 insertions(+), 0 deletions(-)
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -818,6 +818,17 @@ final class AppServices {
Self.accountPushSecretDefaultsPrefix + authorID
}
+ /// True once both the account push secret and address are cached for this
+ /// account — i.e. `ensureAccountPushSecret`/`ensureAccountPushAddress` would
+ /// hit their early returns rather than mint. Used at startup to decide
+ /// whether a pre-reconcile fetch is needed to adopt a sibling's value first.
+ private func hasCachedAccountPushCredentials(authorID: String) -> Bool {
+ let defaults = UserDefaults.standard
+ let secret = defaults.string(forKey: accountPushSecretDefaultsKey(authorID: authorID))
+ let address = defaults.string(forKey: accountPushAddressDefaultsKey(authorID: authorID))
+ return !(secret ?? "").isEmpty && !(address ?? "").isEmpty
+ }
+
private func publishAccountPushSecretDecision(_ secret: String) {
Task.detached { [syncEngine] in
await syncEngine.enqueueDecision(
@@ -2251,6 +2262,23 @@ final class AppServices {
}
isReadyForShareAcceptance = true
await processPendingShareAcceptances()
+ // Only when this device has nothing cached to derive from is
+ // `reconcilePushRegistration` about to *mint* a fresh secret/address
+ // — and minting before a sibling's already-published value has been
+ // fetched is what enqueues divergent per-game addresses that briefly
+ // clobber the converged set. `start()` only constructs the engines,
+ // so fetch the account zone first in exactly that case, letting the
+ // inbound path (`onAccountPushSecret`) adopt and cache the winner so
+ // reconcile derives from it instead of minting. On every later
+ // launch the value is already cached, so this is skipped and startup
+ // is unchanged. If the fetch throws, `run` swallows it and reconcile
+ // still runs (no worse than before).
+ if let authorID = identity.currentID, !authorID.isEmpty,
+ !hasCachedAccountPushCredentials(authorID: authorID) {
+ await syncMonitor.run("startup account sync") {
+ try await syncEngine.fetchChanges(source: "startup")
+ }
+ }
await reconcilePushRegistration()
return true
}