crossmate

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

commit 8295b0d525f4149f67bcaa0ab3688d192145d016
parent eb7b777b68c3ca7b70e8f5843e523d0273c4477c
Author: Michael Camilleri <[email protected]>
Date:   Wed,  1 Jul 2026 23:41:14 +0900

Authenticate realtime engagement frames against forged identity

The engagement relay worker is a blind pass-through, so a frame's
asserted identity was only ever self-reported. The client applied a cell
edit after checking that its author and device were non-empty and not
this device, but never that the author belonged to the game or that its
updatedAt was plausible. A co-player could forge a frame whose updatedAt
sits far in the future and win the per-cell last-writer-wins race
permanently. Because a Moves record is keyed by the edit's own authorID,
the forged stamp also poisoned the victim's own record and rode the
persisted Moves and Game updatedAt into CloudKit, where a genuine later
edit could never overwrite it.

This commit closes the frame to unauthenticated senders and bounds the
timestamp. Incoming updatedAt is clamped to at most 120s past now
(realtimeCellEditMaxFutureSkew) before it enters the last-writer-wins
comparison, so a future stamp can no longer win forever or corrupt
persisted state. Each frame now also carries a per-recipient HMAC: the
sender signs one tag per participant with the friend-channel key it
published to that friend, and the receiver applies the frame only when
the tag addressed to it verifies under FriendEncryptionKeyDirectory.key
and every payload authorID equals the verified sender. A participant can
therefore only ever write its own record, and a frame that fails — no
key yet, a missing or invalid tag, a mismatched author — is dropped. A
drop is safe because the durable Moves sync still converges the change.

Authentication is end-to-end, so the relay worker is untouched, and the
key material already exists: reconcileFriendships befriends every
co-participant, so a pairwise friend key is available for each pair. Two
devices on one account share no friend key for themselves, so their live
edits fall back to Moves sync rather than mirroring instantly; restoring
that path through the account push secret is left for later.

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

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 8++++++++
MCrossmate/Persistence/GameStore.swift | 28++++++++++++++++++++++++++--
MCrossmate/Services/AccountPushCoordinator.swift | 14+++++++++++++-
MCrossmate/Services/EngagementLifecycle.swift | 50+++++++++++++++++++++++++++++++++++++++++++++++---
MCrossmate/Sync/EngagementCoordinator.swift | 53++++++++++++++++++++++++++++++++++++++++++++---------
ACrossmate/Sync/EngagementMessageAuthenticator.swift | 196+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/Unit/Sync/EngagementMessageAuthenticatorTests.swift | 149+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 483 insertions(+), 15 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -61,6 +61,7 @@ 36E2AAF1EE1314E13477EE85 /* NicknameDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3111803C8FFFB0C839217482 /* NicknameDirectory.swift */; }; 38C913D00ED762BD9E355A2D /* KeychainHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33878A29B09A6154C7A63C82 /* KeychainHelper.swift */; }; 3A5483EF2893AE325DF27EE8 /* GameMutator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DC132D49361C56DE79C13E /* GameMutator.swift */; }; + 3B3F30AA1B7AA6D4465CB537 /* EngagementMessageAuthenticatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A24FD313982C8094CD020106 /* EngagementMessageAuthenticatorTests.swift */; }; 3C54AE4AA04342CCF5705B20 /* PlayerNamePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71DFD035381B6252DCD873C9 /* PlayerNamePublisher.swift */; }; 3C54B672A9FCA98C0A304470 /* TipsArchive.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9DC6394CB0F5B85C083FAC7 /* TipsArchive.swift */; }; 41290C86E72D6567C43C31B7 /* ShareLinkShortenerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 057F2B8B8A894D08BB801219 /* ShareLinkShortenerTests.swift */; }; @@ -88,6 +89,7 @@ 5FB26F40F5DB52111E3D1BDC /* CheckResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FD9A43789D0ED123F7A99B0 /* CheckResult.swift */; }; 609364CB79E0C7517298B404 /* ContentKeyDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAFA466405AABA1C06272795 /* ContentKeyDirectory.swift */; }; 61F8B38587EE49D376B53544 /* ReplayCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 603E6FC55F1BD944592379D2 /* ReplayCacheTests.swift */; }; + 648EA338E7718079EA354D7D /* EngagementMessageAuthenticator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B3E0C62CBF5ADF7C01C1D54 /* EngagementMessageAuthenticator.swift */; }; 6850EAE474E589CE1EA2DF68 /* NicknameDirectoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92168360625ECDD36FF50EE8 /* NicknameDirectoryTests.swift */; }; 689DAEC70934027E76E8116E /* KeyboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FDE73AD7C543B29C8E493F8 /* KeyboardView.swift */; }; 6A1CA96FF48CBEEE78EA6D34 /* FriendModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B766E872B12DC79ECCD80941 /* FriendModelTests.swift */; }; @@ -384,8 +386,10 @@ 9A49C3C31F49A85764B84A15 /* SyncState+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SyncState+Helpers.swift"; sourceTree = "<group>"; }; 9A56778AF8190F0D7EB2E27E /* GameStorePushAddressTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStorePushAddressTests.swift; sourceTree = "<group>"; }; 9AF6157D97271205626E207C /* MovesUpdaterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovesUpdaterTests.swift; sourceTree = "<group>"; }; + 9B3E0C62CBF5ADF7C01C1D54 /* EngagementMessageAuthenticator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EngagementMessageAuthenticator.swift; sourceTree = "<group>"; }; 9F13534332699C80382FE682 /* CellPatterns.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellPatterns.swift; sourceTree = "<group>"; }; 9F8D856707B4D76FDBF4AE69 /* FriendEntity+DisplayName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FriendEntity+DisplayName.swift"; sourceTree = "<group>"; }; + A24FD313982C8094CD020106 /* EngagementMessageAuthenticatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EngagementMessageAuthenticatorTests.swift; sourceTree = "<group>"; }; A253416F4FEA271A80B22A73 /* NYTAuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTAuthService.swift; sourceTree = "<group>"; }; A3A251D89028B3CA065DE053 /* PuzzleScoreboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleScoreboard.swift; sourceTree = "<group>"; }; A8C18E9B47668E008BE4CF86 /* Archive.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Archive.swift; sourceTree = "<group>"; }; @@ -486,6 +490,7 @@ 16AAC1E8D2CB3B5117159934 /* CloudQuery.swift */, 44F86F0F1883A93F9622FB67 /* CloudZones.swift */, 8FDE03B4A77A8095ED2C23AB /* EngagementCoordinator.swift */, + 9B3E0C62CBF5ADF7C01C1D54 /* EngagementMessageAuthenticator.swift */, E655698481325C92EF5C348B /* FriendController.swift */, 7A4AFF292381C9B33C0F2CD6 /* FriendZone.swift */, 1B7539E0AD285C5A3AC3DDA2 /* GameArchiver.swift */, @@ -762,6 +767,7 @@ 9998739ED0875A17271B7899 /* AppServicesAnnouncementTests.swift */, 457B06DBFDC358D213A7CE54 /* AuthorIdentityTests.swift */, 67CFF96D54D2DE9C44EB120A /* EngagementCoordinatorTests.swift */, + A24FD313982C8094CD020106 /* EngagementMessageAuthenticatorTests.swift */, 94CEBA27A8AC4FCC92ADE1B4 /* EnsureGameEntityTests.swift */, 2E2702C74378FD2F14D1CE33 /* FriendControllerNicknameReplayTests.swift */, B766E872B12DC79ECCD80941 /* FriendModelTests.swift */, @@ -980,6 +986,7 @@ DB098F40C6950E29B4BF10A7 /* ArchiveTests.swift in Sources */, A98382E7659991FAF0F4ED0A /* AuthorIdentityTests.swift in Sources */, 328309D8CC72CCB5623FB2A1 /* EngagementCoordinatorTests.swift in Sources */, + 3B3F30AA1B7AA6D4465CB537 /* EngagementMessageAuthenticatorTests.swift in Sources */, 02943BA53D2130B910E6DC00 /* EnsureGameEntityTests.swift in Sources */, 931431F8052FC58768C9BC26 /* FriendControllerNicknameReplayTests.swift in Sources */, 6A1CA96FF48CBEEE78EA6D34 /* FriendModelTests.swift in Sources */, @@ -1083,6 +1090,7 @@ 267ED5B329F05A30430B73A0 /* EngagementHost.swift in Sources */, A7FA870D794CA00F7F3F05D2 /* EngagementHostEnvironment.swift in Sources */, D240BF6498A9148855DB7734 /* EngagementLifecycle.swift in Sources */, + 648EA338E7718079EA354D7D /* EngagementMessageAuthenticator.swift in Sources */, 06AE6DF7AA3274480C591E47 /* EngagementStore.swift in Sources */, BD317ECC09C9099AC29B8C5D /* FriendAvatarView.swift in Sources */, C8ACF431021E7BEE61A99153 /* FriendController.swift in Sources */, diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -504,6 +504,12 @@ extension GameEntity { @MainActor @Observable final class GameStore { + /// Upper bound on how far past now an incoming realtime cell edit's + /// `updatedAt` is trusted. Matches the relay worker's auth skew; a genuine + /// cross-device clock difference stays well under it, while a crafted + /// far-future timestamp is clamped so it can't win per-cell LWW forever. + static let realtimeCellEditMaxFutureSkew: TimeInterval = 120 + let persistence: PersistenceController private var context: NSManagedObjectContext { persistence.viewContext } @@ -883,6 +889,12 @@ final class GameStore { /// actually changed. @discardableResult func applyRealtimeCellEdits(_ edits: [RealtimeCellEdit]) -> Int { + // A live edit's `updatedAt` is attacker-controlled over the relay + // channel. Clamp it to a bounded skew past now so a crafted far-future + // timestamp can't win per-cell LWW permanently (and can't poison the + // persisted Moves/Game `updatedAt`, which would then sync to CloudKit). + // A genuine later edit always reclaims the cell once this is bounded. + let maxAcceptableUpdatedAt = Date().addingTimeInterval(Self.realtimeCellEditMaxFutureSkew) let groups = Dictionary(grouping: edits) { edit in RecordSerializer.recordName( forMovesInGame: edit.gameID, @@ -917,10 +929,11 @@ final class GameStore { var changed = false for edit in groupEdits { let position = GridPosition(row: edit.row, col: edit.col) + let clampedUpdatedAt = min(edit.updatedAt, maxAcceptableUpdatedAt) let incoming = TimestampedCell( letter: edit.letter, mark: edit.mark, - updatedAt: edit.updatedAt, + updatedAt: clampedUpdatedAt, authorID: edit.cellAuthorID ) if let current = cells[position], current.updatedAt > incoming.updatedAt { @@ -929,7 +942,7 @@ final class GameStore { cells[position] = incoming changed = true applied += 1 - if edit.updatedAt > latest { latest = edit.updatedAt } + if clampedUpdatedAt > latest { latest = clampedUpdatedAt } } guard changed else { continue } @@ -2404,6 +2417,17 @@ final class GameStore { fetchPlayerEntity(gameID: gameID, authorID: authorID)?.pushAddress } + /// The authorIDs of every participant in `gameID` — the game's known + /// roster, reused from the same summary the library renders. Used to + /// address per-recipient authentication tags on outbound live engagement + /// frames. + func participantAuthorIDs(gameID: UUID) -> [String] { + guard let entity = fetchGameEntity(id: gameID), + let summary = GameSummary(entity: entity, localAuthorID: authorIDProvider()) + else { return [] } + return summary.allParticipants.map(\.authorID).filter { !$0.isEmpty } + } + private func fetchPlayerEntity(gameID: UUID, authorID: String) -> PlayerEntity? { let request = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") request.predicate = NSPredicate( diff --git a/Crossmate/Services/AccountPushCoordinator.swift b/Crossmate/Services/AccountPushCoordinator.swift @@ -1,5 +1,6 @@ import CloudKit import CoreData +import CryptoKit import Foundation import Observation @@ -13,7 +14,7 @@ import Observation final class AccountPushCoordinator { private static let accountPushAddressDefaultsPrefix = "push.accountAddress." private static let accountPushSecretDefaultsPrefix = "push.accountSecret." - private static let friendEncryptionKeyDefaultsPrefix = "push.friendEncryptionKey." + nonisolated private static let friendEncryptionKeyDefaultsPrefix = "push.friendEncryptionKey." private static let publishedFriendEncryptionKeyDefaultsPrefix = "push.friendEncryptionKeyPublished." /// Generation of the locally-held push secret. Bumped on a deliberate @@ -277,6 +278,17 @@ final class AccountPushCoordinator { ) } + /// This device's own friend-channel key for `pairKey` — the key it publishes + /// to that friend (mirrored into the friend's `FriendEncryptionKeyDirectory` + /// under this account's authorID). The counterpart a recipient verifies a + /// live engagement frame against, so the sender signs with the same value. + /// Nil until the key has been minted for the pair. + nonisolated static func localFriendChannelKey(pairKey: String) -> SymmetricKey? { + FriendEncryptionKeyPayload.decode( + UserDefaults.standard.string(forKey: friendEncryptionKeyDefaultsPrefix + pairKey) + )?.symmetricKey + } + private func shouldPublishFriendEncryptionKey(_ encoded: String, pairKey: String) -> Bool { UserDefaults.standard.string( forKey: Self.publishedFriendEncryptionKeyDefaultsPrefix + pairKey diff --git a/Crossmate/Services/EngagementLifecycle.swift b/Crossmate/Services/EngagementLifecycle.swift @@ -280,21 +280,41 @@ final class EngagementLifecycle { deviceID: RecordSerializer.localDeviceID, selection: selection ) - await engagementCoordinator.sendSelection(update) + let auth = EngagementMessageAuthenticator.authTags( + for: EngagementMessage(selection: update), + senderAuthorID: localAuthorID, + recipientAuthorIDs: store.participantAuthorIDs(gameID: gameID) + ) + guard !auth.isEmpty else { return } + await engagementCoordinator.sendSelection(update, senderAuthorID: localAuthorID, auth: auth) } /// Forwards a locally-applied cell edit over the live channel. No-op while /// no channel is live for the game — the durable Moves sync covers it. func sendLocalCellEdit(_ edit: RealtimeCellEdit) { guard engagementStatus.isLive(gameID: edit.gameID) else { return } - Task { await engagementCoordinator.sendCellEdit(edit) } + guard let localAuthorID = identity.currentID, !localAuthorID.isEmpty else { return } + let auth = EngagementMessageAuthenticator.authTags( + for: EngagementMessage(cellEdit: edit), + senderAuthorID: localAuthorID, + recipientAuthorIDs: store.participantAuthorIDs(gameID: edit.gameID) + ) + guard !auth.isEmpty else { return } + Task { await engagementCoordinator.sendCellEdit(edit, senderAuthorID: localAuthorID, auth: auth) } } /// Batch companion to `sendLocalCellEdit`. func sendLocalCellEdits(_ edits: [RealtimeCellEdit]) { guard let gameID = edits.first?.gameID else { return } guard engagementStatus.isLive(gameID: gameID) else { return } - Task { await engagementCoordinator.sendCellEdits(edits) } + guard let localAuthorID = identity.currentID, !localAuthorID.isEmpty else { return } + let auth = EngagementMessageAuthenticator.authTags( + for: EngagementMessage(cellEdits: edits), + senderAuthorID: localAuthorID, + recipientAuthorIDs: store.participantAuthorIDs(gameID: gameID) + ) + guard !auth.isEmpty else { return } + Task { await engagementCoordinator.sendCellEdits(edits, senderAuthorID: localAuthorID, auth: auth) } } func handleEngagementEvent(_ event: EngagementHost.Event) { @@ -335,6 +355,27 @@ final class EngagementLifecycle { } } + /// Gate for a mutating inbound frame: the relay is untrusted, so a frame is + /// applied only if its per-recipient MAC verifies against the sender's + /// friend key and every payload authorID is the verified sender. A drop is + /// safe — the durable Moves/CloudKit path still converges the change. + private func verifyInbound( + _ envelope: EngagementMessage, + engagementID: UUID, + kind: String + ) -> Bool { + guard let localAuthorID = identity.currentID, + EngagementMessageAuthenticator.verify(envelope, localAuthorID: localAuthorID) + else { + syncMonitor.note( + "engagement: dropped unauthenticated \(kind) \(engagementID.uuidString) " + + "sender=\(envelope.senderAuthorID.prefix(8))" + ) + return false + } + return true + } + private func handleEngagementMessage(_ envelope: EngagementMessage, engagementID: UUID) { let latencyMs = max(0, Int(Date().timeIntervalSince(envelope.sentAt) * 1000)) switch envelope.kind { @@ -348,6 +389,7 @@ final class EngagementLifecycle { syncMonitor.note("engagement: ignored malformed cellEdit \(engagementID.uuidString)") return } + guard verifyInbound(envelope, engagementID: engagementID, kind: "cellEdit") else { return } let applied = store.applyRealtimeCellEdit(edit) if applied { syncMonitor.note( @@ -367,6 +409,7 @@ final class EngagementLifecycle { syncMonitor.note("engagement: ignored malformed cellEditBatch \(engagementID.uuidString)") return } + guard verifyInbound(envelope, engagementID: engagementID, kind: "cellEditBatch") else { return } let applied = store.applyRealtimeCellEdits(edits) syncMonitor.note( "engagement: applied cellEditBatch \(engagementID.uuidString) " + @@ -378,6 +421,7 @@ final class EngagementLifecycle { syncMonitor.note("engagement: ignored malformed selection \(engagementID.uuidString)") return } + guard verifyInbound(envelope, engagementID: engagementID, kind: "selection") else { return } engagementStore.set(selection) syncMonitor.note( "engagement: received selection \(engagementID.uuidString) " + diff --git a/Crossmate/Sync/EngagementCoordinator.swift b/Crossmate/Sync/EngagementCoordinator.swift @@ -84,6 +84,13 @@ struct EngagementMessage: Codable, Equatable, Sendable { var selection: EngagementSelectionUpdate? var sentAt: Date var ver: Int + /// The connection's claimed author. The relay is a blind pass-through, so + /// this is only trustworthy once the matching `auth` tag verifies. + var senderAuthorID: String + /// Per-recipient HMAC tags, keyed by recipient authorID (base64). The + /// receiver applies the frame only if the tag addressed to it verifies + /// against `key(for: senderAuthorID)`. See `EngagementMessageAuthenticator`. + var auth: [String: String] init( kind: Kind = .debugText, @@ -92,6 +99,8 @@ struct EngagementMessage: Codable, Equatable, Sendable { cellEdits: [RealtimeCellEdit]? = nil, selection: EngagementSelectionUpdate? = nil, sentAt: Date = Date(), + senderAuthorID: String = "", + auth: [String: String] = [:], ver: Int = 1 ) { self.kind = kind @@ -101,9 +110,17 @@ struct EngagementMessage: Codable, Equatable, Sendable { self.selection = selection self.sentAt = sentAt self.ver = ver + self.senderAuthorID = senderAuthorID + self.auth = auth } - init(cellEdit: RealtimeCellEdit, sentAt: Date = Date(), ver: Int = 1) { + init( + cellEdit: RealtimeCellEdit, + sentAt: Date = Date(), + senderAuthorID: String = "", + auth: [String: String] = [:], + ver: Int = 1 + ) { self.kind = .cellEdit self.text = "" self.cellEdit = cellEdit @@ -111,13 +128,21 @@ struct EngagementMessage: Codable, Equatable, Sendable { self.selection = nil self.sentAt = sentAt self.ver = ver + self.senderAuthorID = senderAuthorID + self.auth = auth } /// Carries one bulk gesture (check/clear/multi-cell undo) as a single /// message. Peers that predate this kind fail to decode it and drop the /// live update; the same cells still arrive durably via the Moves/CloudKit /// path, so they degrade to "appears on sync" rather than breaking. - init(cellEdits: [RealtimeCellEdit], sentAt: Date = Date(), ver: Int = 1) { + init( + cellEdits: [RealtimeCellEdit], + sentAt: Date = Date(), + senderAuthorID: String = "", + auth: [String: String] = [:], + ver: Int = 1 + ) { self.kind = .cellEditBatch self.text = "" self.cellEdit = nil @@ -125,9 +150,17 @@ struct EngagementMessage: Codable, Equatable, Sendable { self.selection = nil self.sentAt = sentAt self.ver = ver + self.senderAuthorID = senderAuthorID + self.auth = auth } - init(selection: EngagementSelectionUpdate, sentAt: Date = Date(), ver: Int = 1) { + init( + selection: EngagementSelectionUpdate, + sentAt: Date = Date(), + senderAuthorID: String = "", + auth: [String: String] = [:], + ver: Int = 1 + ) { self.kind = .selection self.text = "" self.cellEdit = nil @@ -135,6 +168,8 @@ struct EngagementMessage: Codable, Equatable, Sendable { self.selection = selection self.sentAt = sentAt self.ver = ver + self.senderAuthorID = senderAuthorID + self.auth = auth } func encodedData() throws -> Data { @@ -319,10 +354,10 @@ actor EngagementCoordinator { } } - func sendCellEdit(_ edit: RealtimeCellEdit) async { + func sendCellEdit(_ edit: RealtimeCellEdit, senderAuthorID: String, auth: [String: String]) async { guard case .live(let engagementID, _) = state(for: edit.gameID) else { return } do { - let message = EngagementMessage(cellEdit: edit) + let message = EngagementMessage(cellEdit: edit, senderAuthorID: senderAuthorID, auth: auth) try await host.send(engagementID: engagementID, message: message.encodedData()) await log( "engagement: sent cellEdit \(engagementID.uuidString) " + @@ -336,11 +371,11 @@ actor EngagementCoordinator { /// Ships a bulk gesture as one batched message instead of one per cell, so /// a whole-grid action lands on the peer in a single frame. All edits in a /// batch share a game (they originate from one local gesture). - func sendCellEdits(_ edits: [RealtimeCellEdit]) async { + func sendCellEdits(_ edits: [RealtimeCellEdit], senderAuthorID: String, auth: [String: String]) async { guard let first = edits.first else { return } guard case .live(let engagementID, _) = state(for: first.gameID) else { return } do { - let message = EngagementMessage(cellEdits: edits) + let message = EngagementMessage(cellEdits: edits, senderAuthorID: senderAuthorID, auth: auth) try await host.send(engagementID: engagementID, message: message.encodedData()) await log( "engagement: sent cellEditBatch \(engagementID.uuidString) " + @@ -351,10 +386,10 @@ actor EngagementCoordinator { } } - func sendSelection(_ selection: EngagementSelectionUpdate) async { + func sendSelection(_ selection: EngagementSelectionUpdate, senderAuthorID: String, auth: [String: String]) async { guard case .live(let engagementID, _) = state(for: selection.gameID) else { return } do { - let message = EngagementMessage(selection: selection) + let message = EngagementMessage(selection: selection, senderAuthorID: senderAuthorID, auth: auth) try await host.send(engagementID: engagementID, message: message.encodedData()) await log( "engagement: sent selection \(engagementID.uuidString) " + diff --git a/Crossmate/Sync/EngagementMessageAuthenticator.swift b/Crossmate/Sync/EngagementMessageAuthenticator.swift @@ -0,0 +1,196 @@ +import CryptoKit +import Foundation + +/// End-to-end authentication for live engagement frames. The relay worker is a +/// blind pass-through, so a frame's asserted identity is only trustworthy if it +/// carries a MAC the receiver can verify against the sender's friend-channel +/// key. +/// +/// A sender attaches one tag per recipient, computed with the key it published +/// to that friend (`push.friendEncryptionKey.<pairKey>`, mirrored into the +/// recipient's `FriendEncryptionKeyDirectory` under the sender's authorID). A +/// receiver verifies the tag addressed to it under `key(for: senderAuthorID)` +/// and requires every payload `authorID` to equal the verified sender — so a +/// participant can only ever write its own Moves record. Anything that fails +/// (no key yet, missing/invalid tag, author mismatch) is dropped; the durable +/// Moves/CloudKit path still converges it. +/// +/// The friend key also encrypts push payloads elsewhere, so a distinct MAC +/// subkey is HKDF-derived for domain separation. +enum EngagementMessageAuthenticator { + private static let context = "crossmate.engagement.auth.v1" + + private static func macKey(from friendKey: SymmetricKey) -> SymmetricKey { + HKDF<SHA256>.deriveKey( + inputKeyMaterial: friendKey, + info: Data(context.utf8), + outputByteCount: 32 + ) + } + + // MARK: - Canonical form + + private struct CanonicalEdit: Encodable { + let gameID: String + let deviceID: String + let row: Int + let col: Int + let letter: String + let mark: Int16 + let updatedAtMillis: Int64 + let cellAuthorID: String + } + + private struct CanonicalSelection: Encodable { + let gameID: String + let deviceID: String + let row: Int + let col: Int + let directionRaw: Int + let updatedAtMillis: Int64 + } + + private struct Canonical: Encodable { + let context: String + let kind: String + let sender: String + let recipient: String + let edits: [CanonicalEdit]? + let selection: CanonicalSelection? + } + + private static func millis(_ date: Date) -> Int64 { + Int64((date.timeIntervalSince1970 * 1000).rounded()) + } + + private static func canonicalEdit(_ edit: RealtimeCellEdit) -> CanonicalEdit { + CanonicalEdit( + gameID: edit.gameID.uuidString, + deviceID: edit.deviceID, + row: edit.row, + col: edit.col, + letter: edit.letter, + mark: edit.mark.code, + updatedAtMillis: millis(edit.updatedAt), + cellAuthorID: edit.cellAuthorID ?? "" + ) + } + + /// Deterministic bytes bound to a specific sender+recipient, or nil for a + /// kind that carries no authenticated payload (e.g. `debugText`). + private static func canonicalData( + for message: EngagementMessage, + senderAuthorID: String, + recipientAuthorID: String + ) -> Data? { + let edits: [CanonicalEdit]? + let selection: CanonicalSelection? + switch message.kind { + case .cellEdit: + guard let edit = message.cellEdit else { return nil } + edits = [canonicalEdit(edit)] + selection = nil + case .cellEditBatch: + guard let batch = message.cellEdits, !batch.isEmpty else { return nil } + edits = batch.map(canonicalEdit) + selection = nil + case .selection: + guard let sel = message.selection else { return nil } + edits = nil + selection = CanonicalSelection( + gameID: sel.gameID.uuidString, + deviceID: sel.deviceID, + row: sel.row, + col: sel.col, + directionRaw: sel.directionRaw, + updatedAtMillis: millis(sel.updatedAt) + ) + case .debugText: + return nil + } + let canonical = Canonical( + context: context, + kind: message.kind.rawValue, + sender: senderAuthorID, + recipient: recipientAuthorID, + edits: edits, + selection: selection + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return try? encoder.encode(canonical) + } + + /// The `authorID`s the payload writes under. A frame is authentic only if + /// every one equals the verified sender, so a participant can't write a + /// record attributed to another author. + private static func payloadAuthorIDs(_ message: EngagementMessage) -> [String] { + switch message.kind { + case .cellEdit: + return message.cellEdit.map { [$0.authorID] } ?? [] + case .cellEditBatch: + return message.cellEdits?.map(\.authorID) ?? [] + case .selection: + return message.selection.map { [$0.authorID] } ?? [] + case .debugText: + return [] + } + } + + // MARK: - Sign + + /// Per-recipient tag map for an outgoing message. Includes a tag only for + /// recipients this sender already holds a published key for; the rest drop + /// the frame and pick it up via Moves sync. Returns empty when the sender + /// can authenticate to nobody — the caller then skips the live send. + static func authTags( + for message: EngagementMessage, + senderAuthorID: String, + recipientAuthorIDs: [String] + ) -> [String: String] { + guard !senderAuthorID.isEmpty else { return [:] } + var tags: [String: String] = [:] + for recipient in Set(recipientAuthorIDs) + where recipient != senderAuthorID && !recipient.isEmpty { + let pairKey = FriendZone.pairKey(senderAuthorID, recipient) + guard let friendKey = AccountPushCoordinator.localFriendChannelKey(pairKey: pairKey), + let canonical = canonicalData( + for: message, + senderAuthorID: senderAuthorID, + recipientAuthorID: recipient + ) + else { continue } + let mac = HMAC<SHA256>.authenticationCode(for: canonical, using: macKey(from: friendKey)) + tags[recipient] = Data(mac).base64EncodedString() + } + return tags + } + + // MARK: - Verify + + /// True iff `message` carries a tag for `localAuthorID` that verifies under + /// the sender's friend key and every payload authorID equals the sender. + static func verify(_ message: EngagementMessage, localAuthorID: String) -> Bool { + let sender = message.senderAuthorID + guard !sender.isEmpty, !localAuthorID.isEmpty, sender != localAuthorID else { return false } + + let authors = payloadAuthorIDs(message) + guard !authors.isEmpty, authors.allSatisfy({ $0 == sender }) else { return false } + + guard let tagBase64 = message.auth[localAuthorID], + let tag = Data(base64Encoded: tagBase64), + let friendKey = FriendEncryptionKeyDirectory.key(for: sender), + let canonical = canonicalData( + for: message, + senderAuthorID: sender, + recipientAuthorID: localAuthorID + ) + else { return false } + + return HMAC<SHA256>.isValidAuthenticationCode( + tag, + authenticating: canonical, + using: macKey(from: friendKey) + ) + } +} diff --git a/Tests/Unit/Sync/EngagementMessageAuthenticatorTests.swift b/Tests/Unit/Sync/EngagementMessageAuthenticatorTests.swift @@ -0,0 +1,149 @@ +import Foundation +import Testing + +@testable import Crossmate + +@Suite("EngagementMessageAuthenticator") +struct EngagementMessageAuthenticatorTests { + private static let alice = "_alice" + private static let bob = "_bob" + private static let carol = "_carol" + + private static let localKeyPrefix = "push.friendEncryptionKey." + + /// Installs `payload` as Alice's own send key for the Alice↔Bob pair (in + /// `UserDefaults`, where the sender reads it) and as the key Bob mirrors for + /// Alice (in the directory, where the receiver reads it) — the same value in + /// both places, exactly as the friend channel converges it. + private static func withAliceBobKey( + _ body: (FriendEncryptionKeyPayload) async throws -> Void + ) async throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("engagement-auth-\(UUID().uuidString).json") + let pairKey = FriendZone.pairKey(alice, bob) + let defaultsKey = localKeyPrefix + pairKey + defer { + try? FileManager.default.removeItem(at: url) + UserDefaults.standard.removeObject(forKey: defaultsKey) + } + try await FriendEncryptionKeyDirectory.$testingFileURL.withValue(url) { + let payload = try #require(FriendEncryptionKeyPayload.fresh()) + UserDefaults.standard.set(payload.encodedString(), forKey: defaultsKey) + FriendEncryptionKeyDirectory.upsert(payload, for: alice) + try await body(payload) + } + } + + private static func cellEdit( + authorID: String, + letter: String = "A" + ) -> RealtimeCellEdit { + RealtimeCellEdit( + gameID: UUID(uuidString: "22222222-2222-2222-2222-222222222222")!, + authorID: authorID, + deviceID: "device-alice", + row: 1, + col: 2, + letter: letter, + mark: .pen(checked: nil), + updatedAt: Date(timeIntervalSince1970: 1000), + cellAuthorID: authorID + ) + } + + @Test("a tag Alice signs for Bob verifies on Bob's device") + func roundTripVerifies() async throws { + try await Self.withAliceBobKey { _ in + let edit = Self.cellEdit(authorID: Self.alice) + let tags = EngagementMessageAuthenticator.authTags( + for: EngagementMessage(cellEdit: edit), + senderAuthorID: Self.alice, + recipientAuthorIDs: [Self.bob] + ) + #expect(tags[Self.bob] != nil) + + let received = EngagementMessage(cellEdit: edit, senderAuthorID: Self.alice, auth: tags) + #expect(EngagementMessageAuthenticator.verify(received, localAuthorID: Self.bob)) + } + } + + @Test("a forged sender with no matching key is dropped") + func forgedSenderDropped() async throws { + try await Self.withAliceBobKey { _ in + // Carol asserts Alice's identity but can't produce Alice's tag: she + // signs with her own (absent) key, so Bob holds no key for the tag. + let edit = Self.cellEdit(authorID: Self.alice) + let received = EngagementMessage( + cellEdit: edit, + senderAuthorID: Self.alice, + auth: [Self.bob: Data("forged".utf8).base64EncodedString()] + ) + #expect(!EngagementMessageAuthenticator.verify(received, localAuthorID: Self.bob)) + } + } + + @Test("an edit whose authorID differs from the verified sender is dropped") + func authorMismatchDropped() async throws { + try await Self.withAliceBobKey { _ in + // Alice authenticates as herself but carries an edit attributed to + // Carol — she may only write her own record. + let foreign = Self.cellEdit(authorID: Self.carol) + let tags = EngagementMessageAuthenticator.authTags( + for: EngagementMessage(cellEdit: foreign), + senderAuthorID: Self.alice, + recipientAuthorIDs: [Self.bob] + ) + let received = EngagementMessage(cellEdit: foreign, senderAuthorID: Self.alice, auth: tags) + #expect(!EngagementMessageAuthenticator.verify(received, localAuthorID: Self.bob)) + } + } + + @Test("a tampered payload no longer verifies") + func tamperedPayloadDropped() async throws { + try await Self.withAliceBobKey { _ in + let signed = Self.cellEdit(authorID: Self.alice, letter: "A") + let tags = EngagementMessageAuthenticator.authTags( + for: EngagementMessage(cellEdit: signed), + senderAuthorID: Self.alice, + recipientAuthorIDs: [Self.bob] + ) + // Same tag, different letter. + let tampered = Self.cellEdit(authorID: Self.alice, letter: "Z") + let received = EngagementMessage(cellEdit: tampered, senderAuthorID: Self.alice, auth: tags) + #expect(!EngagementMessageAuthenticator.verify(received, localAuthorID: Self.bob)) + } + } + + @Test("with no key for the sender, an otherwise valid tag is dropped") + func missingKeyDropped() async throws { + // No directory entry and no local key are installed for the pair. + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("engagement-auth-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: url) } + try await FriendEncryptionKeyDirectory.$testingFileURL.withValue(url) { + let edit = Self.cellEdit(authorID: Self.alice) + let received = EngagementMessage( + cellEdit: edit, + senderAuthorID: Self.alice, + auth: [Self.bob: Data("anything".utf8).base64EncodedString()] + ) + #expect(!EngagementMessageAuthenticator.verify(received, localAuthorID: Self.bob)) + } + } + + @Test("a sender that can key nobody produces no tags") + func noRecipientsNoTags() async throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("engagement-auth-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: url) } + try await FriendEncryptionKeyDirectory.$testingFileURL.withValue(url) { + let edit = Self.cellEdit(authorID: Self.alice) + let tags = EngagementMessageAuthenticator.authTags( + for: EngagementMessage(cellEdit: edit), + senderAuthorID: Self.alice, + recipientAuthorIDs: [Self.bob] + ) + #expect(tags.isEmpty) + } + } +}