crossmate

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

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 }