crossmate

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

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 }