GamePushCredentials.swift (5300B)
1 import CryptoKit 2 import Foundation 3 4 /// The shared per-game notification credentials, stored in the Game record's 5 /// `notification` field (synced only to CKShare participants, like the 6 /// `engagement` room creds). Carries two distinct kinds of secret: 7 /// 8 /// - `secret` / `credID`: the push-worker auth material. Possession of `secret` 9 /// proves participation — the worker verifies publish signatures and 10 /// credID-scoped address registrations against the copy registered under 11 /// `credID`. `credID` is an unguessable capability that doubles as the 12 /// worker's storage key, exactly as `EngagementRoomCredentials.roomID` does 13 /// for the room worker. 14 /// - `contentKey`: a **worker-blind** symmetric key (base64 of 32 random 15 /// bytes). The structured push payload is encrypted under it (see 16 /// `PushPayloadCipher`) so the worker and APNs only ever see ciphertext for 17 /// the personal fields. Optional so records minted before it existed still 18 /// decode; `ensure`-paths add one to a legacy credential in place. 19 /// 20 /// IMPORTANT: only `secret` and `credID` may ever be sent to the push worker 21 /// (registration sends `{secret}`, publishes name `credID`). The encoded blob 22 /// as a whole — which now also holds `contentKey` — must never leave the device 23 /// for a Worker, or the encryption is pointless. 24 /// 25 /// Unlike room creds these are durable, so there is no expiry. 26 struct GamePushCredentials: Codable, Equatable, Hashable, Sendable { 27 var ver: Int 28 var credID: UUID 29 var secret: String 30 var contentKey: String? 31 32 init(credID: UUID = UUID(), secret: String, contentKey: String? = nil, ver: Int = 1) { 33 self.ver = ver 34 self.credID = credID 35 self.secret = secret 36 self.contentKey = contentKey 37 } 38 39 func encoded() throws -> String { 40 let data = try JSONEncoder().encode(self) 41 guard let string = String(data: data, encoding: .utf8) else { 42 throw GamePushError.invalidPayloadEncoding 43 } 44 return string 45 } 46 47 static func decode(_ string: String?) -> GamePushCredentials? { 48 guard let data = string?.data(using: .utf8) else { return nil } 49 return try? JSONDecoder().decode(GamePushCredentials.self, from: data) 50 } 51 52 /// Mints a fresh credential: a random 256-bit worker auth secret (base64url, 53 /// to satisfy the worker's `isAcceptableSecret` >= 32 key-byte check) and a 54 /// random 256-bit content key (standard base64, decoded directly by the NSE 55 /// via `PushPayloadCipher`). 56 static func fresh() throws -> GamePushCredentials { 57 try GamePushCredentials( 58 secret: Data.secureRandom(count: 32).base64URLEncodedString(), 59 contentKey: Data.secureRandom(count: 32).base64EncodedString() 60 ) 61 } 62 63 /// A fresh content key (standard base64 of 32 random bytes), used to backfill 64 /// a legacy credential minted before content keys existed. 65 static func freshContentKey() throws -> String { 66 try Data.secureRandom(count: 32).base64EncodedString() 67 } 68 } 69 70 /// One address this device should be reachable at, paired with the per-game 71 /// credential it is bound to. The account-scoped sibling address has no game, 72 /// so `gameID` and `credentials` are nil and it registers on the legacy 73 /// (credID-less) key. Game addresses carry the shared credential so the worker 74 /// stores and resolves them under `credID`. 75 struct PushAddressBinding: Hashable, Sendable { 76 let gameID: UUID? 77 let address: String 78 let credentials: GamePushCredentials? 79 80 init(gameID: UUID? = nil, address: String, credentials: GamePushCredentials? = nil) { 81 self.gameID = gameID 82 self.address = address 83 self.credentials = credentials 84 } 85 } 86 87 enum GamePushError: LocalizedError { 88 case invalidPayloadEncoding 89 case invalidSecret 90 91 var errorDescription: String? { 92 switch self { 93 case .invalidPayloadEncoding: 94 "Unable to encode game push credentials." 95 case .invalidSecret: 96 "The game push secret is invalid." 97 } 98 } 99 } 100 101 /// HMAC-SHA256 signer for participant-gated publishes. Mirrors 102 /// `EngagementSocketAuthenticator`: the worker re-derives the identical 103 /// signature from the secret it holds for `credID` and rejects the publish if 104 /// they differ. The signed payload reuses the App Attest request's body hash, 105 /// timestamp, and nonce (already validated and bound to the request) so no 106 /// extra freshness state is needed. 107 enum GamePushSigner { 108 static let signatureVersion = "crossmate-push-game-v1" 109 110 static func signaturePayload( 111 credID: UUID, 112 bodyHash: String, 113 timestamp: String, 114 nonce: String 115 ) -> String { 116 [ 117 signatureVersion, 118 credID.uuidString, 119 bodyHash, 120 timestamp, 121 nonce 122 ].joined(separator: "\n") 123 } 124 125 static func signature(payload: String, secret: String) throws -> String { 126 guard let secretData = Data(base64URLEncoded: secret) else { 127 throw GamePushError.invalidSecret 128 } 129 let key = SymmetricKey(data: secretData) 130 let mac = HMAC<SHA256>.authenticationCode(for: Data(payload.utf8), using: key) 131 return Data(mac).base64URLEncodedString() 132 } 133 }