crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

commit 3ec1ed5e075ebfa9715f59a459ef5d6eec8aa520
parent 89bbb3aeff60751f8d7684db6009dd5a1b2f4924
Author: Michael Camilleri <[email protected]>
Date:   Tue, 30 Jun 2026 03:09:36 +0900

Gate friend invite key publication on a successful enqueue

A friend invite encryption key could be recorded as published without
its Decision ever reaching CloudKit. enqueueFriendDecision drops the
write when its target sync engine is not up yet, but
ensureFriendInvitationKeyPublished marked the per-pair key published
immediately afterwards. Because the published marker suppresses later
attempts, a dropped write stranded the key — the peer could be left
unable to decrypt this device's invites until the key happened to
change.

This commit makes enqueueFriendDecision report whether it actually
enqueued the Decision, and only marks the key published when it did. A
dropped publish now stays unmarked, so the next friendship or
invite-send pass retries it once the engine is available.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 4++++
MCrossmate/Services/AccountPushCoordinator.swift | 9+++++++--
MCrossmate/Sync/FriendController.swift | 12++++++++----
MCrossmate/Sync/SyncEngine.swift | 9+++++++--
ATests/Unit/AccountPushCoordinatorTests.swift | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 103 insertions(+), 8 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -80,6 +80,7 @@ 503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C8064F04FC6177D987ACA2 /* Puzzle.swift */; }; 50C02D37A41D55CFA5D307E2 /* NYTPuzzleUpgraderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B34489D0864DF76AF436E391 /* NYTPuzzleUpgraderTests.swift */; }; 51E6F7F2FC52C2AA87B9DB45 /* PeerPresenceGraceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08E8592B1CB1336E63498706 /* PeerPresenceGraceTests.swift */; }; + 521E877D28502B917C89B66D /* AccountPushCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B166E87D13C41C4D3575169 /* AccountPushCoordinatorTests.swift */; }; 59230713D85AE6895852B06A /* InviteCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10064D171DB7C48D3DE1E769 /* InviteCoordinator.swift */; }; 5992AD4A06D7C6440825E9C6 /* GameArchiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B7539E0AD285C5A3AC3DDA2 /* GameArchiver.swift */; }; 5E89D1F8FDFE56395997281A /* NewGameSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09C81EFA0B7776CB9713CD63 /* NewGameSheet.swift */; }; @@ -313,6 +314,7 @@ 46801B570FC0B2C791ECDED3 /* PlayerSessionNavigationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSessionNavigationTests.swift; sourceTree = "<group>"; }; 47532AED239AEF476D8E9206 /* NotificationStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationStateTests.swift; sourceTree = "<group>"; }; 4A467BC00116EEC8500BE6A1 /* PersistenceRecoveryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceRecoveryTests.swift; sourceTree = "<group>"; }; + 4B166E87D13C41C4D3575169 /* AccountPushCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountPushCoordinatorTests.swift; sourceTree = "<group>"; }; 4B33C21324E1474BCC126AA0 /* MovesCodecLegacyDecodeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovesCodecLegacyDecodeTests.swift; sourceTree = "<group>"; }; 4DB0580C9B7C778F34BE6AC2 /* EngagementLifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EngagementLifecycle.swift; sourceTree = "<group>"; }; 4DC7784917397BCD6B8D679D /* PuzzleCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleCatalog.swift; sourceTree = "<group>"; }; @@ -545,6 +547,7 @@ 212DB6FCF46C41F81C41D232 /* Unit */ = { isa = PBXGroup; children = ( + 4B166E87D13C41C4D3575169 /* AccountPushCoordinatorTests.swift */, 978A96CC6F550ED7A73F8D96 /* AnnouncementCenterTests.swift */, B9EA9CF96312BFF5340CE2A7 /* ArchiveTests.swift */, 60E818B0F4689BAD57660B7C /* GameCursorStoreTests.swift */, @@ -974,6 +977,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 521E877D28502B917C89B66D /* AccountPushCoordinatorTests.swift in Sources */, 6C091D30AAC9F63B7CE6FB58 /* AnnouncementCenterTests.swift in Sources */, D519F54F8CE0BD53D9C6144C /* AppServicesAnnouncementTests.swift in Sources */, DB098F40C6950E29B4BF10A7 /* ArchiveTests.swift in Sources */, diff --git a/Crossmate/Services/AccountPushCoordinator.swift b/Crossmate/Services/AccountPushCoordinator.swift @@ -209,14 +209,19 @@ final class AccountPushCoordinator { let payload = ensureFriendEncryptionKey(pairKey: pairKey) guard let encoded = payload.encodedString() else { return } if shouldPublishFriendEncryptionKey(encoded, pairKey: pairKey) { - await syncEngine.enqueueFriendDecision( + // Only record the publish if the decision was actually enqueued; a + // drop (engine not up yet) must stay unmarked so a later call + // retries instead of treating the key as already published. + let enqueued = await syncEngine.enqueueFriendDecision( kind: RecordSerializer.encryptionKeyDecisionKind, key: authorID, payload: encoded, friendZoneID: friendZoneID, friendZoneScope: friendZoneScope ) - markFriendEncryptionKeyPublished(encoded, pairKey: pairKey) + if enqueued { + markFriendEncryptionKeyPublished(encoded, pairKey: pairKey) + } } await refreshPushRegistration() } diff --git a/Crossmate/Sync/FriendController.swift b/Crossmate/Sync/FriendController.swift @@ -15,14 +15,19 @@ import Foundation /// share without any out-of-band link. @MainActor final class FriendController { + /// Mints (if needed) and publishes this device's per-pair invite encryption + /// key into the friend zone so the pair can seal invite pushes. Supplied by + /// `AppServices`; nil where push isn't wired (e.g. tests). + typealias FriendInvitationKeyPublisher = + (_ pairKey: String, _ friendZoneID: CKRecordZone.ID, _ friendZoneScope: Int16) async -> Void + let container: CKContainer private let persistence: PersistenceController private let syncEngine: SyncEngine private let syncMonitor: SyncMonitor? private let eventLog: EventLog? private let fetchAccountDecisionRecord: (CKRecord.ID) async throws -> CKRecord - private let publishFriendInvitationKey: - ((String, CKRecordZone.ID, Int16) async -> Void)? + private let publishFriendInvitationKey: FriendInvitationKeyPublisher? init( container: CKContainer, @@ -30,8 +35,7 @@ final class FriendController { syncEngine: SyncEngine, syncMonitor: SyncMonitor? = nil, eventLog: EventLog? = nil, - publishFriendInvitationKey: - ((String, CKRecordZone.ID, Int16) async -> Void)? = nil, + publishFriendInvitationKey: FriendInvitationKeyPublisher? = nil, fetchAccountDecisionRecord: ((CKRecord.ID) async throws -> CKRecord)? = nil ) { self.container = container diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -952,6 +952,10 @@ actor SyncEngine { /// zone (shared engine), `scope == 0` means we own it (private engine). /// The zone already exists by the time a friendship is recorded, so no /// `saveZone`. + /// Returns whether the decision was actually enqueued. It is dropped when + /// the target engine isn't up yet; callers that gate on a "published" + /// marker must not record success on a drop, or the write never re-heals. + @discardableResult func enqueueFriendDecision( kind: String, key: String, @@ -959,12 +963,13 @@ actor SyncEngine { version: Int64? = nil, friendZoneID: CKRecordZone.ID, friendZoneScope: Int16 - ) { - guard let engine = friendZoneScope == 1 ? sharedEngine : privateEngine else { return } + ) -> Bool { + guard let engine = friendZoneScope == 1 ? sharedEngine : privateEngine else { return false } registerDecisionSave( kind: kind, key: key, payload: payload, version: version, zoneID: friendZoneID, engine: engine ) + return true } /// Routes a display-name Decision to its zone. The account zone may not diff --git a/Tests/Unit/AccountPushCoordinatorTests.swift b/Tests/Unit/AccountPushCoordinatorTests.swift @@ -0,0 +1,77 @@ +import CloudKit +import CoreData +import Foundation +import Testing + +@testable import Crossmate + +/// Friend-invite encryption keys are minted/published only from the friendship +/// and invite-send paths (not the registration sweep), with a per-pair +/// "published" marker that dedupes the decision write. The marker must be +/// gated on the write actually being enqueued: an unstarted `SyncEngine` drops +/// the decision, and recording it as published anyway would strand the key — +/// it could never re-heal once the engine came up. +@Suite("AccountPushCoordinator friend invite keys", .isolatedNotificationState) +@MainActor +struct AccountPushCoordinatorTests { + + // Persisted UserDefaults keys, mirrored here to pin the format the way the + // CloudKit-field tests pin record names. `AccountPushCoordinator` writes + // these under `UserDefaults.standard`, so tests use a unique pair key and + // clean up to stay isolated from siblings. + private static func keyDefaultsKey(_ pairKey: String) -> String { + "push.friendEncryptionKey." + pairKey + } + + private static func publishedDefaultsKey(_ pairKey: String) -> String { + "push.friendEncryptionKeyPublished." + pairKey + } + + private func makeCoordinator( + persistence: PersistenceController, + syncEngine: SyncEngine + ) -> AccountPushCoordinator { + let prefs = PlayerPreferences( + local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")! + ) + prefs.isICloudSyncEnabled = true + return AccountPushCoordinator( + identity: AuthorIdentity(testing: "alice"), + preferences: prefs, + persistence: persistence, + store: makeTestStore(persistence: persistence), + syncEngine: syncEngine, + syncMonitor: SyncMonitor(log: EventLog()), + pushClient: nil + ) + } + + @Test("a dropped friend-key enqueue mints the key but is not marked published") + func droppedEnqueueIsNotMarkedPublished() async { + let persistence = makeTestPersistence() + // Never started, so its CKSyncEngines are nil and the decision write is + // dropped — the exact case the publish gate must survive. + let engine = SyncEngine( + container: CKContainer(identifier: "iCloud.net.inqk.crossmate.v3"), + persistence: persistence + ) + let coordinator = makeCoordinator(persistence: persistence, syncEngine: engine) + let pairKey = "pair-\(UUID().uuidString)" + defer { + UserDefaults.standard.removeObject(forKey: Self.keyDefaultsKey(pairKey)) + UserDefaults.standard.removeObject(forKey: Self.publishedDefaultsKey(pairKey)) + } + + await coordinator.ensureFriendInvitationKeyPublished( + pairKey: pairKey, + friendZoneID: CKRecordZone.ID(zoneName: "friend-\(pairKey)", ownerName: "owner"), + friendZoneScope: 0 + ) + + // The key is minted locally so this device can listen on its address... + #expect(UserDefaults.standard.string(forKey: Self.keyDefaultsKey(pairKey)) != nil) + // ...but the publish marker stays unset because nothing was enqueued, so + // a later call (engine up) still attempts the decision write. + #expect(UserDefaults.standard.string(forKey: Self.publishedDefaultsKey(pairKey)) == nil) + } +}