commit 8d0cf5634f30b66705356049cc1259d5b2cf33da
parent f0b816158352c3bf853c4630289719c8d220dbbe
Author: Michael Camilleri <[email protected]>
Date: Sat, 11 Jul 2026 05:09:53 +0900
Authenticate inbound game records by zone and creator
Record names were the only identity consulted when applying fetched
records, but any share participant can write records with arbitrary
names into a shared zone. A collaborator could publish Moves or Player
rows that impersonate another player or name a different game entirely,
and sync would apply them. Local rows were also matched on record name
alone, so same-named records from different zones — two shares of the
same game, or a private game and a forged shared one — collapsed into a
single Core Data row.
This commit rejects fetched game-zone records and deletions whose names
disagree with CloudKit's own metadata: the named game must match the
zone the record arrived in, and the author claimed in the name must
match the record's immutable creator. The creator check accepts
CKCurrentUserDefaultName alongside the concrete author ID — CloudKit
reports self-created records with that placeholder, so without it a
fresh install or second device would drop the user's own moves.
Rejected records are traced to the on-device diagnostics log.
Local lookups now match on full zone identity via gameIdentityPredicate.
Owner names participate only for shared-database rows: CloudKit does not
reliably round-trip the owner placeholder for the user's own zones, so
private rows always store a nil ckZoneOwnerName and match on zone name
alone, with the database scope passed explicitly by the delivering
database rather than inferred from the owner spelling. A one-shot
backfill normalises rows written before these invariants held so they
stay matchable under the stricter predicate.
Co-Authored-By: Codex GPT 5.6 Terra <[email protected]>
Co-Authored-By: Claude Fable 5 <[email protected]>
Diffstat:
11 files changed, 605 insertions(+), 23 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -150,6 +150,7 @@
9AD8936D94FD676B23DFBB77 /* RecentChanges.swift in Sources */ = {isa = PBXBuildFile; fileRef = 605CA0FC7AF069CE3A3B38C1 /* RecentChanges.swift */; };
9C52C48DB4996D5C83DEC144 /* PuzzleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57B1734CF731C2E405A39159 /* PuzzleView.swift */; };
9CB8808193A4A106D721D767 /* XDFileType.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC61E2582D94B1E6EC67136 /* XDFileType.swift */; };
+ 9DFE90A6D7C58823CA02CA4E /* InboundRecordIdentityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08C434DB97DE2FBD8BEB867C /* InboundRecordIdentityTests.swift */; };
9FFD01CF6767220EEA20C0E4 /* GamePushCredentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78919F44C3035C48410FC894 /* GamePushCredentials.swift */; };
A0977C7B0B0D928DA569C326 /* NicknameDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3111803C8FFFB0C839217482 /* NicknameDirectory.swift */; };
A133A4B4A0C95AF8708BD7E6 /* PushClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A9F9E7ED4E1AF02F0C71051 /* PushClient.swift */; };
@@ -271,6 +272,7 @@
057F2B8B8A894D08BB801219 /* ShareLinkShortenerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareLinkShortenerTests.swift; sourceTree = "<group>"; };
065CD67A1D9F7B63AE6B42D6 /* FriendAvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendAvatarView.swift; sourceTree = "<group>"; };
07E5E4B165E374FEE732068B /* CloudDiagnostics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudDiagnostics.swift; sourceTree = "<group>"; };
+ 08C434DB97DE2FBD8BEB867C /* InboundRecordIdentityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InboundRecordIdentityTests.swift; sourceTree = "<group>"; };
08E8592B1CB1336E63498706 /* PeerPresenceGraceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerPresenceGraceTests.swift; sourceTree = "<group>"; };
09C81EFA0B7776CB9713CD63 /* NewGameSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewGameSheet.swift; sourceTree = "<group>"; };
09EA25E2AE98ACD029EC0129 /* GameStoreContributingDevicesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStoreContributingDevicesTests.swift; sourceTree = "<group>"; };
@@ -806,6 +808,7 @@
B766E872B12DC79ECCD80941 /* FriendModelTests.swift */,
800CCFBE90554F287E765755 /* FriendZoneTests.swift */,
7B5A8118AC2FE60D877F1D29 /* GamePushCredentialsTests.swift */,
+ 08C434DB97DE2FBD8BEB867C /* InboundRecordIdentityTests.swift */,
4B33C21324E1474BCC126AA0 /* MovesCodecLegacyDecodeTests.swift */,
EF1254FE7BE3672AEC1607B1 /* MovesInboundTests.swift */,
08E8592B1CB1336E63498706 /* PeerPresenceGraceTests.swift */,
@@ -1041,6 +1044,7 @@
1D08DDEDEF5433912CC6D4DB /* GameViewedStoreTests.swift in Sources */,
085B70680087464B8A7BA3EE /* GridSilhouetteTests.swift in Sources */,
AACC9F70AEEDCB3360FFDEFF /* GridStateMergerTests.swift in Sources */,
+ 9DFE90A6D7C58823CA02CA4E /* InboundRecordIdentityTests.swift in Sources */,
0158184A413AE177F75B4150 /* JournalReplayTests.swift in Sources */,
6E67C0DCB0416F382EA065B7 /* JournalUploadTests.swift in Sources */,
2641299DE1F2E84E8C21E037 /* LogScrubberTests.swift in Sources */,
diff --git a/Crossmate/Persistence/PersistenceController.swift b/Crossmate/Persistence/PersistenceController.swift
@@ -62,6 +62,7 @@ final class PersistenceController {
if !inMemory {
backfillCachedSummaryFields()
+ backfillZoneIdentityFields()
}
}
@@ -167,6 +168,49 @@ final class PersistenceController {
}
}
+ /// One-shot pass for `GameEntity` rows written before inbound lookups
+ /// matched on full zone identity (`RecordSerializer.gameIdentityPredicate`).
+ /// Without it, a legacy row is invisible to the new predicate and the next
+ /// fetched record silently spawns a duplicate. Two normalizations:
+ /// a missing `ckZoneName` is derived from `ckRecordName` (game zone and
+ /// record share the `game-<UUID>` spelling), and `ckZoneOwnerName` is
+ /// cleared on private-scope rows — private zones always belong to the
+ /// current user and are matched as `ckZoneOwnerName == NIL`, but rows
+ /// written before that invariant could hold a concrete user-record ID
+ /// when CloudKit round-tripped one instead of the owner placeholder.
+ /// No-ops on every subsequent launch.
+ private func backfillZoneIdentityFields() {
+ let bg = container.newBackgroundContext()
+ bg.perform {
+ let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ req.predicate = NSPredicate(
+ format: "(ckRecordName != nil AND ckZoneName == nil) "
+ + "OR (databaseScope == 0 AND ckZoneOwnerName != nil)"
+ )
+ guard let rows = try? bg.fetch(req), !rows.isEmpty else { return }
+ for entity in rows {
+ if entity.ckZoneName == nil {
+ entity.ckZoneName = entity.ckRecordName
+ }
+ if entity.databaseScope == 0 {
+ entity.ckZoneOwnerName = nil
+ }
+ }
+ if bg.hasChanges {
+ do {
+ try bg.save()
+ } catch {
+ Task { @MainActor [weak self] in
+ self?.eventLog?.note(
+ "PersistenceController: backfillZoneIdentityFields save failed — \(error)",
+ level: "error"
+ )
+ }
+ }
+ }
+ }
+ }
+
// Loaded once and shared across all container instances so that entity
// descriptions are identical objects, which is required for CoreData
// relationship type-checking to pass when tests create multiple containers
diff --git a/Crossmate/Sync/CloudQuery.swift b/Crossmate/Sync/CloudQuery.swift
@@ -105,7 +105,9 @@ extension SyncEngine {
var pings: [Ping] = []
var fetchedCount = 0
for record in collected {
- guard let ping = Ping.parseRecord(record, fetchedFrom: scopeValue) else { continue }
+ guard RecordSerializer.isTrustedGameScopedRecord(record),
+ let ping = Ping.parseRecord(record, fetchedFrom: scopeValue)
+ else { continue }
fetchedCount += 1
let modDate = record.modificationDate ?? Date()
if seen.updateValue(modDate, forKey: record.recordID.recordName) == nil {
@@ -195,8 +197,11 @@ extension SyncEngine {
desiredKeys: RecordSerializer.pingDesiredKeys
)
return PerZoneInvites(
- pings: records.compactMap {
- Ping.parseRecord($0, fetchedFrom: scopeValue)
+ pings: records.compactMap { record in
+ guard RecordSerializer.isTrustedGameScopedRecord(record) else {
+ return nil
+ }
+ return Ping.parseRecord(record, fetchedFrom: scopeValue)
},
recordNames: Set(records.map(\.recordID.recordName)),
scannedAuthorID: target.authorID,
@@ -350,7 +355,8 @@ extension SyncEngine {
continue
}
for record in records {
- guard let ping = Ping.parseRecord(record, fetchedFrom: scopeValue),
+ guard RecordSerializer.isTrustedGameScopedRecord(record),
+ let ping = Ping.parseRecord(record, fetchedFrom: scopeValue),
ping.gameID == gameID else { continue }
deletePing(recordName: ping.recordName, zoneID: zoneID, databaseScope: scopeValue)
await trace(
@@ -413,8 +419,11 @@ extension SyncEngine {
since: since,
desiredKeys: RecordSerializer.playerDesiredKeys
)
- let activities = playerRecords.compactMap { record in
- Session.parseRecord(record, puzzleTitle: zone.title)
+ let activities: [Session] = playerRecords.compactMap { record in
+ guard RecordSerializer.isTrustedGameScopedRecord(record) else {
+ return nil
+ }
+ return Session.parseRecord(record, puzzleTitle: zone.title)
}
return PerZoneActivity(
records: playerRecords,
@@ -1080,7 +1089,8 @@ extension SyncEngine {
)
var expected = Set<JournalDeviceKey>()
for record in movesRecords {
- if let (_, authorID, deviceID) =
+ if RecordSerializer.isTrustedGameScopedRecord(record),
+ let (_, authorID, deviceID) =
RecordSerializer.parseMovesRecordName(record.recordID.recordName) {
expected.insert(JournalDeviceKey(authorID: authorID, deviceID: deviceID))
}
@@ -1096,7 +1106,8 @@ extension SyncEngine {
)
var journals: [DeviceJournal] = []
for record in journalRecords {
- guard let (_, authorID, deviceID) =
+ guard RecordSerializer.isTrustedGameScopedRecord(record),
+ let (_, authorID, deviceID) =
RecordSerializer.parseJournalRecordName(record.recordID.recordName),
let asset = record["entries"] as? CKAsset,
let url = asset.fileURL
diff --git a/Crossmate/Sync/RecordApplier.swift b/Crossmate/Sync/RecordApplier.swift
@@ -81,6 +81,15 @@ extension SyncEngine {
let effects: BatchEffects = await ctx.perform {
var effects = BatchEffects()
for record in records {
+ guard !RecordSerializer.isGameScopedRecordType(record.recordType)
+ || RecordSerializer.isTrustedGameScopedRecord(record)
+ else {
+ effects.traces.append(
+ "rejected untrusted direct record \(record.recordType) " +
+ "\(record.recordID.recordName) in \(record.recordID.zoneID.zoneName)"
+ )
+ continue
+ }
switch record.recordType {
case "Game":
let entity = RecordSerializer.applyGameRecord(
@@ -103,6 +112,7 @@ extension SyncEngine {
record,
value: value,
to: ctx,
+ databaseScope: scopeValue,
localAuthorID: localAuthorID,
onNewAuthor: { _ in effects.rosterRelevant.insert(value.gameID) }
)
@@ -114,6 +124,7 @@ extension SyncEngine {
self.applyPlayerRecord(
record,
in: ctx,
+ databaseScope: scopeValue,
localAuthorID: localAuthorID,
onFirstTime: { effects.playersUpdated.insert($0) },
onPresenceChange: { effects.playerPresenceChanged.insert($0) },
@@ -131,9 +142,22 @@ extension SyncEngine {
}
}
for deletion in deletions {
+ guard !RecordSerializer.isGameScopedRecordType(deletion.1)
+ || RecordSerializer.isTrustedGameScopedDeletion(
+ recordID: deletion.0,
+ recordType: deletion.1
+ )
+ else {
+ effects.traces.append(
+ "rejected untrusted direct deletion \(deletion.1) " +
+ "\(deletion.0.recordName) in \(deletion.0.zoneID.zoneName)"
+ )
+ continue
+ }
self.applyDeletion(
recordID: deletion.0,
recordType: deletion.1,
+ databaseScope: scopeValue,
in: ctx
)
if let id = self.gameID(fromRecordName: deletion.0.recordName) {
@@ -220,6 +244,7 @@ extension SyncEngine {
nonisolated func applyPlayerRecord(
_ record: CKRecord,
in ctx: NSManagedObjectContext,
+ databaseScope: DatabaseScope = .private,
localAuthorID: String?,
onFirstTime: (UUID) -> Void,
onPresenceChange: (UUID) -> Void,
@@ -235,7 +260,12 @@ extension SyncEngine {
?? Date()
let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
- req.predicate = NSPredicate(format: "ckRecordName == %@", ckName)
+ req.predicate = RecordSerializer.gameIdentityPredicate(
+ recordName: ckName,
+ zoneID: record.recordID.zoneID,
+ databaseScope: databaseScope,
+ entityPrefix: "game"
+ )
req.fetchLimit = 1
let entity: PlayerEntity
@@ -247,6 +277,7 @@ extension SyncEngine {
let game = RecordSerializer.ensureGameEntity(
forGameID: gameID,
zoneID: record.recordID.zoneID,
+ databaseScope: databaseScope,
in: ctx
)
entity = PlayerEntity(context: ctx)
@@ -510,9 +541,12 @@ extension SyncEngine {
return created?.id
}
+ /// Callers gate game-scoped deletions through
+ /// `RecordSerializer.isTrustedGameScopedDeletion` before invoking this.
nonisolated func applyDeletion(
recordID: CKRecord.ID,
recordType: CKRecord.RecordType,
+ databaseScope: DatabaseScope,
in ctx: NSManagedObjectContext
) {
let name = recordID.recordName
@@ -532,7 +566,13 @@ extension SyncEngine {
}
}
let req = NSFetchRequest<NSManagedObject>(entityName: entityName)
- req.predicate = NSPredicate(format: "ckRecordName == %@", name)
+ let entityPrefix = entityName == "GameEntity" ? "" : "game"
+ req.predicate = RecordSerializer.gameIdentityPredicate(
+ recordName: name,
+ zoneID: recordID.zoneID,
+ databaseScope: databaseScope,
+ entityPrefix: entityPrefix
+ )
req.fetchLimit = 1
if let obj = try? ctx.fetch(req).first {
ctx.delete(obj)
diff --git a/Crossmate/Sync/RecordBuilder.swift b/Crossmate/Sync/RecordBuilder.swift
@@ -10,6 +10,7 @@ extension SyncEngine {
nonisolated func buildRecord(
for recordID: CKRecord.ID,
in ctx: NSManagedObjectContext,
+ databaseScope: DatabaseScope,
pings: [String: PingPayload],
decisions: [String: String],
decisionVersions: [String: Int64],
@@ -49,7 +50,11 @@ extension SyncEngine {
return ctx.performAndWait {
if name.hasPrefix("game-") {
let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
- req.predicate = NSPredicate(format: "ckRecordName == %@", name)
+ req.predicate = RecordSerializer.gameIdentityPredicate(
+ recordName: name,
+ zoneID: zoneID,
+ databaseScope: databaseScope
+ )
req.fetchLimit = 1
guard let entity = try? ctx.fetch(req).first else { return nil }
return RecordSerializer.gameRecord(
@@ -59,7 +64,12 @@ extension SyncEngine {
)
} else if name.hasPrefix("moves-") {
let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity")
- req.predicate = NSPredicate(format: "ckRecordName == %@", name)
+ req.predicate = RecordSerializer.gameIdentityPredicate(
+ recordName: name,
+ zoneID: zoneID,
+ databaseScope: databaseScope,
+ entityPrefix: "game"
+ )
req.fetchLimit = 1
guard let entity = try? ctx.fetch(req).first,
let gameID = entity.game?.id,
@@ -106,7 +116,12 @@ extension SyncEngine {
)
} else if name.hasPrefix("player-") {
let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
- req.predicate = NSPredicate(format: "ckRecordName == %@", name)
+ req.predicate = RecordSerializer.gameIdentityPredicate(
+ recordName: name,
+ zoneID: zoneID,
+ databaseScope: databaseScope,
+ entityPrefix: "game"
+ )
req.fetchLimit = 1
guard let entity = try? ctx.fetch(req).first,
let gameID = entity.game?.id,
diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift
@@ -305,6 +305,184 @@ enum RecordSerializer {
CKRecordZone.ID(zoneName: "game-\(gameID.uuidString)", ownerName: ownerName)
}
+ // MARK: - Inbound record identity
+
+ /// The record types that live in per-game zones (plus the archive zone)
+ /// and therefore must pass `isTrustedGameScopedRecord` /
+ /// `isTrustedGameScopedDeletion` before touching a game-scoped apply
+ /// path. Everything else (Decision, share metadata, …) has its own
+ /// zone/scope gates.
+ static func isGameScopedRecordType(_ type: CKRecord.RecordType) -> Bool {
+ switch type {
+ case "Game", "Moves", "Player", "Ping", "Journal", Archive.recordType:
+ return true
+ default:
+ return false
+ }
+ }
+
+ /// CloudKit reports the creator of a record that the *fetching* user
+ /// created themselves as the `CKCurrentUserDefaultName` placeholder, not
+ /// their concrete user-record name — so the placeholder must be accepted
+ /// alongside the claimed author, or every self-authored record is
+ /// rejected when it round-trips (fresh install, second device, replay).
+ /// This does not weaken the impersonation defense: a record created by a
+ /// *remote* participant always arrives with their concrete creator ID,
+ /// never the placeholder.
+ private static func creatorMatches(
+ _ creatorUserRecordName: String?,
+ claimedAuthorID: String
+ ) -> Bool {
+ creatorUserRecordName == claimedAuthorID
+ || creatorUserRecordName == CKCurrentUserDefaultName
+ }
+
+ /// Returns whether a fetched record is safe to route into a game-scoped
+ /// apply path. Record names are writable by share participants, so they
+ /// are only an assertion until they agree with CloudKit's zone identity.
+ ///
+ /// Moves, Player, Journal, and Ping records additionally claim an author
+ /// in their name. CloudKit supplies the immutable creator identity for a
+ /// record; require it to match so a collaborator cannot create a new row
+ /// in their own name space that impersonates another participant. `Game`
+ /// has no claimed author — its root record is authenticated by requiring
+ /// its record name to be exactly the per-game zone name.
+ static func isTrustedGameScopedRecord(_ record: CKRecord) -> Bool {
+ isTrustedGameScopedRecord(
+ record,
+ creatorUserRecordName: record.creatorUserRecordID?.recordName
+ )
+ }
+
+ /// Variant used by tests to exercise CloudKit provenance without needing
+ /// a server-created CKRecord.
+ static func isTrustedGameScopedRecord(
+ _ record: CKRecord,
+ creatorUserRecordName: String?
+ ) -> Bool {
+ let recordID = record.recordID
+ switch record.recordType {
+ case "Game":
+ guard let gameID = gameID(fromGameRecordName: recordID.recordName) else {
+ return false
+ }
+ return recordID.zoneID.zoneName == recordName(forGameID: gameID)
+
+ case "Moves":
+ guard let (gameID, authorID, _) = parseMovesRecordName(recordID.recordName) else {
+ return false
+ }
+ return recordID.zoneID.zoneName == recordName(forGameID: gameID)
+ && creatorMatches(creatorUserRecordName, claimedAuthorID: authorID)
+
+ case "Player":
+ guard let (gameID, authorID) = parsePlayerRecordName(recordID.recordName) else {
+ return false
+ }
+ return recordID.zoneID.zoneName == recordName(forGameID: gameID)
+ && creatorMatches(creatorUserRecordName, claimedAuthorID: authorID)
+
+ case "Journal":
+ guard let (gameID, authorID, _) = parseJournalRecordName(recordID.recordName) else {
+ return false
+ }
+ return recordID.zoneID.zoneName == recordName(forGameID: gameID)
+ && creatorMatches(creatorUserRecordName, claimedAuthorID: authorID)
+
+ case "Ping":
+ guard let (gameID, authorID, _) = parsePingRecordName(recordID.recordName),
+ creatorMatches(creatorUserRecordName, claimedAuthorID: authorID)
+ else { return false }
+ // Friend-zone pings deliberately carry the target game's ID while
+ // living in a deterministic friend mailbox. Game-zone pings must
+ // still be confined to the game named by their record.
+ return !recordID.zoneID.zoneName.hasPrefix("game-")
+ || recordID.zoneID.zoneName == recordName(forGameID: gameID)
+
+ case Archive.recordType:
+ guard let gameID = Archive.originalGameID(fromName: recordID.recordName),
+ recordID.zoneID.zoneName == Archive.recordName(forOriginalGameID: gameID),
+ (record["originalGameID"] as? String) == gameID.uuidString
+ else { return false }
+ return true
+
+ default:
+ return false
+ }
+ }
+
+ /// Deletions do not carry creator metadata, but CloudKit does retain their
+ /// full record identity. Reject a deletion whose name is not confined to
+ /// the game zone before it can match a local row. Only consulted for
+ /// `isGameScopedRecordType` types; anything else is out of scope here.
+ static func isTrustedGameScopedDeletion(
+ recordID: CKRecord.ID,
+ recordType: CKRecord.RecordType
+ ) -> Bool {
+ let gameID: UUID?
+ switch recordType {
+ case "Game":
+ gameID = Self.gameID(fromGameRecordName: recordID.recordName)
+ case "Moves":
+ gameID = parseMovesRecordName(recordID.recordName)?.0
+ case "Player":
+ gameID = parsePlayerRecordName(recordID.recordName)?.0
+ case "Journal":
+ gameID = parseJournalRecordName(recordID.recordName)?.0
+ case "Ping":
+ // Same confinement rule as the record case: a friend-mailbox ping
+ // names a game outside its zone by design; a game-zone ping must
+ // name its own zone's game.
+ guard let (pingGameID, _, _) = parsePingRecordName(recordID.recordName) else {
+ return false
+ }
+ return !recordID.zoneID.zoneName.hasPrefix("game-")
+ || recordID.zoneID.zoneName == recordName(forGameID: pingGameID)
+ case Archive.recordType:
+ guard let originalID = Archive.originalGameID(fromName: recordID.recordName) else {
+ return false
+ }
+ return recordID.zoneID.zoneName == Archive.recordName(forOriginalGameID: originalID)
+ default:
+ return false
+ }
+ guard let gameID else { return false }
+ return recordID.zoneID.zoneName == recordName(forGameID: gameID)
+ }
+
+ /// Matches the local row for a record by its full zone identity, so a
+ /// private-zone record can never match a same-named shared-zone row (or
+ /// vice versa, or two shares of the same game by different owners).
+ ///
+ /// The owner name participates only for shared-database zones. CloudKit
+ /// does not reliably round-trip the `CKCurrentUserDefaultName` placeholder
+ /// for the current user's own zones — a fetched zone ID often carries the
+ /// concrete user-record ID instead (see `isAccountZonePrivateDecision`) —
+ /// so private rows always store `nil` and match on zone name alone, which
+ /// is unambiguous there: every private-database zone belongs to the
+ /// current user.
+ static func gameIdentityPredicate(
+ recordName: String,
+ zoneID: CKRecordZone.ID,
+ databaseScope: DatabaseScope,
+ entityPrefix: String = ""
+ ) -> NSPredicate {
+ let zonePrefix = entityPrefix.isEmpty ? "" : "\(entityPrefix)."
+ if databaseScope == .private {
+ return NSPredicate(
+ format: "ckRecordName == %@ AND \(zonePrefix)ckZoneName == %@ AND \(zonePrefix)ckZoneOwnerName == NIL",
+ recordName,
+ zoneID.zoneName
+ )
+ }
+ return NSPredicate(
+ format: "ckRecordName == %@ AND \(zonePrefix)ckZoneName == %@ AND \(zonePrefix)ckZoneOwnerName == %@",
+ recordName,
+ zoneID.zoneName,
+ zoneID.ownerName
+ )
+ }
+
/// Zone ID for the user's account-wide zone in the private database. Holds
/// records that coordinate state between a single iCloud user's own
/// devices — never shared with collaborators, since the private database
@@ -775,21 +953,27 @@ enum RecordSerializer {
static func ensureGameEntity(
forGameID gameID: UUID,
zoneID: CKRecordZone.ID,
+ databaseScope: DatabaseScope = .private,
in ctx: NSManagedObjectContext
) -> GameEntity {
let name = recordName(forGameID: gameID)
let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
- req.predicate = NSPredicate(format: "ckRecordName == %@", name)
+ req.predicate = gameIdentityPredicate(
+ recordName: name,
+ zoneID: zoneID,
+ databaseScope: databaseScope
+ )
req.fetchLimit = 1
if let existing = try? ctx.fetch(req).first { return existing }
let entity = GameEntity(context: ctx)
entity.id = gameID
entity.ckRecordName = name
entity.ckZoneName = zoneID.zoneName
- let ownerName = zoneID.ownerName
- let isOwner = ownerName == CKCurrentUserDefaultName
- entity.ckZoneOwnerName = isOwner ? nil : ownerName
- entity.databaseScope = isOwner ? 0 : 1
+ // Scope, not the owner-name spelling, decides ownership: a private-DB
+ // zone always belongs to the current user even when its fetched zone
+ // ID carries the concrete user-record ID instead of the placeholder.
+ entity.ckZoneOwnerName = databaseScope == .private ? nil : zoneID.ownerName
+ entity.databaseScope = databaseScope.rawValue
entity.title = ""
entity.puzzleSource = ""
entity.createdAt = Date()
@@ -810,6 +994,8 @@ enum RecordSerializer {
let entity = fetchOrCreate(
entityName: "GameEntity",
recordName: recordName,
+ zoneID: record.recordID.zoneID,
+ databaseScope: databaseScope,
in: context
) as! GameEntity
@@ -834,8 +1020,12 @@ enum RecordSerializer {
entity.ckRecordName = recordName
entity.ckSystemFields = encodeSystemFields(of: record)
entity.ckZoneName = record.recordID.zoneID.zoneName
- let ownerName = record.recordID.zoneID.ownerName
- entity.ckZoneOwnerName = ownerName == CKCurrentUserDefaultName ? nil : ownerName
+ // Scope, not the owner-name spelling, decides ownership — CloudKit
+ // round-trips a private zone's owner as either the placeholder or the
+ // concrete user-record ID, and `gameIdentityPredicate` relies on
+ // private rows always storing nil.
+ entity.ckZoneOwnerName =
+ databaseScope == .private ? nil : record.recordID.zoneID.ownerName
entity.databaseScope = databaseScope.rawValue
// Local mutable fields take precedence while a push is in flight.
@@ -981,12 +1171,18 @@ enum RecordSerializer {
_ record: CKRecord,
value: MovesValue,
to ctx: NSManagedObjectContext,
+ databaseScope: DatabaseScope = .private,
localAuthorID: String? = nil,
onNewAuthor: ((String) -> Void)? = nil
) -> Bool {
let ckName = record.recordID.recordName
let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity")
- req.predicate = NSPredicate(format: "ckRecordName == %@", ckName)
+ req.predicate = gameIdentityPredicate(
+ recordName: ckName,
+ zoneID: record.recordID.zoneID,
+ databaseScope: databaseScope,
+ entityPrefix: "game"
+ )
req.fetchLimit = 1
let entity: MovesEntity
@@ -1009,6 +1205,7 @@ enum RecordSerializer {
let game = ensureGameEntity(
forGameID: value.gameID,
zoneID: record.recordID.zoneID,
+ databaseScope: databaseScope,
in: ctx
)
entity = MovesEntity(context: ctx)
@@ -1329,10 +1526,16 @@ enum RecordSerializer {
private static func fetchOrCreate(
entityName: String,
recordName: String,
+ zoneID: CKRecordZone.ID,
+ databaseScope: DatabaseScope,
in context: NSManagedObjectContext
) -> NSManagedObject {
let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
- request.predicate = NSPredicate(format: "ckRecordName == %@", recordName)
+ request.predicate = gameIdentityPredicate(
+ recordName: recordName,
+ zoneID: zoneID,
+ databaseScope: databaseScope
+ )
request.fetchLimit = 1
if let existing = try? context.fetch(request).first {
return existing
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -1479,6 +1479,15 @@ actor SyncEngine {
var effects = BatchEffects()
for mod in event.modifications {
let record = mod.record
+ guard !RecordSerializer.isGameScopedRecordType(record.recordType)
+ || RecordSerializer.isTrustedGameScopedRecord(record)
+ else {
+ effects.traces.append(
+ "rejected untrusted fetched record \(record.recordType) " +
+ "\(record.recordID.recordName) in \(record.recordID.zoneID.zoneName)"
+ )
+ continue
+ }
switch record.recordType {
case "Game":
let entity = RecordSerializer.applyGameRecord(
@@ -1501,6 +1510,7 @@ actor SyncEngine {
record,
value: value,
to: ctx,
+ databaseScope: scope,
localAuthorID: localAuthorID,
onNewAuthor: { _ in effects.rosterRelevant.insert(value.gameID) }
)
@@ -1512,6 +1522,7 @@ actor SyncEngine {
self.applyPlayerRecord(
record,
in: ctx,
+ databaseScope: scope,
localAuthorID: localAuthorID,
onFirstTime: { effects.playersUpdated.insert($0) },
onPresenceChange: { effects.playerPresenceChanged.insert($0) },
@@ -1602,9 +1613,22 @@ actor SyncEngine {
}
}
for deletion in event.deletions {
+ guard !RecordSerializer.isGameScopedRecordType(deletion.recordType)
+ || RecordSerializer.isTrustedGameScopedDeletion(
+ recordID: deletion.recordID,
+ recordType: deletion.recordType
+ )
+ else {
+ effects.traces.append(
+ "rejected untrusted fetched deletion \(deletion.recordType) " +
+ "\(deletion.recordID.recordName) in \(deletion.recordID.zoneID.zoneName)"
+ )
+ continue
+ }
self.applyDeletion(
recordID: deletion.recordID,
recordType: deletion.recordType,
+ databaseScope: scope,
in: ctx
)
if let id = self.gameID(fromRecordName: deletion.recordID.recordName) {
@@ -2256,6 +2280,7 @@ extension SyncEngine: CKSyncEngineDelegate {
let pending = engine.state.pendingRecordZoneChanges
guard !pending.isEmpty else { return nil }
await traceForeignPlayerWrites(in: pending)
+ let scope: DatabaseScope = engine === sharedEngine ? .shared : .private
let pingSnapshot = pendingPings
let decisionSnapshot = pendingDecisionPayloads
let decisionVersionSnapshot = pendingDecisionVersions
@@ -2266,6 +2291,7 @@ extension SyncEngine: CKSyncEngineDelegate {
if let record = self.buildRecord(
for: recordID,
in: buildContext,
+ databaseScope: scope,
pings: pingSnapshot,
decisions: decisionSnapshot,
decisionVersions: decisionVersionSnapshot,
diff --git a/Tests/Unit/RecordSerializerTests.swift b/Tests/Unit/RecordSerializerTests.swift
@@ -655,6 +655,7 @@ struct RecordSerializerTests {
let entity = GameEntity(context: ctx)
entity.id = gameID
entity.ckRecordName = recordName
+ entity.ckZoneName = zone.zoneName
entity.title = "Local Title"
entity.completedAt = localCompletedAt
entity.hasPendingSave = true
@@ -693,6 +694,7 @@ struct RecordSerializerTests {
let entity = GameEntity(context: ctx)
entity.id = gameID
entity.ckRecordName = recordName
+ entity.ckZoneName = zone.zoneName
entity.title = "Completed"
entity.completedAt = localCompletedAt
entity.completedBy = "alice"
@@ -753,6 +755,7 @@ struct RecordSerializerTests {
let newerUpdatedAt = Date(timeIntervalSince1970: 1_700_000_500)
entity.id = gameID
entity.ckRecordName = recordName
+ entity.ckZoneName = zone.zoneName
entity.title = "Local"
entity.puzzleSource = ""
entity.createdAt = Date(timeIntervalSince1970: 1_700_000_000)
diff --git a/Tests/Unit/Sync/EnsureGameEntityTests.swift b/Tests/Unit/Sync/EnsureGameEntityTests.swift
@@ -64,7 +64,7 @@ struct EnsureGameEntityTests {
#expect(all.count == 1)
}
- @Test("derives shared scope from non-default zone owner")
+ @Test("shared scope stores the zone owner")
func sharedScope() throws {
let persistence = makeTestPersistence()
let ctx = persistence.viewContext
@@ -74,6 +74,7 @@ struct EnsureGameEntityTests {
let entity = RecordSerializer.ensureGameEntity(
forGameID: gameID,
zoneID: zoneID,
+ databaseScope: .shared,
in: ctx
)
@@ -81,6 +82,27 @@ struct EnsureGameEntityTests {
#expect(entity.ckZoneOwnerName == "_someOwnerID")
}
+ @Test("private scope stores a nil owner even when the zone ID carries a concrete one")
+ func privateScopeNormalizesOwner() throws {
+ let persistence = makeTestPersistence()
+ let ctx = persistence.viewContext
+ let gameID = UUID()
+ // CloudKit round-trips a private zone's owner as either the
+ // placeholder or the current user's concrete record ID; the stored
+ // row must not depend on which spelling arrived.
+ let zoneID = RecordSerializer.zoneID(for: gameID, ownerName: "_myOwnRecordID")
+
+ let entity = RecordSerializer.ensureGameEntity(
+ forGameID: gameID,
+ zoneID: zoneID,
+ databaseScope: .private,
+ in: ctx
+ )
+
+ #expect(entity.databaseScope == 0)
+ #expect(entity.ckZoneOwnerName == nil)
+ }
+
@Test("later Game record populates the same stub row")
func gameRecordPopulatesStub() throws {
let persistence = makeTestPersistence()
diff --git a/Tests/Unit/Sync/InboundRecordIdentityTests.swift b/Tests/Unit/Sync/InboundRecordIdentityTests.swift
@@ -0,0 +1,210 @@
+import CloudKit
+import CoreData
+import Foundation
+import Testing
+
+@testable import Crossmate
+
+@Suite("Inbound game record identity")
+@MainActor
+struct InboundRecordIdentityTests {
+
+ private let gameID = UUID(uuidString: "AABBCCDD-0000-0000-0000-111122223333")!
+
+ private func movesRecord(in zoneID: CKRecordZone.ID) throws -> CKRecord {
+ try RecordSerializer.movesRecord(
+ from: MovesValue(
+ gameID: gameID,
+ authorID: "alice",
+ deviceID: "devicea",
+ cells: [:],
+ updatedAt: .now
+ ),
+ zone: zoneID,
+ systemFields: nil
+ )
+ }
+
+ @Test("per-game records require their matching zone and CloudKit creator")
+ func recordRequiresMatchingZoneAndCreator() throws {
+ let matchingZone = RecordSerializer.zoneID(for: gameID, ownerName: "owner")
+ let matching = try movesRecord(in: matchingZone)
+ #expect(
+ RecordSerializer.isTrustedGameScopedRecord(
+ matching,
+ creatorUserRecordName: "alice"
+ )
+ )
+ #expect(
+ !RecordSerializer.isTrustedGameScopedRecord(
+ matching,
+ creatorUserRecordName: "mallory"
+ )
+ )
+
+ let foreignZone = RecordSerializer.zoneID(for: UUID(), ownerName: "owner")
+ let crossZone = try movesRecord(in: foreignZone)
+ #expect(
+ !RecordSerializer.isTrustedGameScopedRecord(
+ crossZone,
+ creatorUserRecordName: "alice"
+ )
+ )
+ }
+
+ @Test("self-created records round-trip as the creator placeholder and stay trusted")
+ func recordAcceptsCreatorPlaceholder() throws {
+ // CloudKit reports the creator of a record the fetching user created
+ // themselves as `CKCurrentUserDefaultName`, not their concrete record
+ // name. Rejecting the placeholder would drop every self-authored
+ // record on a fresh install or second device. Accepting it stays
+ // safe: a remote participant's record never arrives carrying it.
+ let zone = RecordSerializer.zoneID(for: gameID, ownerName: "owner")
+ let record = try movesRecord(in: zone)
+ #expect(
+ RecordSerializer.isTrustedGameScopedRecord(
+ record,
+ creatorUserRecordName: CKCurrentUserDefaultName
+ )
+ )
+ #expect(
+ !RecordSerializer.isTrustedGameScopedRecord(
+ record,
+ creatorUserRecordName: nil
+ )
+ )
+ }
+
+ @Test("root game record must be named for its zone")
+ func rootGameRecordRequiresMatchingZone() {
+ let record = CKRecord(
+ recordType: "Game",
+ recordID: CKRecord.ID(
+ recordName: RecordSerializer.recordName(forGameID: gameID),
+ zoneID: RecordSerializer.zoneID(for: UUID(), ownerName: "owner")
+ )
+ )
+ #expect(
+ !RecordSerializer.isTrustedGameScopedRecord(
+ record,
+ creatorUserRecordName: "owner"
+ )
+ )
+ }
+
+ @Test("deletions are confined to the record's game zone")
+ func deletionRequiresMatchingZone() {
+ let recordName = RecordSerializer.recordName(
+ forMovesInGame: gameID,
+ authorID: "alice",
+ deviceID: "devicea"
+ )
+ let valid = CKRecord.ID(
+ recordName: recordName,
+ zoneID: RecordSerializer.zoneID(for: gameID, ownerName: "owner")
+ )
+ #expect(RecordSerializer.isTrustedGameScopedDeletion(recordID: valid, recordType: "Moves"))
+
+ let forged = CKRecord.ID(
+ recordName: recordName,
+ zoneID: RecordSerializer.zoneID(for: UUID(), ownerName: "owner")
+ )
+ #expect(!RecordSerializer.isTrustedGameScopedDeletion(recordID: forged, recordType: "Moves"))
+ }
+
+ @Test("ping deletions follow the same zone confinement as ping records")
+ func pingDeletionConfinement() {
+ let pingName = RecordSerializer.recordName(
+ forPingInGame: gameID,
+ authorID: "alice",
+ deviceID: "devicea",
+ eventTimestampMs: 1_700_000_000_000
+ )
+ // A ping in its own game's zone is fine.
+ let inGameZone = CKRecord.ID(
+ recordName: pingName,
+ zoneID: RecordSerializer.zoneID(for: gameID, ownerName: "owner")
+ )
+ #expect(
+ RecordSerializer.isTrustedGameScopedDeletion(recordID: inGameZone, recordType: "Ping")
+ )
+ // A friend-mailbox ping names a game outside its zone by design.
+ let inFriendZone = CKRecord.ID(
+ recordName: pingName,
+ zoneID: CKRecordZone.ID(zoneName: "friend-pair", ownerName: "owner")
+ )
+ #expect(
+ RecordSerializer.isTrustedGameScopedDeletion(recordID: inFriendZone, recordType: "Ping")
+ )
+ // A ping in a *different* game's zone is not.
+ let crossGameZone = CKRecord.ID(
+ recordName: pingName,
+ zoneID: RecordSerializer.zoneID(for: UUID(), ownerName: "owner")
+ )
+ #expect(
+ !RecordSerializer.isTrustedGameScopedDeletion(recordID: crossGameZone, recordType: "Ping")
+ )
+ }
+
+ @Test("a private game's row is found under either owner-name spelling")
+ func privateGameLookupIgnoresOwnerSpelling() throws {
+ let persistence = makeTestPersistence()
+ let ctx = persistence.viewContext
+ let name = RecordSerializer.recordName(forGameID: gameID)
+ // CloudKit round-trips the current user's own zones with either the
+ // `CKCurrentUserDefaultName` placeholder or their concrete record ID.
+ // Both spellings must resolve to the same private row, or every
+ // fetch after the first would spawn a duplicate game.
+ let placeholderZone = RecordSerializer.zoneID(for: gameID)
+ let concreteZone = RecordSerializer.zoneID(for: gameID, ownerName: "_myOwnRecordID")
+
+ let first = CKRecord(
+ recordType: "Game",
+ recordID: CKRecord.ID(recordName: name, zoneID: placeholderZone)
+ )
+ first["title"] = "Original" as CKRecordValue
+ let second = CKRecord(
+ recordType: "Game",
+ recordID: CKRecord.ID(recordName: name, zoneID: concreteZone)
+ )
+ second["title"] = "Round-tripped" as CKRecordValue
+
+ let firstEntity = RecordSerializer.applyGameRecord(first, to: ctx, databaseScope: .private)
+ let secondEntity = RecordSerializer.applyGameRecord(second, to: ctx, databaseScope: .private)
+
+ #expect(firstEntity.objectID == secondEntity.objectID)
+ #expect(secondEntity.title == "Round-tripped")
+ }
+
+ @Test("same-named records in distinct owner zones do not share a local row")
+ func gameLookupIncludesZoneOwner() throws {
+ let persistence = makeTestPersistence()
+ let ctx = persistence.viewContext
+ let firstZone = RecordSerializer.zoneID(for: gameID, ownerName: "owner-a")
+ let secondZone = RecordSerializer.zoneID(for: gameID, ownerName: "owner-b")
+
+ let first = CKRecord(
+ recordType: "Game",
+ recordID: CKRecord.ID(
+ recordName: RecordSerializer.recordName(forGameID: gameID),
+ zoneID: firstZone
+ )
+ )
+ first["title"] = "First" as CKRecordValue
+ let second = CKRecord(
+ recordType: "Game",
+ recordID: CKRecord.ID(
+ recordName: RecordSerializer.recordName(forGameID: gameID),
+ zoneID: secondZone
+ )
+ )
+ second["title"] = "Second" as CKRecordValue
+
+ let firstEntity = RecordSerializer.applyGameRecord(first, to: ctx, databaseScope: .shared)
+ let secondEntity = RecordSerializer.applyGameRecord(second, to: ctx, databaseScope: .shared)
+
+ #expect(firstEntity.objectID != secondEntity.objectID)
+ #expect(firstEntity.title == "First")
+ #expect(secondEntity.title == "Second")
+ }
+}
diff --git a/Tests/Unit/Sync/MovesInboundTests.swift b/Tests/Unit/Sync/MovesInboundTests.swift
@@ -229,6 +229,7 @@ struct MovesInboundTests {
let game = GameEntity(context: ctx)
game.id = gameID
game.ckRecordName = "game-\(gameID.uuidString)"
+ game.ckZoneName = RecordSerializer.zoneID(for: gameID).zoneName
game.title = ""
game.puzzleSource = ""
game.createdAt = Date(timeIntervalSince1970: 0)
@@ -291,6 +292,7 @@ struct MovesInboundTests {
let game = GameEntity(context: ctx)
game.id = gameID
game.ckRecordName = "game-\(gameID.uuidString)"
+ game.ckZoneName = RecordSerializer.zoneID(for: gameID).zoneName
game.title = ""
game.puzzleSource = ""
game.createdAt = Date(timeIntervalSince1970: 0)
@@ -369,6 +371,7 @@ struct MovesInboundTests {
let game = GameEntity(context: ctx)
game.id = gameID
game.ckRecordName = "game-\(gameID.uuidString)"
+ game.ckZoneName = RecordSerializer.zoneID(for: gameID).zoneName
game.title = ""
game.puzzleSource = ""
game.createdAt = Date(timeIntervalSince1970: 0)
@@ -493,6 +496,7 @@ struct MovesInboundTests {
let game = GameEntity(context: ctx)
game.id = gameID
game.ckRecordName = "game-\(gameID.uuidString)"
+ game.ckZoneName = RecordSerializer.zoneID(for: gameID).zoneName
game.title = ""
game.puzzleSource = ""
game.createdAt = Date(timeIntervalSince1970: 0)