crossmate

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

commit 0d3e1e32bc300d150c64c33ba788df071436cf8f
parent 77d9917f75cf1e9cacfcabecce8dcd8311468525
Author: Michael Camilleri <[email protected]>
Date:   Thu,  2 Jul 2026 10:45:20 +0900

Bind accepted friend-zone shares to the claimed owner

This commit closes a spoofable gap in the friendship bootstrap. The
previous gate checked only that the accepted zone's name matched the
payload's pairKey, but the forger controls both the zone name and the
claimed ownerAuthorID: a co-player could name a zone he owns for the
pair of the user and a third party, claim that party's identity in the
payload, and pass both checks. The user's device then bound the third
party's friendship to the forger's zone — leaking the user's display
name and per-pair invite key to him, letting his zone authenticate
forged .decline Pings under the borrowed identity, and blocking the real
friend's later bootstrap behind the outboxAccepted marker.

applyFriendPing now also requires the accepted zone's ownerName to equal
the payload's ownerAuthorID before accepting. The owner name is
CloudKit-authoritative, and legitimate bootstraps always satisfy the
check: both payload construction sites set ownerAuthorID to the sender's
own user-record name, which is exactly what a shared zone reports as its
owner — the same equivalence outbox writes already rely on.

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

Diffstat:
MCrossmate/Sync/FriendController.swift | 16++++++++++++++++
1 file changed, 16 insertions(+), 0 deletions(-)

diff --git a/Crossmate/Sync/FriendController.swift b/Crossmate/Sync/FriendController.swift @@ -206,6 +206,22 @@ final class FriendController { syncMonitor?.note("accept friendship rejected: accepted zone name does not match pair key") return } + // The name check alone is spoofable: a co-player can *name* his own + // zone for the (us, Carol) pair and claim `ownerAuthorID: Carol` — + // the pairKey then pins to Carol and both checks above pass, binding + // Carol's friendship to a zone the forger owns (leaking our name/ + // invite key to him, letting his zone authenticate `.decline` Pings + // as Carol, and blocking the real Carol's bootstrap behind + // `outboxAccepted`). The zone's `ownerName` is CloudKit-authoritative + // — it is the actual owner's user-record name, which a legitimate + // outbox's owner always is — so also require it to be the claimed + // friend. (Never `CKCurrentUserDefaultName` here: this is a fetched + // share we don't own, and `canAcceptBootstrap` already rejects + // self-pairs.) + guard outboxZoneID.ownerName == payload.ownerAuthorID else { + syncMonitor?.note("accept friendship rejected: accepted zone owner does not match claimed friend") + return + } try await accept(metadata) FriendZone.markOutboxAccepted(pairKey: payload.pairKey) persistFriend(authorID: payload.ownerAuthorID, pairKey: payload.pairKey)