ContentKeyDirectory.swift (2797B)
1 import CryptoKit 2 import Foundation 3 4 /// App Group-shared directory of per-game notification content keys, keyed by 5 /// `gameID` and persisted as a JSON file in the group container. 6 /// 7 /// The structured push payload is encrypted under a game's `contentKey` (see 8 /// `PushPayloadCipher`), which lives in the Game record and syncs to CKShare 9 /// participants. But the code that has to *decrypt* on a suspended device is the 10 /// Notification Service Extension, which cannot reach Core Data or CloudKit. The 11 /// app therefore mirrors every shared game's key into this file (via 12 /// `GameEntity.rebuildContentKeyDirectory`), and the NSE reads it to open the 13 /// sealed payload before display. 14 /// 15 /// The value is the same base64 the Game record stores. Possession of this file 16 /// already implies possession of the device's other at-rest game data, so the 17 /// key is mirrored as-is rather than separately protected — see the design 18 /// discussion: the authoritative copy already sits in Core Data at the same 19 /// file-protection tier. 20 enum ContentKeyDirectory { 21 /// Test-only override for the backing file, mirroring 22 /// `NicknameDirectory.testingFileURL`: a `TaskLocal` so per-test temporary 23 /// URLs flow through actor hops and stay isolated under parallel suites. 24 @TaskLocal static var testingFileURL: URL? 25 26 private static var fileURL: URL? { 27 if let testingFileURL { return testingFileURL } 28 return FileManager.default 29 .containerURL(forSecurityApplicationGroupIdentifier: NotificationState.appGroup)? 30 .appendingPathComponent("content-key-directory.json") 31 } 32 33 static func load() -> [String: String] { 34 guard let url = fileURL, 35 let data = try? Data(contentsOf: url), 36 let directory = try? JSONDecoder().decode([String: String].self, from: data) 37 else { return [:] } 38 return directory 39 } 40 41 /// Replaces the file wholesale — the directory is always rebuilt from Core 42 /// Data ground truth, so there is no merge to do. An empty directory removes 43 /// the file. 44 static func save(_ directory: [String: String]) { 45 guard let url = fileURL else { return } 46 guard !directory.isEmpty else { 47 try? FileManager.default.removeItem(at: url) 48 return 49 } 50 guard let data = try? JSONEncoder().encode(directory) else { return } 51 try? data.write(to: url, options: .atomic) 52 } 53 54 /// The symmetric content key for `gameID`, or `nil` when none is mirrored 55 /// (a game that isn't shared, or one whose key hasn't synced yet). 56 static func key(for gameID: UUID) -> SymmetricKey? { 57 guard let stored = load()[gameID.uuidString] else { return nil } 58 return PushPayloadCipher.key(fromBase64: stored) 59 } 60 }