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 }