commit c1c6032b340e94db5f0b5b5803f68fba7373ca43
parent 8d0cf5634f30b66705356049cc1259d5b2cf33da
Author: Michael Camilleri <[email protected]>
Date: Sat, 11 Jul 2026 23:35:54 +0900
Bind live edits and push access to current game membership
This commit closes two gaps that let someone who was — or had once been
— a co-player affect a game they should no longer reach.
First, a frame arriving on a live engagement channel was authenticated
against the sender's friend key but never bound to the channel's game,
so a co-player sharing any live room could sign a cell edit or selection
naming a different game entirely and have it written to that game's
durable local cache. Inbound frames are now applied only when every
payload gameID matches the game the channel is bound to — resolved from
the coordinator, not the payload — and the verified sender is on that
game's known roster; anything else is dropped and left to converge over
the Moves/CloudKit path.
Second, game push credentials were minted once and never replaced, so a
participant who left or was removed kept the game's credID, worker
secret, and content key — enough to keep receiving, decrypting, and
publishing the game's notifications indefinitely, with no server-side
state anyone could revoke. The zone-wide share is now treated as the
authoritative membership: inbound share records (previously ignored by
sync) baseline each game's accepted roster, and when a previously
accepted participant disappears, every remaining device mints a full
replacement credential and re-registers; the owner also rotates
immediately on removing an accepted participant. A rotation generation
rides inside the existing pushCredential blob and gates inbound adoption
— including the oplock-recovery re-push — so a stale device's
Game-record save can't resurrect the superseded credential.
The push worker now stores a credID-scoped address binding only when the
request proves possession of that game's secret, using the same game
signature a publish carries, so a leaked or cached credID alone no
longer subscribes a device to a game's pushes. PushClient sends each
game's bindings in its own signed request; a legacy client's unsigned
game entries are dropped while its account binding still registers.
Co-Authored-By: Claude Fable 5 <[email protected]>
Diffstat:
19 files changed, 954 insertions(+), 44 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -86,6 +86,7 @@
5264996E0D72A805B20985E8 /* PuzzleGridStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23A7390848B1943C80142934 /* PuzzleGridStats.swift */; };
59230713D85AE6895852B06A /* InviteCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10064D171DB7C48D3DE1E769 /* InviteCoordinator.swift */; };
5992AD4A06D7C6440825E9C6 /* GameArchiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B7539E0AD285C5A3AC3DDA2 /* GameArchiver.swift */; };
+ 5E80545A8ECEB1E2ED371C53 /* PushCredentialRotationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD32BDDE9B007B11BE86B7A0 /* PushCredentialRotationTests.swift */; };
5E89D1F8FDFE56395997281A /* NewGameSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09C81EFA0B7776CB9713CD63 /* NewGameSheet.swift */; };
5ECF5B80D08E5E999A540782 /* SessionMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EB332831AB173ACF6BFEC59 /* SessionMonitor.swift */; };
5EFCD28B3B682DCCF38068D6 /* AnnouncementCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D3ECD0DE71BE567BCEE15F6 /* AnnouncementCenter.swift */; };
@@ -441,6 +442,7 @@
BA67C509B467132D1B7510A4 /* Puzzles */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Puzzles; sourceTree = SOURCE_ROOT; };
BAEDA3C3765CD8D8897FE5D5 /* PendingChangeReapTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingChangeReapTests.swift; sourceTree = "<group>"; };
BCACEED6A9235EC6221F4F66 /* DiagnosticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticsView.swift; sourceTree = "<group>"; };
+ BD32BDDE9B007B11BE86B7A0 /* PushCredentialRotationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushCredentialRotationTests.swift; sourceTree = "<group>"; };
BD63A9B20168F3B81AF4348F /* RecordApplier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordApplier.swift; sourceTree = "<group>"; };
BF6F111BE8750697C4BC7A17 /* NYTToXDConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTToXDConverter.swift; sourceTree = "<group>"; };
BF7062403AC9CFB4FF04BBF3 /* GridSilhouetteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridSilhouetteTests.swift; sourceTree = "<group>"; };
@@ -815,6 +817,7 @@
BAEDA3C3765CD8D8897FE5D5 /* PendingChangeReapTests.swift */,
283C5C97180C805B6C5BF622 /* PerGameZoneTests.swift */,
5C838C184A0C7B1B0A9821CE /* PlayerRecordPresenceTests.swift */,
+ BD32BDDE9B007B11BE86B7A0 /* PushCredentialRotationTests.swift */,
F64DAE64C9AA042B330C526F /* SessionMonitorTests.swift */,
68072F4F3EB5D5A78E03D408 /* ShareRoutingTests.swift */,
A9A01534A21796A4EC7113A9 /* ZoneOrphaningTests.swift */,
@@ -1073,6 +1076,7 @@
309457EC2DFEC476253D54D2 /* PlayerSelectionPublisherTests.swift in Sources */,
00F2108848ADC7B4BF3AA0AE /* PlayerSessionNavigationTests.swift in Sources */,
C6CC673F2D4BB8A1C0439FBB /* PushClientTests.swift in Sources */,
+ 5E80545A8ECEB1E2ED371C53 /* PushCredentialRotationTests.swift in Sources */,
2A273C98FE3AC5E4C9BE1D88 /* PushPayloadCipherTests.swift in Sources */,
A78FF09708EDED7ED50BB55B /* PushPayloadTests.swift in Sources */,
F34EDFD45E2F5006807DDAC7 /* PuzzleCatalogTests.swift in Sources */,
diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents
@@ -34,6 +34,7 @@
<attribute name="puzzleParserVersion" optional="YES" attributeType="Integer 64" defaultValueString="0" renamingIdentifier="puzzleCmVersion" usesScalarValueType="YES"/>
<attribute name="puzzleSource" attributeType="String"/>
<attribute name="replayCacheComplete" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
+ <attribute name="shareParticipants" optional="YES" attributeType="String"/>
<attribute name="title" attributeType="String"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="cells" toMany="YES" deletionRule="Cascade" destinationEntity="CellEntity" inverseName="game" inverseEntity="CellEntity"/>
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -1677,6 +1677,25 @@ final class GameStore {
return fresh
}
+ /// Replaces the game's push credentials wholesale — new `credID`, worker
+ /// secret, and content key at the next rotation generation — because a
+ /// participant left or was removed. The departed device holds every field
+ /// of the old credential, so only full replacement revokes its ability to
+ /// receive, publish, or re-subscribe; the generation is what stops a stale
+ /// device's Game-record re-push from resurrecting the old one. Any
+ /// remaining device may rotate; record-level LWW converges concurrent
+ /// rotations. No-op for a game with no credentials yet (nothing to revoke)
+ /// — `ensurePushCredentials` will mint at the current roster on demand.
+ @discardableResult
+ func rotatePushCredentials(for gameID: UUID) -> GamePushCredentials? {
+ guard let current = GamePushCredentials.decode(notification(for: gameID)),
+ let fresh = try? GamePushCredentials.rotated(after: current),
+ let encoded = try? fresh.encoded(),
+ setNotification(encoded, for: gameID)
+ else { return nil }
+ return fresh
+ }
+
/// Whether any game rows remain in the local store — the cheap, offline
/// signal that this build carried data over from a previous CloudKit
/// generation (used by the v3→v4 transition ahead of any network probe).
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -941,6 +941,31 @@ final class AppServices {
}
}
+ // A previously accepted participant vanished from a game's zone-wide
+ // share — they left or were removed. Rotate the game's push
+ // credentials (they hold every field of the old ones) and re-register
+ // so this device binds under the new credID and drops the old binding.
+ await syncEngine.setOnPushCredentialRotationNeeded { [weak self] gameIDs in
+ guard let self else { return }
+ var rotated = false
+ for gameID in gameIDs where self.store.rotatePushCredentials(for: gameID) != nil {
+ self.syncMonitor.note("push credentials rotated for \(gameID.uuidString) after roster shrink")
+ rotated = true
+ }
+ if rotated {
+ await self.accountPush.reconcilePushRegistration()
+ }
+ }
+
+ // A peer minted or rotated a game's push credential and this device
+ // just adopted it. Re-run registration so the device binds under the
+ // new credID (and unregisters the old binding) now, rather than on the
+ // next launch or APNs token delivery — until then, publishes signed
+ // with the new credential could not reach it.
+ await syncEngine.setOnRemoteCredentialsChanged { [weak self] _ in
+ await self?.accountPush.reconcilePushRegistration()
+ }
+
await syncEngine.setOnBlockedFriendsChanged { [weak self] authorIDs in
let hiddenChanges = await self?.store.reconcileBlockedFriendHiddenGames(
forAuthorIDs: authorIDs
@@ -1035,6 +1060,17 @@ final class AppServices {
await self?.accountPush.adoptInboundPushSecret(secret, version: version)
}
+ shareController.onParticipantRemoved = { [weak self] gameID in
+ guard let self else { return }
+ guard self.store.rotatePushCredentials(for: gameID) != nil else { return }
+ self.syncMonitor.note(
+ "push credentials rotated for \(gameID.uuidString) after participant removal"
+ )
+ Task { @MainActor [weak self] in
+ await self?.accountPush.reconcilePushRegistration()
+ }
+ }
+
shareController.onShareSaved = { [weak self] gameID in
guard let self else { return }
self.store.markShared(gameID: gameID)
diff --git a/Crossmate/Services/EngagementLifecycle.swift b/Crossmate/Services/EngagementLifecycle.swift
@@ -332,7 +332,23 @@ final class EngagementLifecycle {
}
case .channelMessage(let engagementID, let message):
if let envelope = EngagementMessage.decode(message) {
- handleEngagementMessage(envelope, engagementID: engagementID)
+ // The channel, not the payload, decides which game a frame may
+ // touch; the coordinator owns that binding, so resolving it is
+ // an actor hop. Frames on a channel it no longer tracks (stale
+ // socket after a room migration or close) are dropped — the
+ // Moves/CloudKit path converges them. The hop can reorder
+ // frames, which per-cell/per-author last-writer-wins absorbs.
+ Task { [weak self] in
+ guard let self else { return }
+ guard let gameID = await self.engagementCoordinator.gameID(for: engagementID) else {
+ self.syncMonitor.note(
+ "engagement: dropped \(envelope.kind.rawValue) on unbound channel " +
+ engagementID.uuidString
+ )
+ return
+ }
+ self.handleEngagementMessage(envelope, engagementID: engagementID, gameID: gameID)
+ }
} else {
syncMonitor.note("engagement: channel message \(engagementID.uuidString) bytes=\(message.count)")
}
@@ -357,15 +373,23 @@ final class EngagementLifecycle {
/// Gate for a mutating inbound frame: the relay is untrusted, so a frame is
/// applied only if its per-recipient MAC verifies against the sender's
- /// friend key and every payload authorID is the verified sender. A drop is
- /// safe — the durable Moves/CloudKit path still converges the change.
+ /// friend key, every payload authorID is the verified sender, every payload
+ /// gameID is the channel's game (`gameID`), and the sender is a current
+ /// participant of that game. A drop is safe — the durable Moves/CloudKit
+ /// path still converges the change.
private func verifyInbound(
_ envelope: EngagementMessage,
engagementID: UUID,
+ gameID: UUID,
kind: String
) -> Bool {
guard let localAuthorID = identity.currentID,
- EngagementMessageAuthenticator.verify(envelope, localAuthorID: localAuthorID)
+ EngagementMessageAuthenticator.verify(
+ envelope,
+ localAuthorID: localAuthorID,
+ channelGameID: gameID,
+ participantAuthorIDs: store.participantAuthorIDs(gameID: gameID)
+ )
else {
syncMonitor.note(
"engagement: dropped unauthenticated \(kind) \(engagementID.uuidString) " +
@@ -376,7 +400,7 @@ final class EngagementLifecycle {
return true
}
- private func handleEngagementMessage(_ envelope: EngagementMessage, engagementID: UUID) {
+ private func handleEngagementMessage(_ envelope: EngagementMessage, engagementID: UUID, gameID: UUID) {
let latencyMs = max(0, Int(Date().timeIntervalSince(envelope.sentAt) * 1000))
switch envelope.kind {
case .debugText:
@@ -389,7 +413,7 @@ final class EngagementLifecycle {
syncMonitor.note("engagement: ignored malformed cellEdit \(engagementID.uuidString)")
return
}
- guard verifyInbound(envelope, engagementID: engagementID, kind: "cellEdit") else { return }
+ guard verifyInbound(envelope, engagementID: engagementID, gameID: gameID, kind: "cellEdit") else { return }
let applied = store.applyRealtimeCellEdit(edit)
if applied {
syncMonitor.note(
@@ -409,7 +433,7 @@ final class EngagementLifecycle {
syncMonitor.note("engagement: ignored malformed cellEditBatch \(engagementID.uuidString)")
return
}
- guard verifyInbound(envelope, engagementID: engagementID, kind: "cellEditBatch") else { return }
+ guard verifyInbound(envelope, engagementID: engagementID, gameID: gameID, kind: "cellEditBatch") else { return }
let applied = store.applyRealtimeCellEdits(edits)
syncMonitor.note(
"engagement: applied cellEditBatch \(engagementID.uuidString) " +
@@ -421,7 +445,7 @@ final class EngagementLifecycle {
syncMonitor.note("engagement: ignored malformed selection \(engagementID.uuidString)")
return
}
- guard verifyInbound(envelope, engagementID: engagementID, kind: "selection") else { return }
+ guard verifyInbound(envelope, engagementID: engagementID, gameID: gameID, kind: "selection") else { return }
engagementStore.set(selection)
syncMonitor.note(
"engagement: received selection \(engagementID.uuidString) " +
diff --git a/Crossmate/Services/PushClient.swift b/Crossmate/Services/PushClient.swift
@@ -222,11 +222,29 @@ final class PushClient {
for creds in Set(desired.compactMap(\.credentials)) {
await registerGameCredential(creds)
}
- if !desired.isEmpty {
+ // The worker stores a credID-scoped binding only when the request
+ // proves possession of that game's secret (the same header
+ // signature a publish carries), so game bindings go out one
+ // request per credential; the account-scoped address needs no
+ // game proof and rides its own request.
+ let sortedMuted = signature.mutedKinds.sorted()
+ let accountBindings = desired.filter { $0.credentials == nil }
+ if !accountBindings.isEmpty {
try await register(
token: apnsToken,
- bindings: Array(desired),
- mutedKinds: signature.mutedKinds.sorted()
+ bindings: Array(accountBindings),
+ mutedKinds: sortedMuted
+ )
+ }
+ let gameGroups = Dictionary(
+ grouping: desired.filter { $0.credentials != nil }
+ ) { $0.credentials! }
+ for (creds, group) in gameGroups {
+ try await register(
+ token: apnsToken,
+ bindings: group,
+ mutedKinds: sortedMuted,
+ gameCredential: creds
)
}
if !toRemove.isEmpty {
@@ -462,7 +480,8 @@ final class PushClient {
private func register(
token: String,
bindings: [PushAddressBinding],
- mutedKinds: [String]
+ mutedKinds: [String],
+ gameCredential: GamePushCredentials? = nil
) async throws {
var request = URLRequest(url: baseURL.appendingPathComponent("register"))
request.httpMethod = "POST"
@@ -479,7 +498,8 @@ final class PushClient {
request,
method: "POST",
path: "/register",
- body: data
+ body: data,
+ gameCredential: gameCredential
)
try assert204(response)
}
diff --git a/Crossmate/Sync/EngagementCoordinator.swift b/Crossmate/Sync/EngagementCoordinator.swift
@@ -331,6 +331,14 @@ actor EngagementCoordinator {
}
}
+ /// The game a channel is bound to (connecting or live), or nil for an
+ /// unknown or stale engagement. An inbound frame may only be applied to
+ /// this game; a frame arriving on a channel that no longer resolves is
+ /// dropped and converges via Moves sync instead.
+ func gameID(for engagementID: UUID) -> UUID? {
+ stateEntry(for: engagementID)?.0
+ }
+
func channelClosed(engagementID: UUID) async -> UUID? {
guard let (gameID, state) = stateEntry(for: engagementID) else { return nil }
if state.engagementID == engagementID {
diff --git a/Crossmate/Sync/EngagementMessageAuthenticator.swift b/Crossmate/Sync/EngagementMessageAuthenticator.swift
@@ -11,9 +11,13 @@ import Foundation
/// recipient's `FriendEncryptionKeyDirectory` under the sender's authorID). A
/// receiver verifies the tag addressed to it under `key(for: senderAuthorID)`
/// and requires every payload `authorID` to equal the verified sender — so a
-/// participant can only ever write its own Moves record. Anything that fails
-/// (no key yet, missing/invalid tag, author mismatch) is dropped; the durable
-/// Moves/CloudKit path still converges it.
+/// participant can only ever write its own Moves record. The friend key is
+/// pairwise, not per game, so a valid tag alone would let any friend sign a
+/// frame for an arbitrary game ID; the receiver therefore also requires every
+/// payload `gameID` to equal the game its channel is bound to, and the sender
+/// to be a current participant of that game. Anything that fails (no key yet,
+/// missing/invalid tag, author/game mismatch, non-participant) is dropped; the
+/// durable Moves/CloudKit path still converges it.
///
/// The friend key also encrypts push payloads elsewhere, so a distinct MAC
/// subkey is HKDF-derived for domain separation.
@@ -137,6 +141,22 @@ enum EngagementMessageAuthenticator {
}
}
+ /// The `gameID`s the payload writes to. A frame is applied only if every
+ /// one equals the game its channel is bound to, so a room shared for one
+ /// game can't carry a correctly signed edit into another.
+ private static func payloadGameIDs(_ message: EngagementMessage) -> [UUID] {
+ switch message.kind {
+ case .cellEdit:
+ return message.cellEdit.map { [$0.gameID] } ?? []
+ case .cellEditBatch:
+ return message.cellEdits?.map(\.gameID) ?? []
+ case .selection:
+ return message.selection.map { [$0.gameID] } ?? []
+ case .debugText:
+ return []
+ }
+ }
+
// MARK: - Sign
/// Per-recipient tag map for an outgoing message. Includes a tag only for
@@ -169,14 +189,27 @@ enum EngagementMessageAuthenticator {
// MARK: - Verify
/// True iff `message` carries a tag for `localAuthorID` that verifies under
- /// the sender's friend key and every payload authorID equals the sender.
- static func verify(_ message: EngagementMessage, localAuthorID: String) -> Bool {
+ /// the sender's friend key, every payload authorID equals the sender, every
+ /// payload gameID equals `channelGameID` (the game the receiving channel is
+ /// bound to), and the sender is in `participantAuthorIDs` (that game's
+ /// known roster). The structural checks run before any MAC work.
+ static func verify(
+ _ message: EngagementMessage,
+ localAuthorID: String,
+ channelGameID: UUID,
+ participantAuthorIDs: [String]
+ ) -> Bool {
let sender = message.senderAuthorID
guard !sender.isEmpty, !localAuthorID.isEmpty, sender != localAuthorID else { return false }
let authors = payloadAuthorIDs(message)
guard !authors.isEmpty, authors.allSatisfy({ $0 == sender }) else { return false }
+ let gameIDs = payloadGameIDs(message)
+ guard !gameIDs.isEmpty, gameIDs.allSatisfy({ $0 == channelGameID }) else { return false }
+
+ guard participantAuthorIDs.contains(sender) else { return false }
+
guard let tagBase64 = message.auth[localAuthorID],
let tag = Data(base64Encoded: tagBase64),
let friendKey = FriendEncryptionKeyDirectory.key(for: sender),
diff --git a/Crossmate/Sync/GamePushCredentials.swift b/Crossmate/Sync/GamePushCredentials.swift
@@ -22,20 +22,33 @@ import Foundation
/// as a whole — which now also holds `contentKey` — must never leave the device
/// for a Worker, or the encryption is pointless.
///
-/// Unlike room creds these are durable, so there is no expiry.
+/// Unlike room creds these are durable, so there is no expiry. They are,
+/// however, *rotated* — replaced wholesale by any remaining participant when
+/// someone leaves or is removed, so departed access ends. `gen` is the
+/// monotonic rotation generation: a receiver only adopts an inbound credential
+/// whose generation is at least its local one, so a stale device re-pushing
+/// its whole Game record (which carries this blob) can't resurrect superseded
+/// credentials. Optional so blobs minted before rotation existed still decode;
+/// absent means generation 1, matching what a fresh mint uses (see
+/// `RecordSerializer.decisionBaseVersion` for the same convention).
struct GamePushCredentials: Codable, Equatable, Hashable, Sendable {
var ver: Int
var credID: UUID
var secret: String
var contentKey: String?
+ var gen: Int64?
- init(credID: UUID = UUID(), secret: String, contentKey: String? = nil, ver: Int = 1) {
+ init(credID: UUID = UUID(), secret: String, contentKey: String? = nil, gen: Int64? = nil, ver: Int = 1) {
self.ver = ver
self.credID = credID
self.secret = secret
self.contentKey = contentKey
+ self.gen = gen
}
+ /// The rotation generation, with legacy (field-less) blobs reporting 1.
+ var generation: Int64 { gen ?? 1 }
+
func encoded() throws -> String {
let data = try JSONEncoder().encode(self)
guard let string = String(data: data, encoding: .utf8) else {
@@ -65,6 +78,17 @@ struct GamePushCredentials: Codable, Equatable, Hashable, Sendable {
static func freshContentKey() throws -> String {
try Data.secureRandom(count: 32).base64EncodedString()
}
+
+ /// A full replacement credential — new `credID`, worker secret, and content
+ /// key — at the next generation after `current`. Minted by a remaining
+ /// participant when someone leaves or is removed: the departed device holds
+ /// every field of the old credential, so nothing short of replacing all
+ /// three revokes its push access.
+ static func rotated(after current: GamePushCredentials) throws -> GamePushCredentials {
+ var fresh = try GamePushCredentials.fresh()
+ fresh.gen = current.generation + 1
+ return fresh
+ }
}
/// One address this device should be reachable at, paired with the per-game
diff --git a/Crossmate/Sync/RecordApplier.swift b/Crossmate/Sync/RecordApplier.swift
@@ -21,6 +21,18 @@ struct BatchEffects {
/// Games whose inbound Game record changed the notification content key, so
/// the caller re-mirrors the App Group key directory the NSE reads.
var contentKeysChanged = Set<UUID>()
+ /// Game record names whose inbound copy carried a push credential *older*
+ /// (by rotation generation) than the local one — a stale device's re-push
+ /// landed on the server. The local value was kept and flagged pending; the
+ /// caller re-enqueues these records so the rotated credential heals the
+ /// server copy.
+ var staleCredentialRecords = Set<String>()
+ /// Games whose inbound zone-wide `CKShare` lost a previously accepted
+ /// participant — someone left or was removed. Every remaining device that
+ /// observes this rotates the game's push credentials (record-level LWW
+ /// converges concurrent rotations), ending the departed device's ability
+ /// to receive, publish, or re-subscribe to the game's pushes.
+ var credentialRotations = Set<UUID>()
var removed = Set<UUID>()
/// Per-game incoming read cursor from one of *our own* devices: the
/// presence-lease time plus the sibling's "last viewed" cutoff that it
@@ -99,6 +111,7 @@ extension SyncEngine {
onEngagementChange: { effects.engagementChanged.insert($0) },
onCompletedTransition: { effects.completedTransitions.insert($0) },
onContentKeyChange: { effects.contentKeysChanged.insert($0) },
+ onStaleCredentials: { effects.staleCredentialRecords.insert($0) },
onDiagnostic: { effects.traces.append($0) }
)
if let id = entity.id {
@@ -239,6 +252,15 @@ extension SyncEngine {
userInfo: ["gameIDs": effects.rosterRelevant]
)
}
+ // Re-push games whose inbound record tried to downgrade a rotated
+ // credential: the local (newer) value was kept and marked pending, so
+ // this send heals the server copy.
+ for recordName in effects.staleCredentialRecords {
+ enqueueGame(ckRecordName: recordName)
+ }
+ if let onRemoteCredentialsChanged, !effects.contentKeysChanged.isEmpty {
+ await onRemoteCredentialsChanged(effects.contentKeysChanged)
+ }
}
nonisolated func applyPlayerRecord(
@@ -516,6 +538,72 @@ extension SyncEngine {
"\(nsError.localizedDescription)"
}
+ /// Applies an inbound zone-wide `CKShare` for a game zone: hands the
+ /// share's current accepted roster to `applyShareRoster` and returns the
+ /// game ID when a previously accepted participant has disappeared — the
+ /// signal that the game's push credentials must be rotated. Non-game zones
+ /// (friend zones carry shares too) are ignored.
+ nonisolated func applyShareRecord(
+ _ share: CKShare,
+ databaseScope: DatabaseScope,
+ in ctx: NSManagedObjectContext
+ ) -> UUID? {
+ let zoneID = share.recordID.zoneID
+ guard zoneID.zoneName.hasPrefix("game-"),
+ let gameID = UUID(uuidString: String(zoneID.zoneName.dropFirst("game-".count)))
+ else { return nil }
+ // The share's participant list is CloudKit-maintained — participants
+ // cannot forge it — so it is the authoritative membership. Only
+ // accepted non-owner participants matter: they are the only people who
+ // ever had zone access, and therefore the only ones who could hold the
+ // game's credentials.
+ let accepted = share.participants
+ .filter { $0.role != .owner && $0.acceptanceStatus == .accepted }
+ .compactMap { $0.userIdentity.userRecordID?.recordName }
+ let departed = Self.applyShareRoster(
+ gameID: gameID,
+ zoneID: zoneID,
+ acceptedParticipants: accepted,
+ databaseScope: databaseScope,
+ in: ctx
+ )
+ return departed ? gameID : nil
+ }
+
+ /// Records `acceptedParticipants` as the game's last-seen share roster and
+ /// reports whether any member of the previously recorded roster is missing
+ /// from it — someone left or was removed. The first sighting (no recorded
+ /// roster yet) only seeds the baseline and never reports a departure, so
+ /// updating the app doesn't trigger a spurious rotation on every shared
+ /// game. Static and CloudKit-free so tests can drive it directly.
+ nonisolated static func applyShareRoster(
+ gameID: UUID,
+ zoneID: CKRecordZone.ID,
+ acceptedParticipants: [String],
+ databaseScope: DatabaseScope,
+ in ctx: NSManagedObjectContext
+ ) -> Bool {
+ let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ req.predicate = RecordSerializer.gameIdentityPredicate(
+ recordName: "game-\(gameID.uuidString)",
+ zoneID: zoneID,
+ databaseScope: databaseScope,
+ entityPrefix: ""
+ )
+ req.fetchLimit = 1
+ guard let entity = try? ctx.fetch(req).first else { return false }
+
+ let current = Set(acceptedParticipants.filter { !$0.isEmpty })
+ let encoded = current.sorted().joined(separator: ",")
+ let priorEncoded = entity.shareParticipants
+ if priorEncoded != encoded {
+ entity.shareParticipants = encoded
+ }
+ guard let priorEncoded, !priorEncoded.isEmpty else { return false }
+ let prior = Set(priorEncoded.split(separator: ",").map(String.init))
+ return !prior.subtracting(current).isEmpty
+ }
+
/// Applies an inbound `Archive` record. Inert while a live (non-revoked)
/// copy of the original game still exists on this device — the device already
/// holds the data, so surfacing a second row would duplicate it. On a device
diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift
@@ -988,6 +988,7 @@ enum RecordSerializer {
onEngagementChange: ((UUID) -> Void)? = nil,
onCompletedTransition: ((UUID) -> Void)? = nil,
onContentKeyChange: ((UUID) -> Void)? = nil,
+ onStaleCredentials: ((String) -> Void)? = nil,
onDiagnostic: ((String) -> Void)? = nil
) -> GameEntity {
let recordName = record.recordID.recordName
@@ -1097,11 +1098,45 @@ enum RecordSerializer {
// `onContentKeyChange` to re-mirror the App Group key directory the NSE
// reads. This is what lets a freshly-joined participant decrypt the
// first push it receives, rather than waiting for the next launch heal.
+ //
+ // Adoption is generation-gated: rotation replaces the credential at a
+ // higher `gen`, and the Game record is pushed whole, so a stale device
+ // re-pushing an unrelated change (its inbound applies were blocked by
+ // its own `hasPendingSave`) can land the *old* credential back on the
+ // server. Adopting that here would silently re-admit a departed
+ // participant. Instead keep the newer local credential, flag our copy
+ // as pending, and hand the record name to `onStaleCredentials` so the
+ // caller re-pushes it — healing the server to the rotated value.
let incomingNotification = (record["pushCredential"] as? Data)
.flatMap { String(data: $0, encoding: .utf8) }
if entity.notification != incomingNotification {
- entity.notification = incomingNotification
- if let id = entity.id { onContentKeyChange?(id) }
+ let localGen = GamePushCredentials.decode(entity.notification)?.generation
+ let incomingGen = GamePushCredentials.decode(incomingNotification)?.generation
+ // A local credential is superseded only by an inbound one of equal
+ // or higher generation (equal = concurrent rotations; the server
+ // copy wins the tie). An inbound *absence* never clears a local
+ // credential — creds are minted once and only ever replaced, so a
+ // missing field is always a stale record.
+ let incomingSupersedes: Bool
+ if localGen == nil {
+ incomingSupersedes = true
+ } else if let incomingGen, let localGen {
+ incomingSupersedes = incomingGen >= localGen
+ } else {
+ incomingSupersedes = false
+ }
+ if incomingSupersedes {
+ entity.notification = incomingNotification
+ if let id = entity.id { onContentKeyChange?(id) }
+ } else {
+ onDiagnostic?(
+ "stale pushCredential for \(recordName) " +
+ "(inbound gen \(incomingGen.map(String.init) ?? "none") < " +
+ "local gen \(localGen.map(String.init) ?? "none")) — keeping local, re-pushing"
+ )
+ entity.hasPendingSave = true
+ onStaleCredentials?(recordName)
+ }
}
if let asset = record["puzzleSource"] as? CKAsset,
diff --git a/Crossmate/Sync/ShareController.swift b/Crossmate/Sync/ShareController.swift
@@ -41,6 +41,12 @@ final class ShareController {
/// `isShared` flag) can flip without waiting for the user to re-open.
var onShareSaved: (@MainActor (UUID) -> Void)?
+ /// Fired after this device removed an *accepted* participant from a game's
+ /// share. The departed device holds the game's push credentials, so the
+ /// handler rotates them immediately instead of waiting for the share-record
+ /// change to echo back through sync.
+ var onParticipantRemoved: (@MainActor (UUID) -> Void)?
+
/// Author IDs added as direct game participants during this app session,
/// keyed by game. Re-asserted on every invite save so an eventually-
/// consistent share fetch — which can omit a participant added moments
@@ -240,12 +246,13 @@ final class ShareController {
let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)"
guard let share = try await fetchZoneWideShareIfPresent(zoneName: zoneName),
- removeParticipant(userRecordName, from: share)
+ let removed = removeParticipant(userRecordName, from: share)
else {
// No share, or they aren't on it (already removed / never added).
syncMonitor?.recordSuccess("free declined seat")
return
}
+ var removedAccepted = removed.acceptanceStatus == .accepted
do {
_ = try await saveShareForLink(share, for: gameID)
} catch let error as CKError where error.code == .serverRecordChanged {
@@ -253,10 +260,24 @@ final class ShareController {
.userInfo[CKRecordChangedErrorServerRecordKey] as? CKShare else {
throw error
}
- if removeParticipant(userRecordName, from: serverShare) {
+ if let removedAgain = removeParticipant(userRecordName, from: serverShare) {
+ removedAccepted = removedAgain.acceptanceStatus == .accepted
_ = try await saveShareForLink(serverShare, for: gameID)
+ } else {
+ // The server copy no longer lists them — another device
+ // already removed them, and its own removal (or the share
+ // echo) drives any rotation.
+ removedAccepted = false
}
}
+ // A declined invitee never accepted, so they never had zone access
+ // or the game's credentials — no rotation needed. But if the seat
+ // freed belonged to an *accepted* participant, their device holds
+ // the push credentials: signal the owner-side rotation now rather
+ // than waiting for the share-record change to echo back via sync.
+ if removedAccepted {
+ onParticipantRemoved?(gameID)
+ }
syncMonitor?.recordSuccess("free declined seat")
} catch {
syncMonitor?.recordError("free declined seat", error)
@@ -278,18 +299,20 @@ final class ShareController {
}
/// Removes the non-owner participant matching `userRecordName` from `share`,
- /// returning whether one was found. Idempotent: a participant already gone
- /// returns `false` so the caller can skip a redundant save.
+ /// returning the removed participant (its `acceptanceStatus` tells the
+ /// caller whether the person ever had zone access). Idempotent: a
+ /// participant already gone returns `nil` so the caller can skip a
+ /// redundant save.
private func removeParticipant(
_ userRecordName: String,
from share: CKShare
- ) -> Bool {
+ ) -> CKShare.Participant? {
guard let participant = share.participants.first(where: {
$0.role != .owner
&& $0.userIdentity.userRecordID?.recordName == userRecordName
- }) else { return false }
+ }) else { return nil }
share.removeParticipant(participant)
- return true
+ return participant
}
/// Caps the share at `maximumInviteesPerPuzzle` distinct invitees, counting
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -150,6 +150,16 @@ actor SyncEngine {
/// changed (a peer minted or rotated the room). Drives the receiver to
/// reconcile its live connection toward the new creds.
var onRemoteEngagementChanged: (@MainActor @Sendable (Set<UUID>) async -> Void)?
+ /// Fires with the game IDs whose zone-wide share just lost a previously
+ /// accepted participant. The handler rotates the game's push credentials
+ /// so the departed device's cached copy stops granting push access.
+ var onPushCredentialRotationNeeded: (@MainActor @Sendable (Set<UUID>) async -> Void)?
+ /// Fires with the game IDs whose inbound Game record changed the push
+ /// credential blob (a peer minted — or rotated — it). The handler re-runs
+ /// push registration so this device binds under the new credID promptly
+ /// and drops its old binding, instead of waiting for the next launch or
+ /// token refresh.
+ var onRemoteCredentialsChanged: (@MainActor @Sendable (Set<UUID>) async -> Void)?
var onPings: (@MainActor @Sendable ([Ping]) async -> Void)?
private var onAccountChange: (@MainActor @Sendable () async -> Void)?
private var onGameAccessRevoked: (@MainActor @Sendable (UUID) async -> Void)?
@@ -229,6 +239,14 @@ actor SyncEngine {
onRemoteEngagementChanged = cb
}
+ func setOnPushCredentialRotationNeeded(_ cb: @MainActor @Sendable @escaping (Set<UUID>) async -> Void) {
+ onPushCredentialRotationNeeded = cb
+ }
+
+ func setOnRemoteCredentialsChanged(_ cb: @MainActor @Sendable @escaping (Set<UUID>) async -> Void) {
+ onRemoteCredentialsChanged = cb
+ }
+
func setOnPings(_ cb: @MainActor @Sendable @escaping ([Ping]) async -> Void) {
onPings = cb
}
@@ -1497,6 +1515,7 @@ actor SyncEngine {
onEngagementChange: { effects.engagementChanged.insert($0) },
onCompletedTransition: { effects.completedTransitions.insert($0) },
onContentKeyChange: { effects.contentKeysChanged.insert($0) },
+ onStaleCredentials: { effects.staleCredentialRecords.insert($0) },
onDiagnostic: { effects.traces.append($0) }
)
if let id = entity.id {
@@ -1608,6 +1627,19 @@ actor SyncEngine {
effects.rosterRelevant.insert(id)
effects.visibilityCandidateGameIDs.insert(id)
}
+ case CKRecord.SystemType.share:
+ // The zone-wide share is the authoritative membership. A
+ // previously accepted participant disappearing from it is
+ // the departure/revocation signal: rotate the game's push
+ // credentials so the departed device's cached copy stops
+ // granting push access.
+ if let share = record as? CKShare,
+ let gameID = self.applyShareRecord(share, databaseScope: scope, in: ctx) {
+ effects.credentialRotations.insert(gameID)
+ effects.traces.append(
+ "share roster shrank for \(gameID.uuidString) — rotating push credentials"
+ )
+ }
default:
break
}
@@ -1684,6 +1716,12 @@ actor SyncEngine {
if let onRemoteEngagementChanged, !effects.engagementChanged.isEmpty {
await onRemoteEngagementChanged(effects.engagementChanged)
}
+ if let onPushCredentialRotationNeeded, !effects.credentialRotations.isEmpty {
+ await onPushCredentialRotationNeeded(effects.credentialRotations)
+ }
+ if let onRemoteCredentialsChanged, !effects.contentKeysChanged.isEmpty {
+ await onRemoteCredentialsChanged(effects.contentKeysChanged)
+ }
if let onIncomingReadCursor, !effects.readCursors.isEmpty {
await onIncomingReadCursor(effects.readCursors)
}
@@ -1721,6 +1759,12 @@ actor SyncEngine {
for id in effects.removed {
if let cb = onGameRemoved { await cb(id) }
}
+ // Re-push games whose inbound record tried to downgrade a rotated
+ // credential: the local (newer) value was kept and marked pending, so
+ // this send heals the server copy.
+ for recordName in effects.staleCredentialRecords {
+ enqueueGame(ckRecordName: recordName)
+ }
// A game just learned it's complete via sync: upload this device's
// journal (no-op if it logged nothing) so replay can converge. The
// enqueue defers its CKSyncEngine drain via `sendChangesDetached`, so
@@ -2163,9 +2207,44 @@ actor SyncEngine {
else { return false }
writeBackSystemFields(record: serverRecord, in: ctx)
+ adoptNewerServerCredentials(from: serverRecord, in: ctx)
return true
}
+ /// The oplock-recovery retry re-pushes the whole local Game record, and
+ /// while this entity's `hasPendingSave` was set its inbound applies were
+ /// skipped — so the local `notification` blob may predate a credential
+ /// rotation a peer performed in the meantime. Re-pushing it verbatim would
+ /// land the *old* credential back on the server and re-admit a departed
+ /// participant's push access. Before the retry, adopt the server's
+ /// credential whenever its rotation generation is at least ours (the tie
+ /// goes to the server copy, converging concurrent rotations); a *lower*
+ /// server generation is the stale state the retry exists to heal, so local
+ /// wins there.
+ private nonisolated func adoptNewerServerCredentials(
+ from serverRecord: CKRecord,
+ in ctx: NSManagedObjectContext
+ ) {
+ let name = serverRecord.recordID.recordName
+ guard name.hasPrefix("game-") else { return }
+ let serverBlob = (serverRecord["pushCredential"] as? Data)
+ .flatMap { String(data: $0, encoding: .utf8) }
+ guard let serverBlob, let serverCreds = GamePushCredentials.decode(serverBlob) else { return }
+
+ let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ req.predicate = NSPredicate(format: "ckRecordName == %@", name)
+ req.fetchLimit = 1
+ guard let entity = try? ctx.fetch(req).first,
+ entity.notification != serverBlob
+ else { return }
+ let localGen = GamePushCredentials.decode(entity.notification)?.generation
+ guard localGen == nil || serverCreds.generation >= localGen! else { return }
+ entity.notification = serverBlob
+ // The adopted blob may carry a new content key; re-mirror the App Group
+ // directory so the NSE can decrypt pushes sealed under it.
+ GameEntity.rebuildContentKeyDirectory(in: ctx)
+ }
+
private nonisolated func writeBackSystemFields(
record: CKRecord,
in ctx: NSManagedObjectContext
diff --git a/Tests/Unit/RecordSerializerTests.swift b/Tests/Unit/RecordSerializerTests.swift
@@ -426,7 +426,7 @@ struct RecordSerializerTests {
#expect(merged.completedBy == nil)
}
- @Test("applyGameRecord round-trips the notification push credential and clears it when absent")
+ @Test("applyGameRecord round-trips the notification push credential and keeps it when absent")
@MainActor func applyGameRecordNotification() throws {
let persistence = makeTestPersistence()
let ctx = persistence.viewContext
@@ -456,19 +456,25 @@ struct RecordSerializerTests {
#expect(GamePushCredentials.decode(entity.notification) == creds)
#expect(contentKeyChanges == [gameID])
- // A later record without the field clears it (LWW convergence) and the
- // change fires again so the key directory is re-mirrored.
+ // A later record without the field never clears a local credential:
+ // creds are minted once and only ever replaced (rotation), so an
+ // absent field is always a stale record. The local value is kept,
+ // flagged pending, and signalled for a re-push that heals the server.
let cleared = CKRecord(recordType: "Game", recordID: recordID)
cleared["title"] = "T" as CKRecordValue
+ var staleRecords: [String] = []
let merged = RecordSerializer.applyGameRecord(
cleared,
to: ctx,
- onContentKeyChange: { contentKeyChanges.append($0) }
+ onContentKeyChange: { contentKeyChanges.append($0) },
+ onStaleCredentials: { staleRecords.append($0) }
)
try ctx.save()
#expect(merged === entity)
- #expect(merged.notification == nil)
- #expect(contentKeyChanges == [gameID, gameID])
+ #expect(GamePushCredentials.decode(merged.notification) == creds)
+ #expect(contentKeyChanges == [gameID])
+ #expect(staleRecords == [recordID.recordName])
+ #expect(merged.hasPendingSave)
}
@Test("applyGameRecord preserves id and createdAt on second apply, updates title")
diff --git a/Tests/Unit/Sync/EngagementCoordinatorTests.swift b/Tests/Unit/Sync/EngagementCoordinatorTests.swift
@@ -189,6 +189,26 @@ struct EngagementCoordinatorTests {
#expect(EngagementMessage.decode(try #require(host.sentMessages.first?.message))?.text == "now ok")
}
+ @Test("a channel resolves to its game while connecting and live, and not after close")
+ @MainActor
+ func channelGameBinding() async throws {
+ let gameID = UUID(uuidString: "56565656-5656-5656-5656-565656565656")!
+ let host = MockEngagementHost()
+ let coordinator = makeCoordinator(host: host)
+
+ #expect(await coordinator.gameID(for: UUID()) == nil)
+
+ await coordinator.reconcile(gameID: gameID, creds: roomCredentials(), hasPeer: true)
+ let engagementID = try #require(host.connections.first?.engagementID)
+ #expect(await coordinator.gameID(for: engagementID) == gameID)
+
+ #expect(await coordinator.channelOpened(engagementID: engagementID) == gameID)
+ #expect(await coordinator.gameID(for: engagementID) == gameID)
+
+ #expect(await coordinator.channelClosed(engagementID: engagementID) == gameID)
+ #expect(await coordinator.gameID(for: engagementID) == nil)
+ }
+
@Test("reconcile migrates to a rotated room and drops the old connection")
@MainActor
func reconcileMigratesWhenCredsChange() async throws {
diff --git a/Tests/Unit/Sync/EngagementMessageAuthenticatorTests.swift b/Tests/Unit/Sync/EngagementMessageAuthenticatorTests.swift
@@ -16,6 +16,12 @@ struct EngagementMessageAuthenticatorTests {
private static let bob = "_bob"
private static let carol = "_carol"
+ /// The game the receiving channel is bound to; `otherGameID` is a second
+ /// game the sender also knows, used for cross-game injection attempts.
+ private static let gameID = UUID(uuidString: "22222222-2222-2222-2222-222222222222")!
+ private static let otherGameID = UUID(uuidString: "99999999-9999-9999-9999-999999999999")!
+ private static let roster = [alice, bob]
+
private static let localKeyPrefix = "push.friendEncryptionKey."
/// Installs `payload` as Alice's own send key for the Alice↔Bob pair (in
@@ -43,10 +49,11 @@ struct EngagementMessageAuthenticatorTests {
private static func cellEdit(
authorID: String,
- letter: String = "A"
+ letter: String = "A",
+ gameID: UUID = gameID
) -> RealtimeCellEdit {
RealtimeCellEdit(
- gameID: UUID(uuidString: "22222222-2222-2222-2222-222222222222")!,
+ gameID: gameID,
authorID: authorID,
deviceID: "device-alice",
row: 1,
@@ -58,6 +65,35 @@ struct EngagementMessageAuthenticatorTests {
)
}
+ private static func selection(
+ authorID: String,
+ gameID: UUID = gameID
+ ) -> EngagementSelectionUpdate {
+ EngagementSelectionUpdate(
+ gameID: gameID,
+ authorID: authorID,
+ deviceID: "device-alice",
+ selection: PlayerSelection(row: 1, col: 2, direction: .across),
+ updatedAt: Date(timeIntervalSince1970: 1000)
+ )
+ }
+
+ /// `verify` with this suite's fixed channel binding: the channel is bound
+ /// to `gameID` and the game's known roster is Alice and Bob.
+ private static func verify(
+ _ message: EngagementMessage,
+ localAuthorID: String,
+ channelGameID: UUID = gameID,
+ participantAuthorIDs: [String] = roster
+ ) -> Bool {
+ EngagementMessageAuthenticator.verify(
+ message,
+ localAuthorID: localAuthorID,
+ channelGameID: channelGameID,
+ participantAuthorIDs: participantAuthorIDs
+ )
+ }
+
@Test("a tag Alice signs for Bob verifies on Bob's device")
func roundTripVerifies() async throws {
try await Self.withAliceBobKey { _ in
@@ -70,7 +106,7 @@ struct EngagementMessageAuthenticatorTests {
#expect(tags[Self.bob] != nil)
let received = EngagementMessage(cellEdit: edit, senderAuthorID: Self.alice, auth: tags)
- #expect(EngagementMessageAuthenticator.verify(received, localAuthorID: Self.bob))
+ #expect(Self.verify(received, localAuthorID: Self.bob))
}
}
@@ -85,7 +121,7 @@ struct EngagementMessageAuthenticatorTests {
senderAuthorID: Self.alice,
auth: [Self.bob: Data("forged".utf8).base64EncodedString()]
)
- #expect(!EngagementMessageAuthenticator.verify(received, localAuthorID: Self.bob))
+ #expect(!Self.verify(received, localAuthorID: Self.bob))
}
}
@@ -101,7 +137,7 @@ struct EngagementMessageAuthenticatorTests {
recipientAuthorIDs: [Self.bob]
)
let received = EngagementMessage(cellEdit: foreign, senderAuthorID: Self.alice, auth: tags)
- #expect(!EngagementMessageAuthenticator.verify(received, localAuthorID: Self.bob))
+ #expect(!Self.verify(received, localAuthorID: Self.bob))
}
}
@@ -117,7 +153,7 @@ struct EngagementMessageAuthenticatorTests {
// Same tag, different letter.
let tampered = Self.cellEdit(authorID: Self.alice, letter: "Z")
let received = EngagementMessage(cellEdit: tampered, senderAuthorID: Self.alice, auth: tags)
- #expect(!EngagementMessageAuthenticator.verify(received, localAuthorID: Self.bob))
+ #expect(!Self.verify(received, localAuthorID: Self.bob))
}
}
@@ -134,7 +170,78 @@ struct EngagementMessageAuthenticatorTests {
senderAuthorID: Self.alice,
auth: [Self.bob: Data("anything".utf8).base64EncodedString()]
)
- #expect(!EngagementMessageAuthenticator.verify(received, localAuthorID: Self.bob))
+ #expect(!Self.verify(received, localAuthorID: Self.bob))
+ }
+ }
+
+ @Test("a correctly MACed edit for a different game is dropped")
+ func crossGameEditDropped() async throws {
+ try await Self.withAliceBobKey { _ in
+ // Alice shares a live room with Bob for one game and signs a valid
+ // frame targeting another game she knows the UUID of. The tag
+ // verifies, but the payload game is not the channel's game.
+ let edit = Self.cellEdit(authorID: Self.alice, gameID: Self.otherGameID)
+ let tags = EngagementMessageAuthenticator.authTags(
+ for: EngagementMessage(cellEdit: edit),
+ senderAuthorID: Self.alice,
+ recipientAuthorIDs: [Self.bob]
+ )
+ let received = EngagementMessage(cellEdit: edit, senderAuthorID: Self.alice, auth: tags)
+ #expect(!Self.verify(received, localAuthorID: Self.bob))
+ // The same frame is accepted on a channel bound to its own game.
+ #expect(Self.verify(received, localAuthorID: Self.bob, channelGameID: Self.otherGameID))
+ }
+ }
+
+ @Test("a batch containing any cross-game edit is dropped whole")
+ func mixedGameBatchDropped() async throws {
+ try await Self.withAliceBobKey { _ in
+ let edits = [
+ Self.cellEdit(authorID: Self.alice),
+ Self.cellEdit(authorID: Self.alice, gameID: Self.otherGameID),
+ ]
+ let tags = EngagementMessageAuthenticator.authTags(
+ for: EngagementMessage(cellEdits: edits),
+ senderAuthorID: Self.alice,
+ recipientAuthorIDs: [Self.bob]
+ )
+ let received = EngagementMessage(cellEdits: edits, senderAuthorID: Self.alice, auth: tags)
+ #expect(!Self.verify(received, localAuthorID: Self.bob))
+ }
+ }
+
+ @Test("a correctly MACed selection for a different game is dropped")
+ func crossGameSelectionDropped() async throws {
+ try await Self.withAliceBobKey { _ in
+ let selection = Self.selection(authorID: Self.alice, gameID: Self.otherGameID)
+ let tags = EngagementMessageAuthenticator.authTags(
+ for: EngagementMessage(selection: selection),
+ senderAuthorID: Self.alice,
+ recipientAuthorIDs: [Self.bob]
+ )
+ let received = EngagementMessage(selection: selection, senderAuthorID: Self.alice, auth: tags)
+ #expect(!Self.verify(received, localAuthorID: Self.bob))
+ #expect(Self.verify(received, localAuthorID: Self.bob, channelGameID: Self.otherGameID))
+ }
+ }
+
+ @Test("a sender outside the game's roster is dropped")
+ func nonParticipantSenderDropped() async throws {
+ try await Self.withAliceBobKey { _ in
+ // Alice still holds Bob's friend key (they remain friends) but is
+ // no longer a participant of the channel's game.
+ let edit = Self.cellEdit(authorID: Self.alice)
+ let tags = EngagementMessageAuthenticator.authTags(
+ for: EngagementMessage(cellEdit: edit),
+ senderAuthorID: Self.alice,
+ recipientAuthorIDs: [Self.bob]
+ )
+ let received = EngagementMessage(cellEdit: edit, senderAuthorID: Self.alice, auth: tags)
+ #expect(!Self.verify(
+ received,
+ localAuthorID: Self.bob,
+ participantAuthorIDs: [Self.bob, Self.carol]
+ ))
}
}
diff --git a/Tests/Unit/Sync/PushCredentialRotationTests.swift b/Tests/Unit/Sync/PushCredentialRotationTests.swift
@@ -0,0 +1,210 @@
+import CloudKit
+import CoreData
+import Foundation
+import Testing
+
+@testable import Crossmate
+
+/// H4 coverage: rotating a game's push credentials when a participant departs,
+/// and the generation guard that stops a stale device's Game-record re-push
+/// from resurrecting the superseded credential.
+@Suite("Push credential rotation", .isolatedNotificationState)
+@MainActor
+struct PushCredentialRotationTests {
+
+ private let gameID = UUID(uuidString: "AABBCCDD-1111-0000-0000-111122223333")!
+
+ private var zoneID: CKRecordZone.ID {
+ RecordSerializer.zoneID(for: gameID, ownerName: CKCurrentUserDefaultName)
+ }
+
+ private func credentialBlob(gen: Int64?) throws -> String {
+ var creds = try GamePushCredentials.fresh()
+ creds.gen = gen
+ return try creds.encoded()
+ }
+
+ private func gameRecord(blob: String?) -> CKRecord {
+ let record = CKRecord(
+ recordType: "Game",
+ recordID: CKRecord.ID(recordName: "game-\(gameID.uuidString)", zoneID: zoneID)
+ )
+ record["title"] = "Test"
+ if let blob {
+ record["pushCredential"] = blob.data(using: .utf8)
+ }
+ return record
+ }
+
+ // MARK: - GamePushCredentials generation
+
+ @Test("a legacy blob without a generation reports generation 1")
+ func legacyBlobGeneration() throws {
+ let legacy = """
+ {"ver":1,"credID":"11111111-2222-3333-4444-555555555555","secret":"abc"}
+ """
+ let decoded = try #require(GamePushCredentials.decode(legacy))
+ #expect(decoded.generation == 1)
+ }
+
+ @Test("rotation replaces every field and bumps the generation")
+ func rotationReplacesEverything() throws {
+ let original = try GamePushCredentials.fresh()
+ #expect(original.generation == 1)
+
+ let rotated = try GamePushCredentials.rotated(after: original)
+ #expect(rotated.generation == 2)
+ #expect(rotated.credID != original.credID)
+ #expect(rotated.secret != original.secret)
+ #expect(rotated.contentKey != nil)
+ #expect(rotated.contentKey != original.contentKey)
+
+ let roundTripped = GamePushCredentials.decode(try rotated.encoded())
+ #expect(roundTripped == rotated)
+ }
+
+ // MARK: - Inbound generation guard
+
+ @Test("an inbound record at a higher generation is adopted")
+ func higherGenerationAdopted() throws {
+ let ctx = makeTestPersistence().container.viewContext
+ let gen2 = try credentialBlob(gen: 2)
+ let gen3 = try credentialBlob(gen: 3)
+
+ var contentKeyChanges = 0
+ let entity = RecordSerializer.applyGameRecord(
+ gameRecord(blob: gen2),
+ to: ctx,
+ onContentKeyChange: { _ in contentKeyChanges += 1 }
+ )
+ #expect(entity.notification == gen2)
+ #expect(contentKeyChanges == 1)
+
+ _ = RecordSerializer.applyGameRecord(
+ gameRecord(blob: gen3),
+ to: ctx,
+ onContentKeyChange: { _ in contentKeyChanges += 1 }
+ )
+ #expect(entity.notification == gen3)
+ #expect(contentKeyChanges == 2)
+ #expect(!entity.hasPendingSave)
+ }
+
+ @Test("an inbound record at a lower generation is kept out and healed")
+ func lowerGenerationKeptOut() throws {
+ let ctx = makeTestPersistence().container.viewContext
+ let gen2 = try credentialBlob(gen: 2)
+ let gen1 = try credentialBlob(gen: 1)
+
+ let entity = RecordSerializer.applyGameRecord(gameRecord(blob: gen2), to: ctx)
+
+ var staleRecords: [String] = []
+ var contentKeyChanges = 0
+ _ = RecordSerializer.applyGameRecord(
+ gameRecord(blob: gen1),
+ to: ctx,
+ onContentKeyChange: { _ in contentKeyChanges += 1 },
+ onStaleCredentials: { staleRecords.append($0) }
+ )
+ #expect(entity.notification == gen2)
+ #expect(contentKeyChanges == 0)
+ #expect(staleRecords == ["game-\(gameID.uuidString)"])
+ #expect(entity.hasPendingSave, "the kept-newer credential must re-push to heal the server")
+ }
+
+ @Test("an inbound record missing the credential never clears a local one")
+ func absentCredentialKeptOut() throws {
+ let ctx = makeTestPersistence().container.viewContext
+ let gen1 = try credentialBlob(gen: 1)
+
+ let entity = RecordSerializer.applyGameRecord(gameRecord(blob: gen1), to: ctx)
+
+ var staleRecords: [String] = []
+ _ = RecordSerializer.applyGameRecord(
+ gameRecord(blob: nil),
+ to: ctx,
+ onStaleCredentials: { staleRecords.append($0) }
+ )
+ #expect(entity.notification == gen1)
+ #expect(staleRecords == ["game-\(gameID.uuidString)"])
+ }
+
+ // MARK: - Share roster departure detection
+
+ @Test("a roster shrink is detected once; baselines and growth never fire")
+ func shareRosterShrinkDetection() throws {
+ let ctx = makeTestPersistence().container.viewContext
+ _ = RecordSerializer.applyGameRecord(gameRecord(blob: nil), to: ctx)
+
+ func apply(_ accepted: [String]) -> Bool {
+ SyncEngine.applyShareRoster(
+ gameID: gameID,
+ zoneID: zoneID,
+ acceptedParticipants: accepted,
+ databaseScope: .private,
+ in: ctx
+ )
+ }
+
+ // First sighting seeds the baseline silently (an app update must not
+ // rotate every shared game).
+ #expect(!apply(["alice", "bob"]))
+ // Unchanged roster: nothing.
+ #expect(!apply(["alice", "bob"]))
+ // Bob left or was removed.
+ #expect(apply(["alice"]))
+ // Re-observing the shrunken roster is idempotent.
+ #expect(!apply(["alice"]))
+ // Growth (Carol accepted) is not a departure.
+ #expect(!apply(["alice", "carol"]))
+ // Alice's departure from the grown roster fires again.
+ #expect(apply(["carol"]))
+ }
+
+ @Test("a share for an unknown game records nothing and never fires")
+ func shareRosterUnknownGame() throws {
+ let ctx = makeTestPersistence().container.viewContext
+ #expect(!SyncEngine.applyShareRoster(
+ gameID: gameID,
+ zoneID: zoneID,
+ acceptedParticipants: ["alice"],
+ databaseScope: .private,
+ in: ctx
+ ))
+ }
+
+ // MARK: - Store rotation
+
+ @Test("rotating replaces the stored credential; a game without one is a no-op")
+ func storeRotation() throws {
+ let persistence = makeTestPersistence()
+ let store = makeTestStore(persistence: persistence, authorIDProvider: { "alice" })
+ let ctx = persistence.container.viewContext
+
+ let gameID = UUID()
+ let entity = GameEntity(context: ctx)
+ entity.id = gameID
+ entity.title = "Test"
+ entity.puzzleSource = "x"
+ entity.createdAt = Date()
+ entity.updatedAt = Date()
+ entity.ckRecordName = "game-\(gameID.uuidString)"
+ entity.ckZoneName = "game-\(gameID.uuidString)"
+ entity.databaseScope = 1
+ try ctx.save()
+
+ // Nothing minted yet — nothing a departed device could hold.
+ #expect(store.rotatePushCredentials(for: gameID) == nil)
+
+ let original = try #require(store.ensurePushCredentials(for: gameID))
+ let rotated = try #require(store.rotatePushCredentials(for: gameID))
+ #expect(rotated.generation == original.generation + 1)
+ #expect(rotated.credID != original.credID)
+ #expect(rotated.secret != original.secret)
+ #expect(rotated.contentKey != nil)
+ #expect(rotated.contentKey != original.contentKey)
+
+ let persisted = GamePushCredentials.decode(store.notification(for: gameID))
+ #expect(persisted == rotated)
+ }
+}
diff --git a/Tests/Workers/push-worker.test.mjs b/Tests/Workers/push-worker.test.mjs
@@ -1,5 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
+import { createHmac } from "node:crypto";
import { PushRegistry } from "../../Workers/push-worker.js";
import { StubStorage } from "./helpers.mjs";
@@ -120,3 +121,147 @@ test("sweep goes quiet once every rate key has expired", async () => {
assert.deepEqual(storage.keys("rate:"), []);
assert.equal(storage.alarmAt, null, "no live keys, no re-arm");
});
+
+// --- Game-binding registration (H4): a credID-scoped address registration
+// must prove possession of that game's secret, exactly like a publish.
+
+const gameSecret = Buffer.alloc(32, 7).toString("base64url");
+const otherSecret = Buffer.alloc(32, 9).toString("base64url");
+
+function gameSignature(secret, credID, { bodyHash = "hash", timestamp = "1000", nonce = "nonce" } = {}) {
+ const payload = ["crossmate-push-game-v1", credID, bodyHash, timestamp, nonce].join("\n");
+ return createHmac("sha256", Buffer.from(secret, "base64url")).update(payload).digest("base64url");
+}
+
+function registerRequest(signature) {
+ const headers = {
+ "X-Crossmate-Body-SHA256": "hash",
+ "X-Crossmate-Timestamp": "1000",
+ "X-Crossmate-Nonce": "nonce"
+ };
+ if (signature) headers["X-Crossmate-Game-Signature"] = signature;
+ return new Request("https://push.example/register", { method: "POST", headers });
+}
+
+function registerBody(addresses, deviceID = "device1") {
+ return JSON.stringify({
+ deviceID,
+ token: "apns-token",
+ environment: "production",
+ addresses
+ });
+}
+
+async function registerGameCred(registry, credID, secret = gameSecret) {
+ const response = await registry.handleGameRegister(credID, JSON.stringify({ secret }));
+ assert.ok(response.status === 201 || response.status === 204);
+}
+
+test("a correctly signed game binding registers under its credID", async () => {
+ const { registry, storage } = makeRegistry();
+ await registerGameCred(registry, "cred-1");
+
+ const response = await registry.handleRegister(
+ registerRequest(gameSignature(gameSecret, "cred-1")),
+ registerBody([{ address: "addr-a", credID: "cred-1" }]),
+ { deviceID: "device1" }
+ );
+
+ assert.equal(response.status, 204);
+ assert.deepEqual(storage.keys("addr:"), ["addr:cred-1:addr-a:device1"]);
+});
+
+test("an App-Attest-only game binding (no game signature) is not stored", async () => {
+ const { registry, storage } = makeRegistry();
+ await registerGameCred(registry, "cred-1");
+
+ // A departed participant (or anyone who learned the credID) has a valid
+ // App Attest enrollment of their own but not the game secret.
+ const response = await registry.handleRegister(
+ registerRequest(null),
+ registerBody([
+ { address: "addr-account" },
+ { address: "addr-a", credID: "cred-1" }
+ ]),
+ { deviceID: "device1" }
+ );
+
+ // The account-scoped binding still lands (legacy batched clients), but the
+ // unproven game binding is dropped.
+ assert.equal(response.status, 204);
+ assert.deepEqual(storage.keys("addr:"), ["addr:addr-account:device1"]);
+});
+
+test("a game binding signed with the wrong secret is not stored", async () => {
+ const { registry, storage } = makeRegistry();
+ await registerGameCred(registry, "cred-1");
+
+ const response = await registry.handleRegister(
+ registerRequest(gameSignature(otherSecret, "cred-1")),
+ registerBody([{ address: "addr-a", credID: "cred-1" }]),
+ { deviceID: "device1" }
+ );
+
+ assert.equal(response.status, 204);
+ assert.deepEqual(storage.keys("addr:"), []);
+});
+
+test("a game binding naming an unknown credID is not stored", async () => {
+ const { registry, storage } = makeRegistry();
+
+ const response = await registry.handleRegister(
+ registerRequest(gameSignature(gameSecret, "cred-unregistered")),
+ registerBody([{ address: "addr-a", credID: "cred-unregistered" }]),
+ { deviceID: "device1" }
+ );
+
+ assert.equal(response.status, 204);
+ assert.deepEqual(storage.keys("addr:"), []);
+});
+
+test("mixed credIDs in one request drop every game binding", async () => {
+ const { registry, storage } = makeRegistry();
+ await registerGameCred(registry, "cred-1");
+ await registerGameCred(registry, "cred-2", otherSecret);
+
+ // One request carries one signature, so it can prove at most one credID;
+ // a batch that names two proves neither.
+ const response = await registry.handleRegister(
+ registerRequest(gameSignature(gameSecret, "cred-1")),
+ registerBody([
+ { address: "addr-a", credID: "cred-1" },
+ { address: "addr-b", credID: "cred-2" }
+ ]),
+ { deviceID: "device1" }
+ );
+
+ assert.equal(response.status, 204);
+ assert.deepEqual(storage.keys("addr:"), []);
+});
+
+test("after rotation, the old credential cannot bind under the new credID", async () => {
+ const { registry, storage } = makeRegistry();
+ // The room before rotation…
+ await registerGameCred(registry, "cred-old");
+ // …and the replacement a remaining participant registered after someone left.
+ await registerGameCred(registry, "cred-new", otherSecret);
+
+ // The departed participant holds the complete old credential but signs for
+ // the new credID with the old secret — the only credential it has.
+ const response = await registry.handleRegister(
+ registerRequest(gameSignature(gameSecret, "cred-new")),
+ registerBody([{ address: "addr-a", credID: "cred-new" }], "device-departed"),
+ { deviceID: "device-departed" }
+ );
+ assert.equal(response.status, 204);
+ assert.deepEqual(storage.keys("addr:"), [], "old secret must not subscribe to the rotated room");
+
+ // A current participant holding the new secret registers fine.
+ const ok = await registry.handleRegister(
+ registerRequest(gameSignature(otherSecret, "cred-new")),
+ registerBody([{ address: "addr-a", credID: "cred-new" }], "device-current"),
+ { deviceID: "device-current" }
+ );
+ assert.equal(ok.status, 204);
+ assert.deepEqual(storage.keys("addr:"), ["addr:cred-new:addr-a:device-current"]);
+});
diff --git a/Workers/push-worker.js b/Workers/push-worker.js
@@ -45,7 +45,7 @@ export class PushRegistry {
}
if (url.pathname === "/register" && request.method === "POST") {
- return this.handleRegister(bodyText, auth);
+ return this.handleRegister(request, bodyText, auth);
}
if (url.pathname === "/register" && request.method === "DELETE") {
return this.handleUnregister(bodyText, auth);
@@ -342,7 +342,7 @@ export class PushRegistry {
return `appattest-key:${deviceID}:${keyID}`;
}
- async handleRegister(bodyText, auth) {
+ async handleRegister(request, bodyText, auth) {
const body = await readJSONText(bodyText);
if (!body) return badRequest("Body must be JSON");
const { deviceID, token, environment, addresses, mutedKinds } = body;
@@ -362,6 +362,30 @@ export class PushRegistry {
const muted = Array.isArray(mutedKinds)
? mutedKinds.filter((kind) => typeof kind === "string" && kind.length > 0)
: [];
+ // A game-scoped binding is a subscription to that game's pushes, so it
+ // must prove current participation the same way a publish does: a game
+ // signature over this request, verified against the secret registered
+ // under the entry's credID. App Attest alone only proves "some enrolled
+ // installation" — without the secret proof, anyone who ever learned a
+ // credID (e.g. a departed participant, before rotation replaces it) could
+ // re-subscribe to the room. The request carries one signature, so all
+ // credID entries must name a single credID; the client registers each
+ // game's bindings in its own signed request. An unknown credID fails
+ // verification outright (`verifyGameSignature` requires the stored
+ // secret). Unproven credID entries are dropped rather than failing the
+ // request so a legacy client that still batches account + game entries in
+ // one unsigned request keeps its account binding.
+ const credIDs = new Set(
+ addresses
+ .filter((entry) => entry && typeof entry === "object" && typeof entry.credID === "string")
+ .map((entry) => entry.credID)
+ );
+ let verifiedCredID = null;
+ if (credIDs.size === 1) {
+ const credID = credIDs.values().next().value;
+ const verification = await this.verifyGameSignature(request, credID);
+ if (verification.ok) verifiedCredID = credID;
+ }
// Bind this device's APNs token to each address it knows. A game address
// carries the game's `credID` and is stored under it so a publish can only
// reach it when signed with that game's secret; the account-scoped sibling
@@ -371,6 +395,10 @@ export class PushRegistry {
for (const entry of addresses) {
const key = addressStorageKey(entry, deviceID);
if (!key) continue;
+ const entryCredID = entry && typeof entry === "object" && typeof entry.credID === "string"
+ ? entry.credID
+ : "";
+ if (entryCredID && entryCredID !== verifiedCredID) continue;
const registration = { token, environment, updatedAt };
if (muted.length > 0) registration.mutedKinds = muted;
await this.state.storage.put(key, registration);