crossmate

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

commit 989abe0224173f0a0121e65ae5ffe7d6a4f3b6b3
parent 5743ab756d898dd5ffc7fb2a20c29be0d73327fd
Author: Michael Camilleri <[email protected]>
Date:   Thu,  2 Jul 2026 00:10:39 +0900

Verify the accepted friend zone name and scope-gate its key

A friendship is realised as two mailbox zones, both named
friend-<pairKey>, and an accepted friend holds .readWrite on the inbox
the user owns. Two paths trusted that zone's identity without checking
it against the pair. On accept, applyFriendPing read the share's zone
from metadata.share.recordID.zoneID and bound it to payload.pairKey
wholesale, so a co-player could mint a share on a zone he owns but names
for another pair, have it accepted, and flow forged Decision and Ping
records under that borrowed identity. Separately, the encryptionKey
Decision checked the zone name but not the database scope, so a peer who
owns a zone that names itself for the pair could share it in at scope 1
and poison the friend-channel key the user mirrors for that pair,
misrouting or breaking the victim's invite push.

This commit checks the zone in both places. applyFriendPing now derives
the accepted zone name immediately after fetching the share metadata and
rejects the friendship — before accepting the share, so a mis-named zone
is never taken on — unless it matches FriendZone.zoneName for
payload.pairKey. That pairKey is already pinned to the friend's author
by canAcceptBootstrap and both mailbox zones derive the same name, so a
legitimate outbox always matches. The encryptionKey case now also
requires `databaseScope == 0`, mirroring the name case, so a key is
adopted only from the private inbox the user owns rather than any zone a
peer shares in.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

Diffstat:
MCrossmate/Sync/FriendController.swift | 15++++++++++++++-
MCrossmate/Sync/RecordSerializer.swift | 7+++++++
MTests/Unit/RecordSerializerTests.swift | 32++++++++++++++++++++++++++++++++
3 files changed, 53 insertions(+), 1 deletion(-)

diff --git a/Crossmate/Sync/FriendController.swift b/Crossmate/Sync/FriendController.swift @@ -190,10 +190,23 @@ final class FriendController { syncMonitor?.recordStart("accept friendship") do { let metadata = try await fetchShareMetadata(url: url) - try await accept(metadata) // The accepted zone is the friend's inbox — our outbox. Its owner // name is the friend's CloudKit user-record name. let outboxZoneID = metadata.share.recordID.zoneID + // Both mailbox zones are deterministically named `friend-<pairKey>`, + // so a legitimate outbox always matches. The share URL and the zone + // it targets are attacker-controllable: a co-player can mint a share + // on a zone he owns but *names* for another pair, and `payload.pairKey` + // is already pinned to `ownerAuthorID` by `canAcceptBootstrap`. Reject + // unless the accepted zone's name matches that pairKey, so a forged + // share can't be bound to this friendship and used to flow forged + // Decision/Ping records under a borrowed identity. Validate before + // accepting so we never take on a mis-named zone at all. + guard outboxZoneID.zoneName == FriendZone.zoneName(pairKey: payload.pairKey) else { + syncMonitor?.note("accept friendship rejected: accepted zone name does not match pair key") + return + } + try await accept(metadata) FriendZone.markOutboxAccepted(pairKey: payload.pairKey) persistFriend(authorID: payload.ownerAuthorID, pairKey: payload.pairKey) if let localAuthorID, !localAuthorID.isEmpty { diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift @@ -1234,6 +1234,13 @@ enum RecordSerializer { guard zoneID.zoneName == FriendZone.zoneName(pairKey: pairKey) else { return false } + // Like the `name` case: the friend writes their key into *our inbox* + // (their outbox), so it only ever arrives via the zone we own — the + // private engine, scope 0. Reject one seen at scope 1 (that would be + // the friend's own inbox, not the channel they publish to us). The + // zone-name gate alone is insufficient: a peer owns a zone that + // *names* itself for this pair and shares it to us at scope 1. + guard databaseScope == 0 else { return false } FriendEncryptionKeyDirectory.upsert(payload, for: key) return false default: diff --git a/Tests/Unit/RecordSerializerTests.swift b/Tests/Unit/RecordSerializerTests.swift @@ -1078,6 +1078,38 @@ struct RecordSerializerTests { } } + @Test("applyDecisionRecord(.encryptionKey) rejects a key seen at shared scope") + @MainActor func applyEncryptionKeyDecisionRejectsSharedScope() async throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("friend-key-directory-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: url) } + + try await FriendEncryptionKeyDirectory.$testingFileURL.withValue(url) { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + let payload = try #require(FriendEncryptionKeyPayload.fresh()) + // A peer owns a zone that *names* itself for this pair and shares it + // to us; it arrives via the shared DB (scope 1). The zone name + // matches, so only the scope gate rejects it — a forged key must not + // be mirrored into the directory the NSE reads. + let record = RecordSerializer.decisionRecord( + kind: RecordSerializer.encryptionKeyDecisionKind, + key: "_bob", + payload: payload.encodedString(), + zone: friendZoneID(local: "_alice", remote: "_bob") + ) + + let wrote = RecordSerializer.applyDecisionRecord( + record, + to: ctx, + localAuthorID: "_alice", + databaseScope: 1 + ) + #expect(!wrote) + #expect(FriendEncryptionKeyDirectory.payload(for: "_bob") == nil) + } + } + // MARK: - Name decisions /// The pairwise friend zone for (`local`, `remote`) — the only zone a