crossmate

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

commit 5e3014a92bbbec090ce446cea6606b0ee847beff
parent 0d3e1e32bc300d150c64c33ba788df071436cf8f
Author: Michael Camilleri <[email protected]>
Date:   Thu,  2 Jul 2026 12:39:07 +0900

Authenticate invite Pings and require private-scope friend zones

This commit closes the invite half of the forged-Ping identity gap. An
invite Ping's authorID and playerName are self-asserted, so any accepted
friend could write an invite into the user's inbox claiming to be from a
different friend: the Invited row and banner would read 'Carol invited
you' over the forger's share URL, and accepting would join the forger's
game. applyInvitePings now runs invites through the same zone-identity
gate declines already use — a genuine invite only ever arrives in the
pairwise `friend-<pairKey>` zone — so a forged invite is logged and
never stored, which also keeps it from bannering.

The gate itself is hardened as well: it now requires the Ping to have
been fetched from the private database, because a legitimate invite or
decline is always written into the inbox the user owns. A zone-name
match alone could still be satisfied from a zone the forger owns, so
requiring private scope holds even if a mis-owned zone slips past the
acceptance-time checks. Ping carries the fetch scope as
sourceDatabaseScope — intrinsic fetch metadata read in parseRecord, not
a stored field — typed with a new DatabaseScope enum that names the
app's raw 0/1 scope convention; an unknown scope fails closed. Pings
sent from pre-mailbox app builds whose legacy zone the user had joined
arrive shared-scoped and are now rejected with a log entry.

Co-Authored-By: Claude Fable 5 <[email protected]>

Diffstat:
MCrossmate/Services/InviteCoordinator.swift | 50+++++++++++++++++++++++++++++++++++++++-----------
MCrossmate/Sync/CloudQuery.swift | 9++++++---
MCrossmate/Sync/Presence.swift | 36+++++++++++++++++++++++++++++-------
MCrossmate/Sync/SyncEngine.swift | 2+-
MTests/Unit/RecordSerializerTests.swift | 5+++--
MTests/Unit/Sync/FriendModelTests.swift | 77++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
6 files changed, 144 insertions(+), 35 deletions(-)

diff --git a/Crossmate/Services/InviteCoordinator.swift b/Crossmate/Services/InviteCoordinator.swift @@ -189,6 +189,7 @@ final class InviteCoordinator { /// Upserts a durable `InviteEntity` for each inbound `.invite` Ping so the /// Game List's "Invited" section survives the Ping being GC'd. Skips /// self-authored invites, invites not directed to this author, invites + /// whose source zone doesn't authenticate the claimed inviter, invites /// from blocked friends, games already joined, and any `pingRecordName` /// already seen (a declined row is a tombstone that prevents /// resurrection). @@ -199,11 +200,27 @@ final class InviteCoordinator { /// exists by then, so it is absent from this set and isn't re-surfaced. @discardableResult private func applyInvitePings(_ pings: [Ping]) -> Set<String> { - let invites = pings.filter { + let candidates = pings.filter { $0.kind == .invite && $0.authorID != identity.currentID && $0.addressee == identity.currentID } + // `authorID`/`playerName` are self-asserted: any accepted friend can + // write an invite Ping claiming to be from a *different* friend, so + // the Invited row would read "Carol invited you" over the forger's + // share URL. Authenticate the inviter by the zone the record was + // written to, exactly as `applyDeclinePing` does for declines. A + // rejected invite is never stored, so it also never banners — the + // notification loop only surfaces record names this call inserted. + let (invites, forged) = candidates.partitioned { + Self.isAuthenticFriendZonePing($0, localAuthorID: identity.currentID ?? "") + } + for ping in forged { + syncMonitor.note( + "ping(invite): rejected — zone \(ping.sourceZoneName) does not " + + "authenticate inviter \(ping.authorID) for \(ping.gameID.uuidString)" + ) + } guard !invites.isEmpty else { return [] } let ctx = persistence.container.newBackgroundContext() @@ -730,16 +747,27 @@ final class InviteCoordinator { /// claimed record is skipped on re-delivery and would otherwise suppress /// the retry until the app restarts. The banner is queued separately by /// `presentPings`. - /// Whether a `.decline` Ping genuinely came from the account it names. The - /// only account that can write into `friend-<pairKey(owner, decliner)>` is - /// the decliner, so a legitimate decline always lands in the zone whose - /// pairKey pairs the owner with the ping's `authorID`. A ping naming a third - /// party — or one forged into some other friend's inbox — fails this check. - /// Empty inputs (missing owner identity, unparsed zone) fail closed. - static func isAuthenticDeclinePing(_ ping: Ping, ownerAuthorID: String) -> Bool { - guard !ownerAuthorID.isEmpty, !ping.authorID.isEmpty else { return false } + /// Whether a friend-zone Ping (`.invite` or `.decline`) genuinely came + /// from the friend it names in `authorID`. Both facts checked are + /// CloudKit-intrinsic fetch metadata, not writable record fields: + /// - The record sits in `friend-<pairKey(us, ping.authorID)>` — besides us, + /// the only account that can write into that zone is that friend, so a + /// ping naming a third party — or one forged into some other friend's + /// inbox — fails. + /// - It was fetched from the private database: a legitimate invite or + /// decline is always written into *our* inbox, a zone we own. A zone the + /// forger owns can carry any name he likes but only ever reaches us + /// through the shared database, so requiring `.private` holds even if a + /// mis-owned zone slips past acceptance-time checks. (Pings from friends + /// still on a pre-mailbox app version can arrive shared-scoped and are + /// rejected here; the rejection is logged by the callers.) + /// Empty inputs (missing local identity, unparsed zone) and an unknown + /// scope fail closed. + static func isAuthenticFriendZonePing(_ ping: Ping, localAuthorID: String) -> Bool { + guard !localAuthorID.isEmpty, !ping.authorID.isEmpty else { return false } + guard ping.sourceDatabaseScope == .private else { return false } let expected = FriendZone.zoneName( - pairKey: FriendZone.pairKey(ownerAuthorID, ping.authorID) + pairKey: FriendZone.pairKey(localAuthorID, ping.authorID) ) return ping.sourceZoneName == expected } @@ -757,7 +785,7 @@ final class InviteCoordinator { // the decliner by the zone the record was written to — only the decliner // can write to `friend-<pairKey(owner, decliner)>` — so a decline can // only ever free the decliner's own seat. (M3 / [P3-8].) - guard Self.isAuthenticDeclinePing(ping, ownerAuthorID: ownerAuthorID) else { + guard Self.isAuthenticFriendZonePing(ping, localAuthorID: ownerAuthorID) else { syncMonitor.note( "ping(decline): rejected — zone \(ping.sourceZoneName) does not " + "authenticate decliner \(ping.authorID) for \(ping.gameID.uuidString)" diff --git a/Crossmate/Sync/CloudQuery.swift b/Crossmate/Sync/CloudQuery.swift @@ -105,7 +105,7 @@ extension SyncEngine { var pings: [Ping] = [] var fetchedCount = 0 for record in collected { - guard let ping = Ping.parseRecord(record) else { continue } + guard let ping = Ping.parseRecord(record, fetchedFrom: DatabaseScope(rawValue: scopeValue)) else { continue } fetchedCount += 1 let modDate = record.modificationDate ?? Date() if seen.updateValue(modDate, forKey: record.recordID.recordName) == nil { @@ -195,7 +195,9 @@ extension SyncEngine { desiredKeys: RecordSerializer.pingDesiredKeys ) return PerZoneInvites( - pings: records.compactMap(Ping.parseRecord), + pings: records.compactMap { + Ping.parseRecord($0, fetchedFrom: DatabaseScope(rawValue: scopeValue)) + }, recordNames: Set(records.map(\.recordID.recordName)), scannedAuthorID: target.authorID, orphanedZone: nil @@ -348,7 +350,8 @@ extension SyncEngine { continue } for record in records { - guard let ping = Ping.parseRecord(record), ping.gameID == gameID else { continue } + guard let ping = Ping.parseRecord(record, fetchedFrom: DatabaseScope(rawValue: scopeValue)), + ping.gameID == gameID else { continue } deletePing(recordName: ping.recordName, zoneID: zoneID, databaseScope: scopeValue) await trace( "leave invite cleanup: deleting invite ping \(ping.recordName) " + diff --git a/Crossmate/Sync/Presence.swift b/Crossmate/Sync/Presence.swift @@ -59,6 +59,18 @@ enum PingKind: String, Sendable { case hail } +/// The CloudKit database a fetched record or zone belongs to, naming the raw +/// `Int16` convention used throughout the app (Core Data's +/// `GameEntity.databaseScope`, sync bookkeeping, push payloads): `0` is the +/// private database — zones this user owns — and `1` is the shared database — +/// zones this user joined. New code should traffic in this enum and convert +/// with `rawValue` at the Core Data / CloudKit boundaries; the existing +/// `Int16` plumbing migrates incrementally. +enum DatabaseScope: Int16, Sendable { + case `private` = 0 + case shared = 1 +} + struct Ping: Sendable { let recordName: String let gameID: UUID @@ -79,11 +91,18 @@ struct Ping: Sendable { /// independently of the self-asserted `authorID`. `.decline` handling gates /// on this so a forged ping in one friend's inbox can't impersonate another. let sourceZoneName: String + /// Which database the record was fetched from — like `sourceZoneName`, + /// intrinsic fetch context, not a writable payload field. A legitimate + /// invite or decline is written by the friend into *this user's* inbox — a + /// zone this user owns, i.e. `.private` — so the friend-zone authenticity + /// gate requires `.private`, which no zone a forger owns can satisfy. + let sourceDatabaseScope: DatabaseScope? - /// `sourceZoneName` defaults to empty so tests can construct pings without - /// it; an empty zone name can never match a real `friend-<pairKey>` zone, so - /// the `.decline` gate stays fail-closed. Production always parses it from - /// the record via `parseRecord`. + /// `sourceZoneName` defaults to empty and `sourceDatabaseScope` to nil so + /// tests can construct pings without them; an empty zone name can never + /// match a real `friend-<pairKey>` zone and an unknown scope is never + /// `.private`, so the friend-zone gate stays fail-closed. Production + /// always parses both from the fetch via `parseRecord`. init( recordName: String, gameID: UUID, @@ -94,7 +113,8 @@ struct Ping: Sendable { kind: PingKind, payload: String?, addressee: String?, - sourceZoneName: String = "" + sourceZoneName: String = "", + sourceDatabaseScope: DatabaseScope? = nil ) { self.recordName = recordName self.gameID = gameID @@ -106,9 +126,10 @@ struct Ping: Sendable { self.payload = payload self.addressee = addressee self.sourceZoneName = sourceZoneName + self.sourceDatabaseScope = sourceDatabaseScope } - static func parseRecord(_ record: CKRecord) -> Ping? { + static func parseRecord(_ record: CKRecord, fetchedFrom scope: DatabaseScope?) -> Ping? { let name = record.recordID.recordName let gameID: UUID? if name.hasPrefix("ping-") { @@ -138,7 +159,8 @@ struct Ping: Sendable { kind: kind, payload: record["payload"] as? String, addressee: record["addressee"] as? String, - sourceZoneName: record.recordID.zoneID.zoneName + sourceZoneName: record.recordID.zoneID.zoneName, + sourceDatabaseScope: scope ) } } diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -1507,7 +1507,7 @@ actor SyncEngine { effects.rosterRelevant.insert(gameID) } case "Ping": - if let ping = Ping.parseRecord(record) { + if let ping = Ping.parseRecord(record, fetchedFrom: isPrivate ? .private : .shared) { effects.pings.append(ping) } case "Decision": diff --git a/Tests/Unit/RecordSerializerTests.swift b/Tests/Unit/RecordSerializerTests.swift @@ -305,7 +305,7 @@ struct RecordSerializerTests { zone: zone ) - let parsed = try #require(Ping.parseRecord(record)) + let parsed = try #require(Ping.parseRecord(record, fetchedFrom: .shared)) #expect(parsed.gameID == gameID) #expect(parsed.authorID == "alice") #expect(parsed.deviceID == "deviceA") @@ -314,6 +314,7 @@ struct RecordSerializerTests { #expect(parsed.kind == .hail) #expect(parsed.payload == payload) #expect(parsed.addressee == "bob:deviceB") + #expect(parsed.sourceDatabaseScope == .shared) } @Test("hail ping parse requires fetched addressee for routing") @@ -333,7 +334,7 @@ struct RecordSerializerTests { zone: zone ) - let parsed = try #require(Ping.parseRecord(record)) + let parsed = try #require(Ping.parseRecord(record, fetchedFrom: .shared)) #expect(parsed.addressee == "alice") } diff --git a/Tests/Unit/Sync/FriendModelTests.swift b/Tests/Unit/Sync/FriendModelTests.swift @@ -279,13 +279,15 @@ struct FriendModelTests { #expect(try ctx.count(for: gReq) == 1) } - // MARK: - M3: decline-ping authentication + // MARK: - M3: friend-zone ping authentication - private func declinePing( + private func friendZonePing( gameID: UUID = UUID(), + kind: PingKind = .decline, authorID: String, addressee: String, - sourceZoneName: String + sourceZoneName: String, + sourceDatabaseScope: DatabaseScope? = .private ) -> Ping { Ping( recordName: "ping-\(gameID.uuidString)-\(authorID)-device", @@ -294,10 +296,11 @@ struct FriendModelTests { deviceID: "device", playerName: "Bob", puzzleTitle: "Saturday", - kind: .decline, + kind: kind, payload: nil, addressee: addressee, - sourceZoneName: sourceZoneName + sourceZoneName: sourceZoneName, + sourceDatabaseScope: sourceDatabaseScope ) } @@ -306,8 +309,8 @@ struct FriendModelTests { let owner = "_owner" let decliner = "_bob" let zone = FriendZone.zoneName(pairKey: FriendZone.pairKey(owner, decliner)) - let ping = declinePing(authorID: decliner, addressee: owner, sourceZoneName: zone) - #expect(InviteCoordinator.isAuthenticDeclinePing(ping, ownerAuthorID: owner)) + let ping = friendZonePing(authorID: decliner, addressee: owner, sourceZoneName: zone) + #expect(InviteCoordinator.isAuthenticFriendZonePing(ping, localAuthorID: owner)) } @Test("decline naming a third party from another friend's zone is rejected") @@ -317,15 +320,67 @@ struct FriendModelTests { let victim = "_bob" // Carol writes into her own inbox with the owner but forges authorID=Bob. let carolZone = FriendZone.zoneName(pairKey: FriendZone.pairKey(owner, attacker)) - let ping = declinePing(authorID: victim, addressee: owner, sourceZoneName: carolZone) - #expect(!InviteCoordinator.isAuthenticDeclinePing(ping, ownerAuthorID: owner)) + let ping = friendZonePing(authorID: victim, addressee: owner, sourceZoneName: carolZone) + #expect(!InviteCoordinator.isAuthenticFriendZonePing(ping, localAuthorID: owner)) } @Test("decline with no source zone fails closed") func declineMissingZoneRejected() { let owner = "_owner" - let ping = declinePing(authorID: "_bob", addressee: owner, sourceZoneName: "") - #expect(!InviteCoordinator.isAuthenticDeclinePing(ping, ownerAuthorID: owner)) + let ping = friendZonePing(authorID: "_bob", addressee: owner, sourceZoneName: "") + #expect(!InviteCoordinator.isAuthenticFriendZonePing(ping, localAuthorID: owner)) + } + + @Test("decline from a shared-database zone is rejected even with a matching name") + func sharedScopeDeclineRejected() { + let owner = "_owner" + let decliner = "_bob" + // A zone the forger owns can carry the right name, but it only ever + // reaches us through the shared database — our inbox is private. + let zone = FriendZone.zoneName(pairKey: FriendZone.pairKey(owner, decliner)) + let ping = friendZonePing( + authorID: decliner, addressee: owner, + sourceZoneName: zone, sourceDatabaseScope: .shared + ) + #expect(!InviteCoordinator.isAuthenticFriendZonePing(ping, localAuthorID: owner)) + } + + @Test("decline with an unknown database scope fails closed") + func unknownScopeDeclineRejected() { + let owner = "_owner" + let decliner = "_bob" + let zone = FriendZone.zoneName(pairKey: FriendZone.pairKey(owner, decliner)) + let ping = friendZonePing( + authorID: decliner, addressee: owner, + sourceZoneName: zone, sourceDatabaseScope: nil + ) + #expect(!InviteCoordinator.isAuthenticFriendZonePing(ping, localAuthorID: owner)) + } + + @Test("invite from the inviter's pairwise zone authenticates") + func authenticInviteAccepted() { + let invitee = "_alice" + let inviter = "_carol" + let zone = FriendZone.zoneName(pairKey: FriendZone.pairKey(invitee, inviter)) + let ping = friendZonePing( + kind: .invite, authorID: inviter, addressee: invitee, sourceZoneName: zone + ) + #expect(InviteCoordinator.isAuthenticFriendZonePing(ping, localAuthorID: invitee)) + } + + @Test("invite claiming a different friend's identity is rejected") + func forgedInviteRejected() { + let invitee = "_alice" + let attacker = "_mallory" + let impersonated = "_carol" + // Mallory writes into his own inbox with Alice but claims the invite + // is from Carol; the zone pairs Alice with Mallory, not Carol. + let malloryZone = FriendZone.zoneName(pairKey: FriendZone.pairKey(invitee, attacker)) + let ping = friendZonePing( + kind: .invite, authorID: impersonated, addressee: invitee, + sourceZoneName: malloryZone + ) + #expect(!InviteCoordinator.isAuthenticFriendZonePing(ping, localAuthorID: invitee)) } // MARK: - M12: invite fast-accept source cap