crossmate

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

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 }