commit a9d26c566e81cf738bda23084d3e5869bb620411
parent 6e4889ee40133e1bc37fea3deec36785e52b55cc
Author: Michael Camilleri <[email protected]>
Date: Tue, 16 Jun 2026 11:40:15 +0900
Gate push notifications on game participation
The push worker authenticated only that a caller was a genuine Crossmate
install (App Attest), not that it participated in the game to which it
attempted to publish. Because the worker has no notion of games or
participants, its publish path delivered to any address a caller named,
so any enrolled client that learned a derived push address could publish
notifications to that account.
This commit closes that gap by mirroring the engagement room-secret
scheme. Each shared game now carries a per-game push credential — an
unguessable identifier plus a 256-bit secret ('GamePushCredentials') —
in the Game record's 'notification' field, synced only to the game's
share participants. Any participant mints it when absent, and
record-level last-writer-wins converges concurrent mints, exactly as the
engagement creds do.
A signature alone would not have sufficed: the worker still could not
tell which addresses belonged to which game, so a holder of any game's
secret could have signed a publish aimed at another game's addresses.
The binding therefore covers both ends. A device registers each of its
per-game push addresses under that game's credential, and a publish is
signed with the game secret over the request's already-validated body
hash, timestamp, and nonce. The worker verifies the signature against
the secret it holds for the named credential and resolves recipients
only among addresses registered under it, so a caller can reach a game's
participants only when it holds that game's secret — that is, only when
it is a participant.
Account-scoped sibling pushes carry no credential and stay on the
App-Attest-only path; their addresses derive from the account secret in
the private database and were never participant-spoofable.
There is no backwards compatibility: a client that predates this change
loses game notifications once the worker is deployed, which is
acceptable while the app is pre-release. The durable CloudKit path is
unaffected.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
13 files changed, 639 insertions(+), 77 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -46,6 +46,7 @@
2C0DFC182240A2519ED1FA6A /* GameMutatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */; };
2C5A15054CCCBF9FD626AFBB /* GameStorePushAddressTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A56778AF8190F0D7EB2E27E /* GameStorePushAddressTests.swift */; };
2DD78CA0CD587AA4E5C4B178 /* PuzzleScoreboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3A251D89028B3CA065DE053 /* PuzzleScoreboard.swift */; };
+ 30703DDF575DCDA53227DA66 /* GamePushCredentialsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B5A8118AC2FE60D877F1D29 /* GamePushCredentialsTests.swift */; };
309457EC2DFEC476253D54D2 /* PlayerSelectionPublisherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF159746D076E051C2CB590C /* PlayerSelectionPublisherTests.swift */; };
31F2B6A61ED352C7D800149F /* XDAcceptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F4EBC0F07FF815274C028CA /* XDAcceptTests.swift */; };
328309D8CC72CCB5623FB2A1 /* EngagementCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67CFF96D54D2DE9C44EB120A /* EngagementCoordinatorTests.swift */; };
@@ -123,6 +124,7 @@
9AD8936D94FD676B23DFBB77 /* RecentChanges.swift in Sources */ = {isa = PBXBuildFile; fileRef = 605CA0FC7AF069CE3A3B38C1 /* RecentChanges.swift */; };
9C52C48DB4996D5C83DEC144 /* PuzzleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57B1734CF731C2E405A39159 /* PuzzleView.swift */; };
9CB8808193A4A106D721D767 /* XDFileType.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC61E2582D94B1E6EC67136 /* XDFileType.swift */; };
+ 9FFD01CF6767220EEA20C0E4 /* GamePushCredentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78919F44C3035C48410FC894 /* GamePushCredentials.swift */; };
A0977C7B0B0D928DA569C326 /* NicknameDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3111803C8FFFB0C839217482 /* NicknameDirectory.swift */; };
A133A4B4A0C95AF8708BD7E6 /* PushClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A9F9E7ED4E1AF02F0C71051 /* PushClient.swift */; };
A22113A51213068FBF708A56 /* CellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D25D12FF374F83BF4DB83DD /* CellView.swift */; };
@@ -319,11 +321,13 @@
71DFD035381B6252DCD873C9 /* PlayerNamePublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerNamePublisher.swift; sourceTree = "<group>"; };
73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncEngine.swift; sourceTree = "<group>"; };
74C8886A66F0877858A67D62 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = "<group>"; };
+ 78919F44C3035C48410FC894 /* GamePushCredentials.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GamePushCredentials.swift; sourceTree = "<group>"; };
78C92190C4A344EC319A0F88 /* MovesJournalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovesJournalTests.swift; sourceTree = "<group>"; };
7A3E1CB3BA66CA884A4CC985 /* LocalMovesSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalMovesSnapshot.swift; sourceTree = "<group>"; };
7A4AFF292381C9B33C0F2CD6 /* FriendZone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendZone.swift; sourceTree = "<group>"; };
7A7BD8DFDB41BFBA694A0933 /* SessionCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionCoordinator.swift; sourceTree = "<group>"; };
7B3E1A382B24A7803701D947 /* Crossmate.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Crossmate.entitlements; sourceTree = "<group>"; };
+ 7B5A8118AC2FE60D877F1D29 /* GamePushCredentialsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GamePushCredentialsTests.swift; sourceTree = "<group>"; };
7B8B65482CA1739A3863A99E /* ClueList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClueList.swift; sourceTree = "<group>"; };
7C6AB016CA4E2FC69A0E6A4F /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
7DD270E16E00145EF2807EA9 /* MovesUpdater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovesUpdater.swift; sourceTree = "<group>"; };
@@ -444,6 +448,7 @@
E655698481325C92EF5C348B /* FriendController.swift */,
7A4AFF292381C9B33C0F2CD6 /* FriendZone.swift */,
1B7539E0AD285C5A3AC3DDA2 /* GameArchiver.swift */,
+ 78919F44C3035C48410FC894 /* GamePushCredentials.swift */,
14B05C19BD4705876B3DF0EC /* GridStateMerger.swift */,
86470163BFF956F3DE438506 /* Moves.swift */,
7DD270E16E00145EF2807EA9 /* MovesUpdater.swift */,
@@ -701,6 +706,7 @@
94CEBA27A8AC4FCC92ADE1B4 /* EnsureGameEntityTests.swift */,
B766E872B12DC79ECCD80941 /* FriendModelTests.swift */,
800CCFBE90554F287E765755 /* FriendZoneTests.swift */,
+ 7B5A8118AC2FE60D877F1D29 /* GamePushCredentialsTests.swift */,
4B33C21324E1474BCC126AA0 /* MovesCodecLegacyDecodeTests.swift */,
EF1254FE7BE3672AEC1607B1 /* MovesInboundTests.swift */,
08E8592B1CB1336E63498706 /* PeerPresenceGraceTests.swift */,
@@ -915,6 +921,7 @@
712A2764596A2D17A0BBBF3B /* FriendZoneTests.swift in Sources */,
85A798525FE1DC98210A9E82 /* GameCursorStoreTests.swift in Sources */,
2C0DFC182240A2519ED1FA6A /* GameMutatorTests.swift in Sources */,
+ 30703DDF575DCDA53227DA66 /* GamePushCredentialsTests.swift in Sources */,
9AACF424992AE45FD7937064 /* GameStoreCompletionLockTests.swift in Sources */,
4D7BF839BA71E1BF0AE9BCE9 /* GameStoreContributingDevicesTests.swift in Sources */,
262A9CE8C3CB93869190CFF1 /* GameStoreMergedAuthorCellsTests.swift in Sources */,
@@ -1015,6 +1022,7 @@
128915DB37018EE4CC16C856 /* GameCursorStore.swift in Sources */,
0063A5FC9F39E37A67F137FF /* GameListView.swift in Sources */,
3A5483EF2893AE325DF27EE8 /* GameMutator.swift in Sources */,
+ 9FFD01CF6767220EEA20C0E4 /* GamePushCredentials.swift in Sources */,
FC480FE2930EAE406F5BBBDA /* GameRowView.swift in Sources */,
44FF4A5334A4086DEA7D8A7B /* GameShareItem.swift in Sources */,
D58980B92C99122C368D4216 /* GameStore.swift in Sources */,
diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents
@@ -15,6 +15,7 @@
<attribute name="completedBy" optional="YES" attributeType="String"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="engagement" optional="YES" attributeType="String"/>
+ <attribute name="notification" optional="YES" attributeType="String"/>
<attribute name="databaseScope" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="gridHeight" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="gridWidth" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -960,6 +960,51 @@ final class GameStore {
return true
}
+ /// The shared per-game push credential for `gameID` (an encoded
+ /// `GamePushCredentials`), or nil if none has been minted yet.
+ func notification(for gameID: UUID) -> String? {
+ let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
+ request.fetchLimit = 1
+ return (try? context.fetch(request).first)?.notification
+ }
+
+ /// Writes the push credential for `gameID` and enqueues a Game-record push,
+ /// mirroring `setEngagement`. No-op (false) when unchanged or not shared.
+ @discardableResult
+ func setNotification(_ encoded: String?, for gameID: UUID) -> Bool {
+ let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
+ request.fetchLimit = 1
+ guard let entity = try? context.fetch(request).first else { return false }
+ let isShared = entity.ckShareRecordName != nil || entity.databaseScope == 1
+ guard isShared, entity.notification != encoded else { return false }
+ entity.notification = encoded
+ entity.hasPendingSave = true
+ saveContext("setNotification")
+ if let ckName = entity.ckRecordName {
+ onGameUpdated(ckName)
+ }
+ return true
+ }
+
+ /// Returns the shared push credential for `gameID`, minting and persisting a
+ /// fresh one (and enqueuing the Game-record push, so participants converge)
+ /// when the game is shared and none exists yet. Any participant may mint;
+ /// record-level LWW resolves concurrent mints. Returns nil for a non-shared
+ /// or missing game, or if minting fails.
+ @discardableResult
+ func ensurePushCredentials(for gameID: UUID) -> GamePushCredentials? {
+ if let existing = GamePushCredentials.decode(notification(for: gameID)) {
+ return existing
+ }
+ guard let fresh = try? GamePushCredentials.fresh(),
+ let encoded = try? fresh.encoded(),
+ setNotification(encoded, for: gameID)
+ else { return nil }
+ return fresh
+ }
+
// MARK: - Reset
/// Deletes every game (and its cascaded moves, snapshots, and cells) plus
@@ -1388,33 +1433,47 @@ final class GameStore {
}
/// Derives the local author's push address for every shared game the account
- /// participates in (`HMAC(secret, gameID)`), and returns the full set of
- /// those addresses together with the games whose existing Player row was
- /// updated to a new value (so the caller can republish them for peers). The
- /// caller registers the whole set with the push worker. Because the address
- /// is derived — identical on every device — there's nothing to mint and no
- /// convergence to negotiate; this only *writes back* the derived value onto
- /// rows that already exist, and never fabricates a bare row (which would
- /// clobber `name`/`readAt` server-side). A game with no local row still
- /// contributes its derived address to the registration set: a peer learns it
- /// from whichever of the account's devices does publish that row, all of
- /// which derive the same value.
+ /// participates in (`HMAC(secret, gameID)`) and pairs each with that game's
+ /// shared push credential, minting the credential when absent (any
+ /// participant may mint; record-level LWW converges concurrent mints). The
+ /// caller registers the bindings with the push worker, which keys each
+ /// game address under its `credID`. Also returns the games whose existing
+ /// Player row was updated to a new derived address, so the caller can
+ /// republish those rows for peers. The address write-back never fabricates a
+ /// bare Player row (which would clobber `name`/`readAt` server-side); a game
+ /// with no local row still contributes its binding to the registration set.
func reconcileLocalPushAddresses(
authorID: String,
secret: String
- ) -> (addresses: Set<String>, republishGameIDs: [UUID]) {
+ ) -> (bindings: [PushAddressBinding], republishGameIDs: [UUID]) {
let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
request.predicate = NSPredicate(
format: "databaseScope == 1 OR ckShareRecordName != nil"
)
let games = (try? context.fetch(request)) ?? []
- var addresses: Set<String> = []
+ var bindings: [PushAddressBinding] = []
var republishGameIDs: [UUID] = []
+ var gameRecordUpdates: [String] = []
var didChange = false
for game in games {
guard let gameID = game.id else { continue }
+ // Mint the shared push credential in place when absent, so it ships
+ // on the next Game-record push and peers converge on it.
+ var creds = GamePushCredentials.decode(game.notification)
+ if creds == nil,
+ let fresh = try? GamePushCredentials.fresh(),
+ let encoded = try? fresh.encoded() {
+ game.notification = encoded
+ game.hasPendingSave = true
+ creds = fresh
+ didChange = true
+ if let ckName = game.ckRecordName { gameRecordUpdates.append(ckName) }
+ }
+ guard let credentials = creds else { continue }
let address = RecordSerializer.deriveGameAddress(secret: secret, gameID: gameID)
- addresses.insert(address)
+ bindings.append(
+ PushAddressBinding(gameID: gameID, address: address, credentials: credentials)
+ )
// Update an existing row in place; never create one here.
guard let player = fetchPlayerEntity(gameID: gameID, authorID: authorID),
player.pushAddress != address
@@ -1427,7 +1486,12 @@ final class GameStore {
if didChange {
saveContext("reconcileLocalPushAddresses")
}
- return (addresses, republishGameIDs)
+ // Enqueue Game-record pushes for freshly-minted credentials after the
+ // save, mirroring `setNotification`.
+ for ckName in gameRecordUpdates {
+ onGameUpdated(ckName)
+ }
+ return (bindings, republishGameIDs)
}
/// Player record `updatedAt` for `(gameID, authorID)` — `nil` if no row
diff --git a/Crossmate/Services/AccountPushCoordinator.swift b/Crossmate/Services/AccountPushCoordinator.swift
@@ -151,7 +151,10 @@ final class AccountPushCoordinator {
reason: "pushAddress"
)
}
- pushClient.setAddresses(result.addresses.union([accountAddress]))
+ // The account-scoped sibling address carries no game credential.
+ var bindings = Set(result.bindings)
+ bindings.insert(PushAddressBinding(address: accountAddress))
+ pushClient.setAddresses(bindings)
}
/// Stamps `gameID`'s local Player row with the derived push address inside
@@ -197,13 +200,14 @@ final class AccountPushCoordinator {
/// through `rotateAccountPushSecret`, which bumps the generation so it
/// supersedes the converged value.
///
- /// The derived addresses are bearer capabilities: the worker's publish path
- /// delivers to any address an enrolled client names, with no notion of who
- /// owns or participates in it. Authorization therefore rests entirely on
- /// this secret staying inside the account's private CloudKit database. The
- /// secret itself is never a credential the worker can check — protecting it
- /// matters because leaking it lets anyone derive (and push to) every one of
- /// the account's per-game addresses.
+ /// This secret derives the per-game push *addresses*; participation is now
+ /// enforced separately by the per-game push credential in the Game record
+ /// (`GamePushCredentials`), which the worker verifies before delivering a
+ /// game push. The account secret still matters — it stays inside the
+ /// account's private CloudKit database so only the account's own devices can
+ /// derive its addresses, and it backs the account-scoped sibling pushes
+ /// (accountJoined/accountSeen), which carry no game and remain gated by App
+ /// Attest alone.
private func ensureAccountPushSecret(authorID: String) -> String {
let key = accountPushSecretDefaultsKey(authorID: authorID)
if let existing = UserDefaults.standard.string(forKey: key), !existing.isEmpty {
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -286,6 +286,11 @@ final class AppServices {
eventLog: eventLog
)
self.store = store
+ // Publishes resolve (and mint) the game's shared push credential from
+ // the store so the worker can verify participation.
+ self.pushClient?.gameCredentialResolver = { [weak store] gameID in
+ store?.ensurePushCredentials(for: gameID)
+ }
let sessionMonitor = SessionMonitor(
store: store,
diff --git a/Crossmate/Services/PushClient.swift b/Crossmate/Services/PushClient.swift
@@ -21,23 +21,34 @@ final class PushClient {
private var apnsToken: String?
private var authorID: String?
- /// The per-(account, game) addresses this device should be reachable at —
- /// one per shared game the account participates in. The worker maps each
- /// to this device's APNs token so a push to the address reaches here.
- private var addresses: Set<String> = []
+ /// The addresses this device should be reachable at, each bound to its
+ /// game's shared push credential. One per shared game the account
+ /// participates in, plus the account-scoped sibling address (nil
+ /// credential). The worker keys game addresses under their `credID`.
+ private var bindings: Set<PushAddressBinding> = []
/// Push kinds this device has muted in notification settings. Registered
/// with the worker as a denylist so muted pushes are dropped before APNs.
private var mutedKinds: Set<String> = []
private var lastRegistered: Registration?
- /// The `(token, addresses, mutedKinds)` triple last successfully
- /// reconciled with the worker. All must match for a reconcile to be a
- /// no-op, so a token rotation re-binds every address, an address-set
- /// change registers the delta, and a notification-preference change
- /// re-registers every address with the new denylist.
+ /// Resolves (minting if needed) the shared push credential for a game.
+ /// Set by AppServices to read from the GameStore. Publishes use it to sign
+ /// the request the worker verifies against the credential it holds.
+ var gameCredentialResolver: (@MainActor (UUID) -> GamePushCredentials?)?
+
+ /// Game credentials already registered with the worker this session
+ /// (credID → secret), so `/games/:credID/register` is posted at most once
+ /// per credential value.
+ private var registeredGameCredentials: [UUID: String] = [:]
+
+ /// The `(token, bindings, mutedKinds)` triple last successfully reconciled
+ /// with the worker. All must match for a reconcile to be a no-op, so a
+ /// token rotation re-binds every address, a binding change (including a
+ /// credID rotation) registers the delta, and a notification-preference
+ /// change re-registers every address with the new denylist.
private struct Registration: Equatable {
let token: String
- let addresses: Set<String>
+ let bindings: Set<PushAddressBinding>
let mutedKinds: Set<String>
}
@@ -96,14 +107,14 @@ final class PushClient {
authorID = (normalized?.isEmpty == false) ? normalized : nil
}
- /// Sets the full set of addresses this device should be registered under.
- /// The caller (AppServices) recomputes this from Core Data on launch, when
- /// a shared game appears, and on account switch. Addresses that drop out
- /// of the set are unregistered so a left/old-account game stops delivering
- /// here.
- func setAddresses(_ next: Set<String>) {
- if next == addresses { return }
- addresses = next
+ /// Sets the full set of address→credential bindings this device should be
+ /// registered under. The caller (AccountPushCoordinator) recomputes this
+ /// from Core Data on launch, when a shared game appears, and on account
+ /// switch. Bindings that drop out are unregistered so a left/old-account
+ /// game stops delivering here.
+ func setAddresses(_ next: Set<PushAddressBinding>) {
+ if next == bindings { return }
+ bindings = next
Task { await reconcile() }
}
@@ -118,24 +129,29 @@ final class PushClient {
private func reconcile() async {
guard let apnsToken else { return }
- let desired = addresses
+ let desired = bindings
let signature = Registration(
token: apnsToken,
- addresses: desired,
+ bindings: desired,
mutedKinds: mutedKinds
)
if lastRegistered == signature { return }
- let toRemove = (lastRegistered?.addresses ?? []).subtracting(desired)
+ let toRemove = (lastRegistered?.bindings ?? []).subtracting(desired)
do {
+ // Register each game's shared credential first, so the worker
+ // accepts the addresses bound to it.
+ for creds in Set(desired.compactMap(\.credentials)) {
+ await registerGameCredential(creds)
+ }
if !desired.isEmpty {
try await register(
token: apnsToken,
- addresses: Array(desired),
+ bindings: Array(desired),
mutedKinds: signature.mutedKinds.sorted()
)
}
if !toRemove.isEmpty {
- await unregister(addresses: Array(toRemove))
+ await unregister(bindings: Array(toRemove))
}
lastRegistered = signature
} catch {
@@ -178,10 +194,24 @@ final class PushClient {
title: String,
puzzleTitle: String? = nil,
background: Bool = false,
+ gameCredentialed: Bool = true,
extra: [String: Any] = [:],
body: String
) async {
guard !addressees.isEmpty else { return }
+ // A game push must prove participation: resolve (minting if needed) the
+ // game's shared credential, register it with the worker, and sign the
+ // request below. Account-scoped publishes (sibling-device hints) carry
+ // no game and stay on the App-Attest-only path.
+ var credential: GamePushCredentials?
+ if gameCredentialed {
+ guard let creds = gameCredentialResolver?(gameID) else {
+ log("push(\(kind)): skipped (no game credential)")
+ return
+ }
+ await registerGameCredential(creds)
+ credential = creds
+ }
log("push(\(kind)): publishing to \(addressees.count) addressee(s)")
// Stamp the puzzle title onto each addressee's structured payload so the
// receiver's extension can recompose the body from components (swapping
@@ -206,6 +236,12 @@ final class PushClient {
"addressees": addresseePayloads,
"background": background
]
+ // The credID names which registered credential the worker verifies the
+ // game signature against and scopes delivery to. Covered by the body
+ // hash (App Attest), so it can't be swapped without breaking auth.
+ if let credential {
+ payload["credID"] = credential.credID.uuidString
+ }
for (key, value) in extra {
payload[key] = value
}
@@ -218,7 +254,8 @@ final class PushClient {
request,
method: "POST",
path: "/publish",
- body: body
+ body: body,
+ gameCredential: credential
)
guard response.statusCode == 200 else {
throw URLError(.badServerResponse)
@@ -245,6 +282,7 @@ final class PushClient {
addressees: [Addressee(address: address)],
title: "",
background: true,
+ gameCredentialed: false,
extra: extra,
body: ""
)
@@ -273,9 +311,22 @@ final class PushClient {
return formatter
}()
+ /// Encodes the bindings as the worker's `[{address, credID?}]` wire shape.
+ /// A nil credential (the account-scoped address) omits `credID`, so the
+ /// worker stores it on the legacy address-only key.
+ private func addressPayload(for bindings: [PushAddressBinding]) -> [[String: Any]] {
+ bindings.map { binding in
+ var entry: [String: Any] = ["address": binding.address]
+ if let credID = binding.credentials?.credID {
+ entry["credID"] = credID.uuidString
+ }
+ return entry
+ }
+ }
+
private func register(
token: String,
- addresses: [String],
+ bindings: [PushAddressBinding],
mutedKinds: [String]
) async throws {
var request = URLRequest(url: baseURL.appendingPathComponent("register"))
@@ -284,7 +335,7 @@ final class PushClient {
"deviceID": deviceID,
"token": token,
"environment": environment.rawValue,
- "addresses": addresses,
+ "addresses": addressPayload(for: bindings),
"mutedKinds": mutedKinds
]
let data = try JSONSerialization.data(withJSONObject: body)
@@ -298,12 +349,12 @@ final class PushClient {
try assert204(response)
}
- private func unregister(addresses: [String]) async {
+ private func unregister(bindings: [PushAddressBinding]) async {
var request = URLRequest(url: baseURL.appendingPathComponent("register"))
request.httpMethod = "DELETE"
let data = (try? JSONSerialization.data(withJSONObject: [
"deviceID": deviceID,
- "addresses": addresses
+ "addresses": addressPayload(for: bindings)
])) ?? Data()
request.httpBody = data
do {
@@ -319,17 +370,57 @@ final class PushClient {
}
}
+ /// Registers a game's shared push credential with the worker (first-write-
+ /// wins) so it will verify publishes signed with that secret. Idempotent
+ /// and deduped per session; mirrors `EngagementHost.registerRoom`. A 409
+ /// means a different secret is already registered under this credID — only
+ /// possible on an (astronomically unlikely) credID collision, so it is
+ /// logged rather than retried.
+ private func registerGameCredential(_ credentials: GamePushCredentials) async {
+ if registeredGameCredentials[credentials.credID] == credentials.secret { return }
+ let path = "/games/\(credentials.credID.uuidString)/register"
+ var request = URLRequest(
+ url: baseURL
+ .appendingPathComponent("games")
+ .appendingPathComponent(credentials.credID.uuidString)
+ .appendingPathComponent("register")
+ )
+ request.httpMethod = "POST"
+ let data = (try? JSONSerialization.data(withJSONObject: ["secret": credentials.secret])) ?? Data()
+ request.httpBody = data
+ do {
+ let (_, response) = try await sendAuthorized(
+ request,
+ method: "POST",
+ path: path,
+ body: data
+ )
+ switch response.statusCode {
+ case 200..<300:
+ registeredGameCredentials[credentials.credID] = credentials.secret
+ case 409:
+ log("Push game-credential rejected (secret mismatch) for \(credentials.credID)")
+ default:
+ log("Push game-credential register failed: HTTP \(response.statusCode)")
+ }
+ } catch {
+ log("Push game-credential register failed: \(error.localizedDescription)")
+ }
+ }
+
private func sendAuthorized(
_ request: URLRequest,
method: String,
path: String,
- body: Data
+ body: Data,
+ gameCredential: GamePushCredentials? = nil
) async throws -> (Data, HTTPURLResponse) {
try await sendAuthorized(
request,
method: method,
path: path,
body: body,
+ gameCredential: gameCredential,
retryAfterRegistrationReset: true
)
}
@@ -339,6 +430,7 @@ final class PushClient {
method: String,
path: String,
body: Data,
+ gameCredential: GamePushCredentials?,
retryAfterRegistrationReset: Bool
) async throws -> (Data, HTTPURLResponse) {
var request = request
@@ -351,6 +443,26 @@ final class PushClient {
for (key, value) in headers {
request.setValue(value, forHTTPHeaderField: key)
}
+ // Prove game participation by signing the App Attest request's own
+ // body hash / timestamp / nonce with the game secret. The worker
+ // re-derives this from the secret it holds for the body's credID.
+ if let gameCredential,
+ let bodyHash = headers["X-Crossmate-Body-SHA256"],
+ let timestamp = headers["X-Crossmate-Timestamp"],
+ let nonce = headers["X-Crossmate-Nonce"] {
+ let payload = GamePushSigner.signaturePayload(
+ credID: gameCredential.credID,
+ bodyHash: bodyHash,
+ timestamp: timestamp,
+ nonce: nonce
+ )
+ if let signature = try? GamePushSigner.signature(
+ payload: payload,
+ secret: gameCredential.secret
+ ) {
+ request.setValue(signature, forHTTPHeaderField: "X-Crossmate-Game-Signature")
+ }
+ }
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
@@ -362,6 +474,7 @@ final class PushClient {
method: method,
path: path,
body: body,
+ gameCredential: gameCredential,
retryAfterRegistrationReset: false
)
}
diff --git a/Crossmate/Sync/CloudQuery.swift b/Crossmate/Sync/CloudQuery.swift
@@ -551,7 +551,7 @@ extension SyncEngine {
database: database,
zoneID: zoneID,
since: nil,
- desiredKeys: ["title", "completedAt", "completedBy", "shareRecordName", "engagement", "puzzleSource"]
+ desiredKeys: ["title", "completedAt", "completedBy", "shareRecordName", "engagement", "notification", "puzzleSource"]
)
guard !games.isEmpty else {
return PerZoneResult(records: [], hasGame: false)
@@ -661,7 +661,7 @@ extension SyncEngine {
do {
async let gameResultsTask = database.records(
for: [gameRecordID],
- desiredKeys: ["title", "completedAt", "completedBy", "shareRecordName", "engagement", "puzzleSource"]
+ desiredKeys: ["title", "completedAt", "completedBy", "shareRecordName", "engagement", "notification", "puzzleSource"]
)
async let movesTask = queryLiveRecords(
type: "Moves",
@@ -790,7 +790,7 @@ extension SyncEngine {
)
async let gameResultsTask = database.records(
for: [gameRecordID],
- desiredKeys: ["title", "completedAt", "completedBy", "shareRecordName", "engagement", "puzzleSource"]
+ desiredKeys: ["title", "completedAt", "completedBy", "shareRecordName", "engagement", "notification", "puzzleSource"]
)
async let movesTask = self.queryLiveRecords(
type: "Moves",
diff --git a/Crossmate/Sync/GamePushCredentials.swift b/Crossmate/Sync/GamePushCredentials.swift
@@ -0,0 +1,107 @@
+import CryptoKit
+import Foundation
+
+/// The shared per-game push credential, stored in the Game record's
+/// `notification` field (synced only to CKShare participants, like the
+/// `engagement` room creds). Possession of `secret` proves participation: the
+/// push worker verifies publish signatures and credID-scoped address
+/// registrations against the copy registered under `credID`. `credID` is an
+/// unguessable capability that doubles as the worker's storage key, exactly as
+/// `EngagementRoomCredentials.roomID` does for the room worker. Unlike room
+/// creds these are durable, so there is no expiry.
+struct GamePushCredentials: Codable, Equatable, Hashable, Sendable {
+ var ver: Int
+ var credID: UUID
+ var secret: String
+
+ init(credID: UUID = UUID(), secret: String, ver: Int = 1) {
+ self.ver = ver
+ self.credID = credID
+ self.secret = secret
+ }
+
+ func encoded() throws -> String {
+ let data = try JSONEncoder().encode(self)
+ guard let string = String(data: data, encoding: .utf8) else {
+ throw GamePushError.invalidPayloadEncoding
+ }
+ return string
+ }
+
+ static func decode(_ string: String?) -> GamePushCredentials? {
+ guard let data = string?.data(using: .utf8) else { return nil }
+ return try? JSONDecoder().decode(GamePushCredentials.self, from: data)
+ }
+
+ /// Mints a fresh credential with a random 256-bit secret. The worker's
+ /// `isAcceptableSecret` requires the base64url secret to decode to >= 32
+ /// key bytes; 32 bytes satisfies that.
+ static func fresh() throws -> GamePushCredentials {
+ try GamePushCredentials(secret: Data.secureRandom(count: 32).base64URLEncodedString())
+ }
+}
+
+/// One address this device should be reachable at, paired with the per-game
+/// credential it is bound to. The account-scoped sibling address has no game,
+/// so `gameID` and `credentials` are nil and it registers on the legacy
+/// (credID-less) key. Game addresses carry the shared credential so the worker
+/// stores and resolves them under `credID`.
+struct PushAddressBinding: Hashable, Sendable {
+ let gameID: UUID?
+ let address: String
+ let credentials: GamePushCredentials?
+
+ init(gameID: UUID? = nil, address: String, credentials: GamePushCredentials? = nil) {
+ self.gameID = gameID
+ self.address = address
+ self.credentials = credentials
+ }
+}
+
+enum GamePushError: LocalizedError {
+ case invalidPayloadEncoding
+ case invalidSecret
+
+ var errorDescription: String? {
+ switch self {
+ case .invalidPayloadEncoding:
+ "Unable to encode game push credentials."
+ case .invalidSecret:
+ "The game push secret is invalid."
+ }
+ }
+}
+
+/// HMAC-SHA256 signer for participant-gated publishes. Mirrors
+/// `EngagementSocketAuthenticator`: the worker re-derives the identical
+/// signature from the secret it holds for `credID` and rejects the publish if
+/// they differ. The signed payload reuses the App Attest request's body hash,
+/// timestamp, and nonce (already validated and bound to the request) so no
+/// extra freshness state is needed.
+enum GamePushSigner {
+ static let signatureVersion = "crossmate-push-game-v1"
+
+ static func signaturePayload(
+ credID: UUID,
+ bodyHash: String,
+ timestamp: String,
+ nonce: String
+ ) -> String {
+ [
+ signatureVersion,
+ credID.uuidString,
+ bodyHash,
+ timestamp,
+ nonce
+ ].joined(separator: "\n")
+ }
+
+ static func signature(payload: String, secret: String) throws -> String {
+ guard let secretData = Data(base64URLEncoded: secret) else {
+ throw GamePushError.invalidSecret
+ }
+ let key = SymmetricKey(data: secretData)
+ let mac = HMAC<SHA256>.authenticationCode(for: Data(payload.utf8), using: key)
+ return Data(mac).base64URLEncodedString()
+ }
+}
diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift
@@ -323,6 +323,10 @@ enum RecordSerializer {
// when the field is empty; convergence is plain record-level LWW, and
// peers connect to whatever creds the field currently holds.
record["engagement"] = entity.engagement as CKRecordValue?
+ // The shared per-game push credential (encoded GamePushCredentials).
+ // Synced to participants like `engagement`; any participant may mint it
+ // when empty, and record-level LWW converges concurrent mints.
+ record["notification"] = entity.notification as CKRecordValue?
guard includePuzzleSource, let source = entity.puzzleSource else { return }
let url = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
@@ -737,6 +741,11 @@ enum RecordSerializer {
if let id = entity.id { onEngagementChange?(id) }
}
+ // Adopt the shared push credential. No callback: unlike engagement
+ // (which drives a live socket), the credential is read lazily at
+ // registration/publish time, so simply converging the field is enough.
+ entity.notification = record["notification"] as? String
+
if let asset = record["puzzleSource"] as? CKAsset,
let fileURL = asset.fileURL {
do {
diff --git a/Tests/Unit/GameStorePushAddressTests.swift b/Tests/Unit/GameStorePushAddressTests.swift
@@ -138,10 +138,14 @@ struct GameStorePushAddressTests {
// Both shared games contribute their derived address for registration,
// even with no local Player row to publish from.
- #expect(result.addresses == [
+ #expect(Set(result.bindings.map(\.address)) == [
RecordSerializer.deriveGameAddress(secret: Self.secret, gameID: shared1),
RecordSerializer.deriveGameAddress(secret: Self.secret, gameID: shared2)
])
+ // Each binding carries a freshly-minted shared push credential, and the
+ // game now advertises it for participants to converge on.
+ #expect(result.bindings.allSatisfy { $0.credentials != nil })
+ #expect(GamePushCredentials.decode(store.notification(for: shared1)) != nil)
// No existing rows, so nothing to republish — and crucially, no bare row
// was fabricated (the CKError 14 / clobber regression).
#expect(result.republishGameIDs.isEmpty)
@@ -161,7 +165,7 @@ struct GameStorePushAddressTests {
let derived = RecordSerializer.deriveGameAddress(secret: Self.secret, gameID: gameID)
let first = store.reconcileLocalPushAddresses(authorID: Self.authorID, secret: Self.secret)
- #expect(first.addresses == [derived])
+ #expect(first.bindings.map(\.address) == [derived])
#expect(first.republishGameIDs == [gameID])
let player = try #require(
@@ -175,7 +179,36 @@ struct GameStorePushAddressTests {
// Idempotent: the address now matches, so nothing is republished.
let second = store.reconcileLocalPushAddresses(authorID: Self.authorID, secret: Self.secret)
- #expect(second.addresses == [derived])
+ #expect(second.bindings.map(\.address) == [derived])
#expect(second.republishGameIDs.isEmpty)
+ // The credential is minted once and stable across reconciles.
+ let firstCred = try #require(first.bindings.first?.credentials)
+ let secondCred = try #require(second.bindings.first?.credentials)
+ #expect(firstCred.credID == secondCred.credID)
+ #expect(firstCred.secret == secondCred.secret)
+ }
+
+ // MARK: - ensurePushCredentials (minting)
+
+ @Test("ensurePushCredentials mints once for a shared game and is stable")
+ func ensurePushCredentialsMintsStably() throws {
+ let persistence = makeTestPersistence()
+ let store = makeTestStore(persistence: persistence)
+ let gameID = try makeGame(scope: 1, in: persistence.viewContext)
+
+ let first = try #require(store.ensurePushCredentials(for: gameID))
+ let second = try #require(store.ensurePushCredentials(for: gameID))
+ #expect(first == second)
+ // The minted secret decodes to a 256-bit HMAC key.
+ #expect(Data(base64URLEncoded: first.secret)?.count == 32)
+ }
+
+ @Test("ensurePushCredentials returns nil for a non-shared or missing game")
+ func ensurePushCredentialsNonShared() throws {
+ let persistence = makeTestPersistence()
+ let store = makeTestStore(persistence: persistence)
+ let localOnly = try makeGame(scope: 0, in: persistence.viewContext)
+ #expect(store.ensurePushCredentials(for: localOnly) == nil)
+ #expect(store.ensurePushCredentials(for: UUID()) == nil)
}
}
diff --git a/Tests/Unit/RecordSerializerTests.swift b/Tests/Unit/RecordSerializerTests.swift
@@ -356,6 +356,36 @@ struct RecordSerializerTests {
#expect(merged.completedBy == nil)
}
+ @Test("applyGameRecord round-trips the notification push credential and clears it when absent")
+ @MainActor func applyGameRecordNotification() throws {
+ let persistence = makeTestPersistence()
+ let ctx = persistence.viewContext
+ let gameID = UUID()
+ let recordID = CKRecord.ID(
+ recordName: RecordSerializer.recordName(forGameID: gameID),
+ zoneID: RecordSerializer.zoneID(for: gameID)
+ )
+ let (asset, tmpURL) = try makePuzzleAsset()
+ defer { try? FileManager.default.removeItem(at: tmpURL) }
+
+ let creds = try GamePushCredentials.fresh()
+ let record = CKRecord(recordType: "Game", recordID: recordID)
+ record["title"] = "T" as CKRecordValue
+ record["notification"] = try creds.encoded() as CKRecordValue
+ record["puzzleSource"] = asset as CKRecordValue
+ let entity = RecordSerializer.applyGameRecord(record, to: ctx)
+ try ctx.save()
+ #expect(GamePushCredentials.decode(entity.notification) == creds)
+
+ // A later record without the field clears it (LWW convergence).
+ let cleared = CKRecord(recordType: "Game", recordID: recordID)
+ cleared["title"] = "T" as CKRecordValue
+ let merged = RecordSerializer.applyGameRecord(cleared, to: ctx)
+ try ctx.save()
+ #expect(merged === entity)
+ #expect(merged.notification == nil)
+ }
+
@Test("applyGameRecord preserves id and createdAt on second apply, updates title")
@MainActor func applyGameRecordMergesOnServerRecordChanged() throws {
let persistence = makeTestPersistence()
diff --git a/Tests/Unit/Sync/GamePushCredentialsTests.swift b/Tests/Unit/Sync/GamePushCredentialsTests.swift
@@ -0,0 +1,72 @@
+import CryptoKit
+import Foundation
+import Testing
+
+@testable import Crossmate
+
+/// The per-game push credential and its publish signer. The signer's encoding
+/// is a contract with the push worker (`Workers/push-worker.js`), which
+/// re-derives the identical HMAC from the secret it holds for the credID.
+@Suite("Game push credentials")
+struct GamePushCredentialsTests {
+
+ @Test("encode/decode round-trips")
+ func roundTrip() throws {
+ let creds = try GamePushCredentials.fresh()
+ let decoded = try #require(GamePushCredentials.decode(creds.encoded()))
+ #expect(decoded == creds)
+ }
+
+ @Test("fresh mints a 256-bit secret and unique credentials")
+ func freshProperties() throws {
+ let a = try GamePushCredentials.fresh()
+ let b = try GamePushCredentials.fresh()
+ #expect(Data(base64URLEncoded: a.secret)?.count == 32)
+ #expect(a.credID != b.credID)
+ #expect(a.secret != b.secret)
+ }
+
+ @Test("decode tolerates nil and malformed input")
+ func decodeLenient() {
+ #expect(GamePushCredentials.decode(nil) == nil)
+ #expect(GamePushCredentials.decode("not json") == nil)
+ }
+
+ @Test("signature is deterministic and sensitive to every input")
+ func signatureProperties() throws {
+ let credID = UUID()
+ let secret = try GamePushCredentials.fresh().secret
+ let payload = GamePushSigner.signaturePayload(
+ credID: credID, bodyHash: "bh", timestamp: "100", nonce: "n1"
+ )
+ let sig = try GamePushSigner.signature(payload: payload, secret: secret)
+ #expect(try GamePushSigner.signature(payload: payload, secret: secret) == sig)
+
+ // A different secret or any changed payload field yields a different MAC.
+ let otherSecret = try GamePushCredentials.fresh().secret
+ #expect(try GamePushSigner.signature(payload: payload, secret: otherSecret) != sig)
+ let otherPayload = GamePushSigner.signaturePayload(
+ credID: credID, bodyHash: "bh", timestamp: "101", nonce: "n1"
+ )
+ #expect(try GamePushSigner.signature(payload: otherPayload, secret: secret) != sig)
+
+ // URL-safe base64 (the worker compares it as a plain string).
+ let allowed = CharacterSet(charactersIn:
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_")
+ #expect(sig.unicodeScalars.allSatisfy(allowed.contains))
+ }
+
+ @Test("signer keys on the decoded secret bytes (worker parity)")
+ func keyDerivationContract() throws {
+ // A fixed key both sides can reproduce. Guards the contract that the
+ // HMAC key is the base64url-*decoded* secret (not its UTF-8 bytes) and
+ // the output is base64url — exactly what `hmacSHA256` does in the worker.
+ let secret = Data(repeating: 7, count: 32).base64URLEncodedString()
+ let payload = "crossmate-push-game-v1\ncred\nbody\n123\nnonce"
+ let sig = try GamePushSigner.signature(payload: payload, secret: secret)
+
+ let key = SymmetricKey(data: try #require(Data(base64URLEncoded: secret)))
+ let mac = HMAC<SHA256>.authenticationCode(for: Data(payload.utf8), using: key)
+ #expect(sig == Data(mac).base64URLEncodedString())
+ }
+}
diff --git a/Workers/push-worker.js b/Workers/push-worker.js
@@ -39,7 +39,11 @@ export class PushRegistry {
return this.handleUnregister(bodyText, auth);
}
if (url.pathname === "/publish" && request.method === "POST") {
- return this.handlePublish(bodyText, auth);
+ return this.handlePublish(request, bodyText, auth);
+ }
+ const gameRegister = url.pathname.match(/^\/games\/([^/]+)\/register$/);
+ if (gameRegister && request.method === "POST") {
+ return this.handleGameRegister(gameRegister[1], bodyText);
}
return new Response("Not found", { status: 404 });
}
@@ -342,14 +346,18 @@ export class PushRegistry {
const muted = Array.isArray(mutedKinds)
? mutedKinds.filter((kind) => typeof kind === "string" && kind.length > 0)
: [];
- // Bind this device's APNs token to each per-(account, game) address it
- // knows. The address is the lookup key; identity never reaches the worker.
+ // Bind this device's APNs token to each address it knows. A game address
+ // carries the game's `credID` and is stored under it so a publish can only
+ // reach it when signed with that game's secret; the account-scoped sibling
+ // address has no credID and uses the bare key. Identity never reaches the
+ // worker — the (credID-scoped) address is the only lookup key.
const updatedAt = Date.now();
- for (const address of addresses) {
- if (typeof address !== "string" || address.length === 0) continue;
+ for (const entry of addresses) {
+ const key = addressStorageKey(entry, deviceID);
+ if (!key) continue;
const registration = { token, environment, updatedAt };
if (muted.length > 0) registration.mutedKinds = muted;
- await this.state.storage.put(`addr:${address}:${deviceID}`, registration);
+ await this.state.storage.put(key, registration);
}
return new Response(null, { status: 204 });
}
@@ -364,26 +372,53 @@ export class PushRegistry {
if (auth.deviceID !== deviceID) {
return new Response("Authenticated device mismatch", { status: 403 });
}
- for (const address of addresses) {
- if (typeof address !== "string" || address.length === 0) continue;
- await this.state.storage.delete(`addr:${address}:${deviceID}`);
+ for (const entry of addresses) {
+ const key = addressStorageKey(entry, deviceID);
+ if (!key) continue;
+ await this.state.storage.delete(key);
}
return new Response(null, { status: 204 });
}
- // Addresses are bearer capabilities: any App Attest-enrolled client that
- // names an address can publish to it. The worker has no notion of games,
- // accounts, or participants, so there is no ownership check to make here —
- // authorization rests on addresses being HMAC-derived from an account
- // secret that never reaches the worker and is shared only between the
- // account's own devices via the private CloudKit database.
- async handlePublish(bodyText, auth) {
+ // Stores a game's shared push credential (first-write-wins), keyed by the
+ // unguessable credID minted into the Game record. The client (any
+ // participant) registers idempotently before publishing; a different secret
+ // under the same credID is refused with 409 (only reachable on a credID
+ // collision). Mirrors the room worker's `register`.
+ async handleGameRegister(credID, bodyText) {
+ const body = await readJSONText(bodyText);
+ if (!body) return badRequest("Body must be JSON");
+ const secret = typeof body.secret === "string" ? body.secret : "";
+ if (!isAcceptableSecret(secret)) return badRequest("Invalid secret");
+ const key = `gamecred:${credID}`;
+ const stored = await this.state.storage.get(key);
+ if (stored) {
+ if (!timingSafeEqual(stored.secret, secret)) {
+ return new Response("Game credential mismatch", { status: 409 });
+ }
+ return new Response(null, { status: 204 });
+ }
+ await this.state.storage.put(key, { secret, createdAt: Date.now() });
+ return new Response(null, { status: 201 });
+ }
+
+ // Authorization model: a game push must prove participation. The publish
+ // names the game's `credID` (minted into the participant-only Game record)
+ // and is signed with that game's secret; this verifies the signature
+ // against the secret registered under that credID and then resolves targets
+ // only among addresses registered under the same credID. So a caller can
+ // only reach a game's participants if it holds that game's secret — i.e. it
+ // is a participant. Account-scoped sibling pushes (accountJoined/accountSeen)
+ // carry no credID: their addresses derive from the account secret in the
+ // private CloudKit database, so they were never participant-spoofable.
+ async handlePublish(request, bodyText, auth) {
const body = await readJSONText(bodyText);
if (!body) return badRequest("Body must be JSON");
const {
kind,
addressees,
gameID,
+ credID,
fromAuthorID,
senderDeviceID,
readAt,
@@ -395,7 +430,14 @@ export class PushRegistry {
return badRequest("kind and non-empty addressees required");
}
- const targets = await this.resolveTargets(addressees, senderDeviceID);
+ if (credID) {
+ const verification = await this.verifyGameSignature(request, credID);
+ if (!verification.ok) {
+ return new Response(verification.message, { status: verification.status });
+ }
+ }
+
+ const targets = await this.resolveTargets(addressees, senderDeviceID, credID);
if (targets.length === 0) {
return Response.json({ delivered: 0, removed: 0, muted: 0, failed: 0 });
}
@@ -433,7 +475,37 @@ export class PushRegistry {
return Response.json({ delivered, removed, muted, failed });
}
- async resolveTargets(addressees, senderDeviceID) {
+ // Verifies the game-participation signature: HMAC, under the secret
+ // registered for `credID`, over the App Attest request's own body hash,
+ // timestamp, and nonce (already validated by `authenticate`, so they are
+ // bound to this exact request and need no separate freshness check here).
+ async verifyGameSignature(request, credID) {
+ const cred = await this.state.storage.get(`gamecred:${credID}`);
+ if (!cred) {
+ return { ok: false, status: 403, message: "Game not registered" };
+ }
+ const signature = request.headers.get("X-Crossmate-Game-Signature") || "";
+ if (!signature) {
+ return { ok: false, status: 401, message: "Missing game signature" };
+ }
+ const bodyHash = request.headers.get("X-Crossmate-Body-SHA256") || "";
+ const timestamp = request.headers.get("X-Crossmate-Timestamp") || "";
+ const nonce = request.headers.get("X-Crossmate-Nonce") || "";
+ const payload = [
+ "crossmate-push-game-v1",
+ credID,
+ bodyHash,
+ timestamp,
+ nonce
+ ].join("\n");
+ const expected = await hmacSHA256(cred.secret, payload);
+ if (!timingSafeEqual(signature, expected)) {
+ return { ok: false, status: 401, message: "Invalid game signature" };
+ }
+ return { ok: true };
+ }
+
+ async resolveTargets(addressees, senderDeviceID, credID) {
const targets = [];
for (const addressee of addressees) {
if (!addressee || !addressee.address) continue;
@@ -443,7 +515,11 @@ export class PushRegistry {
// so the notification service extension can decode it. Keeping it opaque
// is what lets the app evolve notification meaning without a worker deploy.
const payload = typeof addressee.payload === "string" ? addressee.payload : undefined;
- const prefix = `addr:${addressee.address}:`;
+ // Game pushes resolve only among addresses registered under the same
+ // credID; account pushes use the bare address key.
+ const prefix = credID
+ ? `addr:${credID}:${addressee.address}:`
+ : `addr:${addressee.address}:`;
const map = await this.state.storage.list({ prefix });
for (const [key, value] of map) {
const deviceID = key.slice(prefix.length);
@@ -562,6 +638,46 @@ function badRequest(message) {
return new Response(message, { status: 400 });
}
+// Storage key for a device's registration under one address. A game address
+// arrives as `{address, credID}` and is keyed under its credID so a publish
+// can reach it only when signed with that game's secret; the account-scoped
+// address arrives without a credID and uses the bare key.
+function addressStorageKey(entry, deviceID) {
+ const address = entry && typeof entry === "object" ? entry.address : entry;
+ if (typeof address !== "string" || address.length === 0) return null;
+ const credID = entry && typeof entry === "object" && typeof entry.credID === "string"
+ ? entry.credID
+ : "";
+ return credID
+ ? `addr:${credID}:${address}:${deviceID}`
+ : `addr:${address}:${deviceID}`;
+}
+
+// The secret doubles as the HMAC key for game signatures, so a registered
+// value must decode to at least 32 key bytes (clients mint exactly 32).
+function isAcceptableSecret(secret) {
+ if (!secret) return false;
+ let bytes;
+ try {
+ bytes = base64URLDecode(secret);
+ } catch {
+ return false;
+ }
+ return bytes.length >= 32;
+}
+
+async function hmacSHA256(secret, payload) {
+ const key = await crypto.subtle.importKey(
+ "raw",
+ base64URLDecode(secret),
+ { name: "HMAC", hash: "SHA-256" },
+ false,
+ ["sign"]
+ );
+ const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload));
+ return base64URLEncode(new Uint8Array(signature));
+}
+
function canonicalPushRequest({
method,
path,