crossmate

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

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 }