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 }