GamePushCredentials.swift (6667B)
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. They are, 26 /// however, *rotated* — replaced wholesale by any remaining participant when 27 /// someone leaves or is removed, so departed access ends. `gen` is the 28 /// monotonic rotation generation: a receiver only adopts an inbound credential 29 /// whose generation is at least its local one, so a stale device re-pushing 30 /// its whole Game record (which carries this blob) can't resurrect superseded 31 /// credentials. Optional so blobs minted before rotation existed still decode; 32 /// absent means generation 1, matching what a fresh mint uses (see 33 /// `RecordSerializer.decisionBaseVersion` for the same convention). 34 struct GamePushCredentials: Codable, Equatable, Hashable, Sendable { 35 var ver: Int 36 var credID: UUID 37 var secret: String 38 var contentKey: String? 39 var gen: Int64? 40 41 init(credID: UUID = UUID(), secret: String, contentKey: String? = nil, gen: Int64? = nil, ver: Int = 1) { 42 self.ver = ver 43 self.credID = credID 44 self.secret = secret 45 self.contentKey = contentKey 46 self.gen = gen 47 } 48 49 /// The rotation generation, with legacy (field-less) blobs reporting 1. 50 var generation: Int64 { gen ?? 1 } 51 52 func encoded() throws -> String { 53 let data = try JSONEncoder().encode(self) 54 guard let string = String(data: data, encoding: .utf8) else { 55 throw GamePushError.invalidPayloadEncoding 56 } 57 return string 58 } 59 60 static func decode(_ string: String?) -> GamePushCredentials? { 61 guard let data = string?.data(using: .utf8) else { return nil } 62 return try? JSONDecoder().decode(GamePushCredentials.self, from: data) 63 } 64 65 /// Mints a fresh credential: a random 256-bit worker auth secret (base64url, 66 /// to satisfy the worker's `isAcceptableSecret` >= 32 key-byte check) and a 67 /// random 256-bit content key (standard base64, decoded directly by the NSE 68 /// via `PushPayloadCipher`). 69 static func fresh() throws -> GamePushCredentials { 70 try GamePushCredentials( 71 secret: Data.secureRandom(count: 32).base64URLEncodedString(), 72 contentKey: Data.secureRandom(count: 32).base64EncodedString() 73 ) 74 } 75 76 /// A fresh content key (standard base64 of 32 random bytes), used to backfill 77 /// a legacy credential minted before content keys existed. 78 static func freshContentKey() throws -> String { 79 try Data.secureRandom(count: 32).base64EncodedString() 80 } 81 82 /// A full replacement credential — new `credID`, worker secret, and content 83 /// key — at the next generation after `current`. Minted by a remaining 84 /// participant when someone leaves or is removed: the departed device holds 85 /// every field of the old credential, so nothing short of replacing all 86 /// three revokes its push access. 87 static func rotated(after current: GamePushCredentials) throws -> GamePushCredentials { 88 var fresh = try GamePushCredentials.fresh() 89 fresh.gen = current.generation + 1 90 return fresh 91 } 92 } 93 94 /// One address this device should be reachable at, paired with the per-game 95 /// credential it is bound to. The account-scoped sibling address has no game, 96 /// so `gameID` and `credentials` are nil and it registers on the legacy 97 /// (credID-less) key. Game addresses carry the shared credential so the worker 98 /// stores and resolves them under `credID`. 99 struct PushAddressBinding: Hashable, Sendable { 100 let gameID: UUID? 101 let address: String 102 let credentials: GamePushCredentials? 103 104 init(gameID: UUID? = nil, address: String, credentials: GamePushCredentials? = nil) { 105 self.gameID = gameID 106 self.address = address 107 self.credentials = credentials 108 } 109 } 110 111 enum GamePushError: LocalizedError { 112 case invalidPayloadEncoding 113 case invalidSecret 114 115 var errorDescription: String? { 116 switch self { 117 case .invalidPayloadEncoding: 118 "Unable to encode game push credentials." 119 case .invalidSecret: 120 "The game push secret is invalid." 121 } 122 } 123 } 124 125 /// HMAC-SHA256 signer for participant-gated publishes. Mirrors 126 /// `EngagementSocketAuthenticator`: the worker re-derives the identical 127 /// signature from the secret it holds for `credID` and rejects the publish if 128 /// they differ. The signed payload reuses the App Attest request's body hash, 129 /// timestamp, and nonce (already validated and bound to the request) so no 130 /// extra freshness state is needed. 131 enum GamePushSigner { 132 static let signatureVersion = "crossmate-push-game-v1" 133 134 static func signaturePayload( 135 credID: UUID, 136 bodyHash: String, 137 timestamp: String, 138 nonce: String 139 ) -> String { 140 [ 141 signatureVersion, 142 credID.uuidString, 143 bodyHash, 144 timestamp, 145 nonce 146 ].joined(separator: "\n") 147 } 148 149 static func signature(payload: String, secret: String) throws -> String { 150 guard let secretData = Data(base64URLEncoded: secret) else { 151 throw GamePushError.invalidSecret 152 } 153 let key = SymmetricKey(data: secretData) 154 let mac = HMAC<SHA256>.authenticationCode(for: Data(payload.utf8), using: key) 155 return Data(mac).base64URLEncodedString() 156 } 157 }