crossmate

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

commit 127ef9e38aaba6345023fc7fc31af423b21afdbd
parent da2fe7963f6a01699b51b199668643cbdad28eb6
Author: Michael Camilleri <[email protected]>
Date:   Thu, 18 Jun 2026 16:45:14 +0900

Ignore pairwise friend bootstraps for other players

This commit prevents a third collaborator in a shared game from trying
to accept a friendship bootstrap intended for the other two players.
Friendship pings ride through the game zone as broadcasts, but the
friend zone itself is pairwise, so an uninvolved participant should drop
the bootstrap before touching CloudKit.

The accept path now verifies that the local author ID is the non-owner
member of the payload's pair key. The pairing check lives with the
FriendZone helpers so the owner echo, missing identity, intended
acceptor, and unrelated-player cases are covered without a CloudKit
round trip.

Co-Authored-By: Codex GPT 5.5 <[email protected]>

Diffstat:
MCrossmate/Sync/FriendController.swift | 1+
MCrossmate/Sync/FriendZone.swift | 10++++++++++
MTests/Unit/Sync/FriendZoneTests.swift | 15+++++++++++++++
3 files changed, 26 insertions(+), 0 deletions(-)

diff --git a/Crossmate/Sync/FriendController.swift b/Crossmate/Sync/FriendController.swift @@ -189,6 +189,7 @@ final class FriendController { guard ping.kind == .friend, let payload = FriendZone.BootstrapPayload.decode(ping.payload) else { return } + guard FriendZone.canAcceptBootstrap(payload, localAuthorID: localAuthorID) else { return } if friendExists(pairKey: payload.pairKey) { return } guard let url = URL(string: payload.friendShareURL) else { return } diff --git a/Crossmate/Sync/FriendZone.swift b/Crossmate/Sync/FriendZone.swift @@ -42,6 +42,16 @@ enum FriendZone { localAuthorID != remoteAuthorID && localAuthorID < remoteAuthorID } + /// Whether `localAuthorID` is the intended acceptor for a game-zone + /// friendship bootstrap. `.friend` Pings are broadcast to every game + /// participant, so a third collaborator must ignore a pairwise bootstrap + /// meant for someone else instead of attempting to accept the CKShare. + static func canAcceptBootstrap(_ payload: BootstrapPayload, localAuthorID: String?) -> Bool { + guard let localAuthorID, !localAuthorID.isEmpty else { return false } + guard localAuthorID != payload.ownerAuthorID else { return false } + return pairKey(localAuthorID, payload.ownerAuthorID) == payload.pairKey + } + /// Payload carried in a `.friend` Ping (written into the *game* zone) so /// the non-owner can accept the friend-zone share without an out-of-band /// link. diff --git a/Tests/Unit/Sync/FriendZoneTests.swift b/Tests/Unit/Sync/FriendZoneTests.swift @@ -69,6 +69,21 @@ struct FriendZoneTests { #expect(FriendZone.BootstrapPayload.decode(encoded) == payload) } + @Test("Bootstrap acceptor must be the other member of the pair") + func bootstrapAcceptorMustBePairMember() { + let payload = FriendZone.BootstrapPayload( + friendShareURL: "https://www.icloud.com/share/abc#xyz", + pairKey: FriendZone.pairKey("_alice", "_bob"), + ownerAuthorID: "_alice" + ) + + #expect(FriendZone.canAcceptBootstrap(payload, localAuthorID: "_bob")) + #expect(!FriendZone.canAcceptBootstrap(payload, localAuthorID: "_carol")) + #expect(!FriendZone.canAcceptBootstrap(payload, localAuthorID: "_alice")) + #expect(!FriendZone.canAcceptBootstrap(payload, localAuthorID: nil)) + #expect(!FriendZone.canAcceptBootstrap(payload, localAuthorID: "")) + } + @Test("BootstrapPayload.decode tolerates nil and malformed input") func bootstrapPayloadDecodeTolerant() { #expect(FriendZone.BootstrapPayload.decode(nil) == nil)