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:
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