crossmate

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

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 }