EngagementMessageAuthenticator.swift (9109B)
1 import CryptoKit 2 import Foundation 3 4 /// End-to-end authentication for live engagement frames. The relay worker is a 5 /// blind pass-through, so a frame's asserted identity is only trustworthy if it 6 /// carries a MAC the receiver can verify against the sender's friend-channel 7 /// key. 8 /// 9 /// A sender attaches one tag per recipient, computed with the key it published 10 /// to that friend (`push.friendEncryptionKey.<pairKey>`, mirrored into the 11 /// recipient's `FriendEncryptionKeyDirectory` under the sender's authorID). A 12 /// receiver verifies the tag addressed to it under `key(for: senderAuthorID)` 13 /// and requires every payload `authorID` to equal the verified sender — so a 14 /// participant can only ever write its own Moves record. The friend key is 15 /// pairwise, not per game, so a valid tag alone would let any friend sign a 16 /// frame for an arbitrary game ID; the receiver therefore also requires every 17 /// payload `gameID` to equal the game its channel is bound to, and the sender 18 /// to be a current participant of that game. Anything that fails (no key yet, 19 /// missing/invalid tag, author/game mismatch, non-participant) is dropped; the 20 /// durable Moves/CloudKit path still converges it. 21 /// 22 /// The friend key also encrypts push payloads elsewhere, so a distinct MAC 23 /// subkey is HKDF-derived for domain separation. 24 enum EngagementMessageAuthenticator { 25 private static let context = "crossmate.engagement.auth.v1" 26 27 private static func macKey(from friendKey: SymmetricKey) -> SymmetricKey { 28 HKDF<SHA256>.deriveKey( 29 inputKeyMaterial: friendKey, 30 info: Data(context.utf8), 31 outputByteCount: 32 32 ) 33 } 34 35 // MARK: - Canonical form 36 37 private struct CanonicalEdit: Encodable { 38 let gameID: String 39 let deviceID: String 40 let row: Int 41 let col: Int 42 let letter: String 43 let mark: Int16 44 let updatedAtMillis: Int64 45 let cellAuthorID: String 46 /// Omitted for legacy edits, preserving their v1 canonical bytes. 47 /// Present logical ticks are authenticated end-to-end. 48 let tick: Int64? 49 } 50 51 private struct CanonicalSelection: Encodable { 52 let gameID: String 53 let deviceID: String 54 let row: Int 55 let col: Int 56 let directionRaw: Int 57 let updatedAtMillis: Int64 58 } 59 60 private struct Canonical: Encodable { 61 let context: String 62 let kind: String 63 let sender: String 64 let recipient: String 65 let edits: [CanonicalEdit]? 66 let selection: CanonicalSelection? 67 } 68 69 private static func millis(_ date: Date) -> Int64 { 70 Int64((date.timeIntervalSince1970 * 1000).rounded()) 71 } 72 73 private static func canonicalEdit(_ edit: RealtimeCellEdit) -> CanonicalEdit { 74 CanonicalEdit( 75 gameID: edit.gameID.uuidString, 76 deviceID: edit.deviceID, 77 row: edit.row, 78 col: edit.col, 79 letter: edit.letter, 80 mark: edit.mark.code, 81 updatedAtMillis: millis(edit.updatedAt), 82 cellAuthorID: edit.cellAuthorID ?? "", 83 tick: edit.tick 84 ) 85 } 86 87 /// Deterministic bytes bound to a specific sender+recipient, or nil for a 88 /// kind that carries no authenticated payload (e.g. `debugText`). 89 private static func canonicalData( 90 for message: EngagementMessage, 91 senderAuthorID: String, 92 recipientAuthorID: String 93 ) -> Data? { 94 let edits: [CanonicalEdit]? 95 let selection: CanonicalSelection? 96 switch message.kind { 97 case .cellEdit: 98 guard let edit = message.cellEdit else { return nil } 99 edits = [canonicalEdit(edit)] 100 selection = nil 101 case .cellEditBatch: 102 guard let batch = message.cellEdits, !batch.isEmpty else { return nil } 103 edits = batch.map(canonicalEdit) 104 selection = nil 105 case .selection: 106 guard let sel = message.selection else { return nil } 107 edits = nil 108 selection = CanonicalSelection( 109 gameID: sel.gameID.uuidString, 110 deviceID: sel.deviceID, 111 row: sel.row, 112 col: sel.col, 113 directionRaw: sel.directionRaw, 114 updatedAtMillis: millis(sel.updatedAt) 115 ) 116 case .debugText: 117 return nil 118 } 119 let canonical = Canonical( 120 context: context, 121 kind: message.kind.rawValue, 122 sender: senderAuthorID, 123 recipient: recipientAuthorID, 124 edits: edits, 125 selection: selection 126 ) 127 let encoder = JSONEncoder() 128 encoder.outputFormatting = [.sortedKeys] 129 return try? encoder.encode(canonical) 130 } 131 132 /// The `authorID`s the payload writes under. A frame is authentic only if 133 /// every one equals the verified sender, so a participant can't write a 134 /// record attributed to another author. 135 private static func payloadAuthorIDs(_ message: EngagementMessage) -> [String] { 136 switch message.kind { 137 case .cellEdit: 138 return message.cellEdit.map { [$0.authorID] } ?? [] 139 case .cellEditBatch: 140 return message.cellEdits?.map(\.authorID) ?? [] 141 case .selection: 142 return message.selection.map { [$0.authorID] } ?? [] 143 case .debugText: 144 return [] 145 } 146 } 147 148 /// The `gameID`s the payload writes to. A frame is applied only if every 149 /// one equals the game its channel is bound to, so a room shared for one 150 /// game can't carry a correctly signed edit into another. 151 private static func payloadGameIDs(_ message: EngagementMessage) -> [UUID] { 152 switch message.kind { 153 case .cellEdit: 154 return message.cellEdit.map { [$0.gameID] } ?? [] 155 case .cellEditBatch: 156 return message.cellEdits?.map(\.gameID) ?? [] 157 case .selection: 158 return message.selection.map { [$0.gameID] } ?? [] 159 case .debugText: 160 return [] 161 } 162 } 163 164 // MARK: - Sign 165 166 /// Per-recipient tag map for an outgoing message. Includes a tag only for 167 /// recipients this sender already holds a published key for; the rest drop 168 /// the frame and pick it up via Moves sync. Returns empty when the sender 169 /// can authenticate to nobody — the caller then skips the live send. 170 static func authTags( 171 for message: EngagementMessage, 172 senderAuthorID: String, 173 recipientAuthorIDs: [String] 174 ) -> [String: String] { 175 guard !senderAuthorID.isEmpty else { return [:] } 176 var tags: [String: String] = [:] 177 for recipient in Set(recipientAuthorIDs) 178 where recipient != senderAuthorID && !recipient.isEmpty { 179 let pairKey = FriendZone.pairKey(senderAuthorID, recipient) 180 guard let friendKey = AccountPushCoordinator.localFriendChannelKey(pairKey: pairKey), 181 let canonical = canonicalData( 182 for: message, 183 senderAuthorID: senderAuthorID, 184 recipientAuthorID: recipient 185 ) 186 else { continue } 187 let mac = HMAC<SHA256>.authenticationCode(for: canonical, using: macKey(from: friendKey)) 188 tags[recipient] = Data(mac).base64EncodedString() 189 } 190 return tags 191 } 192 193 // MARK: - Verify 194 195 /// True iff `message` carries a tag for `localAuthorID` that verifies under 196 /// the sender's friend key, every payload authorID equals the sender, every 197 /// payload gameID equals `channelGameID` (the game the receiving channel is 198 /// bound to), and the sender is in `participantAuthorIDs` (that game's 199 /// known roster). The structural checks run before any MAC work. 200 static func verify( 201 _ message: EngagementMessage, 202 localAuthorID: String, 203 channelGameID: UUID, 204 participantAuthorIDs: [String] 205 ) -> Bool { 206 let sender = message.senderAuthorID 207 guard !sender.isEmpty, !localAuthorID.isEmpty, sender != localAuthorID else { return false } 208 209 let authors = payloadAuthorIDs(message) 210 guard !authors.isEmpty, authors.allSatisfy({ $0 == sender }) else { return false } 211 212 let gameIDs = payloadGameIDs(message) 213 guard !gameIDs.isEmpty, gameIDs.allSatisfy({ $0 == channelGameID }) else { return false } 214 215 guard participantAuthorIDs.contains(sender) else { return false } 216 217 guard let tagBase64 = message.auth[localAuthorID], 218 let tag = Data(base64Encoded: tagBase64), 219 let friendKey = FriendEncryptionKeyDirectory.key(for: sender), 220 let canonical = canonicalData( 221 for: message, 222 senderAuthorID: sender, 223 recipientAuthorID: localAuthorID 224 ) 225 else { return false } 226 227 return HMAC<SHA256>.isValidAuthenticationCode( 228 tag, 229 authenticating: canonical, 230 using: macKey(from: friendKey) 231 ) 232 } 233 }