EngagementMessageAuthenticatorTests.swift (12834B)
1 import Foundation 2 import Testing 3 4 @testable import Crossmate 5 6 // Serialised: every `withAliceBobKey` test mutates the same global 7 // `UserDefaults` key (`push.friendEncryptionKey.<pairKey(alice,bob)>`) and 8 // removes it on the way out. Run in parallel, one test's cleanup can delete the 9 // key while another is mid-flight, so `authTags` finds no local send key and 10 // `roundTripVerifies` sees empty tags. The fixed pair is unique to this suite 11 // (AccountPushCoordinatorTests uses a per-test UUID pair), so serialising here 12 // is enough to isolate them. 13 @Suite("EngagementMessageAuthenticator", .serialized) 14 struct EngagementMessageAuthenticatorTests { 15 private static let alice = "_alice" 16 private static let bob = "_bob" 17 private static let carol = "_carol" 18 19 /// The game the receiving channel is bound to; `otherGameID` is a second 20 /// game the sender also knows, used for cross-game injection attempts. 21 private static let gameID = UUID(uuidString: "22222222-2222-2222-2222-222222222222")! 22 private static let otherGameID = UUID(uuidString: "99999999-9999-9999-9999-999999999999")! 23 private static let roster = [alice, bob] 24 25 private static let localKeyPrefix = "push.friendEncryptionKey." 26 27 /// Installs `payload` as Alice's own send key for the Alice↔Bob pair (in 28 /// `UserDefaults`, where the sender reads it) and as the key Bob mirrors for 29 /// Alice (in the directory, where the receiver reads it) — the same value in 30 /// both places, exactly as the friend channel converges it. 31 private static func withAliceBobKey( 32 _ body: (FriendEncryptionKeyPayload) async throws -> Void 33 ) async throws { 34 let url = FileManager.default.temporaryDirectory 35 .appendingPathComponent("engagement-auth-\(UUID().uuidString).json") 36 let pairKey = FriendZone.pairKey(alice, bob) 37 let defaultsKey = localKeyPrefix + pairKey 38 defer { 39 try? FileManager.default.removeItem(at: url) 40 UserDefaults.standard.removeObject(forKey: defaultsKey) 41 } 42 try await FriendEncryptionKeyDirectory.$testingFileURL.withValue(url) { 43 let payload = try #require(FriendEncryptionKeyPayload.fresh()) 44 UserDefaults.standard.set(payload.encodedString(), forKey: defaultsKey) 45 FriendEncryptionKeyDirectory.upsert(payload, for: alice) 46 try await body(payload) 47 } 48 } 49 50 private static func cellEdit( 51 authorID: String, 52 letter: String = "A", 53 gameID: UUID = gameID 54 ) -> RealtimeCellEdit { 55 RealtimeCellEdit( 56 gameID: gameID, 57 authorID: authorID, 58 deviceID: "device-alice", 59 row: 1, 60 col: 2, 61 letter: letter, 62 mark: .pen(checked: nil), 63 updatedAt: Date(timeIntervalSince1970: 1000), 64 cellAuthorID: authorID 65 ) 66 } 67 68 private static func selection( 69 authorID: String, 70 gameID: UUID = gameID 71 ) -> EngagementSelectionUpdate { 72 EngagementSelectionUpdate( 73 gameID: gameID, 74 authorID: authorID, 75 deviceID: "device-alice", 76 selection: PlayerSelection(row: 1, col: 2, direction: .across), 77 updatedAt: Date(timeIntervalSince1970: 1000) 78 ) 79 } 80 81 /// `verify` with this suite's fixed channel binding: the channel is bound 82 /// to `gameID` and the game's known roster is Alice and Bob. 83 private static func verify( 84 _ message: EngagementMessage, 85 localAuthorID: String, 86 channelGameID: UUID = gameID, 87 participantAuthorIDs: [String] = roster 88 ) -> Bool { 89 EngagementMessageAuthenticator.verify( 90 message, 91 localAuthorID: localAuthorID, 92 channelGameID: channelGameID, 93 participantAuthorIDs: participantAuthorIDs 94 ) 95 } 96 97 @Test("a tag Alice signs for Bob verifies on Bob's device") 98 func roundTripVerifies() async throws { 99 try await Self.withAliceBobKey { _ in 100 let edit = Self.cellEdit(authorID: Self.alice) 101 let tags = EngagementMessageAuthenticator.authTags( 102 for: EngagementMessage(cellEdit: edit), 103 senderAuthorID: Self.alice, 104 recipientAuthorIDs: [Self.bob] 105 ) 106 #expect(tags[Self.bob] != nil) 107 108 let received = EngagementMessage(cellEdit: edit, senderAuthorID: Self.alice, auth: tags) 109 #expect(Self.verify(received, localAuthorID: Self.bob)) 110 } 111 } 112 113 @Test("a forged sender with no matching key is dropped") 114 func forgedSenderDropped() async throws { 115 try await Self.withAliceBobKey { _ in 116 // Carol asserts Alice's identity but can't produce Alice's tag: she 117 // signs with her own (absent) key, so Bob holds no key for the tag. 118 let edit = Self.cellEdit(authorID: Self.alice) 119 let received = EngagementMessage( 120 cellEdit: edit, 121 senderAuthorID: Self.alice, 122 auth: [Self.bob: Data("forged".utf8).base64EncodedString()] 123 ) 124 #expect(!Self.verify(received, localAuthorID: Self.bob)) 125 } 126 } 127 128 @Test("an edit whose authorID differs from the verified sender is dropped") 129 func authorMismatchDropped() async throws { 130 try await Self.withAliceBobKey { _ in 131 // Alice authenticates as herself but carries an edit attributed to 132 // Carol — she may only write her own record. 133 let foreign = Self.cellEdit(authorID: Self.carol) 134 let tags = EngagementMessageAuthenticator.authTags( 135 for: EngagementMessage(cellEdit: foreign), 136 senderAuthorID: Self.alice, 137 recipientAuthorIDs: [Self.bob] 138 ) 139 let received = EngagementMessage(cellEdit: foreign, senderAuthorID: Self.alice, auth: tags) 140 #expect(!Self.verify(received, localAuthorID: Self.bob)) 141 } 142 } 143 144 @Test("a tampered payload no longer verifies") 145 func tamperedPayloadDropped() async throws { 146 try await Self.withAliceBobKey { _ in 147 let signed = Self.cellEdit(authorID: Self.alice, letter: "A") 148 let tags = EngagementMessageAuthenticator.authTags( 149 for: EngagementMessage(cellEdit: signed), 150 senderAuthorID: Self.alice, 151 recipientAuthorIDs: [Self.bob] 152 ) 153 // Same tag, different letter. 154 let tampered = Self.cellEdit(authorID: Self.alice, letter: "Z") 155 let received = EngagementMessage(cellEdit: tampered, senderAuthorID: Self.alice, auth: tags) 156 #expect(!Self.verify(received, localAuthorID: Self.bob)) 157 } 158 } 159 160 @Test("a tampered logical tick no longer verifies") 161 func tamperedTickDropped() async throws { 162 try await Self.withAliceBobKey { _ in 163 var signed = Self.cellEdit(authorID: Self.alice) 164 signed.tick = 41 165 let tags = EngagementMessageAuthenticator.authTags( 166 for: EngagementMessage(cellEdit: signed), 167 senderAuthorID: Self.alice, 168 recipientAuthorIDs: [Self.bob] 169 ) 170 let authentic = EngagementMessage( 171 cellEdit: signed, 172 senderAuthorID: Self.alice, 173 auth: tags 174 ) 175 #expect(Self.verify(authentic, localAuthorID: Self.bob)) 176 var tampered = signed 177 tampered.tick = 42 178 let received = EngagementMessage( 179 cellEdit: tampered, 180 senderAuthorID: Self.alice, 181 auth: tags 182 ) 183 #expect(!Self.verify(received, localAuthorID: Self.bob)) 184 } 185 } 186 187 @Test("with no key for the sender, an otherwise valid tag is dropped") 188 func missingKeyDropped() async throws { 189 // No directory entry and no local key are installed for the pair. 190 let url = FileManager.default.temporaryDirectory 191 .appendingPathComponent("engagement-auth-\(UUID().uuidString).json") 192 defer { try? FileManager.default.removeItem(at: url) } 193 try await FriendEncryptionKeyDirectory.$testingFileURL.withValue(url) { 194 let edit = Self.cellEdit(authorID: Self.alice) 195 let received = EngagementMessage( 196 cellEdit: edit, 197 senderAuthorID: Self.alice, 198 auth: [Self.bob: Data("anything".utf8).base64EncodedString()] 199 ) 200 #expect(!Self.verify(received, localAuthorID: Self.bob)) 201 } 202 } 203 204 @Test("a correctly MACed edit for a different game is dropped") 205 func crossGameEditDropped() async throws { 206 try await Self.withAliceBobKey { _ in 207 // Alice shares a live room with Bob for one game and signs a valid 208 // frame targeting another game she knows the UUID of. The tag 209 // verifies, but the payload game is not the channel's game. 210 let edit = Self.cellEdit(authorID: Self.alice, gameID: Self.otherGameID) 211 let tags = EngagementMessageAuthenticator.authTags( 212 for: EngagementMessage(cellEdit: edit), 213 senderAuthorID: Self.alice, 214 recipientAuthorIDs: [Self.bob] 215 ) 216 let received = EngagementMessage(cellEdit: edit, senderAuthorID: Self.alice, auth: tags) 217 #expect(!Self.verify(received, localAuthorID: Self.bob)) 218 // The same frame is accepted on a channel bound to its own game. 219 #expect(Self.verify(received, localAuthorID: Self.bob, channelGameID: Self.otherGameID)) 220 } 221 } 222 223 @Test("a batch containing any cross-game edit is dropped whole") 224 func mixedGameBatchDropped() async throws { 225 try await Self.withAliceBobKey { _ in 226 let edits = [ 227 Self.cellEdit(authorID: Self.alice), 228 Self.cellEdit(authorID: Self.alice, gameID: Self.otherGameID), 229 ] 230 let tags = EngagementMessageAuthenticator.authTags( 231 for: EngagementMessage(cellEdits: edits), 232 senderAuthorID: Self.alice, 233 recipientAuthorIDs: [Self.bob] 234 ) 235 let received = EngagementMessage(cellEdits: edits, senderAuthorID: Self.alice, auth: tags) 236 #expect(!Self.verify(received, localAuthorID: Self.bob)) 237 } 238 } 239 240 @Test("a correctly MACed selection for a different game is dropped") 241 func crossGameSelectionDropped() async throws { 242 try await Self.withAliceBobKey { _ in 243 let selection = Self.selection(authorID: Self.alice, gameID: Self.otherGameID) 244 let tags = EngagementMessageAuthenticator.authTags( 245 for: EngagementMessage(selection: selection), 246 senderAuthorID: Self.alice, 247 recipientAuthorIDs: [Self.bob] 248 ) 249 let received = EngagementMessage(selection: selection, senderAuthorID: Self.alice, auth: tags) 250 #expect(!Self.verify(received, localAuthorID: Self.bob)) 251 #expect(Self.verify(received, localAuthorID: Self.bob, channelGameID: Self.otherGameID)) 252 } 253 } 254 255 @Test("a sender outside the game's roster is dropped") 256 func nonParticipantSenderDropped() async throws { 257 try await Self.withAliceBobKey { _ in 258 // Alice still holds Bob's friend key (they remain friends) but is 259 // no longer a participant of the channel's game. 260 let edit = Self.cellEdit(authorID: Self.alice) 261 let tags = EngagementMessageAuthenticator.authTags( 262 for: EngagementMessage(cellEdit: edit), 263 senderAuthorID: Self.alice, 264 recipientAuthorIDs: [Self.bob] 265 ) 266 let received = EngagementMessage(cellEdit: edit, senderAuthorID: Self.alice, auth: tags) 267 #expect(!Self.verify( 268 received, 269 localAuthorID: Self.bob, 270 participantAuthorIDs: [Self.bob, Self.carol] 271 )) 272 } 273 } 274 275 @Test("a sender that can key nobody produces no tags") 276 func noRecipientsNoTags() async throws { 277 let url = FileManager.default.temporaryDirectory 278 .appendingPathComponent("engagement-auth-\(UUID().uuidString).json") 279 defer { try? FileManager.default.removeItem(at: url) } 280 try await FriendEncryptionKeyDirectory.$testingFileURL.withValue(url) { 281 let edit = Self.cellEdit(authorID: Self.alice) 282 let tags = EngagementMessageAuthenticator.authTags( 283 for: EngagementMessage(cellEdit: edit), 284 senderAuthorID: Self.alice, 285 recipientAuthorIDs: [Self.bob] 286 ) 287 #expect(tags.isEmpty) 288 } 289 } 290 }