crossmate

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

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 }