PushPayloadCipherTests.swift (7300B)
1 import CoreData 2 import CryptoKit 3 import Foundation 4 import Testing 5 6 @testable import Crossmate 7 8 @Suite("Push payload encryption") 9 struct PushPayloadCipherTests { 10 11 private func makeKey(_ seed: UInt8 = 1) -> SymmetricKey { 12 let base64 = Data(repeating: seed, count: 32).base64EncodedString() 13 return PushPayloadCipher.key(fromBase64: base64)! 14 } 15 16 // MARK: - Cipher round trip 17 18 @Test("seal/open round-trips a payload with personal fields") 19 func roundTrip() throws { 20 let key = makeKey() 21 let payload = PushPayload( 22 event: .pause(fills: 3, clears: 1, checks: 2, reveals: 0), 23 puzzleTitle: "The Saturday Stumper", 24 playerName: "Alexandra", 25 diagnostics: PushPayload.Diagnostics(gridWidth: 15, gridHeight: 15) 26 ) 27 let sealed = try #require(PushPayloadCipher.seal(payload, key: key)) 28 // The sealed blob must not leak the cleartext personal fields. 29 let decoded = try #require(Data(base64Encoded: sealed)) 30 let asText = String(decoding: decoded, as: UTF8.self) 31 #expect(!asText.contains("Alexandra")) 32 #expect(!asText.contains("Saturday")) 33 #expect(PushPayloadCipher.open(sealed, key: key) == payload) 34 } 35 36 @Test("open with the wrong key fails") 37 func wrongKeyFails() throws { 38 let sealed = try #require( 39 PushPayloadCipher.seal(PushPayload(event: .win, puzzleTitle: "X", playerName: "Y"), key: makeKey(1)) 40 ) 41 #expect(PushPayloadCipher.open(sealed, key: makeKey(2)) == nil) 42 } 43 44 @Test("open tolerates absent and corrupt input") 45 func tolerantOpen() { 46 let key = makeKey() 47 #expect(PushPayloadCipher.open(nil, key: key) == nil) 48 #expect(PushPayloadCipher.open("not base64 !!!", key: key) == nil) 49 #expect(PushPayloadCipher.open("YWJjZA==", key: key) == nil) // valid base64, not a box 50 } 51 52 @Test("open rejects an oversized sealed blob before any decoding") 53 func oversizedOpen() { 54 let key = makeKey() 55 let oversized = String(repeating: "A", count: PushPayloadCipher.maxEncodedLength + 1) 56 #expect(PushPayloadCipher.open(oversized, key: key) == nil) 57 // A genuine payload sealed at the boundary still opens. 58 let payload = PushPayload(event: .win, puzzleTitle: "X", playerName: "Y") 59 let sealed = PushPayloadCipher.seal(payload, key: key)! 60 #expect(sealed.count <= PushPayloadCipher.maxEncodedLength) 61 #expect(PushPayloadCipher.open(sealed, key: key) == payload) 62 } 63 64 @Test("key rejects material shorter than 32 bytes") 65 func keyLength() { 66 #expect(PushPayloadCipher.key(fromBase64: Data(repeating: 0, count: 16).base64EncodedString()) == nil) 67 #expect(PushPayloadCipher.key(fromBase64: "") == nil) 68 #expect(PushPayloadCipher.key(fromBase64: Data(repeating: 0, count: 32).base64EncodedString()) != nil) 69 } 70 71 // MARK: - App Group directory 72 73 private func withTemporaryDirectoryFile( 74 _ body: @Sendable () async throws -> Void 75 ) async throws { 76 let url = FileManager.default.temporaryDirectory 77 .appendingPathComponent("content-key-directory-\(UUID().uuidString).json") 78 defer { try? FileManager.default.removeItem(at: url) } 79 try await ContentKeyDirectory.$testingFileURL.withValue(url) { 80 try await body() 81 } 82 } 83 84 @Test("directory save/load/key round-trips and resolves a usable key") 85 func directoryRoundTrip() async throws { 86 try await withTemporaryDirectoryFile { 87 #expect(ContentKeyDirectory.load().isEmpty) 88 let gameID = UUID() 89 let keyBase64 = Data(repeating: 7, count: 32).base64EncodedString() 90 ContentKeyDirectory.save([gameID.uuidString: keyBase64]) 91 #expect(ContentKeyDirectory.load() == [gameID.uuidString: keyBase64]) 92 93 // The resolved key must actually open a payload sealed under it. 94 let resolved = try #require(ContentKeyDirectory.key(for: gameID)) 95 let payload = PushPayload(event: .win, puzzleTitle: "X", playerName: "Y") 96 let sealed = try #require(PushPayloadCipher.seal(payload, key: resolved)) 97 #expect(PushPayloadCipher.open(sealed, key: resolved) == payload) 98 #expect(ContentKeyDirectory.key(for: UUID()) == nil) 99 } 100 } 101 102 @Test("saving an empty directory removes the file") 103 func emptySaveRemoves() async throws { 104 try await withTemporaryDirectoryFile { 105 ContentKeyDirectory.save([UUID().uuidString: Data(repeating: 1, count: 32).base64EncodedString()]) 106 #expect(!ContentKeyDirectory.load().isEmpty) 107 ContentKeyDirectory.save([:]) 108 #expect(ContentKeyDirectory.load().isEmpty) 109 } 110 } 111 112 @Test("friend encryption directory resolves a key that opens invite payloads") 113 func friendEncryptionDirectoryRoundTrip() async throws { 114 let url = FileManager.default.temporaryDirectory 115 .appendingPathComponent("friend-key-directory-\(UUID().uuidString).json") 116 defer { try? FileManager.default.removeItem(at: url) } 117 try await FriendEncryptionKeyDirectory.$testingFileURL.withValue(url) { 118 let payload = try #require(FriendEncryptionKeyPayload.fresh()) 119 FriendEncryptionKeyDirectory.upsert(payload, for: "_alice") 120 #expect(FriendEncryptionKeyDirectory.payload(for: "_alice") == payload) 121 122 let key = try #require(FriendEncryptionKeyDirectory.key(for: "_alice")) 123 let invite = PushPayload( 124 event: .invite, 125 puzzleTitle: "Saturday", 126 playerName: "Alice" 127 ) 128 let sealed = try #require(PushPayloadCipher.seal(invite, key: key)) 129 #expect(PushPayloadCipher.open(sealed, key: key) == invite) 130 } 131 } 132 133 // MARK: - Rebuild from Core Data 134 135 @Test("rebuildContentKeyDirectory mirrors games whose credential carries a key") 136 func rebuildFromCoreData() async throws { 137 try await withTemporaryDirectoryFile { 138 try await MainActor.run { 139 let persistence = makeTestPersistence() 140 let ctx = persistence.viewContext 141 142 func addGame(notification: String?) -> UUID { 143 let id = UUID() 144 let game = GameEntity(context: ctx) 145 game.id = id 146 game.title = "Puzzle" 147 game.puzzleSource = "" 148 game.createdAt = Date() 149 game.updatedAt = Date() 150 game.notification = notification 151 return id 152 } 153 let withKey = addGame(notification: try GamePushCredentials.fresh().encoded()) 154 // A legacy credential with no content key is skipped. 155 _ = addGame(notification: try GamePushCredentials(secret: "s").encoded()) 156 _ = addGame(notification: nil) 157 try ctx.save() 158 159 GameEntity.rebuildContentKeyDirectory(in: ctx) 160 161 let directory = ContentKeyDirectory.load() 162 #expect(directory.count == 1) 163 #expect(directory[withKey.uuidString] != nil) 164 } 165 } 166 } 167 }