crossmate

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

commit c1d3dd7b1f832b36ffe718fd2a387755dddad39a
parent 0a9a470714dd8cc1fa0ccc865917bc9c6acf26c1
Author: Michael Camilleri <[email protected]>
Date:   Mon, 29 Jun 2026 21:47:28 +0900

Mirror reconciled push content keys

Encrypted nudge pushes could arrive with only the generic fallback body
when this device had minted the shared game push credential during
registration reconciliation. The main app could seal outgoing payloads
from Core Data, but that path did not refresh the App Group content-key
directory read by the Notification Service Extension, so an inbound push
for the same game could be undecryptable until a later heal rebuilt the
mirror.

This commit refreshes ContentKeyDirectory after
reconcileLocalPushAddresses mints notification credentials, matching the
explicit setNotification path. The registration sweep test now covers
the NSE mirror so fresh credentials cannot be saved without making their
content key available to the extension.

Co-Authored-By: Codex GPT 5.5 <[email protected]>

Diffstat:
MCrossmate/Persistence/GameStore.swift | 5+++++
MTests/Unit/GameStorePushAddressTests.swift | 26++++++++++++++++++++++++++
2 files changed, 31 insertions(+), 0 deletions(-)

diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -1908,6 +1908,7 @@ final class GameStore { var bindings: [PushAddressBinding] = [] var republishGameIDs: [UUID] = [] var gameRecordUpdates: [String] = [] + var notificationCredentialsChanged = false var didChange = false for game in games { guard let gameID = game.id else { continue } @@ -1920,6 +1921,7 @@ final class GameStore { game.notification = encoded game.hasPendingSave = true creds = fresh + notificationCredentialsChanged = true didChange = true if let ckName = game.ckRecordName { gameRecordUpdates.append(ckName) } } @@ -1939,6 +1941,9 @@ final class GameStore { } if didChange { saveContext("reconcileLocalPushAddresses") + if notificationCredentialsChanged { + GameEntity.rebuildContentKeyDirectory(in: context) + } } // Enqueue Game-record pushes for freshly-minted credentials after the // save, mirroring `setNotification`. diff --git a/Tests/Unit/GameStorePushAddressTests.swift b/Tests/Unit/GameStorePushAddressTests.swift @@ -152,6 +152,32 @@ struct GameStorePushAddressTests { #expect(try playerRowCount(in: persistence.viewContext) == 0) } + @Test("reconcile mirrors freshly-minted content keys for the notification extension") + func reconcileMirrorsMintedContentKeys() async throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("content-key-directory-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: url) } + + try await ContentKeyDirectory.$testingFileURL.withValue(url) { + try await MainActor.run { + let persistence = makeTestPersistence() + let store = makeTestStore(persistence: persistence) + let gameID = try makeGame(scope: 1, in: persistence.viewContext) + + #expect(ContentKeyDirectory.load().isEmpty) + + let result = store.reconcileLocalPushAddresses( + authorID: Self.authorID, + secret: Self.secret + ) + let credentials = try #require(result.bindings.first?.credentials) + let contentKey = try #require(credentials.contentKey) + + #expect(ContentKeyDirectory.load()[gameID.uuidString] == contentKey) + } + } + } + @Test("reconcile migrates a stale row to the derived address and lists it") func reconcileMigratesStaleRow() throws { let persistence = makeTestPersistence()