commit b016b8717a3561c655cfcf114c9d7fec5b091b23
parent 2139e0500cccd948a72e516a3a0742ad6c7a5472
Author: Michael Camilleri <[email protected]>
Date: Thu, 2 Jul 2026 00:25:59 +0900
Authenticate decline pings against the source friend zone
This commit closes a trust-boundary gap in how a declined invite is
handled. A .decline Ping is directed back to the game's owner, whose
device frees the declined seat by removing the decliner from the game's
CKShare. The owner acted on the self-reported authorID carried in the
Ping payload, with no check that the record genuinely came from that
account. Because any accepted friend holds .readWrite on the owner's
`friend-<pairKey>` inbox, a friend could forge a decline naming a third
party and evict an unrelated participant from a game the forger was
never part of.
Ping now carries sourceZoneName, read from the record's zone identifier
— intrinsic CloudKit metadata rather than a writable payload field — and
applyDeclinePing gates on it through the new isAuthenticDeclinePing
helper. A decline is honoured only when it arrives in
`friend-<pairKey(owner, decliner)>`, the one zone the decliner alone can
write to, so a decline can only ever free the decliner's own seat. A
forged or misplaced record is rejected and logged rather than acted on.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
3 files changed, 116 insertions(+), 3 deletions(-)
diff --git a/Crossmate/Services/InviteCoordinator.swift b/Crossmate/Services/InviteCoordinator.swift
@@ -726,11 +726,40 @@ 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 }
+ let expected = FriendZone.zoneName(
+ pairKey: FriendZone.pairKey(ownerAuthorID, ping.authorID)
+ )
+ return ping.sourceZoneName == expected
+ }
+
private func applyDeclinePing(_ ping: Ping) async {
- guard ping.addressee == identity.currentID,
- ping.authorID != identity.currentID,
+ guard let ownerAuthorID = identity.currentID,
+ ping.addressee == ownerAuthorID,
+ ping.authorID != ownerAuthorID,
!ping.authorID.isEmpty
else { return }
+ // The decliner is self-reported in `authorID`, but that field is
+ // attacker-writable: any accepted friend holds `.readWrite` on your
+ // `friend-<pairKey>` inbox and could forge a decline naming a third
+ // party to evict them from a game the forger was never in. Authenticate
+ // 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 {
+ syncMonitor.note(
+ "ping(decline): rejected — zone \(ping.sourceZoneName) does not " +
+ "authenticate decliner \(ping.authorID) for \(ping.gameID.uuidString)"
+ )
+ return
+ }
do {
try await shareController.removeFriendParticipant(
fromGameID: ping.gameID,
diff --git a/Crossmate/Sync/Presence.swift b/Crossmate/Sync/Presence.swift
@@ -73,6 +73,40 @@ struct Ping: Sendable {
let payload: String?
/// Recipient authorID for a directed ping. nil means broadcast.
let addressee: String?
+ /// The zone the record was fetched from — intrinsic CloudKit metadata, not
+ /// a writable payload field. For a friend-zone ping this is
+ /// `friend-<pairKey>`, which authenticates the *writer* of the zone
+ /// 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
+
+ /// `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`.
+ init(
+ recordName: String,
+ gameID: UUID,
+ authorID: String,
+ deviceID: String,
+ playerName: String,
+ puzzleTitle: String,
+ kind: PingKind,
+ payload: String?,
+ addressee: String?,
+ sourceZoneName: String = ""
+ ) {
+ self.recordName = recordName
+ self.gameID = gameID
+ self.authorID = authorID
+ self.deviceID = deviceID
+ self.playerName = playerName
+ self.puzzleTitle = puzzleTitle
+ self.kind = kind
+ self.payload = payload
+ self.addressee = addressee
+ self.sourceZoneName = sourceZoneName
+ }
static func parseRecord(_ record: CKRecord) -> Ping? {
let name = record.recordID.recordName
@@ -103,7 +137,8 @@ struct Ping: Sendable {
puzzleTitle: (record["puzzleTitle"] as? String) ?? "",
kind: kind,
payload: record["payload"] as? String,
- addressee: record["addressee"] as? String
+ addressee: record["addressee"] as? String,
+ sourceZoneName: record.recordID.zoneID.zoneName
)
}
}
diff --git a/Tests/Unit/Sync/FriendModelTests.swift b/Tests/Unit/Sync/FriendModelTests.swift
@@ -278,4 +278,53 @@ struct FriendModelTests {
gReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
#expect(try ctx.count(for: gReq) == 1)
}
+
+ // MARK: - M3: decline-ping authentication
+
+ private func declinePing(
+ gameID: UUID = UUID(),
+ authorID: String,
+ addressee: String,
+ sourceZoneName: String
+ ) -> Ping {
+ Ping(
+ recordName: "ping-\(gameID.uuidString)-\(authorID)-device",
+ gameID: gameID,
+ authorID: authorID,
+ deviceID: "device",
+ playerName: "Bob",
+ puzzleTitle: "Saturday",
+ kind: .decline,
+ payload: nil,
+ addressee: addressee,
+ sourceZoneName: sourceZoneName
+ )
+ }
+
+ @Test("decline from the decliner's own friend zone authenticates")
+ func authenticDeclineAccepted() {
+ 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))
+ }
+
+ @Test("decline naming a third party from another friend's zone is rejected")
+ func forgedDeclineRejected() {
+ let owner = "_owner"
+ let attacker = "_carol"
+ 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))
+ }
+
+ @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))
+ }
}