crossmate

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

PushPayloadCipher.swift (2824B)


      1 import CryptoKit
      2 import Foundation
      3 
      4 /// Symmetric encryption for the structured `PushPayload`, so the Cloudflare
      5 /// push worker (and APNs) only ever sees ciphertext for the personal fields it
      6 /// would otherwise carry in cleartext — the sender's player name, the puzzle
      7 /// title, the composed alert body, and the pause diagnostics. The plaintext
      8 /// stays a `PushPayload`; only its on-the-wire representation changes from a
      9 /// base64 JSON blob the worker forwards verbatim to a sealed box it forwards
     10 /// just as opaquely.
     11 ///
     12 /// The key is the per-game `contentKey`: 32 random bytes minted into the Game
     13 /// record and synced to CKShare participants alongside the engagement/push
     14 /// credentials, then mirrored into the App Group so the notification service
     15 /// extension can read it. Unlike the push credential, it is **never** sent to
     16 /// any Worker — that is the whole point — so a Worker holding the push secret
     17 /// still cannot read the payload.
     18 ///
     19 /// Opening is deliberately failure-tolerant. A recipient that does not yet hold
     20 /// the key (a just-joined participant whose Game record hasn't synced, or whose
     21 /// app hasn't mirrored it into the App Group yet) gets `nil` and falls back to
     22 /// the generic cleartext body the sender always ships.
     23 enum PushPayloadCipher {
     24     /// Builds the symmetric key from the stored base64 `contentKey`. The Game
     25     /// record mints exactly 32 bytes; anything shorter is treated as absent.
     26     static func key(fromBase64 string: String) -> SymmetricKey? {
     27         guard let data = Data(base64Encoded: string), data.count >= 32 else { return nil }
     28         return SymmetricKey(data: data.prefix(32))
     29     }
     30 
     31     /// Seals a payload into a base64 string of the AES-GCM combined box
     32     /// (`nonce | ciphertext | tag`). Returns `nil` if encoding or sealing
     33     /// fails, leaving the caller to ship the push without an encrypted payload.
     34     static func seal(_ payload: PushPayload, key: SymmetricKey) -> String? {
     35         guard let plaintext = try? JSONEncoder().encode(payload),
     36               let sealed = try? AES.GCM.seal(plaintext, using: key),
     37               let combined = sealed.combined
     38         else { return nil }
     39         return combined.base64EncodedString()
     40     }
     41 
     42     /// Opens a sealed payload. Returns `nil` on any failure — an absent or
     43     /// wrong key, a corrupt box, or plaintext this build can't decode.
     44     static func open(_ encoded: String?, key: SymmetricKey) -> PushPayload? {
     45         guard let encoded,
     46               let combined = Data(base64Encoded: encoded),
     47               let box = try? AES.GCM.SealedBox(combined: combined),
     48               let plaintext = try? AES.GCM.open(box, using: key),
     49               let payload = try? JSONDecoder().decode(PushPayload.self, from: plaintext)
     50         else { return nil }
     51         return payload
     52     }
     53 }