crossmate

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

PushPayloadCipher.swift (3154B)


      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     /// Ceiling on an encoded sealed box. APNs caps a whole notification
     25     /// payload at 4 KB, so any genuine `enc` is well under this; refusing
     26     /// longer input bounds the base64/AES work a hostile value can demand.
     27     static let maxEncodedLength = 8 * 1024
     28 
     29     /// Builds the symmetric key from the stored base64 `contentKey`. The Game
     30     /// record mints exactly 32 bytes; anything shorter is treated as absent.
     31     static func key(fromBase64 string: String) -> SymmetricKey? {
     32         guard let data = Data(base64Encoded: string), data.count >= 32 else { return nil }
     33         return SymmetricKey(data: data.prefix(32))
     34     }
     35 
     36     /// Seals a payload into a base64 string of the AES-GCM combined box
     37     /// (`nonce | ciphertext | tag`). Returns `nil` if encoding or sealing
     38     /// fails, leaving the caller to ship the push without an encrypted payload.
     39     static func seal(_ payload: PushPayload, key: SymmetricKey) -> String? {
     40         guard let plaintext = try? JSONEncoder().encode(payload),
     41               let sealed = try? AES.GCM.seal(plaintext, using: key),
     42               let combined = sealed.combined
     43         else { return nil }
     44         return combined.base64EncodedString()
     45     }
     46 
     47     /// Opens a sealed payload. Returns `nil` on any failure — an absent or
     48     /// wrong key, an oversized blob, a corrupt box, or plaintext this build
     49     /// can't decode.
     50     static func open(_ encoded: String?, key: SymmetricKey) -> PushPayload? {
     51         guard let encoded, encoded.count <= maxEncodedLength,
     52               let combined = Data(base64Encoded: encoded),
     53               let box = try? AES.GCM.SealedBox(combined: combined),
     54               let plaintext = try? AES.GCM.open(box, using: key),
     55               let payload = try? JSONDecoder().decode(PushPayload.self, from: plaintext)
     56         else { return nil }
     57         return payload
     58     }
     59 }