crossmate

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

FriendEncryptionKeyDirectory.swift (3149B)


      1 import CryptoKit
      2 import Foundation
      3 
      4 struct FriendEncryptionKeyPayload: Codable, Equatable, Sendable {
      5     static let currentVersion = 1
      6 
      7     var version: Int
      8     var address: String
      9     var contentKey: String
     10 
     11     init(version: Int = Self.currentVersion, address: String, contentKey: String) {
     12         self.version = version
     13         self.address = address
     14         self.contentKey = contentKey
     15     }
     16 
     17     func encodedString() -> String? {
     18         guard let data = try? JSONEncoder().encode(self) else { return nil }
     19         return String(data: data, encoding: .utf8)
     20     }
     21 
     22     static func decode(_ raw: String?) -> FriendEncryptionKeyPayload? {
     23         guard let raw, let data = raw.data(using: .utf8) else { return nil }
     24         return try? JSONDecoder().decode(Self.self, from: data)
     25     }
     26 
     27     static func fresh() -> FriendEncryptionKeyPayload? {
     28         var bytes = [UInt8](repeating: 0, count: 32)
     29         guard SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) == errSecSuccess else {
     30             return nil
     31         }
     32         return FriendEncryptionKeyPayload(
     33             address: "friend-\(UUID().uuidString)",
     34             contentKey: Data(bytes).base64EncodedString()
     35         )
     36     }
     37 
     38     var symmetricKey: SymmetricKey? {
     39         PushPayloadCipher.key(fromBase64: contentKey)
     40     }
     41 }
     42 
     43 /// App Group-shared directory of friend-channel encryption keys, keyed by the
     44 /// friend's authorID. The main app mirrors keys learned from friend-zone
     45 /// `Decision` records here so the Notification Service Extension can decrypt
     46 /// an invite push before the game itself exists locally.
     47 enum FriendEncryptionKeyDirectory {
     48     @TaskLocal static var testingFileURL: URL?
     49 
     50     private static var fileURL: URL? {
     51         if let testingFileURL { return testingFileURL }
     52         return FileManager.default
     53             .containerURL(forSecurityApplicationGroupIdentifier: NotificationState.appGroup)?
     54             .appendingPathComponent("friend-encryption-key-directory.json")
     55     }
     56 
     57     static func load() -> [String: FriendEncryptionKeyPayload] {
     58         guard let url = fileURL,
     59               let data = try? Data(contentsOf: url),
     60               let directory = try? JSONDecoder().decode([String: FriendEncryptionKeyPayload].self, from: data)
     61         else { return [:] }
     62         return directory
     63     }
     64 
     65     static func save(_ directory: [String: FriendEncryptionKeyPayload]) {
     66         guard let url = fileURL else { return }
     67         guard !directory.isEmpty else {
     68             try? FileManager.default.removeItem(at: url)
     69             return
     70         }
     71         guard let data = try? JSONEncoder().encode(directory) else { return }
     72         try? data.write(to: url, options: .atomic)
     73     }
     74 
     75     static func upsert(_ payload: FriendEncryptionKeyPayload, for authorID: String) {
     76         guard !authorID.isEmpty else { return }
     77         var directory = load()
     78         directory[authorID] = payload
     79         save(directory)
     80     }
     81 
     82     static func payload(for authorID: String) -> FriendEncryptionKeyPayload? {
     83         load()[authorID]
     84     }
     85 
     86     static func key(for authorID: String) -> SymmetricKey? {
     87         payload(for: authorID)?.symmetricKey
     88     }
     89 }