crossmate

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

commit f6a40456a5a1b40eeae08b77989a9bbc0151de0a
parent ceeed13065267d9693c6e9f3fad216ffbbdf84a8
Author: Michael Camilleri <[email protected]>
Date:   Thu, 25 Jun 2026 06:33:22 +0900

Encrypt the push payload so the push worker can't read it

The Cloudflare push worker — and APNs behind it — previously received
every notification's personal content in cleartext: the sender's player
name and chosen puzzle title, the fully composed alert body, and the
pause diagnostics. The worker forwarded these without inspecting them,
but forwarding is not blindness — Cloudflare terminates TLS, so anything
in the request body was readable at the edge.

Now the sender seals the structured PushPayload with AES-GCM before it
leaves the device and ships only the ciphertext as enc; the worker
forwards that opaquely, exactly as it did the old cleartext payload. The
cleartext alert body on the wire is reduced to a generic 'New activity
in one of your puzzles', and the Notification Service Extension
recomposes the real wording on the receiving device after decrypting,
reusing the same builders that already substituted a private nickname so
no personal text is ever composed worker-side.

The key is a per-game content key. Rather than a new record field, it
rides inside the existing GamePushCredentials blob in the Game record's
notification field, synced only to CKShare participants. Crucially it is
worker-blind: only secret and credID are ever sent to the worker for
auth, never the encoded blob, so the worker holds no copy of the key it
would need to decrypt. Because the extension cannot reach Core Data, the
app mirrors each game's key into an App Group file (ContentKeyDirectory)
rebuilt at launch, on a local mint, and whenever sync adopts an inbound
credential, so a freshly joined participant can decrypt the first push
it receives.

A credential minted before content keys existed is backfilled with one
in place, preserving its credID and secret so worker registration stays
valid. A recipient still running an older build finds enc rather than
payload and falls back to the generic body and the coarse kind until it
updates.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 20++++++++++++++++++++
ACrossmate/Models/GameEntity+ContentKey.swift | 26++++++++++++++++++++++++++
MCrossmate/Persistence/GameStore.swift | 29+++++++++++++++++++++--------
MCrossmate/Services/AppServices.swift | 13+++++++++++++
MCrossmate/Services/PushClient.swift | 36+++++++++++++++++++++++++++++++-----
MCrossmate/Sync/GamePushCredentials.swift | 50++++++++++++++++++++++++++++++++++++++------------
MCrossmate/Sync/RecordApplier.swift | 11++++++++++-
MCrossmate/Sync/RecordSerializer.swift | 26++++++++++++++++++--------
MCrossmate/Sync/SyncEngine.swift | 8+++++++-
MNotificationService/NotificationService.swift | 66++++++++++++++++++++++++++++++++++++++----------------------------
AShared/ContentKeyDirectory.swift | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AShared/PushPayloadCipher.swift | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/Unit/PushPayloadCipherTests.swift | 134+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MTests/Unit/RecordSerializerTests.swift | 21++++++++++++++++++---
MWorkers/push-worker.js | 31+++++++++++++++++++++----------
15 files changed, 508 insertions(+), 76 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -30,6 +30,7 @@ 13C0F34520828020AD825D07 /* JoiningPuzzleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18FF14E0D73B0D2DB427F08 /* JoiningPuzzleView.swift */; }; 14749A042380925B7CA902F2 /* XDMarkup.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAF18C52558DBD58ECAD4964 /* XDMarkup.swift */; }; 15768439C1783A1780FBB824 /* SessionCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A7BD8DFDB41BFBA694A0933 /* SessionCoordinator.swift */; }; + 16D7328AE0BB1F7ED46235C8 /* GameEntity+ContentKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24A4B5C8EC4A46906C07F819 /* GameEntity+ContentKey.swift */; }; 17A754692F05B97DBDD645F2 /* PlayerSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F0B4F65D017C1FBAC3B23DF /* PlayerSelection.swift */; }; 18D5BB584DBF92A2EC580AEA /* NotificationNavigationBrokerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEDD63AD5E33E2B0399780EF /* NotificationNavigationBrokerTests.swift */; }; 197DDF45C36B9570BB9AE4B5 /* AuthorIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1F1471BE4D6D84361DD692B /* AuthorIdentity.swift */; }; @@ -44,6 +45,7 @@ 2641299DE1F2E84E8C21E037 /* LogScrubberTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C06E2CC3A77CB306BD2DF867 /* LogScrubberTests.swift */; }; 267ED5B329F05A30430B73A0 /* EngagementHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18C701DAE36000DE19F7CC95 /* EngagementHost.swift */; }; 26DC22F88FA10C47BC06975E /* PersistenceRecoveryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A467BC00116EEC8500BE6A1 /* PersistenceRecoveryTests.swift */; }; + 2A273C98FE3AC5E4C9BE1D88 /* PushPayloadCipherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C793B5B9684D49328E42129 /* PushPayloadCipherTests.swift */; }; 2AF2550B08CE79F8615B3076 /* FriendZone.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A4AFF292381C9B33C0F2CD6 /* FriendZone.swift */; }; 2C0DFC182240A2519ED1FA6A /* GameMutatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */; }; 2C5A15054CCCBF9FD626AFBB /* GameStorePushAddressTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A56778AF8190F0D7EB2E27E /* GameStorePushAddressTests.swift */; }; @@ -83,6 +85,7 @@ 5ECF5B80D08E5E999A540782 /* SessionMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EB332831AB173ACF6BFEC59 /* SessionMonitor.swift */; }; 5EFCD28B3B682DCCF38068D6 /* AnnouncementCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D3ECD0DE71BE567BCEE15F6 /* AnnouncementCenter.swift */; }; 5FB26F40F5DB52111E3D1BDC /* CheckResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FD9A43789D0ED123F7A99B0 /* CheckResult.swift */; }; + 609364CB79E0C7517298B404 /* ContentKeyDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAFA466405AABA1C06272795 /* ContentKeyDirectory.swift */; }; 61F8B38587EE49D376B53544 /* ReplayCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 603E6FC55F1BD944592379D2 /* ReplayCacheTests.swift */; }; 6850EAE474E589CE1EA2DF68 /* NicknameDirectoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92168360625ECDD36FF50EE8 /* NicknameDirectoryTests.swift */; }; 689DAEC70934027E76E8116E /* KeyboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FDE73AD7C543B29C8E493F8 /* KeyboardView.swift */; }; @@ -116,12 +119,14 @@ 8B356C953DA0FAF149C3391A /* Puzzles in Resources */ = {isa = PBXBuildFile; fileRef = BA67C509B467132D1B7510A4 /* Puzzles */; }; 8D8A9F70731C98DD00BE1DA5 /* Layouts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 836B8D4B351C9225162A82C0 /* Layouts.swift */; }; 8F5CB2F94E083D06D7E04280 /* PlayerSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B331CC55827FEF3420ABCE /* PlayerSession.swift */; }; + 8FEE38B2B540F0E91560747F /* PushPayloadCipher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33DE10D2A5AFBAC73469BD33 /* PushPayloadCipher.swift */; }; 903681985C17FCB5F97773A9 /* OpenPuzzleBannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8CA0FC750259EB1D762B0EE /* OpenPuzzleBannerTests.swift */; }; 91703E54DB4679C1911BF994 /* Moves.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86470163BFF956F3DE438506 /* Moves.swift */; }; 924B29C1EEB29F849A6824C3 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74C8886A66F0877858A67D62 /* AboutView.swift */; }; 931431F8052FC58768C9BC26 /* FriendControllerNicknameReplayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E2702C74378FD2F14D1CE33 /* FriendControllerNicknameReplayTests.swift */; }; 93DB3DD9A8EE994B92E7C084 /* GridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED48AD9C3A7A113D101BBD21 /* GridView.swift */; }; 9502840161DB88BB6BB409D5 /* Journal.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF3D29B227D2B0E699423C48 /* Journal.swift */; }; + 95170ECF07E94E7581C2B66F /* ContentKeyDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAFA466405AABA1C06272795 /* ContentKeyDirectory.swift */; }; 9582AA583F5EA008FFC82B64 /* ZoneOrphaningTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9A01534A21796A4EC7113A9 /* ZoneOrphaningTests.swift */; }; 9789150602A3321D2E1E7E81 /* Media.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0BF60C84D92A9024AC1A53FC /* Media.xcassets */; }; 978F91DBAE94BC5DA1D94705 /* DriveMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70AD1A006E6D03E4429E3BF0 /* DriveMonitor.swift */; }; @@ -208,6 +213,7 @@ F8D37DBE75D7B3F039A8FAC8 /* ImportedBrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A832061C19BA0F073617CA2 /* ImportedBrowseView.swift */; }; F8DDA34AC1A6B6499C5D222E /* PlayerPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46148CF0F4D719692F81A6EC /* PlayerPreferences.swift */; }; FC480FE2930EAE406F5BBBDA /* GameRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD2570A5A3573D66B3C4A52 /* GameRowView.swift */; }; + FC4853B4261B06945D0D1470 /* PushPayloadCipher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33DE10D2A5AFBAC73469BD33 /* PushPayloadCipher.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -270,6 +276,7 @@ 1B7539E0AD285C5A3AC3DDA2 /* GameArchiver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameArchiver.swift; sourceTree = "<group>"; }; 1D3ECD0DE71BE567BCEE15F6 /* AnnouncementCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementCenter.swift; sourceTree = "<group>"; }; 20B331CC55827FEF3420ABCE /* PlayerSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSession.swift; sourceTree = "<group>"; }; + 24A4B5C8EC4A46906C07F819 /* GameEntity+ContentKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GameEntity+ContentKey.swift"; sourceTree = "<group>"; }; 27ECEA51DE42D07495744EF8 /* JournalReplay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JournalReplay.swift; sourceTree = "<group>"; }; 283C5C97180C805B6C5BF622 /* PerGameZoneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerGameZoneTests.swift; sourceTree = "<group>"; }; 298A9C54A1CC753E860E174E /* FriendsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendsView.swift; sourceTree = "<group>"; }; @@ -282,6 +289,7 @@ 31C534911020BE4ED2E5065D /* GameStoreUnreadMovesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStoreUnreadMovesTests.swift; sourceTree = "<group>"; }; 3292748EAE27B608C769D393 /* PlayerRoster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRoster.swift; sourceTree = "<group>"; }; 33878A29B09A6154C7A63C82 /* KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHelper.swift; sourceTree = "<group>"; }; + 33DE10D2A5AFBAC73469BD33 /* PushPayloadCipher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushPayloadCipher.swift; sourceTree = "<group>"; }; 3413F8755236FC0578AF8109 /* NYTBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTBrowseView.swift; sourceTree = "<group>"; }; 362E3A93102B6C9AECD4133A /* PuzzleSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleSessionTests.swift; sourceTree = "<group>"; }; 3EF436B410916399336AC106 /* RecordEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordEditorView.swift; sourceTree = "<group>"; }; @@ -324,6 +332,7 @@ 6940546CFA1E87EF814AA6BB /* HardwareKeyboardInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HardwareKeyboardInputView.swift; sourceTree = "<group>"; }; 6B1F07B5DDE2A8B49B28392A /* GridThumbnailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridThumbnailView.swift; sourceTree = "<group>"; }; 6BDD06460A76D4AF31077732 /* InputMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputMonitor.swift; sourceTree = "<group>"; }; + 6C793B5B9684D49328E42129 /* PushPayloadCipherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushPayloadCipherTests.swift; sourceTree = "<group>"; }; 6C7F3A9BD7FAF81CB77032A6 /* GridStateMergerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridStateMergerTests.swift; sourceTree = "<group>"; }; 6F0B4F65D017C1FBAC3B23DF /* PlayerSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSelection.swift; sourceTree = "<group>"; }; 6F34401948BC53DA9C93D64B /* GameCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameCardView.swift; sourceTree = "<group>"; }; @@ -432,6 +441,7 @@ E935CE4384F3B67CC22EEBAC /* ClueBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClueBar.swift; sourceTree = "<group>"; }; EAC61E2582D94B1E6EC67136 /* XDFileType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XDFileType.swift; sourceTree = "<group>"; }; EAF18C52558DBD58ECAD4964 /* XDMarkup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XDMarkup.swift; sourceTree = "<group>"; }; + EAFA466405AABA1C06272795 /* ContentKeyDirectory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentKeyDirectory.swift; sourceTree = "<group>"; }; ED2D830B9EFAD753C233BEB4 /* GameListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameListView.swift; sourceTree = "<group>"; }; ED48AD9C3A7A113D101BBD21 /* GridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridView.swift; sourceTree = "<group>"; }; ED8154F949E1D94252F70765 /* NYTAuthServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTAuthServiceTests.swift; sourceTree = "<group>"; }; @@ -558,6 +568,7 @@ 1813630FA05C194AFF43855C /* PlayerRosterTests.swift */, FF159746D076E051C2CB590C /* PlayerSelectionPublisherTests.swift */, 46801B570FC0B2C791ECDED3 /* PlayerSessionNavigationTests.swift */, + 6C793B5B9684D49328E42129 /* PushPayloadCipherTests.swift */, E87E28DC9402A4369647DE50 /* PushPayloadTests.swift */, FDE193CAB325C991952D7CE5 /* PUZToXDConverterTests.swift */, B8560440C548752EE93E0ED9 /* PuzzleCatalogTests.swift */, @@ -590,6 +601,7 @@ 9F8D856707B4D76FDBF4AE69 /* FriendEntity+DisplayName.swift */, 465F2BB469EFE84CF3733398 /* Game.swift */, 8D2AD5021F1AF0DB44FA4540 /* GameCursorStore.swift */, + 24A4B5C8EC4A46906C07F819 /* GameEntity+ContentKey.swift */, B9AE0F26E602A9246F5C6ABF /* GameViewedStore.swift */, E25A040EA4DC9672C895A7AC /* InviteEntity+DisplayName.swift */, FED7C2528355BC007E48B7EF /* ParticipantSummaries.swift */, @@ -708,9 +720,11 @@ 9BF7383FE2AB07F12434C013 /* Shared */ = { isa = PBXGroup; children = ( + EAFA466405AABA1C06272795 /* ContentKeyDirectory.swift */, 3111803C8FFFB0C839217482 /* NicknameDirectory.swift */, 2D2FD896D75863554E31654C /* NotificationState.swift */, C2C9D3E7FCE2D42C5B7E3856 /* PushPayload.swift */, + 33DE10D2A5AFBAC73469BD33 /* PushPayloadCipher.swift */, CE7CEB8980A9664BAAA5D196 /* PuzzleNotificationText.swift */, ); path = Shared; @@ -932,10 +946,12 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 95170ECF07E94E7581C2B66F /* ContentKeyDirectory.swift in Sources */, A0977C7B0B0D928DA569C326 /* NicknameDirectory.swift in Sources */, 7D4A56FBB1C5D5F89271B77F /* NotificationService.swift in Sources */, 88BACA1689459AC9AED20D43 /* NotificationState.swift in Sources */, F8A4B3A1F9601654C60550B3 /* PushPayload.swift in Sources */, + FC4853B4261B06945D0D1470 /* PushPayloadCipher.swift in Sources */, 351CB23C537BAB61863D95F6 /* PuzzleNotificationText.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -991,6 +1007,7 @@ 04062BCD473ED244159B1066 /* PlayerRosterTests.swift in Sources */, 309457EC2DFEC476253D54D2 /* PlayerSelectionPublisherTests.swift in Sources */, 00F2108848ADC7B4BF3AA0AE /* PlayerSessionNavigationTests.swift in Sources */, + 2A273C98FE3AC5E4C9BE1D88 /* PushPayloadCipherTests.swift in Sources */, A78FF09708EDED7ED50BB55B /* PushPayloadTests.swift in Sources */, F34EDFD45E2F5006807DDAC7 /* PuzzleCatalogTests.swift in Sources */, 7FCD3F582B5ADC235E1F88A0 /* PuzzleNotificationTextTests.swift in Sources */, @@ -1038,6 +1055,7 @@ BAB41DBF7D099B1EE46B4ACB /* ClueBar.swift in Sources */, 036EC1EDDEFD17DCDD9B5F1A /* ClueList.swift in Sources */, 2571BA6482B3E896A80FF393 /* CompactSlider.swift in Sources */, + 609364CB79E0C7517298B404 /* ContentKeyDirectory.swift in Sources */, DE9E4FAB098731A650F2D306 /* CrossmateApp.swift in Sources */, C30C0C4E54E4209A22843872 /* CrossmateModel.xcdatamodeld in Sources */, C472EF02D8C7B0AC1D2284B8 /* CustomButtons.swift in Sources */, @@ -1059,6 +1077,7 @@ 5992AD4A06D7C6440825E9C6 /* GameArchiver.swift in Sources */, D5022BFB2F8F2E5904EDF5C8 /* GameCardView.swift in Sources */, 128915DB37018EE4CC16C856 /* GameCursorStore.swift in Sources */, + 16D7328AE0BB1F7ED46235C8 /* GameEntity+ContentKey.swift in Sources */, 0063A5FC9F39E37A67F137FF /* GameListView.swift in Sources */, 3A5483EF2893AE325DF27EE8 /* GameMutator.swift in Sources */, 9FFD01CF6767220EEA20C0E4 /* GamePushCredentials.swift in Sources */, @@ -1109,6 +1128,7 @@ E354A588DBA74627A9CD5591 /* Presence.swift in Sources */, A133A4B4A0C95AF8708BD7E6 /* PushClient.swift in Sources */, 43E311FBD68B7D35A4D29743 /* PushPayload.swift in Sources */, + 8FEE38B2B540F0E91560747F /* PushPayloadCipher.swift in Sources */, F2F7CB23DA62BF714632B097 /* PushRequestAuthenticator.swift in Sources */, 503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */, 350722635E9A17324148CACC /* PuzzleCatalog.swift in Sources */, diff --git a/Crossmate/Models/GameEntity+ContentKey.swift b/Crossmate/Models/GameEntity+ContentKey.swift @@ -0,0 +1,26 @@ +import CoreData +import Foundation + +extension GameEntity { + /// Rewrites the App Group content-key directory from Core Data ground truth: + /// one `gameID → contentKey` entry per shared game whose notification + /// credentials carry a key. The Notification Service Extension reads it to + /// decrypt the structured push payload (`PushPayloadCipher`) on a suspended + /// device, where it can't reach Core Data. Called after a local mint + /// (`GameStore.setNotification`), after sync adopts an inbound credential, + /// and once at launch as a heal. Must run inside the context's queue + /// (`performAndWait`) when `ctx` is a background context. + static func rebuildContentKeyDirectory(in ctx: NSManagedObjectContext) { + let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") + req.predicate = NSPredicate(format: "notification != nil") + var directory: [String: String] = [:] + for game in (try? ctx.fetch(req)) ?? [] { + guard let id = game.id, + let key = GamePushCredentials.decode(game.notification)?.contentKey, + !key.isEmpty + else { continue } + directory[id.uuidString] = key + } + ContentKeyDirectory.save(directory) + } +} diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -1244,8 +1244,10 @@ final class GameStore { 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. + /// Writes the notification credentials for `gameID` and enqueues a + /// Game-record push, mirroring `setEngagement`. On a real change also + /// re-mirrors the App Group content-key directory the NSE reads (the blob + /// carries the content key). No-op (false) when unchanged or not shared. @discardableResult func setNotification(_ encoded: String?, for gameID: UUID) -> Bool { let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") @@ -1257,20 +1259,31 @@ final class GameStore { entity.notification = encoded entity.hasPendingSave = true saveContext("setNotification") + GameEntity.rebuildContentKeyDirectory(in: context) 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. + /// Returns the shared notification credentials 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. A + /// legacy credential minted before content keys existed is backfilled with a + /// fresh one in place, preserving its `credID`/`secret` so worker + /// registration stays valid. 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)) { + if var existing = GamePushCredentials.decode(notification(for: gameID)) { + if existing.contentKey != nil { return existing } + // Backfill a content key onto a legacy credential, keeping its auth + // material so the worker registration is unaffected. + guard let key = try? GamePushCredentials.freshContentKey() else { return existing } + existing.contentKey = key + guard let encoded = try? existing.encoded(), setNotification(encoded, for: gameID) + else { return existing } return existing } guard let fresh = try? GamePushCredentials.fresh(), diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -424,6 +424,15 @@ final class AppServices { self.pushClient?.gameCredentialResolver = { [weak store] gameID in store?.ensurePushCredentials(for: gameID) } + // Publishes encrypt the structured payload under the game's content key, + // which rides in the same notification credential the push secret does + // (minted/backfilled on first use) so the worker only ever forwards + // ciphertext for the personal fields. + self.pushClient?.contentKeyResolver = { [weak store] gameID in + guard let keyString = store?.ensurePushCredentials(for: gameID)?.contentKey + else { return nil } + return PushPayloadCipher.key(fromBase64: keyString) + } let sessionMonitor = SessionMonitor( store: store, @@ -566,6 +575,10 @@ final class AppServices { let nicknameCtx = persistence.container.newBackgroundContext() nicknameCtx.performAndWait { FriendEntity.rebuildNicknameDirectory(in: nicknameCtx) + // Heal the App Group content-key directory from the same context — + // covers the first run after the feature shipped and any rebuild a + // crash or extension write skipped. Cheap: one fetch over the games. + GameEntity.rebuildContentKeyDirectory(in: nicknameCtx) } appDelegate.onRemoteNotification = { diff --git a/Crossmate/Services/PushClient.swift b/Crossmate/Services/PushClient.swift @@ -1,3 +1,4 @@ +import CryptoKit import Foundation /// Uploads this device's APNs token to the Crossmate push worker and keeps @@ -36,6 +37,18 @@ final class PushClient { /// the request the worker verifies against the credential it holds. var gameCredentialResolver: (@MainActor (UUID) -> GamePushCredentials?)? + /// Resolves (minting if needed) the per-game content key used to encrypt the + /// structured payload. Set by AppServices to read from the GameStore. When + /// nil (or unresolved), a publish ships its generic cleartext body with no + /// encrypted payload — degraded but never leaking personal text. + var contentKeyResolver: (@MainActor (UUID) -> SymmetricKey?)? + + /// The cleartext alert body the worker forwards to APNs in place of the real + /// notification text. The personal wording is encrypted into the payload and + /// recomposed on the device by the notification service extension; this is + /// all the worker (and a recipient whose NSE can't decrypt) ever sees. + static let genericAlertBody = "New activity in one of your puzzles" + /// Game credentials already registered with the worker this session /// (credID → secret), so `/games/:credID/register` is posted at most once /// per credential value. @@ -239,12 +252,23 @@ final class PushClient { // receiver's extension can recompose the body from components (swapping // in its private nickname) rather than editing the sender's text. // Injected here so the call sites pass it once, not per addressee. + // Resolve (and mint) the game's content key only when there is a payload + // to seal, so account-scoped pushes that carry none don't mint one. + let needsContentKey = broadcastPayload != nil || addressees.contains { $0.payload != nil } + let contentKey = needsContentKey ? contentKeyResolver?(gameID) : nil + // Encrypt each addressee's structured payload under the content key and + // ship it as `enc`. The per-recipient cleartext `body` (personalised + // pause counts) is deliberately *not* sent: those counts live in the + // sealed payload's event, and the receiver's NSE recomposes the body — + // so the worker never sees the wording. An addressee whose payload can't + // be sealed (no key resolved) still gets the push, with the generic body. let addresseePayloads: [[String: Any]] = addressees.map { addressee in var entry: [String: Any] = ["address": addressee.address] - if let body = addressee.body { entry["body"] = body } if var payload = addressee.payload { if let puzzleTitle { payload.puzzleTitle = puzzleTitle } - if let encoded = payload.encodedString() { entry["payload"] = encoded } + if let contentKey, let sealed = PushPayloadCipher.seal(payload, key: contentKey) { + entry["enc"] = sealed + } } return entry } @@ -254,7 +278,9 @@ final class PushClient { "fromAuthorID": authorID ?? "", "senderDeviceID": deviceID, "title": title, - "alertBody": body, + // Generic wording only; the real body is sealed into the payload and + // recomposed on-device. A bodyless (background) push stays bodyless. + "alertBody": body.isEmpty ? "" : Self.genericAlertBody, "addressees": addresseePayloads, "background": background ] @@ -279,8 +305,8 @@ final class PushClient { if let excludeAddress { payload["excludeAddress"] = excludeAddress } if var broadcastPayload { if let puzzleTitle { broadcastPayload.puzzleTitle = puzzleTitle } - if let encoded = broadcastPayload.encodedString() { - payload["payload"] = encoded + if let contentKey, let sealed = PushPayloadCipher.seal(broadcastPayload, key: contentKey) { + payload["enc"] = sealed } } } diff --git a/Crossmate/Sync/GamePushCredentials.swift b/Crossmate/Sync/GamePushCredentials.swift @@ -1,23 +1,39 @@ import CryptoKit import Foundation -/// The shared per-game push credential, stored in the Game record's +/// The shared per-game notification credentials, 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. +/// `engagement` room creds). Carries two distinct kinds of secret: +/// +/// - `secret` / `credID`: the push-worker auth material. Possession of `secret` +/// proves participation — the 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. +/// - `contentKey`: a **worker-blind** symmetric key (base64 of 32 random +/// bytes). The structured push payload is encrypted under it (see +/// `PushPayloadCipher`) so the worker and APNs only ever see ciphertext for +/// the personal fields. Optional so records minted before it existed still +/// decode; `ensure`-paths add one to a legacy credential in place. +/// +/// IMPORTANT: only `secret` and `credID` may ever be sent to the push worker +/// (registration sends `{secret}`, publishes name `credID`). The encoded blob +/// as a whole — which now also holds `contentKey` — must never leave the device +/// for a Worker, or the encryption is pointless. +/// +/// 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 + var contentKey: String? - init(credID: UUID = UUID(), secret: String, ver: Int = 1) { + init(credID: UUID = UUID(), secret: String, contentKey: String? = nil, ver: Int = 1) { self.ver = ver self.credID = credID self.secret = secret + self.contentKey = contentKey } func encoded() throws -> String { @@ -33,11 +49,21 @@ struct GamePushCredentials: Codable, Equatable, Hashable, Sendable { 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. + /// Mints a fresh credential: a random 256-bit worker auth secret (base64url, + /// to satisfy the worker's `isAcceptableSecret` >= 32 key-byte check) and a + /// random 256-bit content key (standard base64, decoded directly by the NSE + /// via `PushPayloadCipher`). static func fresh() throws -> GamePushCredentials { - try GamePushCredentials(secret: Data.secureRandom(count: 32).base64URLEncodedString()) + try GamePushCredentials( + secret: Data.secureRandom(count: 32).base64URLEncodedString(), + contentKey: Data.secureRandom(count: 32).base64EncodedString() + ) + } + + /// A fresh content key (standard base64 of 32 random bytes), used to backfill + /// a legacy credential minted before content keys existed. + static func freshContentKey() throws -> String { + try Data.secureRandom(count: 32).base64EncodedString() } } diff --git a/Crossmate/Sync/RecordApplier.swift b/Crossmate/Sync/RecordApplier.swift @@ -18,6 +18,9 @@ struct BatchEffects { var playersUpdated = Set<UUID>() var playerPresenceChanged = Set<UUID>() var engagementChanged = Set<UUID>() + /// Games whose inbound Game record changed the notification content key, so + /// the caller re-mirrors the App Group key directory the NSE reads. + var contentKeysChanged = Set<UUID>() var removed = Set<UUID>() /// Per-game incoming read cursor from one of *our own* devices: the /// `readAt` horizon plus the encoded per-peer "seen" baseline that sibling @@ -76,7 +79,8 @@ extension SyncEngine { to: ctx, databaseScope: scopeValue, onEngagementChange: { effects.engagementChanged.insert($0) }, - onCompletedTransition: { effects.completedTransitions.insert($0) } + onCompletedTransition: { effects.completedTransitions.insert($0) }, + onContentKeyChange: { effects.contentKeysChanged.insert($0) } ) if let id = entity.id { effects.rosterRelevant.insert(id) } case "Moves": @@ -135,6 +139,11 @@ extension SyncEngine { ) } } + // Re-mirror the App Group key directory once the batch is saved, so + // a just-adopted content key is available to the NSE immediately. + if !effects.contentKeysChanged.isEmpty { + GameEntity.rebuildContentKeyDirectory(in: ctx) + } return effects } diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift @@ -344,9 +344,11 @@ 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. + // The shared per-game notification credentials (encoded + // GamePushCredentials: the push auth secret + credID, plus the + // worker-blind content key the payload is encrypted under). 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 @@ -677,7 +679,8 @@ enum RecordSerializer { to context: NSManagedObjectContext, databaseScope: Int16 = 0, onEngagementChange: ((UUID) -> Void)? = nil, - onCompletedTransition: ((UUID) -> Void)? = nil + onCompletedTransition: ((UUID) -> Void)? = nil, + onContentKeyChange: ((UUID) -> Void)? = nil ) -> GameEntity { let recordName = record.recordID.recordName let entity = fetchOrCreate( @@ -767,10 +770,17 @@ 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 + // Adopt the shared notification credentials. The credential is read + // lazily at registration/publish time, so converging the field is + // enough — but a change may carry a new embedded content key, so fire + // `onContentKeyChange` to re-mirror the App Group key directory the NSE + // reads. This is what lets a freshly-joined participant decrypt the + // first push it receives, rather than waiting for the next launch heal. + let incomingNotification = record["notification"] as? String + if entity.notification != incomingNotification { + entity.notification = incomingNotification + if let id = entity.id { onContentKeyChange?(id) } + } if let asset = record["puzzleSource"] as? CKAsset, let fileURL = asset.fileURL { diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -1486,7 +1486,8 @@ actor SyncEngine { to: ctx, databaseScope: scope, onEngagementChange: { effects.engagementChanged.insert($0) }, - onCompletedTransition: { effects.completedTransitions.insert($0) } + onCompletedTransition: { effects.completedTransitions.insert($0) }, + onContentKeyChange: { effects.contentKeysChanged.insert($0) } ) if let id = entity.id { effects.rosterRelevant.insert(id) } case "Moves": @@ -1622,6 +1623,11 @@ actor SyncEngine { ) } } + // Re-mirror the App Group key directory once the batch is saved, so + // a just-adopted content key is available to the NSE immediately. + if !effects.contentKeysChanged.isEmpty { + GameEntity.rebuildContentKeyDirectory(in: ctx) + } return effects } diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift @@ -42,42 +42,52 @@ final class NotificationService: UNNotificationServiceExtension { let userInfo = request.content.userInfo let kind = userInfo["kind"] as? String let gameID = (userInfo["gameID"] as? String).flatMap(UUID.init(uuidString:)) - let payload = PushPayload.decode(from: userInfo["payload"] as? String) - // The alert body was composed by the *sender* with their own chosen - // name ("Alice solved …"). If the user gave that friend a private - // nickname (mirrored authorID → nickname into the App Group, with - // `fromAuthorID` identifying the sender), rebuild the body from the - // push's structured fields — `PushPayload.composedBody` runs the same - // builders the sender used, but with the nickname substituted for the - // name. Recomposing from components rather than editing the sender's - // text means a friend's later rename can never desync the result, and - // there is no string surgery to misfire. + // Resolve the structured payload. Current senders ship it encrypted + // (`enc`) under the game's content key, which the app mirrors into the + // App Group keyed by gameID; decrypt it here. Fall back to the legacy + // cleartext `payload` an older sender may still send. When `enc` is + // present but no key has synced yet (a just-joined participant), this is + // nil and the generic cleartext body stands. + let encrypted = userInfo["enc"] as? String + let payload: PushPayload? = { + if let encrypted, let gameID, + let key = ContentKeyDirectory.key(for: gameID), + let opened = PushPayloadCipher.open(encrypted, key: key) { + return opened + } + return PushPayload.decode(from: userInfo["payload"] as? String) + }() + + // The wire body is now a generic placeholder ("New activity in one of + // your puzzles") — the real wording never leaves the sender in cleartext. + // Recompose it here from the (decrypted) structured fields: + // `PushPayload.composedBody` runs the same builders the sender used, + // substituting the recipient's private nickname for the sender when one + // is set (mirrored authorID → nickname into the App Group, with + // `fromAuthorID` identifying the sender), otherwise the sender's own + // `playerName` carried in the payload. Recomposing from components means + // a friend's later rename can never desync the result. // Record *why* the rewrite did or didn't happen as a diagnostics - // receipt. It is a silent no-op in several distinct cases (no sender - // identity, no nickname for this friend, or a body that can't be - // rebuilt — a bodyless event or an older sender missing the structured - // fields). Without this line they're indistinguishable after the fact; - // with it, the next occurrence names the cause. + // receipt — a silent no-op otherwise hides several distinct causes (a + // bodyless background event, an `enc` we couldn't decrypt, or an older + // sender with no structured fields). With it, the next occurrence names + // the cause. let fromAuthorID = (userInfo["fromAuthorID"] as? String) .flatMap { $0.isEmpty ? nil : $0 } + let nickname = fromAuthorID.flatMap { NicknameDirectory.entry(for: $0)?.nickname } + let fromPrefix = fromAuthorID.map { String($0.prefix(8)) } ?? "nil" let rewriteOutcome: String if bestAttemptContent.body.isEmpty { rewriteOutcome = "skipped=empty-body" - } else if let fromAuthorID { - let fromPrefix = String(fromAuthorID.prefix(8)) - if let entry = NicknameDirectory.entry(for: fromAuthorID) { - if let rebuilt = payload?.composedBody(playerName: entry.nickname) { - bestAttemptContent.body = rebuilt - rewriteOutcome = "applied from=\(fromPrefix) nickname=\"\(entry.nickname)\"" - } else { - rewriteOutcome = "skipped=not-composable from=\(fromPrefix)" - } - } else { - rewriteOutcome = "skipped=no-entry from=\(fromPrefix)" - } + } else if let payload, + let rebuilt = payload.composedBody(playerName: nickname ?? payload.playerName ?? "") { + bestAttemptContent.body = rebuilt + rewriteOutcome = "applied via=\(nickname != nil ? "nickname" : "payload-name") from=\(fromPrefix)" + } else if payload == nil { + rewriteOutcome = encrypted != nil ? "skipped=undecryptable from=\(fromPrefix)" : "skipped=no-payload from=\(fromPrefix)" } else { - rewriteOutcome = "skipped=no-from-author" + rewriteOutcome = "skipped=not-composable from=\(fromPrefix)" } VisibleNotificationReceiptLog.record( body: rewriteOutcome, diff --git a/Shared/ContentKeyDirectory.swift b/Shared/ContentKeyDirectory.swift @@ -0,0 +1,60 @@ +import CryptoKit +import Foundation + +/// App Group-shared directory of per-game notification content keys, keyed by +/// `gameID` and persisted as a JSON file in the group container. +/// +/// The structured push payload is encrypted under a game's `contentKey` (see +/// `PushPayloadCipher`), which lives in the Game record and syncs to CKShare +/// participants. But the code that has to *decrypt* on a suspended device is the +/// Notification Service Extension, which cannot reach Core Data or CloudKit. The +/// app therefore mirrors every shared game's key into this file (via +/// `GameEntity.rebuildContentKeyDirectory`), and the NSE reads it to open the +/// sealed payload before display. +/// +/// The value is the same base64 the Game record stores. Possession of this file +/// already implies possession of the device's other at-rest game data, so the +/// key is mirrored as-is rather than separately protected — see the design +/// discussion: the authoritative copy already sits in Core Data at the same +/// file-protection tier. +enum ContentKeyDirectory { + /// Test-only override for the backing file, mirroring + /// `NicknameDirectory.testingFileURL`: a `TaskLocal` so per-test temporary + /// URLs flow through actor hops and stay isolated under parallel suites. + @TaskLocal static var testingFileURL: URL? + + private static var fileURL: URL? { + if let testingFileURL { return testingFileURL } + return FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: NotificationState.appGroup)? + .appendingPathComponent("content-key-directory.json") + } + + static func load() -> [String: String] { + guard let url = fileURL, + let data = try? Data(contentsOf: url), + let directory = try? JSONDecoder().decode([String: String].self, from: data) + else { return [:] } + return directory + } + + /// Replaces the file wholesale — the directory is always rebuilt from Core + /// Data ground truth, so there is no merge to do. An empty directory removes + /// the file. + static func save(_ directory: [String: String]) { + guard let url = fileURL else { return } + guard !directory.isEmpty else { + try? FileManager.default.removeItem(at: url) + return + } + guard let data = try? JSONEncoder().encode(directory) else { return } + try? data.write(to: url, options: .atomic) + } + + /// The symmetric content key for `gameID`, or `nil` when none is mirrored + /// (a game that isn't shared, or one whose key hasn't synced yet). + static func key(for gameID: UUID) -> SymmetricKey? { + guard let stored = load()[gameID.uuidString] else { return nil } + return PushPayloadCipher.key(fromBase64: stored) + } +} diff --git a/Shared/PushPayloadCipher.swift b/Shared/PushPayloadCipher.swift @@ -0,0 +1,53 @@ +import CryptoKit +import Foundation + +/// Symmetric encryption for the structured `PushPayload`, so the Cloudflare +/// push worker (and APNs) only ever sees ciphertext for the personal fields it +/// would otherwise carry in cleartext — the sender's player name, the puzzle +/// title, the composed alert body, and the pause diagnostics. The plaintext +/// stays a `PushPayload`; only its on-the-wire representation changes from a +/// base64 JSON blob the worker forwards verbatim to a sealed box it forwards +/// just as opaquely. +/// +/// The key is the per-game `contentKey`: 32 random bytes minted into the Game +/// record and synced to CKShare participants alongside the engagement/push +/// credentials, then mirrored into the App Group so the notification service +/// extension can read it. Unlike the push credential, it is **never** sent to +/// any Worker — that is the whole point — so a Worker holding the push secret +/// still cannot read the payload. +/// +/// Opening is deliberately failure-tolerant. A recipient that does not yet hold +/// the key (a just-joined participant whose Game record hasn't synced, or whose +/// app hasn't mirrored it into the App Group yet) gets `nil` and falls back to +/// the generic cleartext body the sender always ships. +enum PushPayloadCipher { + /// Builds the symmetric key from the stored base64 `contentKey`. The Game + /// record mints exactly 32 bytes; anything shorter is treated as absent. + static func key(fromBase64 string: String) -> SymmetricKey? { + guard let data = Data(base64Encoded: string), data.count >= 32 else { return nil } + return SymmetricKey(data: data.prefix(32)) + } + + /// Seals a payload into a base64 string of the AES-GCM combined box + /// (`nonce | ciphertext | tag`). Returns `nil` if encoding or sealing + /// fails, leaving the caller to ship the push without an encrypted payload. + static func seal(_ payload: PushPayload, key: SymmetricKey) -> String? { + guard let plaintext = try? JSONEncoder().encode(payload), + let sealed = try? AES.GCM.seal(plaintext, using: key), + let combined = sealed.combined + else { return nil } + return combined.base64EncodedString() + } + + /// Opens a sealed payload. Returns `nil` on any failure — an absent or + /// wrong key, a corrupt box, or plaintext this build can't decode. + static func open(_ encoded: String?, key: SymmetricKey) -> PushPayload? { + guard let encoded, + let combined = Data(base64Encoded: encoded), + let box = try? AES.GCM.SealedBox(combined: combined), + let plaintext = try? AES.GCM.open(box, using: key), + let payload = try? JSONDecoder().decode(PushPayload.self, from: plaintext) + else { return nil } + return payload + } +} diff --git a/Tests/Unit/PushPayloadCipherTests.swift b/Tests/Unit/PushPayloadCipherTests.swift @@ -0,0 +1,134 @@ +import CoreData +import CryptoKit +import Foundation +import Testing + +@testable import Crossmate + +@Suite("Push payload encryption") +struct PushPayloadCipherTests { + + private func makeKey(_ seed: UInt8 = 1) -> SymmetricKey { + let base64 = Data(repeating: seed, count: 32).base64EncodedString() + return PushPayloadCipher.key(fromBase64: base64)! + } + + // MARK: - Cipher round trip + + @Test("seal/open round-trips a payload with personal fields") + func roundTrip() throws { + let key = makeKey() + let payload = PushPayload( + event: .pause(fills: 3, clears: 1, checks: 2, reveals: 0), + puzzleTitle: "The Saturday Stumper", + playerName: "Alexandra", + diagnostics: PushPayload.Diagnostics(gridWidth: 15, gridHeight: 15) + ) + let sealed = try #require(PushPayloadCipher.seal(payload, key: key)) + // The sealed blob must not leak the cleartext personal fields. + let decoded = try #require(Data(base64Encoded: sealed)) + let asText = String(decoding: decoded, as: UTF8.self) + #expect(!asText.contains("Alexandra")) + #expect(!asText.contains("Saturday")) + #expect(PushPayloadCipher.open(sealed, key: key) == payload) + } + + @Test("open with the wrong key fails") + func wrongKeyFails() throws { + let sealed = try #require( + PushPayloadCipher.seal(PushPayload(event: .win, puzzleTitle: "X", playerName: "Y"), key: makeKey(1)) + ) + #expect(PushPayloadCipher.open(sealed, key: makeKey(2)) == nil) + } + + @Test("open tolerates absent and corrupt input") + func tolerantOpen() { + let key = makeKey() + #expect(PushPayloadCipher.open(nil, key: key) == nil) + #expect(PushPayloadCipher.open("not base64 !!!", key: key) == nil) + #expect(PushPayloadCipher.open("YWJjZA==", key: key) == nil) // valid base64, not a box + } + + @Test("key rejects material shorter than 32 bytes") + func keyLength() { + #expect(PushPayloadCipher.key(fromBase64: Data(repeating: 0, count: 16).base64EncodedString()) == nil) + #expect(PushPayloadCipher.key(fromBase64: "") == nil) + #expect(PushPayloadCipher.key(fromBase64: Data(repeating: 0, count: 32).base64EncodedString()) != nil) + } + + // MARK: - App Group directory + + private func withTemporaryDirectoryFile( + _ body: @Sendable () async throws -> Void + ) 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 body() + } + } + + @Test("directory save/load/key round-trips and resolves a usable key") + func directoryRoundTrip() async throws { + try await withTemporaryDirectoryFile { + #expect(ContentKeyDirectory.load().isEmpty) + let gameID = UUID() + let keyBase64 = Data(repeating: 7, count: 32).base64EncodedString() + ContentKeyDirectory.save([gameID.uuidString: keyBase64]) + #expect(ContentKeyDirectory.load() == [gameID.uuidString: keyBase64]) + + // The resolved key must actually open a payload sealed under it. + let resolved = try #require(ContentKeyDirectory.key(for: gameID)) + let payload = PushPayload(event: .win, puzzleTitle: "X", playerName: "Y") + let sealed = try #require(PushPayloadCipher.seal(payload, key: resolved)) + #expect(PushPayloadCipher.open(sealed, key: resolved) == payload) + #expect(ContentKeyDirectory.key(for: UUID()) == nil) + } + } + + @Test("saving an empty directory removes the file") + func emptySaveRemoves() async throws { + try await withTemporaryDirectoryFile { + ContentKeyDirectory.save([UUID().uuidString: Data(repeating: 1, count: 32).base64EncodedString()]) + #expect(!ContentKeyDirectory.load().isEmpty) + ContentKeyDirectory.save([:]) + #expect(ContentKeyDirectory.load().isEmpty) + } + } + + // MARK: - Rebuild from Core Data + + @Test("rebuildContentKeyDirectory mirrors games whose credential carries a key") + func rebuildFromCoreData() async throws { + try await withTemporaryDirectoryFile { + try await MainActor.run { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + + func addGame(notification: String?) -> UUID { + let id = UUID() + let game = GameEntity(context: ctx) + game.id = id + game.title = "Puzzle" + game.puzzleSource = "" + game.createdAt = Date() + game.updatedAt = Date() + game.notification = notification + return id + } + let withKey = addGame(notification: try GamePushCredentials.fresh().encoded()) + // A legacy credential with no content key is skipped. + _ = addGame(notification: try GamePushCredentials(secret: "s").encoded()) + _ = addGame(notification: nil) + try ctx.save() + + GameEntity.rebuildContentKeyDirectory(in: ctx) + + let directory = ContentKeyDirectory.load() + #expect(directory.count == 1) + #expect(directory[withKey.uuidString] != nil) + } + } + } +} diff --git a/Tests/Unit/RecordSerializerTests.swift b/Tests/Unit/RecordSerializerTests.swift @@ -380,22 +380,37 @@ struct RecordSerializerTests { let (asset, tmpURL) = try makePuzzleAsset() defer { try? FileManager.default.removeItem(at: tmpURL) } + // The credential carries both the worker auth secret and the worker-blind + // content key in one blob (the `notification` field). let creds = try GamePushCredentials.fresh() + #expect(creds.contentKey != nil) 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) + var contentKeyChanges: [UUID] = [] + let entity = RecordSerializer.applyGameRecord( + record, + to: ctx, + onContentKeyChange: { contentKeyChanges.append($0) } + ) try ctx.save() #expect(GamePushCredentials.decode(entity.notification) == creds) + #expect(contentKeyChanges == [gameID]) - // A later record without the field clears it (LWW convergence). + // A later record without the field clears it (LWW convergence) and the + // change fires again so the key directory is re-mirrored. let cleared = CKRecord(recordType: "Game", recordID: recordID) cleared["title"] = "T" as CKRecordValue - let merged = RecordSerializer.applyGameRecord(cleared, to: ctx) + let merged = RecordSerializer.applyGameRecord( + cleared, + to: ctx, + onContentKeyChange: { contentKeyChanges.append($0) } + ) try ctx.save() #expect(merged === entity) #expect(merged.notification == nil) + #expect(contentKeyChanges == [gameID, gameID]) } @Test("applyGameRecord preserves id and createdAt on second apply, updates title") diff --git a/Workers/push-worker.js b/Workers/push-worker.js @@ -428,7 +428,8 @@ export class PushRegistry { broadcast, excludeAddress, collapseID, - payload + payload, + enc } = body; if (!kind) { return badRequest("kind required"); @@ -451,7 +452,7 @@ export class PushRegistry { } const targets = broadcast === true - ? await this.resolveBroadcastTargets(credID, senderDeviceID, excludeAddress, alertBody, payload) + ? await this.resolveBroadcastTargets(credID, senderDeviceID, excludeAddress, alertBody, payload, enc) : await this.resolveTargets(addressees, senderDeviceID, credID); if (targets.length === 0) { return Response.json({ delivered: 0, removed: 0, muted: 0, failed: 0 }); @@ -477,6 +478,7 @@ export class PushRegistry { title, body: target.body || alertBody, payload: target.payload, + enc: target.enc, collapseID: typeof collapseID === "string" ? collapseID : undefined, background: background === true }); @@ -529,11 +531,14 @@ export class PushRegistry { for (const addressee of addressees) { if (!addressee || !addressee.address) continue; const body = typeof addressee.body === "string" ? addressee.body : undefined; - // Opaque, app-encoded semantics (base64 JSON of `PushPayload`). The - // worker never inspects it — it just forwards it into the APNs userInfo - // so the notification service extension can decode it. Keeping it opaque - // is what lets the app evolve notification meaning without a worker deploy. + // Opaque, app-encoded semantics. `enc` is the encrypted (sealed) payload + // current clients send; `payload` is the legacy cleartext base64 JSON an + // older client may still send. Either way the worker never inspects it — + // it just forwards it into the APNs userInfo for the notification service + // extension to decode. Keeping it opaque is what lets the app evolve + // notification meaning (and now encrypt it) without a worker deploy. const payload = typeof addressee.payload === "string" ? addressee.payload : undefined; + const enc = typeof addressee.enc === "string" ? addressee.enc : undefined; // Game pushes resolve only among addresses registered under the same // credID; account pushes use the bare address key. const prefix = credID @@ -549,6 +554,7 @@ export class PushRegistry { storageKey: key, body, payload, + enc, ...value }); } @@ -561,10 +567,11 @@ export class PushRegistry { // addresses (base64url / `acct-…`) and device IDs (hex) never contain a // colon, so the first colon after the prefix splits address from deviceID. // The sender's own device and (via `excludeAddress`) its account's other - // devices are skipped, and the uniform `body`/`payload` ride every target. - async resolveBroadcastTargets(credID, senderDeviceID, excludeAddress, alertBody, payload) { + // devices are skipped, and the uniform `body`/`payload`/`enc` ride every target. + async resolveBroadcastTargets(credID, senderDeviceID, excludeAddress, alertBody, payload, enc) { const body = typeof alertBody === "string" ? alertBody : undefined; const forwarded = typeof payload === "string" ? payload : undefined; + const forwardedEnc = typeof enc === "string" ? enc : undefined; const prefix = `addr:${credID}:`; const map = await this.state.storage.list({ prefix }); const targets = []; @@ -582,6 +589,7 @@ export class PushRegistry { storageKey: key, body, payload: forwarded, + enc: forwardedEnc, ...value }); } @@ -607,8 +615,11 @@ export class PushRegistry { if (message.fromAuthorID) apnsPayload.fromAuthorID = message.fromAuthorID; if (message.senderDeviceID) apnsPayload.senderDeviceID = message.senderDeviceID; if (message.readAt) apnsPayload.readAt = message.readAt; - // Forward the opaque app payload verbatim when present. Absent for older - // app builds, which the extension handles by falling back to `kind`. + // Forward the opaque app payload verbatim when present. `enc` is the + // encrypted payload current clients send; `payload` is the legacy cleartext + // form an older client may still send. Both are absent for older app builds, + // which the extension handles by falling back to `kind`. + if (message.enc) apnsPayload.enc = message.enc; if (message.payload) apnsPayload.payload = message.payload; // A "nudge" rouse is ephemeral: deliver now or discard, since "come play"