crossmate

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

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 }