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 }