crossmate

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

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:
MCrossmate/Services/InviteCoordinator.swift | 33+++++++++++++++++++++++++++++++--
MCrossmate/Sync/Presence.swift | 37++++++++++++++++++++++++++++++++++++-
MTests/Unit/Sync/FriendModelTests.swift | 49+++++++++++++++++++++++++++++++++++++++++++++++++
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)) + } }