PushPayloadCipherTests.swift (5581B)
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("key rejects material shorter than 32 bytes") 53 func keyLength() { 54 #expect(PushPayloadCipher.key(fromBase64: Data(repeating: 0, count: 16).base64EncodedString()) == nil) 55 #expect(PushPayloadCipher.key(fromBase64: "") == nil) 56 #expect(PushPayloadCipher.key(fromBase64: Data(repeating: 0, count: 32).base64EncodedString()) != nil) 57 } 58 59 // MARK: - App Group directory 60 61 private func withTemporaryDirectoryFile( 62 _ body: @Sendable () async throws -> Void 63 ) async throws { 64 let url = FileManager.default.temporaryDirectory 65 .appendingPathComponent("content-key-directory-\(UUID().uuidString).json") 66 defer { try? FileManager.default.removeItem(at: url) } 67 try await ContentKeyDirectory.$testingFileURL.withValue(url) { 68 try await body() 69 } 70 } 71 72 @Test("directory save/load/key round-trips and resolves a usable key") 73 func directoryRoundTrip() async throws { 74 try await withTemporaryDirectoryFile { 75 #expect(ContentKeyDirectory.load().isEmpty) 76 let gameID = UUID() 77 let keyBase64 = Data(repeating: 7, count: 32).base64EncodedString() 78 ContentKeyDirectory.save([gameID.uuidString: keyBase64]) 79 #expect(ContentKeyDirectory.load() == [gameID.uuidString: keyBase64]) 80 81 // The resolved key must actually open a payload sealed under it. 82 let resolved = try #require(ContentKeyDirectory.key(for: gameID)) 83 let payload = PushPayload(event: .win, puzzleTitle: "X", playerName: "Y") 84 let sealed = try #require(PushPayloadCipher.seal(payload, key: resolved)) 85 #expect(PushPayloadCipher.open(sealed, key: resolved) == payload) 86 #expect(ContentKeyDirectory.key(for: UUID()) == nil) 87 } 88 } 89 90 @Test("saving an empty directory removes the file") 91 func emptySaveRemoves() async throws { 92 try await withTemporaryDirectoryFile { 93 ContentKeyDirectory.save([UUID().uuidString: Data(repeating: 1, count: 32).base64EncodedString()]) 94 #expect(!ContentKeyDirectory.load().isEmpty) 95 ContentKeyDirectory.save([:]) 96 #expect(ContentKeyDirectory.load().isEmpty) 97 } 98 } 99 100 // MARK: - Rebuild from Core Data 101 102 @Test("rebuildContentKeyDirectory mirrors games whose credential carries a key") 103 func rebuildFromCoreData() async throws { 104 try await withTemporaryDirectoryFile { 105 try await MainActor.run { 106 let persistence = makeTestPersistence() 107 let ctx = persistence.viewContext 108 109 func addGame(notification: String?) -> UUID { 110 let id = UUID() 111 let game = GameEntity(context: ctx) 112 game.id = id 113 game.title = "Puzzle" 114 game.puzzleSource = "" 115 game.createdAt = Date() 116 game.updatedAt = Date() 117 game.notification = notification 118 return id 119 } 120 let withKey = addGame(notification: try GamePushCredentials.fresh().encoded()) 121 // A legacy credential with no content key is skipped. 122 _ = addGame(notification: try GamePushCredentials(secret: "s").encoded()) 123 _ = addGame(notification: nil) 124 try ctx.save() 125 126 GameEntity.rebuildContentKeyDirectory(in: ctx) 127 128 let directory = ContentKeyDirectory.load() 129 #expect(directory.count == 1) 130 #expect(directory[withKey.uuidString] != nil) 131 } 132 } 133 } 134 }